@carlonicora/nestjs-neo4jsonapi 2.3.0 → 2.4.0

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.
Files changed (220) hide show
  1. package/dist/agents/common/usage-attribution.d.ts +218 -0
  2. package/dist/agents/common/usage-attribution.d.ts.map +1 -0
  3. package/dist/agents/common/usage-attribution.js +235 -0
  4. package/dist/agents/common/usage-attribution.js.map +1 -0
  5. package/dist/agents/community.summariser/services/community.summariser.service.d.ts.map +1 -1
  6. package/dist/agents/community.summariser/services/community.summariser.service.js +13 -1
  7. package/dist/agents/community.summariser/services/community.summariser.service.js.map +1 -1
  8. package/dist/agents/contextualiser/contexts/contextualiser.context.d.ts +24 -1
  9. package/dist/agents/contextualiser/contexts/contextualiser.context.d.ts.map +1 -1
  10. package/dist/agents/contextualiser/contexts/contextualiser.context.js +32 -0
  11. package/dist/agents/contextualiser/contexts/contextualiser.context.js.map +1 -1
  12. package/dist/agents/contextualiser/factories/contextualiser.context.factory.d.ts +7 -0
  13. package/dist/agents/contextualiser/factories/contextualiser.context.factory.d.ts.map +1 -1
  14. package/dist/agents/contextualiser/factories/contextualiser.context.factory.js +5 -0
  15. package/dist/agents/contextualiser/factories/contextualiser.context.factory.js.map +1 -1
  16. package/dist/agents/contextualiser/nodes/atomicfacts.node.service.d.ts.map +1 -1
  17. package/dist/agents/contextualiser/nodes/atomicfacts.node.service.js +4 -0
  18. package/dist/agents/contextualiser/nodes/atomicfacts.node.service.js.map +1 -1
  19. package/dist/agents/contextualiser/nodes/chunk.node.service.d.ts.map +1 -1
  20. package/dist/agents/contextualiser/nodes/chunk.node.service.js +4 -0
  21. package/dist/agents/contextualiser/nodes/chunk.node.service.js.map +1 -1
  22. package/dist/agents/contextualiser/nodes/chunk.vector.node.service.d.ts.map +1 -1
  23. package/dist/agents/contextualiser/nodes/chunk.vector.node.service.js +15 -0
  24. package/dist/agents/contextualiser/nodes/chunk.vector.node.service.js.map +1 -1
  25. package/dist/agents/contextualiser/nodes/keyconcepts.node.service.d.ts.map +1 -1
  26. package/dist/agents/contextualiser/nodes/keyconcepts.node.service.js +11 -0
  27. package/dist/agents/contextualiser/nodes/keyconcepts.node.service.js.map +1 -1
  28. package/dist/agents/contextualiser/nodes/question.refiner.node.service.d.ts.map +1 -1
  29. package/dist/agents/contextualiser/nodes/question.refiner.node.service.js +4 -0
  30. package/dist/agents/contextualiser/nodes/question.refiner.node.service.js.map +1 -1
  31. package/dist/agents/contextualiser/nodes/rational.node.service.d.ts.map +1 -1
  32. package/dist/agents/contextualiser/nodes/rational.node.service.js +4 -0
  33. package/dist/agents/contextualiser/nodes/rational.node.service.js.map +1 -1
  34. package/dist/agents/contextualiser/services/contextualiser.service.d.ts +7 -0
  35. package/dist/agents/contextualiser/services/contextualiser.service.d.ts.map +1 -1
  36. package/dist/agents/contextualiser/services/contextualiser.service.js +17 -5
  37. package/dist/agents/contextualiser/services/contextualiser.service.js.map +1 -1
  38. package/dist/agents/drift/contexts/drift.context.d.ts +25 -1
  39. package/dist/agents/drift/contexts/drift.context.d.ts.map +1 -1
  40. package/dist/agents/drift/contexts/drift.context.js +32 -0
  41. package/dist/agents/drift/contexts/drift.context.js.map +1 -1
  42. package/dist/agents/drift/nodes/followup.node.service.d.ts.map +1 -1
  43. package/dist/agents/drift/nodes/followup.node.service.js +4 -0
  44. package/dist/agents/drift/nodes/followup.node.service.js.map +1 -1
  45. package/dist/agents/drift/nodes/hyde.node.service.d.ts.map +1 -1
  46. package/dist/agents/drift/nodes/hyde.node.service.js +9 -1
  47. package/dist/agents/drift/nodes/hyde.node.service.js.map +1 -1
  48. package/dist/agents/drift/nodes/primer.answer.node.service.d.ts.map +1 -1
  49. package/dist/agents/drift/nodes/primer.answer.node.service.js +4 -0
  50. package/dist/agents/drift/nodes/primer.answer.node.service.js.map +1 -1
  51. package/dist/agents/drift/nodes/synthesis.node.service.d.ts.map +1 -1
  52. package/dist/agents/drift/nodes/synthesis.node.service.js +4 -0
  53. package/dist/agents/drift/nodes/synthesis.node.service.js.map +1 -1
  54. package/dist/agents/drift/services/drift.search.service.d.ts +9 -0
  55. package/dist/agents/drift/services/drift.search.service.d.ts.map +1 -1
  56. package/dist/agents/drift/services/drift.search.service.js +22 -5
  57. package/dist/agents/drift/services/drift.search.service.js.map +1 -1
  58. package/dist/agents/graph/services/graph.search.service.d.ts.map +1 -1
  59. package/dist/agents/graph/services/graph.search.service.js +10 -1
  60. package/dist/agents/graph/services/graph.search.service.js.map +1 -1
  61. package/dist/agents/graph/tools/tool.factory.d.ts +6 -0
  62. package/dist/agents/graph/tools/tool.factory.d.ts.map +1 -1
  63. package/dist/agents/graph/tools/tool.factory.js.map +1 -1
  64. package/dist/agents/index.d.ts +7 -0
  65. package/dist/agents/index.d.ts.map +1 -1
  66. package/dist/agents/index.js +13 -1
  67. package/dist/agents/index.js.map +1 -1
  68. package/dist/agents/operator/contexts/operator.context.d.ts +37 -1
  69. package/dist/agents/operator/contexts/operator.context.d.ts.map +1 -1
  70. package/dist/agents/operator/contexts/operator.context.js +8 -0
  71. package/dist/agents/operator/contexts/operator.context.js.map +1 -1
  72. package/dist/agents/operator/interfaces/operator.tool.interface.d.ts +8 -0
  73. package/dist/agents/operator/interfaces/operator.tool.interface.d.ts.map +1 -1
  74. package/dist/agents/operator/interfaces/operator.tool.interface.js.map +1 -1
  75. package/dist/agents/operator/services/operator.service.d.ts +8 -0
  76. package/dist/agents/operator/services/operator.service.d.ts.map +1 -1
  77. package/dist/agents/operator/services/operator.service.js +29 -0
  78. package/dist/agents/operator/services/operator.service.js.map +1 -1
  79. package/dist/agents/operator/tools/operator.tool.registry.d.ts.map +1 -1
  80. package/dist/agents/operator/tools/operator.tool.registry.js +2 -1
  81. package/dist/agents/operator/tools/operator.tool.registry.js.map +1 -1
  82. package/dist/agents/operator/tools/search-communities.tool.d.ts +8 -2
  83. package/dist/agents/operator/tools/search-communities.tool.d.ts.map +1 -1
  84. package/dist/agents/operator/tools/search-communities.tool.js +16 -4
  85. package/dist/agents/operator/tools/search-communities.tool.js.map +1 -1
  86. package/dist/agents/operator/tools/search-documents.tool.d.ts.map +1 -1
  87. package/dist/agents/operator/tools/search-documents.tool.js +5 -0
  88. package/dist/agents/operator/tools/search-documents.tool.js.map +1 -1
  89. package/dist/agents/responder/contexts/responder.context.d.ts +31 -0
  90. package/dist/agents/responder/contexts/responder.context.d.ts.map +1 -1
  91. package/dist/agents/responder/contexts/responder.context.js +8 -0
  92. package/dist/agents/responder/contexts/responder.context.js.map +1 -1
  93. package/dist/agents/responder/factories/responder.context.factory.d.ts +4 -0
  94. package/dist/agents/responder/factories/responder.context.factory.d.ts.map +1 -1
  95. package/dist/agents/responder/factories/responder.context.factory.js +2 -0
  96. package/dist/agents/responder/factories/responder.context.factory.js.map +1 -1
  97. package/dist/agents/responder/nodes/graph.node.service.d.ts +6 -0
  98. package/dist/agents/responder/nodes/graph.node.service.d.ts.map +1 -1
  99. package/dist/agents/responder/nodes/graph.node.service.js +20 -0
  100. package/dist/agents/responder/nodes/graph.node.service.js.map +1 -1
  101. package/dist/agents/responder/nodes/planner.node.service.d.ts.map +1 -1
  102. package/dist/agents/responder/nodes/planner.node.service.js +9 -0
  103. package/dist/agents/responder/nodes/planner.node.service.js.map +1 -1
  104. package/dist/agents/responder/nodes/responder.answer.node.service.d.ts.map +1 -1
  105. package/dist/agents/responder/nodes/responder.answer.node.service.js +9 -0
  106. package/dist/agents/responder/nodes/responder.answer.node.service.js.map +1 -1
  107. package/dist/agents/responder/services/responder.service.d.ts +16 -0
  108. package/dist/agents/responder/services/responder.service.d.ts.map +1 -1
  109. package/dist/agents/responder/services/responder.service.js +29 -5
  110. package/dist/agents/responder/services/responder.service.js.map +1 -1
  111. package/dist/agents/summariser/services/summariser.service.d.ts +7 -0
  112. package/dist/agents/summariser/services/summariser.service.d.ts.map +1 -1
  113. package/dist/agents/summariser/services/summariser.service.js +15 -3
  114. package/dist/agents/summariser/services/summariser.service.js.map +1 -1
  115. package/dist/common/helpers/index.d.ts +1 -0
  116. package/dist/common/helpers/index.d.ts.map +1 -1
  117. package/dist/common/helpers/index.js +1 -0
  118. package/dist/common/helpers/index.js.map +1 -1
  119. package/dist/common/helpers/map-with-concurrency.d.ts +23 -0
  120. package/dist/common/helpers/map-with-concurrency.d.ts.map +1 -0
  121. package/dist/common/helpers/map-with-concurrency.js +60 -0
  122. package/dist/common/helpers/map-with-concurrency.js.map +1 -0
  123. package/dist/common/tokens.d.ts +2 -1
  124. package/dist/common/tokens.d.ts.map +1 -1
  125. package/dist/common/tokens.js +2 -1
  126. package/dist/common/tokens.js.map +1 -1
  127. package/dist/config/base.config.d.ts +19 -0
  128. package/dist/config/base.config.d.ts.map +1 -1
  129. package/dist/config/base.config.js +30 -3
  130. package/dist/config/base.config.js.map +1 -1
  131. package/dist/config/interfaces/config.ai.interface.d.ts +10 -1
  132. package/dist/config/interfaces/config.ai.interface.d.ts.map +1 -1
  133. package/dist/config/interfaces/config.chunk.queues.interface.d.ts +13 -0
  134. package/dist/config/interfaces/config.chunk.queues.interface.d.ts.map +1 -1
  135. package/dist/config/interfaces/config.credits.interface.d.ts +1 -1
  136. package/dist/core/blocknote/services/blocknote.service.d.ts.map +1 -1
  137. package/dist/core/blocknote/services/blocknote.service.js +7 -0
  138. package/dist/core/blocknote/services/blocknote.service.js.map +1 -1
  139. package/dist/core/llm/services/audio.llm.service.d.ts +70 -1
  140. package/dist/core/llm/services/audio.llm.service.d.ts.map +1 -1
  141. package/dist/core/llm/services/audio.llm.service.js +118 -3
  142. package/dist/core/llm/services/audio.llm.service.js.map +1 -1
  143. package/dist/core/llm/services/document-ai.service.d.ts +6 -0
  144. package/dist/core/llm/services/document-ai.service.d.ts.map +1 -1
  145. package/dist/core/llm/services/document-ai.service.js +1 -0
  146. package/dist/core/llm/services/document-ai.service.js.map +1 -1
  147. package/dist/core/llm/services/embedder.service.d.ts +105 -0
  148. package/dist/core/llm/services/embedder.service.d.ts.map +1 -1
  149. package/dist/core/llm/services/embedder.service.js +237 -7
  150. package/dist/core/llm/services/embedder.service.js.map +1 -1
  151. package/dist/core/llm/services/llm.service.d.ts +103 -6
  152. package/dist/core/llm/services/llm.service.d.ts.map +1 -1
  153. package/dist/core/llm/services/llm.service.js +570 -148
  154. package/dist/core/llm/services/llm.service.js.map +1 -1
  155. package/dist/core/llm/services/model.service.d.ts +25 -0
  156. package/dist/core/llm/services/model.service.d.ts.map +1 -1
  157. package/dist/core/llm/services/model.service.js +76 -4
  158. package/dist/core/llm/services/model.service.js.map +1 -1
  159. package/dist/core/llm/utils/repair-truncated-json.d.ts +29 -0
  160. package/dist/core/llm/utils/repair-truncated-json.d.ts.map +1 -0
  161. package/dist/core/llm/utils/repair-truncated-json.js +111 -0
  162. package/dist/core/llm/utils/repair-truncated-json.js.map +1 -0
  163. package/dist/foundations/assistant/services/assistant.service.d.ts.map +1 -1
  164. package/dist/foundations/assistant/services/assistant.service.js +12 -0
  165. package/dist/foundations/assistant/services/assistant.service.js.map +1 -1
  166. package/dist/foundations/atomicfact/services/atomicfact.service.d.ts +7 -0
  167. package/dist/foundations/atomicfact/services/atomicfact.service.d.ts.map +1 -1
  168. package/dist/foundations/atomicfact/services/atomicfact.service.js +6 -0
  169. package/dist/foundations/atomicfact/services/atomicfact.service.js.map +1 -1
  170. package/dist/foundations/chunk/processors/chunk.processor.d.ts.map +1 -1
  171. package/dist/foundations/chunk/processors/chunk.processor.js +7 -1
  172. package/dist/foundations/chunk/processors/chunk.processor.js.map +1 -1
  173. package/dist/foundations/chunk/repositories/chunk.repository.d.ts +90 -2
  174. package/dist/foundations/chunk/repositories/chunk.repository.d.ts.map +1 -1
  175. package/dist/foundations/chunk/repositories/chunk.repository.js +205 -31
  176. package/dist/foundations/chunk/repositories/chunk.repository.js.map +1 -1
  177. package/dist/foundations/chunk/services/chunk.service.d.ts.map +1 -1
  178. package/dist/foundations/chunk/services/chunk.service.js +50 -1
  179. package/dist/foundations/chunk/services/chunk.service.js.map +1 -1
  180. package/dist/foundations/chunker/chunker.module.d.ts.map +1 -1
  181. package/dist/foundations/chunker/chunker.module.js +4 -1
  182. package/dist/foundations/chunker/chunker.module.js.map +1 -1
  183. package/dist/foundations/chunker/services/chunker.service.d.ts +57 -1
  184. package/dist/foundations/chunker/services/chunker.service.d.ts.map +1 -1
  185. package/dist/foundations/chunker/services/chunker.service.js +207 -16
  186. package/dist/foundations/chunker/services/chunker.service.js.map +1 -1
  187. package/dist/foundations/chunker/services/types/pdf.service.d.ts +42 -1
  188. package/dist/foundations/chunker/services/types/pdf.service.d.ts.map +1 -1
  189. package/dist/foundations/chunker/services/types/pdf.service.js +111 -22
  190. package/dist/foundations/chunker/services/types/pdf.service.js.map +1 -1
  191. package/dist/foundations/chunker/services/types/semanticsplitter.service.d.ts.map +1 -1
  192. package/dist/foundations/chunker/services/types/semanticsplitter.service.js +24 -3
  193. package/dist/foundations/chunker/services/types/semanticsplitter.service.js.map +1 -1
  194. package/dist/foundations/company/repositories/company.repository.d.ts +1 -1
  195. package/dist/foundations/company/repositories/company.repository.d.ts.map +1 -1
  196. package/dist/foundations/company/repositories/company.repository.js +31 -14
  197. package/dist/foundations/company/repositories/company.repository.js.map +1 -1
  198. package/dist/foundations/how-to/processors/how-to.processor.d.ts.map +1 -1
  199. package/dist/foundations/how-to/processors/how-to.processor.js +20 -7
  200. package/dist/foundations/how-to/processors/how-to.processor.js.map +1 -1
  201. package/dist/foundations/keyconcept/repositories/keyconcept.repository.d.ts +25 -1
  202. package/dist/foundations/keyconcept/repositories/keyconcept.repository.d.ts.map +1 -1
  203. package/dist/foundations/keyconcept/repositories/keyconcept.repository.js +38 -2
  204. package/dist/foundations/keyconcept/repositories/keyconcept.repository.js.map +1 -1
  205. package/dist/foundations/keyconcept/services/keyconcept.service.d.ts +5 -0
  206. package/dist/foundations/keyconcept/services/keyconcept.service.d.ts.map +1 -1
  207. package/dist/foundations/keyconcept/services/keyconcept.service.js +4 -0
  208. package/dist/foundations/keyconcept/services/keyconcept.service.js.map +1 -1
  209. package/dist/foundations/tokenusage/entities/tokenusage.js +1 -1
  210. package/dist/foundations/tokenusage/enums/tokenusage.type.d.ts +4 -1
  211. package/dist/foundations/tokenusage/enums/tokenusage.type.d.ts.map +1 -1
  212. package/dist/foundations/tokenusage/enums/tokenusage.type.js +3 -0
  213. package/dist/foundations/tokenusage/enums/tokenusage.type.js.map +1 -1
  214. package/dist/foundations/tokenusage/repositories/tokenusage.admin.repository.js +3 -3
  215. package/dist/foundations/tokenusage/repositories/tokenusage.repository.js +2 -2
  216. package/dist/foundations/tokenusage/services/tokenusage.service.d.ts +6 -1
  217. package/dist/foundations/tokenusage/services/tokenusage.service.d.ts.map +1 -1
  218. package/dist/foundations/tokenusage/services/tokenusage.service.js +7 -2
  219. package/dist/foundations/tokenusage/services/tokenusage.service.js.map +1 -1
  220. package/package.json +1 -1
@@ -65,6 +65,7 @@ const llm_cache_service_1 = require("./llm-cache.service");
65
65
  const model_service_1 = require("../../llm/services/model.service");
66
66
  const schema_utils_1 = require("../../llm/utils/schema.utils");
67
67
  const mock_from_zod_1 = require("../utils/mock-from-zod");
68
+ const repair_truncated_json_1 = require("../utils/repair-truncated-json");
68
69
  const llm_raw_response_1 = require("../common/llm-raw-response");
69
70
  const llm_call_dumper_service_1 = require("./llm-call-dumper.service");
70
71
  const openrouter_fetch_1 = require("./openrouter-fetch");
@@ -86,6 +87,40 @@ const DEFAULT_REQUEST_WATCHDOG_MS = 30_000;
86
87
  const DEFAULT_REQUEST_DEADLINE_ATTEMPTS = 3;
87
88
  /** Grace on top of the budgeted attempts, covering backoff between retries. */
88
89
  const DEADLINE_SLACK_MS = 15_000;
90
+ /**
91
+ * How long a FAILED stream may wait for its `usage` promise before giving up on
92
+ * it. Unlike `call()`, a stream's `result` promise runs under no outer deadline,
93
+ * so an unbounded await in its catch would replace a settled rejection with a
94
+ * caller that hangs forever. Two seconds is generous for a promise the SDK has
95
+ * normally already settled, and irrelevant to the happy path.
96
+ */
97
+ const USAGE_SETTLE_TIMEOUT_MS = 2_000;
98
+ /**
99
+ * Waits before each EXTRA attempt of the transient-network retry, in ms — two
100
+ * extras, so three attempts in total.
101
+ *
102
+ * Deliberately LONG. LangChain's `AsyncCaller` already retries roughly six times
103
+ * FAST underneath every `.invoke(...)`, so by the time a failure surfaces here
104
+ * the quick retries are spent and the fault has lasted seconds, not
105
+ * milliseconds — a DNS outage, a provider brown-out, a saturated egress NAT.
106
+ * Retrying fast again would burn both extra attempts inside the same bad second
107
+ * and change nothing. (Evidence: a crashed worker run with 28 `ENOTFOUND` DNS
108
+ * failures under load, every one of them already through LangChain's fast
109
+ * retries.)
110
+ */
111
+ const TRANSIENT_RETRY_WAITS_MS = [5_000, 15_000];
112
+ /**
113
+ * Network-level error codes that say "the request never reached a working
114
+ * provider" — the class of failure another attempt can actually fix.
115
+ */
116
+ const TRANSIENT_ERROR_CODES = new Set([
117
+ "ENOTFOUND",
118
+ "EAI_AGAIN",
119
+ "ECONNRESET",
120
+ "ETIMEDOUT",
121
+ "ECONNREFUSED",
122
+ "EPIPE",
123
+ ]);
89
124
  /**
90
125
  * True for the abort a request timeout raises, whichever layer raised it — the
91
126
  * OpenAI SDK's `APIConnectionTimeoutError`, undici's `TimeoutError`/
@@ -245,6 +280,37 @@ let LLMService = LLMService_1 = class LLMService {
245
280
  attemptTimeoutMs(explicit) {
246
281
  return explicit ?? this.config.get("ai")?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
247
282
  }
283
+ /**
284
+ * Reads a stream's `usage` promise WITHOUT ever hanging on it.
285
+ *
286
+ * Used only from the streaming error paths. `streamObject`/`streamText`
287
+ * usually settle `usage` even when `object`/`text` reject — a schema-invalid
288
+ * or aborted generation is still billed — so it is worth awaiting. But the
289
+ * `result` promise those catches belong to has no outer deadline (unlike
290
+ * `call()`, which runs under `runBounded`), so if the SDK ever rejected the
291
+ * content promise while leaving `usage` pending, an unbounded await would turn
292
+ * a prompt rejection into a caller that waits forever. Rejection is handled by
293
+ * `.catch`; PENDENCY is handled by the race. The loser's timer is cleared, so
294
+ * a settled call leaves no timer behind.
295
+ *
296
+ * @returns the usage object, or undefined if it rejected or did not settle in time
297
+ */
298
+ async readUsageBounded(usage, timeoutMs = USAGE_SETTLE_TIMEOUT_MS) {
299
+ let timer;
300
+ try {
301
+ return await Promise.race([
302
+ Promise.resolve(usage).catch(() => undefined),
303
+ new Promise((resolve) => {
304
+ timer = setTimeout(() => resolve(undefined), timeoutMs);
305
+ timer.unref?.();
306
+ }),
307
+ ]);
308
+ }
309
+ finally {
310
+ if (timer)
311
+ clearTimeout(timer);
312
+ }
313
+ }
248
314
  /**
249
315
  * Runs one provider call under a WATCHDOG and an ABSOLUTE DEADLINE.
250
316
  *
@@ -305,6 +371,102 @@ let LLMService = LLMService_1 = class LLMService {
305
371
  clearTimeout(deadline);
306
372
  }
307
373
  }
374
+ /**
375
+ * True for a failure that means the request never reached a working provider,
376
+ * and is therefore worth another attempt: a DNS/socket-level error code, an
377
+ * HTTP 429 or 5xx, or the message text either of those arrives as.
378
+ *
379
+ * All three are checked because the same failure wears different clothes per
380
+ * transport: undici puts the code on `error.cause.code` behind a bare
381
+ * `TypeError: fetch failed`, the OpenAI SDK puts the status on the error and
382
+ * the code in the message, and LangChain re-wraps both in a plain `Error`.
383
+ *
384
+ * Deliberately NOT transient: a stall that burned its whole deadline
385
+ * ({@link LLMTimeoutError}), a refusal (402/403), a malformed request (400) or
386
+ * a parse failure. Those either already had their retry (`call()` re-issues a
387
+ * timed-out attempt once, escalating the OpenRouter pin) or will fail
388
+ * identically forever. The 429 vocabulary matches the one
389
+ * `VisionLLMService.isRateLimitError` retries on, so the two agree on what a
390
+ * rate limit looks like.
391
+ */
392
+ isTransientNetworkError(err) {
393
+ if (err === undefined || err === null)
394
+ return false;
395
+ const candidate = err;
396
+ const cause = candidate.cause;
397
+ const codes = [candidate.code, cause?.code];
398
+ if (codes.some((code) => typeof code === "string" && TRANSIENT_ERROR_CODES.has(code)))
399
+ return true;
400
+ const statuses = [candidate.status, candidate.statusCode, candidate.response?.status, cause?.status];
401
+ if (statuses.some((status) => typeof status === "number" && (status === 429 || (status >= 500 && status <= 599))))
402
+ return true;
403
+ const own = err instanceof Error ? err.message : String(err);
404
+ const causeMessage = cause instanceof Error ? cause.message : "";
405
+ const haystack = `${own} ${causeMessage}`.toLowerCase();
406
+ for (const code of TRANSIENT_ERROR_CODES)
407
+ if (haystack.includes(code.toLowerCase()))
408
+ return true;
409
+ return (haystack.includes("socket hang up") ||
410
+ haystack.includes("fetch failed") ||
411
+ haystack.includes("network error") ||
412
+ haystack.includes("econnaborted") ||
413
+ // 429 / rate limiting — matched as a whole word so a token count never
414
+ // reads as a status code.
415
+ /\b429\b/.test(haystack) ||
416
+ haystack.includes("rate limit") ||
417
+ haystack.includes("resource exhausted") ||
418
+ haystack.includes("too many requests") ||
419
+ // 5xx. Only the codes providers actually emit, again whole-word: a blanket
420
+ // /5\d\d/ would retry "context length 512 exceeded" forever.
421
+ /\b(500|502|503|504|529)\b/.test(haystack) ||
422
+ haystack.includes("internal server error") ||
423
+ haystack.includes("bad gateway") ||
424
+ haystack.includes("service unavailable") ||
425
+ haystack.includes("gateway timeout") ||
426
+ haystack.includes("overloaded"));
427
+ }
428
+ /**
429
+ * Sleeps the jittered backoff for one transient retry and says so in the log.
430
+ * ±20% jitter so a fleet of workers knocked out by the same DNS blip does not
431
+ * come back in lockstep and knock it out again.
432
+ */
433
+ async waitBeforeTransientRetry(label, attempt, error) {
434
+ const base = TRANSIENT_RETRY_WAITS_MS[attempt];
435
+ const waitMs = Math.round(base * (0.8 + Math.random() * 0.4));
436
+ this.logger.warn(`[${label}] transient network failure on attempt ${attempt + 1}/${TRANSIENT_RETRY_WAITS_MS.length + 1} — ` +
437
+ `retrying in ${Math.round(waitMs / 1000)}s: ${error instanceof Error ? error.message : String(error)}`);
438
+ await new Promise((resolve) => {
439
+ const timer = setTimeout(resolve, waitMs);
440
+ timer.unref?.();
441
+ });
442
+ }
443
+ /**
444
+ * Runs a provider call under {@link runBounded}, retrying it when — and only
445
+ * when — it failed for a transient network reason
446
+ * ({@link isTransientNetworkError}). Two extra attempts, with the long
447
+ * jittered waits {@link TRANSIENT_RETRY_WAITS_MS} explains.
448
+ *
449
+ * Owns a FRESH AbortController per attempt: a controller that has already
450
+ * aborted stays aborted forever, so re-using one would make every retry abort
451
+ * before it sent a byte. `work` therefore receives the signal rather than
452
+ * capturing one from the caller.
453
+ */
454
+ async runWithTransientRetry(label, attemptTimeoutMs, work) {
455
+ for (let attempt = 0;; attempt++) {
456
+ const controller = new AbortController();
457
+ try {
458
+ return await this.runBounded(label, attemptTimeoutMs, controller, () => work(controller.signal));
459
+ }
460
+ catch (error) {
461
+ if (attempt >= TRANSIENT_RETRY_WAITS_MS.length || !this.isTransientNetworkError(error))
462
+ throw error;
463
+ // The attempt is over; release whatever socket it may still hold before
464
+ // waiting out the backoff.
465
+ controller.abort();
466
+ await this.waitBeforeTransientRetry(label, attempt, error);
467
+ }
468
+ }
469
+ }
308
470
  /**
309
471
  * Records token usage for cost/observability attribution. Never throws —
310
472
  * a persistence failure logs a warning and the LLM call continues, so
@@ -315,6 +477,18 @@ let LLMService = LLMService_1 = class LLMService {
315
477
  * is the ONLY token-usage write inside the package; any future package caller
316
478
  * MUST use the same token rather than injecting `TokenUsageService` directly
317
479
  * (see the token's docblock for why).
480
+ *
481
+ * ZERO-TOKEN SUCCESS RULE: a call that SUCCEEDED but reported no usage at all
482
+ * (the provider omitted `usage_metadata`) is still recorded — the call really
483
+ * happened and must stay visible — but with `applyMinimum: false`, so it
484
+ * costs 0 credits instead of being floored to `minCreditsPerRecord`. Flooring
485
+ * exists to stop sub-cent REAL usage rounding to nothing, not to invent a
486
+ * charge for tokens nobody measured. A success carrying real counts keeps the
487
+ * floor exactly as before.
488
+ *
489
+ * This is deliberately NOT the same rule as the zero-token FAILURE rule in
490
+ * {@link persistUsageOnFailure}, which writes nothing at all: there the
491
+ * provider was never reached, so there is no call to make visible.
318
492
  */
319
493
  async persistUsage(params, tokens) {
320
494
  // Attribution is opt-in: the caller decides which entity this usage is
@@ -322,6 +496,7 @@ let LLMService = LLMService_1 = class LLMService {
322
496
  // so we skip — the package stays domain-agnostic.
323
497
  if (!params.relationshipId || !params.relationshipType)
324
498
  return;
499
+ const measured = tokens.input + tokens.output + (tokens.cached ?? 0) > 0;
325
500
  try {
326
501
  await (this.tokenUsageRecorder ?? this.tokenUsageService).recordTokenUsage({
327
502
  tokens,
@@ -329,12 +504,35 @@ let LLMService = LLMService_1 = class LLMService {
329
504
  relationshipId: params.relationshipId,
330
505
  relationshipType: params.relationshipType,
331
506
  modelWeight: params.modelWeight,
507
+ ...(measured ? {} : { applyMinimum: false }),
332
508
  });
333
509
  }
334
510
  catch (err) {
335
511
  this.logger.warn(`TokenUsage persistence failed — continuing: ${String(err)}`);
336
512
  }
337
513
  }
514
+ /**
515
+ * Records what a FAILED call already burned. A failure is not a free call:
516
+ * the provider bills every round it served, so a tool loop that dies on its
517
+ * final structured invocation has already been charged for six figures of
518
+ * input tokens. Billing only successful calls understates real spend.
519
+ *
520
+ * ZERO-TOKEN RULE: a failure that consumed nothing (the provider was never
521
+ * reached — input validation, an unreachable host, an immediate abort) is NOT
522
+ * recorded. `recordTokenUsage` floors every record at `minCreditsPerRecord`,
523
+ * so writing a 0/0 row would invent a charge for tokens nobody spent; the
524
+ * floor exists to stop sub-cent REAL usage rounding to nothing, not to price
525
+ * a call that never happened. Such failures remain fully visible through the
526
+ * dump session, which closes with `finalStatus: "error"`.
527
+ *
528
+ * Never throws (delegates to {@link persistUsage}), so it can sit in a catch
529
+ * block without masking the original error.
530
+ */
531
+ async persistUsageOnFailure(params, tokens) {
532
+ if (tokens.input + tokens.output + (tokens.cached ?? 0) === 0)
533
+ return;
534
+ await this.persistUsage(params, tokens);
535
+ }
338
536
  /**
339
537
  * Converts AgentMessageType to LangChain BaseMessage
340
538
  */
@@ -606,14 +804,15 @@ let LLMService = LLMService_1 = class LLMService {
606
804
  // reports it while it is still open, and the deadline guarantees this
607
805
  // promise settles even if the provider never answers.
608
806
  const attemptTimeoutMs = this.attemptTimeoutMs(params.timeout);
609
- const controller = new AbortController();
610
807
  const label = `${params.metadata?.nodeName ?? "llm.call"}:${aiConfig.model}`;
611
808
  try {
612
- const result = await this.runBounded(label, attemptTimeoutMs, controller, () => this._invokeOriginal(params, session, (i, o, c) => {
809
+ // The abort signal now comes from the retry wrapper, which owns a fresh
810
+ // controller per attempt — a reused one would abort every retry instantly.
811
+ const result = await this.runWithTransientRetry(label, attemptTimeoutMs, (signal) => this._invokeOriginal(params, session, (i, o, c) => {
613
812
  totalInput += i;
614
813
  totalOutput += o;
615
814
  totalCached += c;
616
- }, (kind) => parseFallbacks.push(kind), (w) => warnings.push(w), { attemptTimeoutMs, signal: controller.signal, label }));
815
+ }, (kind) => parseFallbacks.push(kind), (w) => warnings.push(w), { attemptTimeoutMs, signal, label }));
617
816
  session.close({
618
817
  finalStatus: "success",
619
818
  totalTokens: { input: totalInput, output: totalOutput, cached: totalCached },
@@ -643,8 +842,26 @@ let LLMService = LLMService_1 = class LLMService {
643
842
  warnings,
644
843
  parseFallbacks,
645
844
  });
845
+ // The provider charged for everything spent up to the failure — a timeout
846
+ // mid tool-loop can burn six figures of input tokens, all of them already
847
+ // reported through `addTokens` by the time we get here. Never throws, so
848
+ // this cannot mask the original error.
849
+ await this.persistUsageOnFailure({
850
+ tokenUsageType: params.tokenUsageType,
851
+ relationshipId: params.relationshipId,
852
+ relationshipType: params.relationshipType,
853
+ modelWeight,
854
+ }, { input: totalInput, output: totalOutput, cached: totalCached });
646
855
  console.error("[LLMService] Error calling LLM:", error);
647
- throw new Error(`LLM service error: ${message}`);
856
+ // The message text is load-bearing — callers match on "LLM service error:"
857
+ // — so it stays byte-for-byte identical, and the original error rides along
858
+ // as `cause`. Without it every network diagnosis stopped at this wrapper:
859
+ // the ENOTFOUND / status / stack that explained the failure was thrown
860
+ // away here. Assigned rather than passed as `new Error(msg, { cause })`
861
+ // because this package targets ES2021, whose Error takes one argument.
862
+ const wrapped = new Error(`LLM service error: ${message}`);
863
+ wrapped.cause = error;
864
+ throw wrapped;
648
865
  }
649
866
  }
650
867
  async _invokeOriginal(params, session, addTokens, addParseFallback, addWarning,
@@ -785,12 +1002,21 @@ let LLMService = LLMService_1 = class LLMService {
785
1002
  `in=${iterationUsage?.input_tokens ?? 0} out=${iterationUsage?.output_tokens ?? 0} ` +
786
1003
  `reasoning=${iterationUsage?.output_token_details?.reasoning ?? 0} ` +
787
1004
  `tools=[${requestedTools.join(",") || "none"}]`);
788
- // Track token usage
1005
+ // Track token usage. Reported to the caller AS IT IS SPENT, not at the
1006
+ // end: this iteration is already billed by the provider, and a later
1007
+ // failure (a timeout on the final structured call, an unparseable
1008
+ // answer) must not make those tokens disappear from `call()`'s totals.
1009
+ // The final return therefore adds only ITS OWN delta — see `addTokens`
1010
+ // at the return sites below.
789
1011
  const responseUsage = toolResponse.usage_metadata;
790
1012
  if (responseUsage) {
791
- totalInputTokens += responseUsage.input_tokens ?? 0;
792
- totalOutputTokens += responseUsage.output_tokens ?? 0;
793
- totalCachedTokens += responseUsage.input_token_details?.cache_read ?? 0;
1013
+ const iterationInput = responseUsage.input_tokens ?? 0;
1014
+ const iterationOutput = responseUsage.output_tokens ?? 0;
1015
+ const iterationCached = responseUsage.input_token_details?.cache_read ?? 0;
1016
+ totalInputTokens += iterationInput;
1017
+ totalOutputTokens += iterationOutput;
1018
+ totalCachedTokens += iterationCached;
1019
+ addTokens(iterationInput, iterationOutput, iterationCached);
794
1020
  }
795
1021
  // Check for tool calls
796
1022
  const toolCalls = toolResponse.tool_calls ?? [];
@@ -998,9 +1224,15 @@ let LLMService = LLMService_1 = class LLMService {
998
1224
  },
999
1225
  finishReason: raw?.response_metadata?.finish_reason,
1000
1226
  });
1001
- const input = totalInputTokens + (raw?.usage_metadata?.input_tokens ?? 0);
1002
- const output = totalOutputTokens + (raw?.usage_metadata?.output_tokens ?? 0);
1003
- const cached = totalCachedTokens + (raw?.usage_metadata?.input_token_details?.cache_read ?? 0);
1227
+ // The final structured response's OWN usage. Reported separately from the
1228
+ // totals because the tool loop has already handed its share to `addTokens`;
1229
+ // re-reporting the sum would bill every tool iteration twice.
1230
+ const finalInput = raw?.usage_metadata?.input_tokens ?? 0;
1231
+ const finalOutput = raw?.usage_metadata?.output_tokens ?? 0;
1232
+ const finalCached = raw?.usage_metadata?.input_token_details?.cache_read ?? 0;
1233
+ const input = totalInputTokens + finalInput;
1234
+ const output = totalOutputTokens + finalOutput;
1235
+ const cached = totalCachedTokens + finalCached;
1004
1236
  /**
1005
1237
  * One summary line per completed call, emitted at EVERY return site — the
1006
1238
  * degraded runs are the ones worth diagnosing, so a fallback-parsed call must
@@ -1047,7 +1279,7 @@ let LLMService = LLMService_1 = class LLMService {
1047
1279
  const validated = params.outputSchema.parse(normaliseStrictOutput(toolCallArgs));
1048
1280
  console.warn("[LLMService] Fallback tool_calls parsing succeeded");
1049
1281
  logCallSummary("fallback:tool_calls");
1050
- addTokens(input, output, cached);
1282
+ addTokens(finalInput, finalOutput, finalCached);
1051
1283
  return {
1052
1284
  ...validated,
1053
1285
  tokenUsage: { input, output, cached },
@@ -1084,7 +1316,7 @@ let LLMService = LLMService_1 = class LLMService {
1084
1316
  const validated = params.outputSchema.parse(cleanedArgs);
1085
1317
  console.warn("[LLMService] Lenient tool_calls parsing succeeded");
1086
1318
  logCallSummary("fallback:lenient");
1087
- addTokens(input, output, cached);
1319
+ addTokens(finalInput, finalOutput, finalCached);
1088
1320
  return {
1089
1321
  ...validated,
1090
1322
  tokenUsage: { input, output, cached },
@@ -1103,13 +1335,41 @@ let LLMService = LLMService_1 = class LLMService {
1103
1335
  const validated = params.outputSchema.parse(normaliseStrictOutput(manualParse));
1104
1336
  console.warn("[LLMService] Fallback parsing succeeded");
1105
1337
  logCallSummary("fallback:raw");
1106
- addTokens(input, output, cached);
1338
+ addTokens(finalInput, finalOutput, finalCached);
1107
1339
  return {
1108
1340
  ...validated,
1109
1341
  tokenUsage: { input, output, cached },
1110
1342
  };
1111
1343
  }
1112
1344
  catch (fallbackError) {
1345
+ // Last rung. A MAX_TOKENS truncation (`finish_reason: "length"`) stops
1346
+ // the payload mid-value, so every rung above — all of which need the
1347
+ // whole document to parse — rejects a response whose completed elements
1348
+ // were perfectly good. Trim to the last complete value, close the open
1349
+ // containers, and validate exactly as the raw rung does.
1350
+ const repaired = (0, repair_truncated_json_1.repairTruncatedJson)(rawContent);
1351
+ if (repaired !== null) {
1352
+ try {
1353
+ const validated = params.outputSchema.parse(normaliseStrictOutput(JSON.parse(repaired)));
1354
+ this.logger.warn(`[${label}] parseFallback: "truncation-repair" — recovered a truncated payload ` +
1355
+ `(finishReason=${finishReason}, ${rawContent.length}→${repaired.length} chars)`);
1356
+ logCallSummary("fallback:truncation-repair");
1357
+ addTokens(finalInput, finalOutput, finalCached);
1358
+ return {
1359
+ ...validated,
1360
+ tokenUsage: { input, output, cached },
1361
+ };
1362
+ }
1363
+ catch {
1364
+ // Repaired text still does not satisfy the schema — fall through to
1365
+ // the diagnostic below, which reports the ORIGINAL failure.
1366
+ }
1367
+ }
1368
+ // Every salvage attempt failed, so this call is about to throw — but the
1369
+ // unparseable answer was generated and billed like any other. Report it
1370
+ // before unwinding, so `call()`'s catch can record it. No return site
1371
+ // follows this one, so nothing is counted twice.
1372
+ addTokens(finalInput, finalOutput, finalCached);
1113
1373
  throw new Error(`LLM failed to return structured output. ` +
1114
1374
  `Finish reason: ${finishReason}. ` +
1115
1375
  `Raw content preview: ${rawContent.substring(0, 200)}...` +
@@ -1138,7 +1398,7 @@ let LLMService = LLMService_1 = class LLMService {
1138
1398
  return salvageParse();
1139
1399
  }
1140
1400
  logCallSummary("clean");
1141
- addTokens(input, output, cached);
1401
+ addTokens(finalInput, finalOutput, finalCached);
1142
1402
  return {
1143
1403
  ...result,
1144
1404
  tokenUsage: {
@@ -1261,77 +1521,132 @@ let LLMService = LLMService_1 = class LLMService {
1261
1521
  // final-structured response, just streamed instead of awaited atomically.
1262
1522
  // Avoids widening the dumper's union type for a single call site.
1263
1523
  session.startIteration("final-structured", []);
1264
- // Abort the stream if the provider stalls, so a hung connection can't pin
1265
- // the session open indefinitely. The AbortError surfaces as a rejection on
1266
- // the awaited promises below (caught + logged).
1267
- const controller = new AbortController();
1268
- const timeoutId = setTimeout(() => controller.abort(), this.attemptTimeoutMs(params.timeout));
1524
+ const attemptTimeoutMs = this.attemptTimeoutMs(params.timeout);
1525
+ const label = `${params.metadata?.nodeName ?? "llm.streamCall"}:${aiConfig.model}`;
1526
+ // One attempt = one abort controller + one whole-stream timeout, so a hung
1527
+ // connection can't pin the session open indefinitely. The bound is NOT
1528
+ // `runWithTransientRetry`'s per-attempt deadline: a stream outlives its
1529
+ // first byte, so its timer must stay armed until the stream finishes.
1530
+ //
1269
1531
  // Schema cast: `streamObject`'s typing is a conditional union over the
1270
1532
  // output mode (`object` / `enum` / `array` / `no-schema`). Our T is always
1271
1533
  // a Zod object schema; the runtime call is correct.
1272
- const streamResult = streamObject({
1273
- model,
1274
- schema: params.outputSchema,
1275
- system,
1276
- prompt: finalInstructions,
1277
- temperature: params.temperature,
1278
- maxOutputTokens: params.maxTokens,
1279
- maxRetries: 2,
1280
- abortSignal: controller.signal,
1281
- });
1534
+ const startAttempt = () => {
1535
+ const controller = new AbortController();
1536
+ const timeoutId = setTimeout(() => controller.abort(), attemptTimeoutMs);
1537
+ timeoutId.unref?.();
1538
+ return {
1539
+ controller,
1540
+ timeoutId,
1541
+ handle: streamObject({
1542
+ model,
1543
+ schema: params.outputSchema,
1544
+ system,
1545
+ prompt: finalInstructions,
1546
+ temperature: params.temperature,
1547
+ maxOutputTokens: params.maxTokens,
1548
+ maxRetries: 2,
1549
+ abortSignal: controller.signal,
1550
+ }),
1551
+ };
1552
+ };
1553
+ let attempt = startAttempt();
1554
+ // Set the instant the caller starts reading the stream. Past that point a
1555
+ // restart would REPLAY output the consumer has already seen, so the
1556
+ // transient retry below only ever fires while the consumer is still idle —
1557
+ // which is precisely the window in which a DNS/connect failure lands.
1558
+ let consumerStarted = false;
1282
1559
  // Build the result Promise that closes the session once the stream finishes.
1283
1560
  // This is awaitable independently of consuming the streams — `streamObject`
1284
1561
  // internally tees the source, so consuming `textStream` (or not) doesn't
1285
1562
  // affect `result` resolution.
1286
1563
  const resultPromise = (async () => {
1287
- try {
1288
- const finalObject = (await streamResult.object);
1289
- const usage = await streamResult.usage;
1290
- const input = usage?.inputTokens ?? 0;
1291
- const output = usage?.outputTokens ?? 0;
1292
- const cached = usage?.inputTokenDetails?.cacheReadTokens ?? 0;
1293
- session.recordResponse({
1294
- content: JSON.stringify(finalObject),
1295
- tokenUsage: { input, output },
1296
- finishReason: String(await streamResult.finishReason),
1297
- });
1298
- session.close({
1299
- finalStatus: "success",
1300
- totalTokens: { input, output, cached },
1301
- warnings: [],
1302
- parseFallbacks: [],
1303
- });
1304
- await this.persistUsage({
1305
- tokenUsageType: params.tokenUsageType,
1306
- relationshipId: params.relationshipId,
1307
- relationshipType: params.relationshipType,
1308
- modelWeight,
1309
- }, { input, output, cached });
1310
- return { ...finalObject, tokenUsage: { input, output }, modelWeight };
1311
- }
1312
- catch (error) {
1313
- const message = error instanceof Error ? error.message : String(error);
1314
- const stack = error instanceof Error ? (error.stack ?? "").split("\n").slice(0, 10).join("\n") : undefined;
1315
- session.close({
1316
- finalStatus: "error",
1317
- errorMessage: message,
1318
- errorStack: stack,
1319
- totalTokens: { input: 0, output: 0 },
1320
- warnings: [],
1321
- parseFallbacks: [],
1322
- });
1323
- console.error("[LLMService.streamCall] Error:", error);
1324
- throw new Error(`LLM streamCall error: ${message}`);
1325
- }
1326
- finally {
1327
- clearTimeout(timeoutId);
1564
+ for (let retry = 0;; retry++) {
1565
+ try {
1566
+ const finalObject = (await attempt.handle.object);
1567
+ const usage = await attempt.handle.usage;
1568
+ const input = usage?.inputTokens ?? 0;
1569
+ const output = usage?.outputTokens ?? 0;
1570
+ const cached = usage?.inputTokenDetails?.cacheReadTokens ?? 0;
1571
+ session.recordResponse({
1572
+ content: JSON.stringify(finalObject),
1573
+ tokenUsage: { input, output },
1574
+ finishReason: String(await attempt.handle.finishReason),
1575
+ });
1576
+ session.close({
1577
+ finalStatus: "success",
1578
+ totalTokens: { input, output, cached },
1579
+ warnings: [],
1580
+ parseFallbacks: [],
1581
+ });
1582
+ clearTimeout(attempt.timeoutId);
1583
+ await this.persistUsage({
1584
+ tokenUsageType: params.tokenUsageType,
1585
+ relationshipId: params.relationshipId,
1586
+ relationshipType: params.relationshipType,
1587
+ modelWeight,
1588
+ }, { input, output, cached });
1589
+ return { ...finalObject, tokenUsage: { input, output }, modelWeight };
1590
+ }
1591
+ catch (error) {
1592
+ // Restartable only while nothing has been delivered — see
1593
+ // `consumerStarted`.
1594
+ if (!consumerStarted && retry < TRANSIENT_RETRY_WAITS_MS.length && this.isTransientNetworkError(error)) {
1595
+ clearTimeout(attempt.timeoutId);
1596
+ attempt.controller.abort();
1597
+ await this.waitBeforeTransientRetry(label, retry, error);
1598
+ attempt = startAttempt();
1599
+ continue;
1600
+ }
1601
+ clearTimeout(attempt.timeoutId);
1602
+ const message = error instanceof Error ? error.message : String(error);
1603
+ const stack = error instanceof Error ? (error.stack ?? "").split("\n").slice(0, 10).join("\n") : undefined;
1604
+ // `streamObject` may have settled `usage` even though `object`
1605
+ // rejected (a schema-invalid payload is still a billed generation).
1606
+ // Read it defensively so the session and the ledger report the real
1607
+ // figures instead of the hard-coded 0/0 this used to close with.
1608
+ // Bounded: this promise has no outer deadline, so a `usage` that never
1609
+ // settles must not stop `result` from rejecting (see readUsageBounded).
1610
+ const usage = await this.readUsageBounded(attempt.handle.usage);
1611
+ const input = usage?.inputTokens ?? 0;
1612
+ const output = usage?.outputTokens ?? 0;
1613
+ const cached = usage?.inputTokenDetails?.cacheReadTokens ?? 0;
1614
+ session.close({
1615
+ finalStatus: "error",
1616
+ errorMessage: message,
1617
+ errorStack: stack,
1618
+ totalTokens: { input, output, cached },
1619
+ warnings: [],
1620
+ parseFallbacks: [],
1621
+ });
1622
+ await this.persistUsageOnFailure({
1623
+ tokenUsageType: params.tokenUsageType,
1624
+ relationshipId: params.relationshipId,
1625
+ relationshipType: params.relationshipType,
1626
+ modelWeight,
1627
+ }, { input, output, cached });
1628
+ console.error("[LLMService.streamCall] Error:", error);
1629
+ const wrapped = new Error(`LLM streamCall error: ${message}`);
1630
+ wrapped.cause = error;
1631
+ throw wrapped;
1632
+ }
1328
1633
  }
1329
1634
  })();
1330
1635
  // Surface (don't swallow) a rejected result even when the caller never
1331
1636
  // awaits `result` — e.g. on abort/timeout or an unreachable provider.
1332
1637
  resultPromise.catch((err) => this.logger.warn(`streamCall result rejected: ${String(err)}`));
1638
+ // Wrapped rather than handed over directly so that (a) the consumer's first
1639
+ // pull marks the stream unrestartable, and (b) the `partialObjectStream`
1640
+ // getter — one of three getters that lock the source on first access — is
1641
+ // touched only if the consumer actually reads, and on whichever attempt
1642
+ // finally connected.
1643
+ async function* partialObjects() {
1644
+ consumerStarted = true;
1645
+ for await (const partial of attempt.handle.partialObjectStream)
1646
+ yield partial;
1647
+ }
1333
1648
  return {
1334
- partialObjectStream: streamResult.partialObjectStream,
1649
+ partialObjectStream: partialObjects(),
1335
1650
  result: resultPromise,
1336
1651
  };
1337
1652
  }
@@ -1407,62 +1722,101 @@ let LLMService = LLMService_1 = class LLMService {
1407
1722
  });
1408
1723
  const model = provider.chatModel(aiConfig.model);
1409
1724
  session.startIteration("final-structured", []);
1410
- // Abort the stream if the provider stalls (see streamCall). The AbortError
1411
- // surfaces as a rejection on the awaited promises below (caught + logged).
1412
- const controller = new AbortController();
1413
- const timeoutId = setTimeout(() => controller.abort(), this.attemptTimeoutMs(params.timeout));
1414
- const streamResult = streamText({
1415
- model,
1416
- system,
1417
- prompt: params.prompt,
1418
- temperature: params.temperature,
1419
- maxOutputTokens: params.maxTokens,
1420
- maxRetries: 2,
1421
- abortSignal: controller.signal,
1422
- });
1725
+ const attemptTimeoutMs = this.attemptTimeoutMs(params.timeout);
1726
+ const label = `${params.metadata?.nodeName ?? "llm.streamText"}:${aiConfig.model}`;
1727
+ // One attempt = one abort controller + one whole-stream timeout (see
1728
+ // streamCall). The AbortError surfaces as a rejection on the awaited
1729
+ // promises below (caught + logged).
1730
+ const startAttempt = () => {
1731
+ const controller = new AbortController();
1732
+ const timeoutId = setTimeout(() => controller.abort(), attemptTimeoutMs);
1733
+ timeoutId.unref?.();
1734
+ return {
1735
+ controller,
1736
+ timeoutId,
1737
+ handle: streamText({
1738
+ model,
1739
+ system,
1740
+ prompt: params.prompt,
1741
+ temperature: params.temperature,
1742
+ maxOutputTokens: params.maxTokens,
1743
+ maxRetries: 2,
1744
+ abortSignal: controller.signal,
1745
+ }),
1746
+ };
1747
+ };
1748
+ let attempt = startAttempt();
1749
+ // See streamCall: a stream may only be restarted while the consumer has not
1750
+ // yet read a single part.
1751
+ let consumerStarted = false;
1423
1752
  const resultPromise = (async () => {
1424
- try {
1425
- const text = await streamResult.text;
1426
- const reasoning = (await streamResult.reasoningText) ?? "";
1427
- const usage = await streamResult.usage;
1428
- const input = usage?.inputTokens ?? 0;
1429
- const output = usage?.outputTokens ?? 0;
1430
- const cached = usage?.inputTokenDetails?.cacheReadTokens ?? 0;
1431
- session.recordResponse({
1432
- content: text,
1433
- tokenUsage: { input, output },
1434
- finishReason: String(await streamResult.finishReason),
1435
- });
1436
- session.close({
1437
- finalStatus: "success",
1438
- totalTokens: { input, output, cached },
1439
- warnings: [],
1440
- parseFallbacks: [],
1441
- });
1442
- await this.persistUsage({
1443
- tokenUsageType: params.tokenUsageType,
1444
- relationshipId: params.relationshipId,
1445
- relationshipType: params.relationshipType,
1446
- modelWeight,
1447
- }, { input, output, cached });
1448
- return { text, reasoning, tokenUsage: { input, output }, modelWeight };
1449
- }
1450
- catch (error) {
1451
- const message = error instanceof Error ? error.message : String(error);
1452
- const stack = error instanceof Error ? (error.stack ?? "").split("\n").slice(0, 10).join("\n") : undefined;
1453
- session.close({
1454
- finalStatus: "error",
1455
- errorMessage: message,
1456
- errorStack: stack,
1457
- totalTokens: { input: 0, output: 0 },
1458
- warnings: [],
1459
- parseFallbacks: [],
1460
- });
1461
- console.error("[LLMService.streamText] Error:", error);
1462
- throw new Error(`LLM streamText error: ${message}`);
1463
- }
1464
- finally {
1465
- clearTimeout(timeoutId);
1753
+ for (let retry = 0;; retry++) {
1754
+ try {
1755
+ const text = await attempt.handle.text;
1756
+ const reasoning = (await attempt.handle.reasoningText) ?? "";
1757
+ const usage = await attempt.handle.usage;
1758
+ const input = usage?.inputTokens ?? 0;
1759
+ const output = usage?.outputTokens ?? 0;
1760
+ const cached = usage?.inputTokenDetails?.cacheReadTokens ?? 0;
1761
+ session.recordResponse({
1762
+ content: text,
1763
+ tokenUsage: { input, output },
1764
+ finishReason: String(await attempt.handle.finishReason),
1765
+ });
1766
+ session.close({
1767
+ finalStatus: "success",
1768
+ totalTokens: { input, output, cached },
1769
+ warnings: [],
1770
+ parseFallbacks: [],
1771
+ });
1772
+ clearTimeout(attempt.timeoutId);
1773
+ await this.persistUsage({
1774
+ tokenUsageType: params.tokenUsageType,
1775
+ relationshipId: params.relationshipId,
1776
+ relationshipType: params.relationshipType,
1777
+ modelWeight,
1778
+ }, { input, output, cached });
1779
+ return { text, reasoning, tokenUsage: { input, output }, modelWeight };
1780
+ }
1781
+ catch (error) {
1782
+ // Restartable only while nothing has been delivered — see
1783
+ // `consumerStarted`.
1784
+ if (!consumerStarted && retry < TRANSIENT_RETRY_WAITS_MS.length && this.isTransientNetworkError(error)) {
1785
+ clearTimeout(attempt.timeoutId);
1786
+ attempt.controller.abort();
1787
+ await this.waitBeforeTransientRetry(label, retry, error);
1788
+ attempt = startAttempt();
1789
+ continue;
1790
+ }
1791
+ clearTimeout(attempt.timeoutId);
1792
+ const message = error instanceof Error ? error.message : String(error);
1793
+ const stack = error instanceof Error ? (error.stack ?? "").split("\n").slice(0, 10).join("\n") : undefined;
1794
+ // A narration that streamed for 20s and then broke was still generated
1795
+ // and still billed — read whatever usage settled (see streamCall), under
1796
+ // the same bound so `result` always settles.
1797
+ const usage = await this.readUsageBounded(attempt.handle.usage);
1798
+ const input = usage?.inputTokens ?? 0;
1799
+ const output = usage?.outputTokens ?? 0;
1800
+ const cached = usage?.inputTokenDetails?.cacheReadTokens ?? 0;
1801
+ session.close({
1802
+ finalStatus: "error",
1803
+ errorMessage: message,
1804
+ errorStack: stack,
1805
+ totalTokens: { input, output, cached },
1806
+ warnings: [],
1807
+ parseFallbacks: [],
1808
+ });
1809
+ await this.persistUsageOnFailure({
1810
+ tokenUsageType: params.tokenUsageType,
1811
+ relationshipId: params.relationshipId,
1812
+ relationshipType: params.relationshipType,
1813
+ modelWeight,
1814
+ }, { input, output, cached });
1815
+ console.error("[LLMService.streamText] Error:", error);
1816
+ const wrapped = new Error(`LLM streamText error: ${message}`);
1817
+ wrapped.cause = error;
1818
+ throw wrapped;
1819
+ }
1466
1820
  }
1467
1821
  })();
1468
1822
  // Normalize the AI SDK `fullStream` to text/reasoning deltas. Consuming this
@@ -1470,7 +1824,11 @@ let LLMService = LLMService_1 = class LLMService {
1470
1824
  // resolve. Reasoning-capable models interleave `reasoning-delta` parts (e.g.
1471
1825
  // Ollama emits the full thinking trace before answer content).
1472
1826
  async function* normalizedStream() {
1473
- for await (const part of streamResult.fullStream) {
1827
+ // The first pull marks the stream unrestartable (see `consumerStarted`),
1828
+ // and reads `attempt` late so a consumer that starts after a transient
1829
+ // retry gets the stream that actually connected.
1830
+ consumerStarted = true;
1831
+ for await (const part of attempt.handle.fullStream) {
1474
1832
  if (part.type === "text-delta") {
1475
1833
  yield { type: "text", delta: part.text };
1476
1834
  }
@@ -1531,6 +1889,12 @@ let LLMService = LLMService_1 = class LLMService {
1531
1889
  outputSchemaName: params.tool.name,
1532
1890
  });
1533
1891
  const attemptTimeoutMs = this.attemptTimeoutMs(params.timeout);
1892
+ // Hoisted above the try so the catch can bill the attempts the provider
1893
+ // already served before the failure. Accumulated (not overwritten) across
1894
+ // attempts: the nudge retry is a SECOND request and is charged as one.
1895
+ let totalInputTokens = 0;
1896
+ let totalOutputTokens = 0;
1897
+ let totalCachedTokens = 0;
1534
1898
  try {
1535
1899
  const model = this.modelService.getLLM({
1536
1900
  modelWeight,
@@ -1610,10 +1974,18 @@ let LLMService = LLMService_1 = class LLMService {
1610
1974
  return null;
1611
1975
  };
1612
1976
  // Each provider invocation is bounded independently — the nudge retry is a
1613
- // second request and gets its own budget, not the leftovers of the first.
1977
+ // second request and gets its own budget, not the leftovers of the first
1978
+ // and each is retried on a transient network failure, with its own fresh
1979
+ // abort controller per attempt.
1614
1980
  const invokeBounded = async (messages, attempt) => {
1615
- const controller = new AbortController();
1616
- return (await this.runBounded(`extractViaTool:${params.tool.name}:${attempt}`, attemptTimeoutMs, controller, () => bound.invoke(messages, { signal: controller.signal })));
1981
+ const response = (await this.runWithTransientRetry(`extractViaTool:${params.tool.name}:${attempt}`, attemptTimeoutMs, (signal) => bound.invoke(messages, { signal })));
1982
+ // Bill as we go: an attempt that answered is charged whether or not its
1983
+ // payload turns out to be usable.
1984
+ const usage = response.usage_metadata;
1985
+ totalInputTokens += usage?.input_tokens ?? 0;
1986
+ totalOutputTokens += usage?.output_tokens ?? 0;
1987
+ totalCachedTokens += usage?.input_token_details?.cache_read ?? 0;
1988
+ return response;
1617
1989
  };
1618
1990
  let response = await invokeBounded(baseMessages, "attempt-1");
1619
1991
  let parsed = tryExtract(response);
@@ -1629,9 +2001,11 @@ let LLMService = LLMService_1 = class LLMService {
1629
2001
  }
1630
2002
  if (parsed === null)
1631
2003
  throw new Error("extractViaTool: model did not call the tool");
2004
+ // The winning response's OWN usage — what the dump's response entry
2005
+ // describes. The session total and the ledger use the accumulated figures
2006
+ // instead, so a nudge retry bills both requests rather than only the last.
1632
2007
  const inputTokens = response.usage_metadata?.input_tokens ?? 0;
1633
2008
  const outputTokens = response.usage_metadata?.output_tokens ?? 0;
1634
- const cachedTokens = response.usage_metadata?.input_token_details?.cache_read ?? 0;
1635
2009
  session.recordResponse({
1636
2010
  content: JSON.stringify(parsed),
1637
2011
  tokenUsage: { input: inputTokens, output: outputTokens },
@@ -1639,7 +2013,7 @@ let LLMService = LLMService_1 = class LLMService {
1639
2013
  });
1640
2014
  session.close({
1641
2015
  finalStatus: "success",
1642
- totalTokens: { input: inputTokens, output: outputTokens, cached: cachedTokens },
2016
+ totalTokens: { input: totalInputTokens, output: totalOutputTokens, cached: totalCachedTokens },
1643
2017
  warnings: [],
1644
2018
  parseFallbacks: [],
1645
2019
  });
@@ -1648,7 +2022,7 @@ let LLMService = LLMService_1 = class LLMService {
1648
2022
  relationshipId: params.relationshipId,
1649
2023
  relationshipType: params.relationshipType,
1650
2024
  modelWeight,
1651
- }, { input: inputTokens, output: outputTokens, cached: cachedTokens });
2025
+ }, { input: totalInputTokens, output: totalOutputTokens, cached: totalCachedTokens });
1652
2026
  // Write-through on a miss so the next identical cacheable call hits.
1653
2027
  if (cacheKey && this.cache)
1654
2028
  await this.cache.set(cacheKey, parsed);
@@ -1659,10 +2033,18 @@ let LLMService = LLMService_1 = class LLMService {
1659
2033
  session.close({
1660
2034
  finalStatus: "error",
1661
2035
  errorMessage: message,
1662
- totalTokens: { input: 0, output: 0 },
2036
+ totalTokens: { input: totalInputTokens, output: totalOutputTokens, cached: totalCachedTokens },
1663
2037
  warnings: [],
1664
2038
  parseFallbacks: [],
1665
2039
  });
2040
+ // Two refusals still cost two generations — bill what was served before
2041
+ // giving up. Never throws, so the original error survives untouched.
2042
+ await this.persistUsageOnFailure({
2043
+ tokenUsageType: params.tokenUsageType,
2044
+ relationshipId: params.relationshipId,
2045
+ relationshipType: params.relationshipType,
2046
+ modelWeight,
2047
+ }, { input: totalInputTokens, output: totalOutputTokens, cached: totalCachedTokens });
1666
2048
  console.error("[LLMService.extractViaTool] Error:", error);
1667
2049
  throw error instanceof Error ? error : new Error(message);
1668
2050
  }
@@ -1670,14 +2052,22 @@ let LLMService = LLMService_1 = class LLMService {
1670
2052
  /**
1671
2053
  * Single-step model invocation with tools bound — the durable-checkpointing
1672
2054
  * counterpart to {@link call}'s internal tool loop. Performs exactly ONE
1673
- * model invocation (no tool execution, no loop, no retries, no structured
1674
- * output) and returns the raw AIMessage with any `tool_calls` untouched, so
1675
- * the caller (e.g. the operator agent) can checkpoint state and execute the
1676
- * tool calls itself.
2055
+ * model STEP (no tool execution, no loop, no structured output) and returns
2056
+ * the raw AIMessage with any `tool_calls` untouched, so the caller (e.g. the
2057
+ * operator agent) can checkpoint state and execute the tool calls itself.
2058
+ *
2059
+ * "One step" is not "one socket": the step is bounded like every other call
2060
+ * (per-attempt budget, watchdog, deadline) and re-issued on a transient
2061
+ * network failure. What it never does is re-run the AGENT — the caller's
2062
+ * checkpointed state is untouched either way.
1677
2063
  *
1678
2064
  * Reuses {@link call}'s model construction (`modelService.getLLM` +
1679
- * `bindTools`) and `LLMCallDumper` hooks. Token usage for the step is
1680
- * returned to the caller (which aggregates it) rather than tracked here.
2065
+ * `bindTools`) and `LLMCallDumper` hooks. The step's token usage is still
2066
+ * RETURNED to the caller (so an agent can keep its own running total), and —
2067
+ * when `relationshipId`/`relationshipType` are supplied — is now also
2068
+ * persisted here, exactly like every other provider call in this service.
2069
+ * A caller that omits the attribution gets the previous behaviour: nothing is
2070
+ * written. Each step is billed once, by whichever path completes it.
1681
2071
  *
1682
2072
  * @param params.systemPrompts - System prompts, prepended (in order) as
1683
2073
  * SystemMessages before `messages`
@@ -1685,6 +2075,9 @@ let LLMService = LLMService_1 = class LLMService {
1685
2075
  * @param params.tools - Tools to bind (NOT executed by this method)
1686
2076
  * @param params.temperature - Optional temperature override
1687
2077
  * @param params.metadata - Optional metadata for dump-session tracking
2078
+ * @param params.tokenUsageType - Optional usage type for the recorded row
2079
+ * @param params.relationshipId - Optional entity this usage is attributed to
2080
+ * @param params.relationshipType - Optional entity type for the attribution
1688
2081
  *
1689
2082
  * @returns The raw AIMessage (tool_calls intact) plus this call's token usage
1690
2083
  */
@@ -1709,10 +2102,28 @@ let LLMService = LLMService_1 = class LLMService {
1709
2102
  })),
1710
2103
  outputSchemaName: "callStep",
1711
2104
  });
2105
+ // This method used to be the ONE provider call in the service with no bound
2106
+ // of any kind: no per-attempt budget on the model, no watchdog, no deadline.
2107
+ // A stalled operator step therefore hung its durable run forever, which is
2108
+ // exactly the failure `runBounded` exists to end — so it gets the same
2109
+ // treatment as `call()`, plus the transient-network retry.
2110
+ const attemptTimeoutMs = this.attemptTimeoutMs();
2111
+ const label = `llm.callStep:${aiConfig.model}`;
2112
+ // Hoisted above the try so the catch reports and bills whatever the step
2113
+ // managed to consume before it threw.
2114
+ let input = 0;
2115
+ let output = 0;
2116
+ // Prompt-cache hits, priced at `cachedInputCostPer1MTokens` by `computeCost`.
2117
+ // The operator re-sends its whole conversation every step, so a cache hit is
2118
+ // the NORM here: dropping this would price the entire prompt at the uncached
2119
+ // rate and over-bill every operator row. Credited exactly as `call()` and
2120
+ // `streamCall` credit theirs.
2121
+ let cached = 0;
1712
2122
  try {
1713
2123
  const baseModel = this.modelService.getLLM({
1714
2124
  temperature: params.temperature,
1715
2125
  modelWeight,
2126
+ timeoutMs: attemptTimeoutMs,
1716
2127
  });
1717
2128
  const modelWithTools = params.tools.length > 0 ? baseModel.bindTools(params.tools) : baseModel;
1718
2129
  const conversationMessages = [
@@ -1720,12 +2131,11 @@ let LLMService = LLMService_1 = class LLMService {
1720
2131
  ...params.messages,
1721
2132
  ];
1722
2133
  session.startIteration("tool-loop", conversationMessages);
1723
- const response = (await (params.metadata
1724
- ? modelWithTools.invoke(conversationMessages, { metadata: params.metadata })
1725
- : modelWithTools.invoke(conversationMessages)));
2134
+ const response = (await this.runWithTransientRetry(label, attemptTimeoutMs, (signal) => modelWithTools.invoke(conversationMessages, params.metadata ? { metadata: params.metadata, signal } : { signal })));
1726
2135
  const raw = response;
1727
- const input = raw.usage_metadata?.input_tokens ?? 0;
1728
- const output = raw.usage_metadata?.output_tokens ?? 0;
2136
+ input = raw.usage_metadata?.input_tokens ?? 0;
2137
+ output = raw.usage_metadata?.output_tokens ?? 0;
2138
+ cached = raw.usage_metadata?.input_token_details?.cache_read ?? 0;
1729
2139
  session.recordResponse({
1730
2140
  content: typeof response.content === "string" ? response.content : "",
1731
2141
  toolCalls: (response.tool_calls ?? []).map((c) => ({
@@ -1738,10 +2148,16 @@ let LLMService = LLMService_1 = class LLMService {
1738
2148
  });
1739
2149
  session.close({
1740
2150
  finalStatus: "success",
1741
- totalTokens: { input, output },
2151
+ totalTokens: { input, output, cached },
1742
2152
  warnings: [],
1743
2153
  parseFallbacks: [],
1744
2154
  });
2155
+ await this.persistUsage({
2156
+ tokenUsageType: params.tokenUsageType,
2157
+ relationshipId: params.relationshipId,
2158
+ relationshipType: params.relationshipType,
2159
+ modelWeight,
2160
+ }, { input, output, cached });
1745
2161
  return { message: response, tokenUsage: { input, output } };
1746
2162
  }
1747
2163
  catch (error) {
@@ -1751,10 +2167,16 @@ let LLMService = LLMService_1 = class LLMService {
1751
2167
  finalStatus: "error",
1752
2168
  errorMessage: message,
1753
2169
  errorStack: stack,
1754
- totalTokens: { input: 0, output: 0 },
2170
+ totalTokens: { input, output, cached },
1755
2171
  warnings: [],
1756
2172
  parseFallbacks: [],
1757
2173
  });
2174
+ await this.persistUsageOnFailure({
2175
+ tokenUsageType: params.tokenUsageType,
2176
+ relationshipId: params.relationshipId,
2177
+ relationshipType: params.relationshipType,
2178
+ modelWeight,
2179
+ }, { input, output, cached });
1758
2180
  console.error("[LLMService.callStep] Error:", error);
1759
2181
  throw error instanceof Error ? error : new Error(message);
1760
2182
  }