@openclaw/lobster 2026.9.4 → 2026.9.5

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 (322) hide show
  1. package/assets/activity.svg +4 -0
  2. package/assets/icon.png +0 -0
  3. package/dist/index.js +39 -10
  4. package/node_modules/@clawdbot/lobster/README.md +112 -1
  5. package/node_modules/@clawdbot/lobster/bin/clawd.invoke.js +13 -7
  6. package/node_modules/@clawdbot/lobster/bin/lobster.js +7 -7
  7. package/node_modules/@clawdbot/lobster/bin/openclaw.invoke.js +16 -10
  8. package/node_modules/@clawdbot/lobster/dist/src/abortable_process.js +297 -0
  9. package/node_modules/@clawdbot/lobster/dist/src/abortable_process.js.map +1 -0
  10. package/node_modules/@clawdbot/lobster/dist/src/cli.js +96 -248
  11. package/node_modules/@clawdbot/lobster/dist/src/cli.js.map +1 -1
  12. package/node_modules/@clawdbot/lobster/dist/src/commands/commands_list.js.map +1 -1
  13. package/node_modules/@clawdbot/lobster/dist/src/commands/registry.js +2 -0
  14. package/node_modules/@clawdbot/lobster/dist/src/commands/registry.js.map +1 -1
  15. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/approve.js +2 -0
  16. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/approve.js.map +1 -1
  17. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/ask.js +3 -1
  18. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/ask.js.map +1 -1
  19. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/dedupe.js.map +1 -1
  20. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/diff_last.js +6 -1
  21. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/diff_last.js.map +1 -1
  22. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/email_triage.js.map +1 -1
  23. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/exec.js +30 -34
  24. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/exec.js.map +1 -1
  25. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/gog_gmail_search.js +12 -25
  26. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/gog_gmail_search.js.map +1 -1
  27. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/gog_gmail_send.js +19 -25
  28. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/gog_gmail_send.js.map +1 -1
  29. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/group_by.js.map +1 -1
  30. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/head.js.map +1 -1
  31. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/json.js.map +1 -1
  32. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js +596 -42
  33. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/llm_invoke.js.map +1 -1
  34. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/map.js.map +1 -1
  35. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/openclaw_agent.js +139 -0
  36. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/openclaw_agent.js.map +1 -0
  37. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/openclaw_invoke.js +20 -2
  38. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/openclaw_invoke.js.map +1 -1
  39. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/pick.js.map +1 -1
  40. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/sort.js.map +1 -1
  41. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/state.js +48 -20
  42. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/state.js.map +1 -1
  43. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/table.js.map +1 -1
  44. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/template.js.map +1 -1
  45. package/node_modules/@clawdbot/lobster/dist/src/commands/stdlib/where.js.map +1 -1
  46. package/node_modules/@clawdbot/lobster/dist/src/commands/workflows/workflows_list.js.map +1 -1
  47. package/node_modules/@clawdbot/lobster/dist/src/commands/workflows/workflows_run.js.map +1 -1
  48. package/node_modules/@clawdbot/lobster/dist/src/core/cost_tracker.js +45 -3
  49. package/node_modules/@clawdbot/lobster/dist/src/core/cost_tracker.js.map +1 -1
  50. package/node_modules/@clawdbot/lobster/dist/src/core/filters.js.map +1 -1
  51. package/node_modules/@clawdbot/lobster/dist/src/core/retry.js.map +1 -1
  52. package/node_modules/@clawdbot/lobster/dist/src/core/tool_runtime.js +147 -21
  53. package/node_modules/@clawdbot/lobster/dist/src/core/tool_runtime.js.map +1 -1
  54. package/node_modules/@clawdbot/lobster/dist/src/input_request.js +4 -2
  55. package/node_modules/@clawdbot/lobster/dist/src/input_request.js.map +1 -1
  56. package/node_modules/@clawdbot/lobster/dist/src/parser.js +4 -3
  57. package/node_modules/@clawdbot/lobster/dist/src/parser.js.map +1 -1
  58. package/node_modules/@clawdbot/lobster/dist/src/pipeline_resume_state.js +241 -74
  59. package/node_modules/@clawdbot/lobster/dist/src/pipeline_resume_state.js.map +1 -1
  60. package/node_modules/@clawdbot/lobster/dist/src/read_line.js +52 -7
  61. package/node_modules/@clawdbot/lobster/dist/src/read_line.js.map +1 -1
  62. package/node_modules/@clawdbot/lobster/dist/src/read_response_text.js +86 -0
  63. package/node_modules/@clawdbot/lobster/dist/src/read_response_text.js.map +1 -0
  64. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/pr-monitor.js +4 -2
  65. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/pr-monitor.js.map +1 -1
  66. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/stages/pr-view.js +20 -34
  67. package/node_modules/@clawdbot/lobster/dist/src/recipes/github/stages/pr-view.js.map +1 -1
  68. package/node_modules/@clawdbot/lobster/dist/src/recipes/registry.js.map +1 -1
  69. package/node_modules/@clawdbot/lobster/dist/src/renderers/json.js.map +1 -1
  70. package/node_modules/@clawdbot/lobster/dist/src/resume.js.map +1 -1
  71. package/node_modules/@clawdbot/lobster/dist/src/runtime.js +187 -17
  72. package/node_modules/@clawdbot/lobster/dist/src/runtime.js.map +1 -1
  73. package/node_modules/@clawdbot/lobster/dist/src/sdk/Lobster.js +10 -2
  74. package/node_modules/@clawdbot/lobster/dist/src/sdk/Lobster.js.map +1 -1
  75. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/approve.js.map +1 -1
  76. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/diff.js +13 -82
  77. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/diff.js.map +1 -1
  78. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/exec.js +22 -32
  79. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/exec.js.map +1 -1
  80. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/state.js +9 -101
  81. package/node_modules/@clawdbot/lobster/dist/src/sdk/primitives/state.js.map +1 -1
  82. package/node_modules/@clawdbot/lobster/dist/src/sdk/runtime.js +2 -1
  83. package/node_modules/@clawdbot/lobster/dist/src/sdk/runtime.js.map +1 -1
  84. package/node_modules/@clawdbot/lobster/dist/src/shell.js.map +1 -1
  85. package/node_modules/@clawdbot/lobster/dist/src/state/store.js +619 -19
  86. package/node_modules/@clawdbot/lobster/dist/src/state/store.js.map +1 -1
  87. package/node_modules/@clawdbot/lobster/dist/src/token.js.map +1 -1
  88. package/node_modules/@clawdbot/lobster/dist/src/validation.js.map +1 -1
  89. package/node_modules/@clawdbot/lobster/dist/src/workflows/file.js +963 -283
  90. package/node_modules/@clawdbot/lobster/dist/src/workflows/file.js.map +1 -1
  91. package/node_modules/@clawdbot/lobster/dist/src/workflows/github_pr_monitor.js +27 -28
  92. package/node_modules/@clawdbot/lobster/dist/src/workflows/github_pr_monitor.js.map +1 -1
  93. package/node_modules/@clawdbot/lobster/dist/src/workflows/graph.js +9 -2
  94. package/node_modules/@clawdbot/lobster/dist/src/workflows/graph.js.map +1 -1
  95. package/node_modules/@clawdbot/lobster/dist/src/workflows/registry.js.map +1 -1
  96. package/node_modules/@clawdbot/lobster/dist/test/approval_id.test.js.map +1 -1
  97. package/node_modules/@clawdbot/lobster/dist/test/approve_preview.test.js.map +1 -1
  98. package/node_modules/@clawdbot/lobster/dist/test/bin_smoke.test.js +25 -0
  99. package/node_modules/@clawdbot/lobster/dist/test/bin_smoke.test.js.map +1 -0
  100. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js +5170 -0
  101. package/node_modules/@clawdbot/lobster/dist/test/cancellation.test.js.map +1 -0
  102. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke.test.js +22 -0
  103. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke.test.js.map +1 -1
  104. package/node_modules/@clawdbot/lobster/dist/test/clawd_invoke_legacy.test.js.map +1 -1
  105. package/node_modules/@clawdbot/lobster/dist/test/cli_run_file_args_json.test.js.map +1 -1
  106. package/node_modules/@clawdbot/lobster/dist/test/commands_list.test.js.map +1 -1
  107. package/node_modules/@clawdbot/lobster/dist/test/condition_comparison.test.js.map +1 -1
  108. package/node_modules/@clawdbot/lobster/dist/test/core_tool_runtime.test.js.map +1 -1
  109. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js +1733 -1
  110. package/node_modules/@clawdbot/lobster/dist/test/cost_tracker.test.js.map +1 -1
  111. package/node_modules/@clawdbot/lobster/dist/test/dedupe.test.js.map +1 -1
  112. package/node_modules/@clawdbot/lobster/dist/test/diff_last.test.js.map +1 -1
  113. package/node_modules/@clawdbot/lobster/dist/test/doctor.test.js.map +1 -1
  114. package/node_modules/@clawdbot/lobster/dist/test/dry_run.test.js.map +1 -1
  115. package/node_modules/@clawdbot/lobster/dist/test/email_triage.test.js.map +1 -1
  116. package/node_modules/@clawdbot/lobster/dist/test/exec_stdin.test.js.map +1 -1
  117. package/node_modules/@clawdbot/lobster/dist/test/for_each.test.js +254 -0
  118. package/node_modules/@clawdbot/lobster/dist/test/for_each.test.js.map +1 -1
  119. package/node_modules/@clawdbot/lobster/dist/test/github_pr_notify_format.test.js.map +1 -1
  120. package/node_modules/@clawdbot/lobster/dist/test/github_pr_summary.test.js.map +1 -1
  121. package/node_modules/@clawdbot/lobster/dist/test/github_pr_view.test.js +176 -0
  122. package/node_modules/@clawdbot/lobster/dist/test/github_pr_view.test.js.map +1 -0
  123. package/node_modules/@clawdbot/lobster/dist/test/gog_gmail_send.test.js +100 -0
  124. package/node_modules/@clawdbot/lobster/dist/test/gog_gmail_send.test.js.map +1 -0
  125. package/node_modules/@clawdbot/lobster/dist/test/group_by.test.js.map +1 -1
  126. package/node_modules/@clawdbot/lobster/dist/test/helpers/wait_for_pid.js +19 -0
  127. package/node_modules/@clawdbot/lobster/dist/test/helpers/wait_for_pid.js.map +1 -0
  128. package/node_modules/@clawdbot/lobster/dist/test/http_response_body_cap.test.js +318 -0
  129. package/node_modules/@clawdbot/lobster/dist/test/http_response_body_cap.test.js.map +1 -0
  130. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js +946 -1
  131. package/node_modules/@clawdbot/lobster/dist/test/llm_invoke.test.js.map +1 -1
  132. package/node_modules/@clawdbot/lobster/dist/test/llm_task_invoke.test.js +103 -0
  133. package/node_modules/@clawdbot/lobster/dist/test/llm_task_invoke.test.js.map +1 -1
  134. package/node_modules/@clawdbot/lobster/dist/test/map.test.js.map +1 -1
  135. package/node_modules/@clawdbot/lobster/dist/test/multi_approval_resume.test.js.map +1 -1
  136. package/node_modules/@clawdbot/lobster/dist/test/on_error.test.js.map +1 -1
  137. package/node_modules/@clawdbot/lobster/dist/test/openclaw_agent.test.js +169 -0
  138. package/node_modules/@clawdbot/lobster/dist/test/openclaw_agent.test.js.map +1 -0
  139. package/node_modules/@clawdbot/lobster/dist/test/openclaw_dispatch_retry.test.js +96 -0
  140. package/node_modules/@clawdbot/lobster/dist/test/openclaw_dispatch_retry.test.js.map +1 -0
  141. package/node_modules/@clawdbot/lobster/dist/test/openclaw_invoke_alias.test.js.map +1 -1
  142. package/node_modules/@clawdbot/lobster/dist/test/parallel.test.js.map +1 -1
  143. package/node_modules/@clawdbot/lobster/dist/test/parser.test.js +57 -0
  144. package/node_modules/@clawdbot/lobster/dist/test/parser.test.js.map +1 -1
  145. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js +173 -0
  146. package/node_modules/@clawdbot/lobster/dist/test/process_output_limit.test.js.map +1 -0
  147. package/node_modules/@clawdbot/lobster/dist/test/read_line.test.js +54 -0
  148. package/node_modules/@clawdbot/lobster/dist/test/read_line.test.js.map +1 -1
  149. package/node_modules/@clawdbot/lobster/dist/test/release_changelog.test.js +39 -0
  150. package/node_modules/@clawdbot/lobster/dist/test/release_changelog.test.js.map +1 -0
  151. package/node_modules/@clawdbot/lobster/dist/test/request_input.test.js +1 -1
  152. package/node_modules/@clawdbot/lobster/dist/test/request_input.test.js.map +1 -1
  153. package/node_modules/@clawdbot/lobster/dist/test/resume.test.js.map +1 -1
  154. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js +201 -0
  155. package/node_modules/@clawdbot/lobster/dist/test/sdk_exec.test.js.map +1 -0
  156. package/node_modules/@clawdbot/lobster/dist/test/sdk_lobster.test.js.map +1 -1
  157. package/node_modules/@clawdbot/lobster/dist/test/shell.test.js.map +1 -1
  158. package/node_modules/@clawdbot/lobster/dist/test/sort.test.js.map +1 -1
  159. package/node_modules/@clawdbot/lobster/dist/test/state.test.js +584 -1
  160. package/node_modules/@clawdbot/lobster/dist/test/state.test.js.map +1 -1
  161. package/node_modules/@clawdbot/lobster/dist/test/step_retry.test.js.map +1 -1
  162. package/node_modules/@clawdbot/lobster/dist/test/step_stdin_epipe.test.js +44 -0
  163. package/node_modules/@clawdbot/lobster/dist/test/step_stdin_epipe.test.js.map +1 -0
  164. package/node_modules/@clawdbot/lobster/dist/test/step_timeout.test.js +67 -1
  165. package/node_modules/@clawdbot/lobster/dist/test/step_timeout.test.js.map +1 -1
  166. package/node_modules/@clawdbot/lobster/dist/test/template.test.js.map +1 -1
  167. package/node_modules/@clawdbot/lobster/dist/test/template_filters.test.js +37 -0
  168. package/node_modules/@clawdbot/lobster/dist/test/template_filters.test.js.map +1 -1
  169. package/node_modules/@clawdbot/lobster/dist/test/tool_envelope_version.test.js.map +1 -1
  170. package/node_modules/@clawdbot/lobster/dist/test/tool_mode.test.js.map +1 -1
  171. package/node_modules/@clawdbot/lobster/dist/test/validation.test.js.map +1 -1
  172. package/node_modules/@clawdbot/lobster/dist/test/where.test.js +94 -0
  173. package/node_modules/@clawdbot/lobster/dist/test/where.test.js.map +1 -0
  174. package/node_modules/@clawdbot/lobster/dist/test/workflow_args_env.test.js +5 -3
  175. package/node_modules/@clawdbot/lobster/dist/test/workflow_args_env.test.js.map +1 -1
  176. package/node_modules/@clawdbot/lobster/dist/test/workflow_composition.test.js.map +1 -1
  177. package/node_modules/@clawdbot/lobster/dist/test/workflow_file.test.js +647 -1
  178. package/node_modules/@clawdbot/lobster/dist/test/workflow_file.test.js.map +1 -1
  179. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js +12 -2
  180. package/node_modules/@clawdbot/lobster/dist/test/workflow_graph.test.js.map +1 -1
  181. package/node_modules/@clawdbot/lobster/dist/test/workflows.test.js.map +1 -1
  182. package/node_modules/@clawdbot/lobster/package.json +74 -73
  183. package/node_modules/typebox/build/compile/validator.d.mts +1 -1
  184. package/node_modules/typebox/build/compile/validator.mjs +1 -1
  185. package/node_modules/typebox/build/format/idna/idn-hostname.mjs +7 -5
  186. package/node_modules/typebox/build/format/iri.mjs +5 -1
  187. package/node_modules/typebox/build/guard/emit.mjs +2 -4
  188. package/node_modules/typebox/build/guard/guard.mjs +6 -18
  189. package/node_modules/typebox/build/guard/string.mjs +16 -24
  190. package/node_modules/typebox/build/schema/build.mjs +2 -2
  191. package/node_modules/typebox/build/schema/check.mjs +1 -1
  192. package/node_modules/typebox/build/schema/compile.d.mts +3 -2
  193. package/node_modules/typebox/build/schema/compile.mjs +8 -8
  194. package/node_modules/typebox/build/schema/engine/_context.d.mts +6 -7
  195. package/node_modules/typebox/build/schema/engine/_context.mjs +23 -15
  196. package/node_modules/typebox/build/schema/engine/_functions.d.mts +2 -2
  197. package/node_modules/typebox/build/schema/engine/_functions.mjs +1 -1
  198. package/node_modules/typebox/build/schema/engine/_reducer.d.mts +3 -3
  199. package/node_modules/typebox/build/schema/engine/_refine.d.mts +5 -5
  200. package/node_modules/typebox/build/schema/engine/_refine.mjs +4 -7
  201. package/node_modules/typebox/build/schema/engine/_stack.d.mts +34 -16
  202. package/node_modules/typebox/build/schema/engine/_stack.mjs +148 -104
  203. package/node_modules/typebox/build/schema/engine/additionalItems.d.mts +4 -4
  204. package/node_modules/typebox/build/schema/engine/additionalProperties.d.mts +8 -8
  205. package/node_modules/typebox/build/schema/engine/additionalProperties.mjs +26 -18
  206. package/node_modules/typebox/build/schema/engine/allOf.d.mts +4 -4
  207. package/node_modules/typebox/build/schema/engine/allOf.mjs +3 -3
  208. package/node_modules/typebox/build/schema/engine/anyOf.d.mts +4 -4
  209. package/node_modules/typebox/build/schema/engine/anyOf.mjs +5 -10
  210. package/node_modules/typebox/build/schema/engine/boolean.d.mts +4 -4
  211. package/node_modules/typebox/build/schema/engine/boolean.mjs +2 -6
  212. package/node_modules/typebox/build/schema/engine/const.d.mts +4 -4
  213. package/node_modules/typebox/build/schema/engine/const.mjs +2 -6
  214. package/node_modules/typebox/build/schema/engine/contains.d.mts +4 -4
  215. package/node_modules/typebox/build/schema/engine/contains.mjs +2 -6
  216. package/node_modules/typebox/build/schema/engine/dependencies.d.mts +4 -4
  217. package/node_modules/typebox/build/schema/engine/dependencies.mjs +3 -6
  218. package/node_modules/typebox/build/schema/engine/dependentRequired.d.mts +4 -4
  219. package/node_modules/typebox/build/schema/engine/dependentRequired.mjs +2 -6
  220. package/node_modules/typebox/build/schema/engine/dependentSchemas.d.mts +4 -4
  221. package/node_modules/typebox/build/schema/engine/dynamicRef.d.mts +4 -4
  222. package/node_modules/typebox/build/schema/engine/dynamicRef.mjs +11 -7
  223. package/node_modules/typebox/build/schema/engine/enum.d.mts +4 -4
  224. package/node_modules/typebox/build/schema/engine/enum.mjs +2 -6
  225. package/node_modules/typebox/build/schema/engine/exclusiveMaximum.d.mts +4 -4
  226. package/node_modules/typebox/build/schema/engine/exclusiveMaximum.mjs +2 -6
  227. package/node_modules/typebox/build/schema/engine/exclusiveMinimum.d.mts +4 -4
  228. package/node_modules/typebox/build/schema/engine/exclusiveMinimum.mjs +2 -6
  229. package/node_modules/typebox/build/schema/engine/format.d.mts +4 -4
  230. package/node_modules/typebox/build/schema/engine/format.mjs +2 -6
  231. package/node_modules/typebox/build/schema/engine/if.d.mts +4 -4
  232. package/node_modules/typebox/build/schema/engine/if.mjs +7 -15
  233. package/node_modules/typebox/build/schema/engine/items.d.mts +4 -4
  234. package/node_modules/typebox/build/schema/engine/items.mjs +1 -1
  235. package/node_modules/typebox/build/schema/engine/maxContains.d.mts +4 -4
  236. package/node_modules/typebox/build/schema/engine/maxContains.mjs +2 -6
  237. package/node_modules/typebox/build/schema/engine/maxItems.d.mts +4 -4
  238. package/node_modules/typebox/build/schema/engine/maxItems.mjs +2 -6
  239. package/node_modules/typebox/build/schema/engine/maxLength.d.mts +4 -4
  240. package/node_modules/typebox/build/schema/engine/maxLength.mjs +2 -6
  241. package/node_modules/typebox/build/schema/engine/maxProperties.d.mts +4 -4
  242. package/node_modules/typebox/build/schema/engine/maxProperties.mjs +2 -6
  243. package/node_modules/typebox/build/schema/engine/maximum.d.mts +4 -4
  244. package/node_modules/typebox/build/schema/engine/maximum.mjs +2 -6
  245. package/node_modules/typebox/build/schema/engine/minContains.d.mts +4 -4
  246. package/node_modules/typebox/build/schema/engine/minContains.mjs +2 -6
  247. package/node_modules/typebox/build/schema/engine/minItems.d.mts +5 -5
  248. package/node_modules/typebox/build/schema/engine/minItems.mjs +2 -6
  249. package/node_modules/typebox/build/schema/engine/minLength.d.mts +4 -4
  250. package/node_modules/typebox/build/schema/engine/minLength.mjs +2 -6
  251. package/node_modules/typebox/build/schema/engine/minProperties.d.mts +4 -4
  252. package/node_modules/typebox/build/schema/engine/minProperties.mjs +2 -6
  253. package/node_modules/typebox/build/schema/engine/minimum.d.mts +4 -4
  254. package/node_modules/typebox/build/schema/engine/minimum.mjs +2 -6
  255. package/node_modules/typebox/build/schema/engine/multipleOf.d.mts +4 -4
  256. package/node_modules/typebox/build/schema/engine/multipleOf.mjs +2 -6
  257. package/node_modules/typebox/build/schema/engine/not.d.mts +4 -4
  258. package/node_modules/typebox/build/schema/engine/not.mjs +4 -8
  259. package/node_modules/typebox/build/schema/engine/oneOf.d.mts +4 -4
  260. package/node_modules/typebox/build/schema/engine/oneOf.mjs +6 -11
  261. package/node_modules/typebox/build/schema/engine/pattern.d.mts +4 -4
  262. package/node_modules/typebox/build/schema/engine/pattern.mjs +3 -7
  263. package/node_modules/typebox/build/schema/engine/patternProperties.d.mts +4 -4
  264. package/node_modules/typebox/build/schema/engine/patternProperties.mjs +1 -1
  265. package/node_modules/typebox/build/schema/engine/prefixItems.d.mts +4 -4
  266. package/node_modules/typebox/build/schema/engine/prefixItems.mjs +1 -1
  267. package/node_modules/typebox/build/schema/engine/properties.d.mts +4 -4
  268. package/node_modules/typebox/build/schema/engine/properties.mjs +1 -1
  269. package/node_modules/typebox/build/schema/engine/propertyNames.d.mts +4 -4
  270. package/node_modules/typebox/build/schema/engine/propertyNames.mjs +4 -10
  271. package/node_modules/typebox/build/schema/engine/recursiveRef.d.mts +4 -4
  272. package/node_modules/typebox/build/schema/engine/recursiveRef.mjs +11 -7
  273. package/node_modules/typebox/build/schema/engine/ref.d.mts +4 -4
  274. package/node_modules/typebox/build/schema/engine/ref.mjs +19 -15
  275. package/node_modules/typebox/build/schema/engine/required.d.mts +4 -4
  276. package/node_modules/typebox/build/schema/engine/required.mjs +2 -6
  277. package/node_modules/typebox/build/schema/engine/schema.d.mts +7 -7
  278. package/node_modules/typebox/build/schema/engine/schema.mjs +137 -133
  279. package/node_modules/typebox/build/schema/engine/type.d.mts +4 -4
  280. package/node_modules/typebox/build/schema/engine/type.mjs +2 -6
  281. package/node_modules/typebox/build/schema/engine/unevaluatedItems.d.mts +4 -4
  282. package/node_modules/typebox/build/schema/engine/unevaluatedItems.mjs +5 -9
  283. package/node_modules/typebox/build/schema/engine/unevaluatedProperties.d.mts +4 -4
  284. package/node_modules/typebox/build/schema/engine/unevaluatedProperties.mjs +5 -9
  285. package/node_modules/typebox/build/schema/engine/uniqueItems.d.mts +4 -4
  286. package/node_modules/typebox/build/schema/engine/uniqueItems.mjs +2 -6
  287. package/node_modules/typebox/build/schema/errors.d.mts +2 -2
  288. package/node_modules/typebox/build/schema/errors.mjs +8 -14
  289. package/node_modules/typebox/build/schema/intern/index.d.mts +1 -0
  290. package/node_modules/typebox/build/schema/intern/index.mjs +1 -0
  291. package/node_modules/typebox/build/schema/intern/intern.d.mts +11 -0
  292. package/node_modules/typebox/build/schema/intern/intern.mjs +293 -0
  293. package/node_modules/typebox/build/schema/meta/draft_2019_09.d.mts +2 -0
  294. package/node_modules/typebox/build/schema/meta/draft_2019_09.mjs +288 -0
  295. package/node_modules/typebox/build/schema/meta/draft_2020_12.d.mts +2 -0
  296. package/node_modules/typebox/build/schema/meta/draft_2020_12.mjs +300 -0
  297. package/node_modules/typebox/build/schema/meta/draft_3.d.mts +2 -0
  298. package/node_modules/typebox/build/schema/meta/draft_3.mjs +143 -0
  299. package/node_modules/typebox/build/schema/meta/draft_4.d.mts +2 -0
  300. package/node_modules/typebox/build/schema/meta/draft_4.mjs +149 -0
  301. package/node_modules/typebox/build/schema/meta/draft_6.d.mts +2 -0
  302. package/node_modules/typebox/build/schema/meta/draft_6.mjs +155 -0
  303. package/node_modules/typebox/build/schema/meta/draft_7.d.mts +2 -0
  304. package/node_modules/typebox/build/schema/meta/draft_7.mjs +172 -0
  305. package/node_modules/typebox/build/schema/meta/index.d.mts +2 -0
  306. package/node_modules/typebox/build/schema/meta/index.mjs +2 -0
  307. package/node_modules/typebox/build/schema/meta/meta.d.mts +9 -0
  308. package/node_modules/typebox/build/schema/meta/meta.mjs +15 -0
  309. package/node_modules/typebox/build/schema/meta/schema.d.mts +6 -0
  310. package/node_modules/typebox/build/schema/meta/schema.mjs +1 -0
  311. package/node_modules/typebox/build/schema/parse.d.mts +1 -0
  312. package/node_modules/typebox/build/schema/parse.mjs +10 -5
  313. package/node_modules/typebox/build/schema/resolve/resolve.d.mts +8 -3
  314. package/node_modules/typebox/build/schema/resolve/resolve.mjs +268 -139
  315. package/node_modules/typebox/build/schema/schema.d.mts +2 -0
  316. package/node_modules/typebox/build/schema/schema.mjs +2 -0
  317. package/node_modules/typebox/build/value/errors/errors.d.mts +2 -12
  318. package/node_modules/typebox/build/value/errors/errors.mjs +2 -7
  319. package/node_modules/typebox/package.json +1 -1
  320. package/node_modules/typebox/readme.md +59 -89
  321. package/openclaw.plugin.json +1 -1
  322. package/package.json +10 -7
@@ -1,11 +1,15 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { promises as fsp } from "node:fs";
4
+ import http from "node:http";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
6
7
  import { PassThrough } from "node:stream";
8
+ import { createDefaultRegistry } from "../src/commands/registry.js";
7
9
  import { CostTracker } from "../src/core/cost_tracker.js";
10
+ import { createLlmSpendLedger, llmProvenanceOf, restoreLlmProvenance, } from "../src/commands/stdlib/llm_invoke.js";
8
11
  import { runWorkflowFile } from "../src/workflows/file.js";
12
+ import { decodeToken } from "../src/token.js";
9
13
  test("CostTracker records usage and computes totals", () => {
10
14
  const tracker = new CostTracker();
11
15
  tracker.recordUsage("step1", "gpt-4o", { inputTokens: 1000, outputTokens: 500 });
@@ -107,7 +111,7 @@ test("CostTracker checkLimit warns when action=warn and limit exceeded", () => {
107
111
  tracker.checkLimit({ max_usd: 0.01, action: "warn" }, stderr);
108
112
  assert.match(out, /\[WARN\] Cost/);
109
113
  });
110
- async function runWorkflow(workflow, envOverride) {
114
+ async function runWorkflow(workflow, envOverride, llmAdapters) {
111
115
  const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-"));
112
116
  const stateDir = path.join(tmpDir, "state");
113
117
  const filePath = path.join(tmpDir, "workflow.lobster");
@@ -125,6 +129,8 @@ async function runWorkflow(workflow, envOverride) {
125
129
  stderr,
126
130
  env: { ...process.env, LOBSTER_STATE_DIR: stateDir, ...envOverride },
127
131
  mode: "tool",
132
+ registry: createDefaultRegistry(),
133
+ llmAdapters,
128
134
  },
129
135
  });
130
136
  return { result, stderrOutput };
@@ -228,4 +234,1730 @@ test("CostTracker escapes unknown model ids in warnings", () => {
228
234
  assert.equal(stderr.output().includes("\u2028"), false);
229
235
  assert.equal(stderr.output().includes("\u2029"), false);
230
236
  });
237
+ // The step payloads are written to files rather than passed with `node -e`, because
238
+ // `cmd /s /c` hands the inline quoting to node verbatim and the JSON never reaches stdout.
239
+ async function emitJsonCommand(payload) {
240
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-emit-"));
241
+ const filePath = path.join(dir, "emit.mjs");
242
+ const literal = JSON.stringify(JSON.stringify(payload));
243
+ await fsp.writeFile(filePath, `process.stdout.write(${literal});\n`, "utf8");
244
+ return `node ${filePath}`;
245
+ }
246
+ function liveItem(source = "http") {
247
+ return {
248
+ kind: "llm.invoke",
249
+ model: "gpt-4o",
250
+ source,
251
+ cached: source !== "http",
252
+ cacheKey: "b3f1c0",
253
+ status: "completed",
254
+ createdAt: "2026-08-03T00:00:00.000Z",
255
+ usage: { inputTokens: 1000, outputTokens: 500 },
256
+ };
257
+ }
258
+ // Every field a replayed llm.invoke item carries, printed by a step that never called a
259
+ // provider. Nothing inside a JSON payload can be trusted to mean "already paid for".
260
+ function forgedReplayItem(source = "cache") {
261
+ return { ...liveItem(source), cached: true, replayed: true };
262
+ }
263
+ // Minimal OpenClaw-shaped provider: llm.invoke auto-detects it from OPENCLAW_URL, and the
264
+ // request count shows whether a step reached a provider or replayed a stored answer.
265
+ async function startFakeProvider(holdUntil = 1, usageFor = () => ({
266
+ inputTokens: 1000,
267
+ outputTokens: 500,
268
+ }),
269
+ // Lets a case fix the order two concurrent calls reach the ledger in.
270
+ delayMsFor = () => 0, dataFor = () => ({ summary: "hello" })) {
271
+ let requests = 0;
272
+ const held = [];
273
+ const releaseHeld = () => {
274
+ while (held.length)
275
+ held.shift()?.();
276
+ };
277
+ const server = http.createServer((req, res) => {
278
+ let body = "";
279
+ req.setEncoding("utf8");
280
+ req.on("data", (chunk) => (body += chunk));
281
+ req.on("end", () => {
282
+ requests += 1;
283
+ const seen = requests;
284
+ const parsed = JSON.parse(body || "{}");
285
+ const answer = () => {
286
+ res.writeHead(200, { "content-type": "application/json" });
287
+ res.end(JSON.stringify({
288
+ ok: true,
289
+ result: {
290
+ ok: true,
291
+ result: {
292
+ runId: `invoke_${seen}`,
293
+ model: parsed.args?.model,
294
+ prompt: parsed.args?.prompt,
295
+ output: { data: dataFor(seen) },
296
+ ...(usageFor(seen) ? { usage: usageFor(seen) } : null),
297
+ },
298
+ },
299
+ }));
300
+ };
301
+ const enqueue = () => {
302
+ held.push(answer);
303
+ if (requests >= holdUntil)
304
+ releaseHeld();
305
+ };
306
+ const delayMs = delayMsFor(String(parsed.args?.prompt ?? ""));
307
+ if (delayMs > 0)
308
+ setTimeout(enqueue, delayMs);
309
+ else
310
+ enqueue();
311
+ });
312
+ });
313
+ await new Promise((resolve) => server.listen(0, resolve));
314
+ const address = server.address();
315
+ const port = typeof address === "object" && address ? address.port : 0;
316
+ return {
317
+ url: `http://localhost:${port}`,
318
+ requests: () => requests,
319
+ close: () => new Promise((resolve) => server.close(() => resolve())),
320
+ };
321
+ }
322
+ test("workflow cost tracking bills a cached llm.invoke replay only once", async () => {
323
+ const provider = await startFakeProvider();
324
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
325
+ const step = "llm.invoke --model gpt-4o --prompt Summarize";
326
+ try {
327
+ const { result } = await runWorkflow({
328
+ steps: [
329
+ { id: "live", pipeline: step },
330
+ { id: "from-cache", pipeline: step },
331
+ ],
332
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
333
+ assert.equal(result.status, "ok");
334
+ assert.equal(provider.requests(), 1);
335
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
336
+ assert.equal(result._meta?.cost?.totalOutputTokens, 500);
337
+ assert.equal(result._meta?.cost?.estimatedCostUsd, 0.0075);
338
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["live"]);
339
+ }
340
+ finally {
341
+ await provider.close();
342
+ await fsp.rm(cacheDir, { recursive: true, force: true });
343
+ }
344
+ });
345
+ test("workflow cost tracking bills a run-state llm.invoke replay only once", async () => {
346
+ const provider = await startFakeProvider();
347
+ const step = "llm.invoke --disable-cache --model gpt-4o --prompt Summarize";
348
+ try {
349
+ const { result } = await runWorkflow({
350
+ steps: [
351
+ { id: "live", pipeline: step },
352
+ { id: "from-run-state", pipeline: step },
353
+ ],
354
+ }, { OPENCLAW_URL: provider.url, LOBSTER_RUN_STATE_KEY: "cost-tracker-replay" });
355
+ assert.equal(result.status, "ok");
356
+ assert.equal(provider.requests(), 1);
357
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
358
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["live"]);
359
+ }
360
+ finally {
361
+ await provider.close();
362
+ }
363
+ });
364
+ test("cost_limit stop is not tripped by a replayed llm.invoke", async () => {
365
+ const provider = await startFakeProvider();
366
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
367
+ const step = "llm.invoke --model gpt-4o --prompt Summarize";
368
+ try {
369
+ const { result } = await runWorkflow({
370
+ cost_limit: { max_usd: 0.01, action: "stop" },
371
+ steps: [
372
+ { id: "live", pipeline: step },
373
+ { id: "from-cache", pipeline: step },
374
+ { id: "after", command: "echo done" },
375
+ ],
376
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
377
+ assert.equal(result.status, "ok");
378
+ assert.equal(result._meta?.cost?.estimatedCostUsd, 0.0075);
379
+ }
380
+ finally {
381
+ await provider.close();
382
+ await fsp.rm(cacheDir, { recursive: true, force: true });
383
+ }
384
+ });
385
+ test("cost_limit stop still trips on repeated live calls", async () => {
386
+ const live = await emitJsonCommand(liveItem());
387
+ await assert.rejects(() => runWorkflow({
388
+ cost_limit: { max_usd: 0.01, action: "stop" },
389
+ steps: [
390
+ { id: "live", command: live },
391
+ { id: "live-again", command: live },
392
+ ],
393
+ }).then((x) => x.result), /Cost limit exceeded/);
394
+ });
395
+ test("workflow cost tracking bills a live call from an adapter named like a replay source", async () => {
396
+ const { result } = await runWorkflow({
397
+ steps: [
398
+ { id: "live-cache-provider", command: await emitJsonCommand(liveItem("cache")) },
399
+ { id: "live-run-state-provider", command: await emitJsonCommand(liveItem("run_state")) },
400
+ ],
401
+ });
402
+ assert.equal(result.status, "ok");
403
+ assert.equal(result._meta?.cost?.totalInputTokens, 2000);
404
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["live-cache-provider", "live-run-state-provider"]);
405
+ });
406
+ test("workflow cost tracking bills a step that prints the full replay shape", async () => {
407
+ const { result } = await runWorkflow({
408
+ steps: [
409
+ { id: "forged-cache", command: await emitJsonCommand(forgedReplayItem("cache")) },
410
+ { id: "forged-run-state", command: await emitJsonCommand(forgedReplayItem("run_state")) },
411
+ {
412
+ id: "partial-shape",
413
+ command: await emitJsonCommand({
414
+ replayed: true,
415
+ model: "gpt-4o",
416
+ usage: { inputTokens: 1000, outputTokens: 500 },
417
+ }),
418
+ },
419
+ ],
420
+ });
421
+ assert.equal(result.status, "ok");
422
+ assert.equal(result._meta?.cost?.totalInputTokens, 3000);
423
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["forged-cache", "forged-run-state", "partial-shape"]);
424
+ });
425
+ test("cost_limit stop cannot be bypassed by a step that prints the full replay shape", async () => {
426
+ const live = await emitJsonCommand(liveItem());
427
+ const forged = await emitJsonCommand(forgedReplayItem());
428
+ await assert.rejects(() => runWorkflow({
429
+ cost_limit: { max_usd: 0.01, action: "stop" },
430
+ steps: [
431
+ { id: "live", command: live },
432
+ { id: "forged-replay", command: forged },
433
+ ],
434
+ }).then((x) => x.result), /Cost limit exceeded/);
435
+ });
436
+ test("cost_limit stop cannot be bypassed by copying a settled call's public fields", async () => {
437
+ const provider = await startFakeProvider();
438
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
439
+ const copy = await copyJsonStdinPath();
440
+ try {
441
+ await assert.rejects(() => runWorkflow({
442
+ cost_limit: { max_usd: 0.01, action: "stop" },
443
+ steps: [
444
+ {
445
+ id: "live",
446
+ pipeline: "llm.invoke --model gpt-4o --prompt Summarize",
447
+ },
448
+ {
449
+ id: "public-copy",
450
+ pipeline: `exec --stdin json --json=true node ${copy}`,
451
+ stdin: "$live.json",
452
+ },
453
+ ],
454
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir }).then((x) => x.result), /Cost limit exceeded/);
455
+ assert.equal(provider.requests(), 1);
456
+ }
457
+ finally {
458
+ await provider.close();
459
+ await fsp.rm(cacheDir, { recursive: true, force: true });
460
+ }
461
+ });
462
+ // `... | json` is a supported step shape: the renderer prints the items and returns an empty
463
+ // stream, so the workflow reads the step's JSON back from stdout. Accounting must still tell a
464
+ // replay from a live call there, and must still bill output that only looks replayed.
465
+ // Runs a parent workflow that composes a child through a `workflow:` step. Both files live in
466
+ // one directory so the parent can name the child by relative path.
467
+ async function runComposedWorkflow(parent, child, envOverride) {
468
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-"));
469
+ const stateDir = path.join(tmpDir, "state");
470
+ const parentPath = path.join(tmpDir, "workflow.lobster");
471
+ const childPath = path.join(tmpDir, "child.lobster");
472
+ await fsp.writeFile(parentPath, JSON.stringify(parent, null, 2), "utf8");
473
+ await fsp.writeFile(childPath, JSON.stringify(child, null, 2), "utf8");
474
+ const stderr = new PassThrough();
475
+ let stderrOutput = "";
476
+ stderr.on("data", (d) => {
477
+ stderrOutput += String(d);
478
+ });
479
+ const result = await runWorkflowFile({
480
+ filePath: parentPath,
481
+ ctx: {
482
+ stdin: process.stdin,
483
+ stdout: process.stdout,
484
+ stderr,
485
+ env: { ...process.env, LOBSTER_STATE_DIR: stateDir, ...envOverride },
486
+ mode: "tool",
487
+ registry: createDefaultRegistry(),
488
+ },
489
+ });
490
+ return { result, stderrOutput };
491
+ }
492
+ test("workflow cost tracking bills a live call a nested workflow made", async () => {
493
+ const provider = await startFakeProvider();
494
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
495
+ try {
496
+ const { result } = await runComposedWorkflow({ steps: [{ id: "child", workflow: "child.lobster" }] }, { steps: [{ id: "llm", pipeline: "llm.invoke --model gpt-4o --prompt Summarize | json" }] }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
497
+ assert.equal(result.status, "ok");
498
+ assert.equal(provider.requests(), 1);
499
+ // The composing run counts the spend its child handed back, exactly once.
500
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
501
+ assert.equal(result._meta?.cost?.totalOutputTokens, 500);
502
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["child"]);
503
+ }
504
+ finally {
505
+ await provider.close();
506
+ await fsp.rm(cacheDir, { recursive: true, force: true });
507
+ }
508
+ });
509
+ test("workflow cost tracking bills a cached replay a nested workflow returned only once", async () => {
510
+ const provider = await startFakeProvider();
511
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
512
+ const step = "llm.invoke --model gpt-4o --prompt Summarize | json";
513
+ try {
514
+ const { result } = await runComposedWorkflow({
515
+ steps: [
516
+ { id: "live", pipeline: step },
517
+ { id: "child", workflow: "child.lobster" },
518
+ ],
519
+ }, { steps: [{ id: "llm", pipeline: step }] }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
520
+ assert.equal(result.status, "ok");
521
+ assert.equal(provider.requests(), 1);
522
+ // The child replayed the answer the parent already paid for; billing it again would
523
+ // charge the run for tokens no provider was asked to spend.
524
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
525
+ assert.equal(result._meta?.cost?.totalOutputTokens, 500);
526
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["live"]);
527
+ }
528
+ finally {
529
+ await provider.close();
530
+ await fsp.rm(cacheDir, { recursive: true, force: true });
531
+ }
532
+ });
533
+ test("cost_limit stop is not tripped by a replay a nested workflow returned", async () => {
534
+ const provider = await startFakeProvider();
535
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
536
+ const step = "llm.invoke --model gpt-4o --prompt Summarize | json";
537
+ try {
538
+ const { result } = await runComposedWorkflow({
539
+ cost_limit: { max_usd: 0.01, action: "stop" },
540
+ steps: [
541
+ { id: "live", pipeline: step },
542
+ { id: "child", workflow: "child.lobster" },
543
+ ],
544
+ }, { steps: [{ id: "llm", pipeline: step }] }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
545
+ // One call is $0.0075; billing the child's replay too would exceed the $0.01 limit.
546
+ assert.equal(result.status, "ok");
547
+ assert.equal(provider.requests(), 1);
548
+ assert.equal(result._meta?.cost?.estimatedCostUsd, 0.0075);
549
+ }
550
+ finally {
551
+ await provider.close();
552
+ await fsp.rm(cacheDir, { recursive: true, force: true });
553
+ }
554
+ });
555
+ test("workflow cost tracking bills a nested workflow that prints the full replay shape", async () => {
556
+ const emitPath = await emitJsonPath(forgedReplayItem());
557
+ const { result } = await runComposedWorkflow({ steps: [{ id: "child", workflow: "child.lobster" }] }, { steps: [{ id: "forged", command: `node ${emitPath}` }] });
558
+ // Nothing a child prints can exempt itself: provenance is a symbol this process attaches.
559
+ assert.equal(result.status, "ok");
560
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
561
+ assert.equal(result._meta?.cost?.totalOutputTokens, 500);
562
+ });
563
+ async function emitJsonPath(payload) {
564
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-emit-"));
565
+ const filePath = path.join(dir, "emit.mjs");
566
+ const literal = JSON.stringify(JSON.stringify(payload));
567
+ await fsp.writeFile(filePath, `process.stdout.write(${literal});\n`, "utf8");
568
+ return filePath.split(path.sep).join("/");
569
+ }
570
+ async function copyJsonStdinPath() {
571
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-copy-"));
572
+ const filePath = path.join(dir, "copy.mjs");
573
+ await fsp.writeFile(filePath, 'let input = "";\nprocess.stdin.setEncoding("utf8");\nprocess.stdin.on("data", (chunk) => (input += chunk));\nprocess.stdin.on("end", () => process.stdout.write(input));\n', "utf8");
574
+ return filePath.split(path.sep).join("/");
575
+ }
576
+ test("workflow cost tracking bills a cached llm.invoke replay only once behind a renderer", async () => {
577
+ const provider = await startFakeProvider();
578
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
579
+ const step = "llm.invoke --model gpt-4o --prompt Summarize | json";
580
+ try {
581
+ const { result } = await runWorkflow({
582
+ steps: [
583
+ { id: "live", pipeline: step },
584
+ { id: "from-cache", pipeline: step },
585
+ ],
586
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
587
+ assert.equal(result.status, "ok");
588
+ assert.equal(provider.requests(), 1);
589
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
590
+ assert.equal(result._meta?.cost?.totalOutputTokens, 500);
591
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["live"]);
592
+ }
593
+ finally {
594
+ await provider.close();
595
+ await fsp.rm(cacheDir, { recursive: true, force: true });
596
+ }
597
+ });
598
+ test("workflow cost tracking bills a run-state llm.invoke replay only once behind a renderer", async () => {
599
+ const provider = await startFakeProvider();
600
+ const step = "llm.invoke --disable-cache --model gpt-4o --prompt Summarize | json";
601
+ try {
602
+ const { result } = await runWorkflow({
603
+ steps: [
604
+ { id: "live", pipeline: step },
605
+ { id: "from-run-state", pipeline: step },
606
+ ],
607
+ }, { OPENCLAW_URL: provider.url, LOBSTER_RUN_STATE_KEY: "cost-tracker-rendered-replay" });
608
+ assert.equal(result.status, "ok");
609
+ assert.equal(provider.requests(), 1);
610
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
611
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["live"]);
612
+ }
613
+ finally {
614
+ await provider.close();
615
+ }
616
+ });
617
+ test("cost_limit stop is not tripped by a replayed llm.invoke behind a renderer", async () => {
618
+ const provider = await startFakeProvider();
619
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
620
+ const step = "llm.invoke --model gpt-4o --prompt Summarize | json";
621
+ try {
622
+ const { result } = await runWorkflow({
623
+ cost_limit: { max_usd: 0.01, action: "stop" },
624
+ steps: [
625
+ { id: "live", pipeline: step },
626
+ { id: "from-cache", pipeline: step },
627
+ { id: "after", command: "echo done" },
628
+ ],
629
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
630
+ assert.equal(result.status, "ok");
631
+ assert.equal(result._meta?.cost?.estimatedCostUsd, 0.0075);
632
+ }
633
+ finally {
634
+ await provider.close();
635
+ await fsp.rm(cacheDir, { recursive: true, force: true });
636
+ }
637
+ });
638
+ test("workflow cost tracking bills a rendered step that prints the full replay shape", async () => {
639
+ const forgedCache = await emitJsonPath(forgedReplayItem("cache"));
640
+ const forgedRunState = await emitJsonPath(forgedReplayItem("run_state"));
641
+ const { result } = await runWorkflow({
642
+ steps: [
643
+ { id: "forged-cache", pipeline: `exec --json=true node ${forgedCache} | json` },
644
+ { id: "forged-run-state", pipeline: `exec --json=true node ${forgedRunState} | json` },
645
+ ],
646
+ });
647
+ assert.equal(result.status, "ok");
648
+ assert.equal(result._meta?.cost?.totalInputTokens, 2000);
649
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["forged-cache", "forged-run-state"]);
650
+ });
651
+ test("cost_limit stop cannot be bypassed by a rendered step that prints the replay shape", async () => {
652
+ const live = await emitJsonCommand(liveItem());
653
+ const forged = await emitJsonPath(forgedReplayItem());
654
+ await assert.rejects(() => runWorkflow({
655
+ cost_limit: { max_usd: 0.01, action: "stop" },
656
+ steps: [
657
+ { id: "live", command: live },
658
+ { id: "forged-replay", pipeline: `exec --json=true node ${forged} | json` },
659
+ ],
660
+ }).then((x) => x.result), /Cost limit exceeded/);
661
+ });
662
+ // A projection such as `pick model,usage` builds a new object from named fields, so an item's
663
+ // own marker does not reach accounting. The usage record it carries is the thing being billed,
664
+ // and it crosses the projection by reference.
665
+ test("workflow cost tracking bills a cached llm.invoke replay only once through a projection", async () => {
666
+ const provider = await startFakeProvider();
667
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
668
+ const step = "llm.invoke --model gpt-4o --prompt Summarize | pick model,usage";
669
+ try {
670
+ const { result } = await runWorkflow({
671
+ steps: [
672
+ { id: "live", pipeline: step },
673
+ { id: "from-cache", pipeline: step },
674
+ ],
675
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
676
+ assert.equal(result.status, "ok");
677
+ assert.equal(provider.requests(), 1);
678
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
679
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["live"]);
680
+ }
681
+ finally {
682
+ await provider.close();
683
+ await fsp.rm(cacheDir, { recursive: true, force: true });
684
+ }
685
+ });
686
+ test("workflow cost tracking bills a run-state llm.invoke replay only once through a projection", async () => {
687
+ const provider = await startFakeProvider();
688
+ const step = "llm.invoke --disable-cache --model gpt-4o --prompt Summarize | pick model,usage";
689
+ try {
690
+ const { result } = await runWorkflow({
691
+ steps: [
692
+ { id: "live", pipeline: step },
693
+ { id: "from-run-state", pipeline: step },
694
+ ],
695
+ }, { OPENCLAW_URL: provider.url, LOBSTER_RUN_STATE_KEY: "cost-tracker-projected-replay" });
696
+ assert.equal(result.status, "ok");
697
+ assert.equal(provider.requests(), 1);
698
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
699
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["live"]);
700
+ }
701
+ finally {
702
+ await provider.close();
703
+ }
704
+ });
705
+ test("workflow cost tracking prices a projected live call from the charge it settled", async () => {
706
+ const provider = await startFakeProvider();
707
+ try {
708
+ const { result } = await runWorkflow({
709
+ steps: [
710
+ {
711
+ id: "live",
712
+ pipeline: "llm.invoke --disable-cache --model gpt-4o --prompt Summarize | pick usage",
713
+ },
714
+ ],
715
+ }, { OPENCLAW_URL: provider.url });
716
+ assert.equal(result.status, "ok");
717
+ assert.equal(provider.requests(), 1);
718
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
719
+ assert.equal(result._meta?.cost?.estimatedCostUsd, 0.0075);
720
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.model), ["gpt-4o"]);
721
+ }
722
+ finally {
723
+ await provider.close();
724
+ }
725
+ });
726
+ test("workflow cost tracking prices a live call whose model field a step rewrote", async () => {
727
+ const provider = await startFakeProvider();
728
+ try {
729
+ const { result } = await runWorkflow({
730
+ steps: [
731
+ {
732
+ id: "live",
733
+ pipeline: "llm.invoke --disable-cache --model gpt-4o --prompt Summarize | map model=gpt-3.5-turbo",
734
+ },
735
+ ],
736
+ }, { OPENCLAW_URL: provider.url });
737
+ assert.equal(result.status, "ok");
738
+ assert.equal(provider.requests(), 1);
739
+ assert.equal(result._meta?.cost?.estimatedCostUsd, 0.0075);
740
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.model), ["gpt-4o"]);
741
+ }
742
+ finally {
743
+ await provider.close();
744
+ }
745
+ });
746
+ test("workflow cost tracking bills a projected step that prints the full replay shape", async () => {
747
+ const forged = await emitJsonPath(forgedReplayItem("cache"));
748
+ const { result } = await runWorkflow({
749
+ steps: [
750
+ { id: "forged-picked", pipeline: `exec --json=true node ${forged} | pick model,usage` },
751
+ {
752
+ id: "forged-picked-rendered",
753
+ pipeline: `exec --json=true node ${forged} | pick model,usage | json`,
754
+ },
755
+ ],
756
+ });
757
+ assert.equal(result.status, "ok");
758
+ assert.equal(result._meta?.cost?.totalInputTokens, 2000);
759
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["forged-picked", "forged-picked-rendered"]);
760
+ });
761
+ // A command that fails the first time it runs and succeeds afterwards, so a step carrying it
762
+ // takes exactly two attempts under `retry`.
763
+ async function failsOnceCommand() {
764
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-flaky-"));
765
+ const marker = JSON.stringify(path.join(dir, "attempted"));
766
+ const filePath = path.join(dir, "flaky.mjs");
767
+ await fsp.writeFile(filePath, `import fs from "node:fs";\n` +
768
+ `if (fs.existsSync(${marker})) process.stdout.write("ok");\n` +
769
+ `else { fs.writeFileSync(${marker}, "1"); process.exit(1); }\n`, "utf8");
770
+ return `node ${filePath}`;
771
+ }
772
+ // `llm.invoke` stores the live answer before it returns, and a step's cost is recorded only
773
+ // once the step succeeds. So when a later part of a retried step fails, the provider has
774
+ // already been paid and the retry replays that answer: the replay is the only place the spend
775
+ // can still be recorded, and exempting it would report $0 for a call that really happened.
776
+ test("workflow cost tracking bills a replay standing in for a retried step's live call", async () => {
777
+ const provider = await startFakeProvider();
778
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
779
+ const flaky = await failsOnceCommand();
780
+ try {
781
+ const { result } = await runWorkflow({
782
+ steps: [
783
+ {
784
+ id: "invoke-then-fail",
785
+ retry: { max: 2, delay_ms: 1 },
786
+ parallel: {
787
+ branches: [
788
+ { id: "invoke", pipeline: "llm.invoke --model gpt-4o --prompt Summarize" },
789
+ { id: "flaky", command: flaky },
790
+ ],
791
+ },
792
+ },
793
+ ],
794
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
795
+ assert.equal(result.status, "ok");
796
+ assert.equal(provider.requests(), 1);
797
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
798
+ assert.equal(result._meta?.cost?.totalOutputTokens, 500);
799
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["invoke"]);
800
+ }
801
+ finally {
802
+ await provider.close();
803
+ await fsp.rm(cacheDir, { recursive: true, force: true });
804
+ }
805
+ });
806
+ // The spend a retried step recovers is real money, so it has to reach `cost_limit` too.
807
+ test("cost_limit stop counts the live call a retried step's replay stands in for", async () => {
808
+ const provider = await startFakeProvider();
809
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
810
+ const flaky = await failsOnceCommand();
811
+ try {
812
+ await assert.rejects(() => runWorkflow({
813
+ cost_limit: { max_usd: 0.001, action: "stop" },
814
+ steps: [
815
+ {
816
+ id: "invoke-then-fail",
817
+ retry: { max: 2, delay_ms: 1 },
818
+ parallel: {
819
+ branches: [
820
+ { id: "invoke", pipeline: "llm.invoke --model gpt-4o --prompt Summarize" },
821
+ { id: "flaky", command: flaky },
822
+ ],
823
+ },
824
+ },
825
+ { id: "after", command: "echo done" },
826
+ ],
827
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir }).then((x) => x.result), /Cost limit exceeded/);
828
+ assert.equal(provider.requests(), 1);
829
+ }
830
+ finally {
831
+ await provider.close();
832
+ await fsp.rm(cacheDir, { recursive: true, force: true });
833
+ }
834
+ });
835
+ // A command that always fails, so a step carrying it never succeeds.
836
+ async function alwaysFailsCommand() {
837
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-fail-"));
838
+ const filePath = path.join(dir, "fail.mjs");
839
+ await fsp.writeFile(filePath, "process.exit(1);\n", "utf8");
840
+ return `node ${filePath}`;
841
+ }
842
+ // The charge a run opens belongs to that run. If its step never succeeds, the charge is never
843
+ // recovered — and a later run that replays the same stored answer called no provider, so
844
+ // billing it there would move one run's spend onto another. The ledger is per-run, so it
845
+ // cannot: this also covers a live call made outside any cost accounting, such as the SDK
846
+ // pipeline API, whose cache entry a workflow may later reuse.
847
+ test("workflow cost tracking does not bill a replay of a call another run paid for", async () => {
848
+ const provider = await startFakeProvider();
849
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
850
+ const failing = await alwaysFailsCommand();
851
+ const invoke = "llm.invoke --model gpt-4o --prompt Summarize";
852
+ const env = { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir };
853
+ try {
854
+ const first = await runWorkflow({
855
+ steps: [
856
+ {
857
+ id: "spends",
858
+ on_error: "continue",
859
+ parallel: {
860
+ branches: [
861
+ { id: "invoke", pipeline: invoke },
862
+ { id: "fails", command: failing },
863
+ ],
864
+ },
865
+ },
866
+ ],
867
+ }, env);
868
+ assert.equal(first.result.status, "ok");
869
+ // The step failed, so no item of it was ever billed -- but the provider had already
870
+ // answered, and the charge that survives it is the only record of that. The run that
871
+ // made the call is where it belongs; the alternative is that it is billed nowhere at all.
872
+ assert.equal(first.result._meta?.cost?.totalInputTokens, 1000);
873
+ assert.deepEqual(first.result._meta?.cost?.byStep.map((entry) => entry.stepId), ["invoke"]);
874
+ const second = await runWorkflow({ steps: [{ id: "replays", pipeline: invoke }] }, env);
875
+ assert.equal(second.result.status, "ok");
876
+ assert.equal(provider.requests(), 1);
877
+ // The point of the case: this run paid for nothing, so it is charged nothing.
878
+ assert.equal(second.result._meta?.cost, undefined);
879
+ }
880
+ finally {
881
+ await provider.close();
882
+ await fsp.rm(cacheDir, { recursive: true, force: true });
883
+ }
884
+ });
885
+ // Two identical calls that race on a cold cache are two provider charges under one cache key.
886
+ // If the step is then retried, both replays have to be billable: a ledger that only remembered
887
+ // "this key is unpaid" would settle once and silently drop the second real charge.
888
+ test("workflow cost tracking bills both live calls a retried step's replays stand in for", async () => {
889
+ const provider = await startFakeProvider(2);
890
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
891
+ const flaky = await failsOnceCommand();
892
+ const invoke = "llm.invoke --model gpt-4o --prompt Summarize";
893
+ try {
894
+ const { result } = await runWorkflow({
895
+ steps: [
896
+ {
897
+ id: "invoke-twice-then-fail",
898
+ retry: { max: 2, delay_ms: 1 },
899
+ parallel: {
900
+ branches: [
901
+ { id: "invoke-a", pipeline: invoke },
902
+ { id: "invoke-b", pipeline: invoke },
903
+ { id: "flaky", command: flaky },
904
+ ],
905
+ },
906
+ },
907
+ ],
908
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
909
+ assert.equal(result.status, "ok");
910
+ assert.equal(provider.requests(), 2);
911
+ assert.equal(result._meta?.cost?.totalInputTokens, 2000);
912
+ assert.equal(result._meta?.cost?.totalOutputTokens, 1000);
913
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId).sort(), [
914
+ "invoke-a",
915
+ "invoke-b",
916
+ ]);
917
+ }
918
+ finally {
919
+ await provider.close();
920
+ await fsp.rm(cacheDir, { recursive: true, force: true });
921
+ }
922
+ });
923
+ // A gate pauses a run rather than resetting what it spent. The resume is a different run — a
924
+ // different process, even — so the call made before the gate is only in the paused run's
925
+ // record; if a later step repeats the prompt, it replays and correctly bills nothing. Without
926
+ // carrying that record forward, the provider call before the gate would appear in no total at
927
+ // all, and a `cost_limit` could be walked past one gate at a time.
928
+ // A gate pauses a run, it does not reset what the run has spent. The resume is a separate run
929
+ // with its own accounting, so a call made before the gate lives only in the paused run's
930
+ // record: if a later step repeats the prompt it replays, correctly bills nothing, and the
931
+ // provider call would end up in no total at all. Carrying the record forward also stops a
932
+ // `cost_limit` being walked past one gate at a time.
933
+ test("workflow cost tracking carries spend across an approval resume", async () => {
934
+ const provider = await startFakeProvider();
935
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-resume-"));
936
+ const cacheDir = path.join(tmpDir, "cache");
937
+ const stateDir = path.join(tmpDir, "state");
938
+ await fsp.mkdir(cacheDir, { recursive: true });
939
+ const invoke = "llm.invoke --model gpt-4o --prompt Summarize";
940
+ const filePath = path.join(tmpDir, "workflow.lobster");
941
+ await fsp.writeFile(filePath, JSON.stringify({
942
+ steps: [
943
+ { id: "live", pipeline: invoke },
944
+ { id: "gate", run: "echo gate", approval: "Continue?" },
945
+ { id: "replay", pipeline: invoke },
946
+ ],
947
+ }), "utf8");
948
+ const env = {
949
+ ...process.env,
950
+ LOBSTER_STATE_DIR: stateDir,
951
+ LOBSTER_CACHE_DIR: cacheDir,
952
+ OPENCLAW_URL: provider.url,
953
+ };
954
+ const ctx = () => ({
955
+ stdin: process.stdin,
956
+ stdout: process.stdout,
957
+ stderr: new PassThrough(),
958
+ env,
959
+ mode: "tool",
960
+ registry: createDefaultRegistry(),
961
+ });
962
+ try {
963
+ const paused = await runWorkflowFile({ filePath, ctx: ctx() });
964
+ assert.equal(paused.status, "needs_approval");
965
+ const token = paused.requiresApproval?.resumeToken;
966
+ assert.equal(typeof token, "string");
967
+ const resumed = await runWorkflowFile({
968
+ filePath,
969
+ ctx: ctx(),
970
+ resume: decodeToken(token),
971
+ approved: true,
972
+ });
973
+ assert.equal(resumed.status, "ok");
974
+ assert.equal(provider.requests(), 1);
975
+ // The live call happened before the gate; the step after it only replayed.
976
+ assert.equal(resumed._meta?.cost?.totalInputTokens, 1000);
977
+ assert.equal(resumed._meta?.cost?.totalOutputTokens, 500);
978
+ assert.deepEqual(resumed._meta?.cost?.byStep.map((entry) => entry.stepId), ["live"]);
979
+ }
980
+ finally {
981
+ await provider.close();
982
+ await fsp.rm(tmpDir, { recursive: true, force: true });
983
+ }
984
+ });
985
+ // A pipeline can pause *between* paying for a call and billing it: `llm.invoke | ask` in tool
986
+ // mode suspends after the model answered, and `ask` consumes the items, so the usage never
987
+ // reaches accounting. The charge exists only as an outstanding entry in the paused run's
988
+ // ledger — if that does not travel with the resume state, the replay a later step produces is
989
+ // exempt and the call is billed nowhere.
990
+ test("workflow cost tracking carries an unbilled call across a pipeline input resume", async () => {
991
+ const provider = await startFakeProvider();
992
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-input-"));
993
+ const cacheDir = path.join(tmpDir, "cache");
994
+ const stateDir = path.join(tmpDir, "state");
995
+ await fsp.mkdir(cacheDir, { recursive: true });
996
+ const invoke = "llm.invoke --model gpt-4o --prompt Summarize";
997
+ const filePath = path.join(tmpDir, "workflow.lobster");
998
+ await fsp.writeFile(filePath, JSON.stringify({
999
+ steps: [
1000
+ { id: "ask-after-invoke", pipeline: `${invoke} | ask --prompt Continue?` },
1001
+ { id: "replay", pipeline: invoke },
1002
+ ],
1003
+ }), "utf8");
1004
+ const env = {
1005
+ ...process.env,
1006
+ LOBSTER_STATE_DIR: stateDir,
1007
+ LOBSTER_CACHE_DIR: cacheDir,
1008
+ OPENCLAW_URL: provider.url,
1009
+ };
1010
+ const ctx = () => ({
1011
+ stdin: process.stdin,
1012
+ stdout: process.stdout,
1013
+ stderr: new PassThrough(),
1014
+ env,
1015
+ mode: "tool",
1016
+ registry: createDefaultRegistry(),
1017
+ });
1018
+ try {
1019
+ const paused = await runWorkflowFile({ filePath, ctx: ctx() });
1020
+ assert.equal(paused.status, "needs_input");
1021
+ const token = paused.requiresInput?.resumeToken;
1022
+ assert.equal(typeof token, "string");
1023
+ const resumed = await runWorkflowFile({
1024
+ filePath,
1025
+ ctx: ctx(),
1026
+ resume: decodeToken(token),
1027
+ response: { decision: "approve" },
1028
+ });
1029
+ assert.equal(resumed.status, "ok");
1030
+ assert.equal(provider.requests(), 1);
1031
+ // `ask` swallowed the item that carried the usage, so the replay in the next step is
1032
+ // the only carrier left for a call the provider really answered.
1033
+ assert.equal(resumed._meta?.cost?.totalInputTokens, 1000);
1034
+ assert.equal(resumed._meta?.cost?.totalOutputTokens, 500);
1035
+ }
1036
+ finally {
1037
+ await provider.close();
1038
+ await fsp.rm(tmpDir, { recursive: true, force: true });
1039
+ }
1040
+ });
1041
+ // The provider is paid the moment it answers, before Lobster stores anything. A coordinated
1042
+ // state/cache publication failure rolls both stores back, so the retry makes another live call.
1043
+ // Both calls must be charged even though only the retry produces a step result.
1044
+ test("workflow cost tracking bills both calls when the first cache write fails", async () => {
1045
+ const provider = await startFakeProvider();
1046
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-unwritable-"));
1047
+ const cacheDir = path.join(tmpDir, "cache");
1048
+ const cacheNamespaceDir = path.join(cacheDir, "llm.invoke");
1049
+ const originalMkdir = fsp.mkdir;
1050
+ let cacheNamespaceMkdirs = 0;
1051
+ try {
1052
+ Object.defineProperty(fsp, "mkdir", {
1053
+ configurable: true,
1054
+ writable: true,
1055
+ async value(target, options) {
1056
+ if (String(target) === cacheNamespaceDir && ++cacheNamespaceMkdirs === 2) {
1057
+ throw Object.assign(new Error("cache directory unavailable"), { code: "EIO" });
1058
+ }
1059
+ return originalMkdir(target, options);
1060
+ },
1061
+ });
1062
+ const { result } = await runWorkflow({
1063
+ steps: [
1064
+ {
1065
+ id: "invoke",
1066
+ retry: { max: 2, delay_ms: 1 },
1067
+ pipeline: "llm.invoke --model gpt-4o --prompt Summarize",
1068
+ },
1069
+ ],
1070
+ }, {
1071
+ OPENCLAW_URL: provider.url,
1072
+ LOBSTER_CACHE_DIR: cacheDir,
1073
+ LOBSTER_RUN_STATE_KEY: "cost-tracker-unwritable-cache",
1074
+ });
1075
+ assert.equal(result.status, "ok");
1076
+ assert.equal(provider.requests(), 2);
1077
+ assert.equal(result._meta?.cost?.totalInputTokens, 2000);
1078
+ assert.equal(result._meta?.cost?.totalOutputTokens, 1000);
1079
+ }
1080
+ finally {
1081
+ Object.defineProperty(fsp, "mkdir", {
1082
+ configurable: true,
1083
+ writable: true,
1084
+ value: originalMkdir,
1085
+ });
1086
+ await provider.close();
1087
+ await fsp.rm(tmpDir, { recursive: true, force: true });
1088
+ }
1089
+ });
1090
+ test("workflow cost tracking bills one call when a parallel replay branch is declared first", async () => {
1091
+ const provider = await startFakeProvider();
1092
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1093
+ const step = "llm.invoke --model gpt-4o --prompt Summarize";
1094
+ try {
1095
+ // The replay branch is declared before the live one, so it is accounted first: without
1096
+ // holding replays back it claims the charge the refresh branch opened and is billed for
1097
+ // it, and the refresh branch -- billed whatever its own claim returns -- is billed again.
1098
+ const { result } = await runWorkflow({
1099
+ steps: [
1100
+ { id: "warm", pipeline: step },
1101
+ {
1102
+ id: "both",
1103
+ parallel: {
1104
+ wait: "all",
1105
+ branches: [
1106
+ { id: "replay", pipeline: step },
1107
+ { id: "refresh", pipeline: `${step} --refresh` },
1108
+ ],
1109
+ },
1110
+ },
1111
+ ],
1112
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
1113
+ assert.equal(result.status, "ok");
1114
+ assert.equal(provider.requests(), 2, "one warm-up call and one refresh");
1115
+ assert.equal(result._meta?.cost?.totalInputTokens, 2000);
1116
+ assert.equal(result._meta?.cost?.totalOutputTokens, 1000);
1117
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["warm", "refresh"]);
1118
+ }
1119
+ finally {
1120
+ await provider.close();
1121
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1122
+ }
1123
+ });
1124
+ test("cost_limit stop is not tripped by a parallel replay branch declared first", async () => {
1125
+ const provider = await startFakeProvider();
1126
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1127
+ const step = "llm.invoke --model gpt-4o --prompt Summarize";
1128
+ try {
1129
+ // Two provider calls cost $0.0150; the third charge the ordering bug added took the run
1130
+ // to $0.0225 and stopped it at a budget it had not spent.
1131
+ const { result } = await runWorkflow({
1132
+ cost_limit: { max_usd: 0.02, action: "stop" },
1133
+ steps: [
1134
+ { id: "warm", pipeline: step },
1135
+ {
1136
+ id: "both",
1137
+ parallel: {
1138
+ wait: "all",
1139
+ branches: [
1140
+ { id: "replay", pipeline: step },
1141
+ { id: "refresh", pipeline: `${step} --refresh` },
1142
+ ],
1143
+ },
1144
+ },
1145
+ { id: "after", command: "echo done" },
1146
+ ],
1147
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
1148
+ assert.equal(result.status, "ok");
1149
+ assert.equal(result._meta?.cost?.estimatedCostUsd, 0.015);
1150
+ assert.equal(String(result.output?.[0]).trim(), "done", "the step after ran");
1151
+ }
1152
+ finally {
1153
+ await provider.close();
1154
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1155
+ }
1156
+ });
1157
+ test("workflow cost tracking bills a call a nested workflow made before a plain final step", async () => {
1158
+ const provider = await startFakeProvider();
1159
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1160
+ try {
1161
+ const { result } = await runComposedWorkflow({ steps: [{ id: "child", workflow: "child.lobster" }] }, {
1162
+ steps: [
1163
+ { id: "llm", pipeline: "llm.invoke --model gpt-4o --prompt Summarize | json" },
1164
+ { id: "done", command: "echo done" },
1165
+ ],
1166
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
1167
+ assert.equal(result.status, "ok");
1168
+ assert.equal(provider.requests(), 1);
1169
+ // Only the child's last step crosses the composition boundary, and it carries no items.
1170
+ // The charge the child opened in this run's ledger is still a call this run paid for.
1171
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
1172
+ assert.equal(result._meta?.cost?.totalOutputTokens, 500);
1173
+ }
1174
+ finally {
1175
+ await provider.close();
1176
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1177
+ }
1178
+ });
1179
+ test("workflow cost tracking bills a call whose only item an ask gate consumed", async () => {
1180
+ const provider = await startFakeProvider();
1181
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-input-"));
1182
+ const cacheDir = path.join(tmpDir, "cache");
1183
+ const stateDir = path.join(tmpDir, "state");
1184
+ await fsp.mkdir(cacheDir, { recursive: true });
1185
+ const filePath = path.join(tmpDir, "workflow.lobster");
1186
+ // Nothing replays the call after the resume, so the item `ask` swallowed was the only
1187
+ // carrier its usage ever had.
1188
+ await fsp.writeFile(filePath, JSON.stringify({
1189
+ steps: [
1190
+ {
1191
+ id: "ask-after-invoke",
1192
+ pipeline: "llm.invoke --model gpt-4o --prompt Summarize | ask --prompt Continue?",
1193
+ },
1194
+ { id: "done", command: "echo done" },
1195
+ ],
1196
+ }), "utf8");
1197
+ const env = {
1198
+ ...process.env,
1199
+ LOBSTER_STATE_DIR: stateDir,
1200
+ LOBSTER_CACHE_DIR: cacheDir,
1201
+ OPENCLAW_URL: provider.url,
1202
+ };
1203
+ const ctx = () => ({
1204
+ stdin: process.stdin,
1205
+ stdout: process.stdout,
1206
+ stderr: new PassThrough(),
1207
+ env,
1208
+ mode: "tool",
1209
+ registry: createDefaultRegistry(),
1210
+ });
1211
+ try {
1212
+ const paused = await runWorkflowFile({ filePath, ctx: ctx() });
1213
+ assert.equal(paused.status, "needs_input");
1214
+ const token = paused.requiresInput?.resumeToken;
1215
+ assert.equal(typeof token, "string");
1216
+ const resumed = await runWorkflowFile({
1217
+ filePath,
1218
+ ctx: ctx(),
1219
+ resume: decodeToken(token),
1220
+ response: { decision: "approve" },
1221
+ });
1222
+ assert.equal(resumed.status, "ok");
1223
+ assert.equal(provider.requests(), 1);
1224
+ assert.equal(resumed._meta?.cost?.totalInputTokens, 1000);
1225
+ assert.equal(resumed._meta?.cost?.totalOutputTokens, 500);
1226
+ }
1227
+ finally {
1228
+ await provider.close();
1229
+ await fsp.rm(tmpDir, { recursive: true, force: true });
1230
+ }
1231
+ });
1232
+ test("workflow cost tracking bills a rendered call when a later stage emits its own items", async () => {
1233
+ const provider = await startFakeProvider();
1234
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1235
+ const stateDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-state-"));
1236
+ try {
1237
+ // `json` records the model items it rendered and hands the next stage an empty stream;
1238
+ // `state.get` then emits an item of its own, which carries no usage.
1239
+ const { result } = await runWorkflow({
1240
+ steps: [
1241
+ {
1242
+ id: "render-then-read",
1243
+ pipeline: "llm.invoke --model gpt-4o --prompt Summarize | json | state.get cost-probe",
1244
+ },
1245
+ ],
1246
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir });
1247
+ assert.equal(result.status, "ok");
1248
+ assert.equal(provider.requests(), 1);
1249
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
1250
+ assert.equal(result._meta?.cost?.totalOutputTokens, 500);
1251
+ }
1252
+ finally {
1253
+ await provider.close();
1254
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1255
+ await fsp.rm(stateDir, { recursive: true, force: true });
1256
+ }
1257
+ });
1258
+ test("workflow cost tracking bills a call once when only a JSON round trip of its item survives", async () => {
1259
+ const provider = await startFakeProvider();
1260
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1261
+ const stateDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-state-"));
1262
+ try {
1263
+ // `state.set` stores the model item and `state.get` reads it back as a plain object: the
1264
+ // numbers survive, the in-process mark does not. Nothing bills the original -- the step
1265
+ // ends on an unrelated key -- so the call is billed to the step that made it, and the
1266
+ // copy surfacing a step later must not be billed on top of it.
1267
+ const { result } = await runWorkflow({
1268
+ steps: [
1269
+ {
1270
+ id: "store",
1271
+ pipeline: "llm.invoke --model gpt-4o --prompt Summarize | state.set probe | state.get sink",
1272
+ },
1273
+ { id: "reload", pipeline: "state.get probe" },
1274
+ ],
1275
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir });
1276
+ assert.equal(result.status, "ok");
1277
+ assert.equal(provider.requests(), 1);
1278
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
1279
+ assert.equal(result._meta?.cost?.totalOutputTokens, 500);
1280
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["store"]);
1281
+ }
1282
+ finally {
1283
+ await provider.close();
1284
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1285
+ await fsp.rm(stateDir, { recursive: true, force: true });
1286
+ }
1287
+ });
1288
+ test("llm spend ledger bills a copy of an item only for a call nothing accounted for", () => {
1289
+ const ledger = createLlmSpendLedger();
1290
+ ledger.record("key-a", { model: "gpt-4o", usage: { inputTokens: 1000, outputTokens: 500 } });
1291
+ // A step can print any cache key it likes, so a key alone settles nothing: an item whose
1292
+ // cost the run never recorded is billed on its own account and leaves the charge open, which
1293
+ // is what stops an invented item from hiding real spend.
1294
+ assert.equal(ledger.billCopy("key-a", "gpt-4o", { inputTokens: 1, outputTokens: 1 }), true);
1295
+ assert.equal(ledger.billCopy("key-b", "gpt-4o", { inputTokens: 1000, outputTokens: 500 }), true);
1296
+ assert.equal(ledger.billCopy("key-a", "gpt-5", { inputTokens: 1000, outputTokens: 500 }), true);
1297
+ assert.equal(ledger.outstanding().length, 1);
1298
+ // The recorded cost settles the charge, and the copy is billed as its carrier. Key order
1299
+ // changes across a JSON round trip; the numbers do not.
1300
+ assert.equal(ledger.billCopy("key-a", "gpt-4o", { outputTokens: 500, inputTokens: 1000 }), true);
1301
+ assert.deepEqual(ledger.outstanding(), []);
1302
+ // Public fields never prove that a later item is the same call.
1303
+ assert.equal(ledger.billCopy("key-a", "gpt-4o", { inputTokens: 1000, outputTokens: 500 }), true);
1304
+ });
1305
+ test("llm spend ledger does not trust a copy of a call the run already settled", () => {
1306
+ const ledger = createLlmSpendLedger();
1307
+ const usage = { inputTokens: 1000, outputTokens: 500 };
1308
+ ledger.record("key-a", { model: "gpt-4o", usage });
1309
+ // However the call was accounted for, ordinary JSON cannot prove it came from that call.
1310
+ assert.ok(ledger.claim("key-a"));
1311
+ assert.equal(ledger.billCopy("key-a", "gpt-4o", { ...usage }), true);
1312
+ assert.equal(ledger.billCopy("key-a", "gpt-4o", { inputTokens: 7 }), true);
1313
+ });
1314
+ // Two identical calls that race on a cold cache do not cost the same: output length varies with
1315
+ // sampling. Only one of the two answers is stored, so every replay of them carries that one
1316
+ // answer's numbers -- billing each replay by the copy it holds reports both calls at the price
1317
+ // of whichever won the cache write.
1318
+ test("workflow cost tracking bills each retried replay at what its own call cost", async () => {
1319
+ const provider = await startFakeProvider(2, (seen) => ({
1320
+ inputTokens: seen === 1 ? 1000 : 3000,
1321
+ outputTokens: seen === 1 ? 500 : 100,
1322
+ }));
1323
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1324
+ const flaky = await failsOnceCommand();
1325
+ const invoke = "llm.invoke --model gpt-4o --prompt Summarize";
1326
+ try {
1327
+ const { result } = await runWorkflow({
1328
+ steps: [
1329
+ {
1330
+ id: "invoke-twice-then-fail",
1331
+ retry: { max: 2, delay_ms: 1 },
1332
+ parallel: {
1333
+ branches: [
1334
+ { id: "invoke-a", pipeline: invoke },
1335
+ { id: "invoke-b", pipeline: invoke },
1336
+ { id: "flaky", command: flaky },
1337
+ ],
1338
+ },
1339
+ },
1340
+ ],
1341
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
1342
+ assert.equal(result.status, "ok");
1343
+ assert.equal(provider.requests(), 2);
1344
+ // 1000 + 3000 and 500 + 100: what the provider was actually paid, whichever answer the
1345
+ // two replays ended up carrying.
1346
+ assert.equal(result._meta?.cost?.totalInputTokens, 4000);
1347
+ assert.equal(result._meta?.cost?.totalOutputTokens, 600);
1348
+ }
1349
+ finally {
1350
+ await provider.close();
1351
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1352
+ }
1353
+ });
1354
+ test("cost_limit stop halts before the next step when no item carried the call's usage", async () => {
1355
+ const provider = await startFakeProvider();
1356
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-limit-"));
1357
+ const marker = path.join(tmpDir, "ran.txt").replaceAll(path.sep, "/");
1358
+ const script = path.join(tmpDir, "touch.mjs");
1359
+ await fsp.writeFile(script, `import fs from "node:fs";
1360
+ fs.writeFileSync("${marker}", "ran");
1361
+ `, "utf8");
1362
+ try {
1363
+ // The renderer consumes the model item and `state.get` emits one of its own, so the step
1364
+ // completes with nothing carrying the call's usage. The budget is already blown when the
1365
+ // step ends, and the step after it must not get to run its side effect.
1366
+ await assert.rejects(() => runWorkflow({
1367
+ cost_limit: { max_usd: 0.001, action: "stop" },
1368
+ steps: [
1369
+ {
1370
+ id: "spends",
1371
+ pipeline: "llm.invoke --model gpt-4o --prompt Summarize | json | state.get sink",
1372
+ },
1373
+ { id: "after", command: `node ${script}` },
1374
+ ],
1375
+ }, {
1376
+ OPENCLAW_URL: provider.url,
1377
+ LOBSTER_CACHE_DIR: path.join(tmpDir, "cache"),
1378
+ LOBSTER_STATE_DIR: path.join(tmpDir, "state"),
1379
+ }).then((run) => run.result), /Cost limit exceeded/);
1380
+ await assert.rejects(() => fsp.access(marker), "the step after the limit must not run");
1381
+ }
1382
+ finally {
1383
+ await provider.close();
1384
+ await fsp.rm(tmpDir, { recursive: true, force: true });
1385
+ }
1386
+ });
1387
+ test("cost_limit stop halts before the next step when the step that spent it failed", async () => {
1388
+ const provider = await startFakeProvider();
1389
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-limit-"));
1390
+ const marker = path.join(tmpDir, "ran.txt").replaceAll(path.sep, "/");
1391
+ const script = path.join(tmpDir, "touch.mjs");
1392
+ await fsp.writeFile(script, `import fs from "node:fs";
1393
+ fs.writeFileSync("${marker}", "ran");
1394
+ `, "utf8");
1395
+ const failing = await alwaysFailsCommand();
1396
+ try {
1397
+ // One branch pays a provider, another fails, and `on_error: continue` advances the run.
1398
+ // No item of the failed step is ever billed, so the charge it left behind is the only
1399
+ // record of the spend -- and the budget is already gone by the time the next step is
1400
+ // asked to run its side effect.
1401
+ await assert.rejects(() => runWorkflow({
1402
+ cost_limit: { max_usd: 0.001, action: "stop" },
1403
+ steps: [
1404
+ {
1405
+ id: "spends",
1406
+ on_error: "continue",
1407
+ parallel: {
1408
+ branches: [
1409
+ { id: "invoke", pipeline: "llm.invoke --model gpt-4o --prompt Summarize" },
1410
+ { id: "fails", command: failing },
1411
+ ],
1412
+ },
1413
+ },
1414
+ { id: "after", command: `node ${script}` },
1415
+ ],
1416
+ }, {
1417
+ OPENCLAW_URL: provider.url,
1418
+ LOBSTER_CACHE_DIR: path.join(tmpDir, "cache"),
1419
+ }).then((run) => run.result), /Cost limit exceeded/);
1420
+ await assert.rejects(() => fsp.access(marker), "the step after the limit must not run");
1421
+ }
1422
+ finally {
1423
+ await provider.close();
1424
+ await fsp.rm(tmpDir, { recursive: true, force: true });
1425
+ }
1426
+ });
1427
+ test("llm spend ledger matches a copy on the token counts that are billed", () => {
1428
+ const ledger = createLlmSpendLedger();
1429
+ ledger.record("key-a", {
1430
+ model: "gpt-4o",
1431
+ usage: { inputTokens: 1000, outputTokens: 500, totalTokens: 1500 },
1432
+ });
1433
+ // A stage that rebuilds an item can drop or recompute a field nothing is charged for. The
1434
+ // charge is the same charge, so the copy has to settle it rather than be billed beside it.
1435
+ assert.equal(ledger.billCopy("key-a", "gpt-4o", { inputTokens: 1000, outputTokens: 500 }), true);
1436
+ assert.deepEqual(ledger.outstanding(), []);
1437
+ });
1438
+ test("llm spend ledger matches a copy across the token field names providers use", () => {
1439
+ const ledger = createLlmSpendLedger();
1440
+ ledger.record("key-a", { model: "gpt-4o", usage: { inputTokens: 1000, outputTokens: 500 } });
1441
+ // `CostTracker` bills these spellings as the same tokens, so the ledger has to read them as
1442
+ // the same charge.
1443
+ assert.equal(ledger.billCopy("key-a", "gpt-4o", { prompt_tokens: 1000, completion_tokens: 500 }), true);
1444
+ assert.deepEqual(ledger.outstanding(), []);
1445
+ // A cost the run never recorded still settles nothing.
1446
+ ledger.record("key-b", { model: "gpt-4o", usage: { inputTokens: 1000, outputTokens: 500 } });
1447
+ assert.equal(ledger.billCopy("key-b", "gpt-4o", { prompt_tokens: 999 }), true);
1448
+ assert.equal(ledger.outstanding().length, 1);
1449
+ });
1450
+ test("workflow cost tracking bills a call once when the copy of its item lost the cache key", async () => {
1451
+ const provider = await startFakeProvider();
1452
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1453
+ const stateDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-state-"));
1454
+ try {
1455
+ // `state.get` reads the stored item back without its marks and `pick` projects it down to
1456
+ // the two fields that matter, dropping the cache key with them. All that reaches the
1457
+ // accounting is a cost, and it is the cost of the call whose charge is still open.
1458
+ const { result } = await runWorkflow({
1459
+ steps: [
1460
+ {
1461
+ id: "invoke",
1462
+ pipeline: "llm.invoke --model gpt-4o --prompt Summarize | state.set probe | state.get probe | pick model,usage",
1463
+ },
1464
+ ],
1465
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir });
1466
+ assert.equal(result.status, "ok");
1467
+ assert.equal(provider.requests(), 1);
1468
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
1469
+ assert.equal(result._meta?.cost?.totalOutputTokens, 500);
1470
+ }
1471
+ finally {
1472
+ await provider.close();
1473
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1474
+ await fsp.rm(stateDir, { recursive: true, force: true });
1475
+ }
1476
+ });
1477
+ test("workflow cost tracking does not let an answer that cost nothing stand in for one that did", async () => {
1478
+ // The first answer reports no usage at all, so nothing bills it and nothing ever will. A
1479
+ // charge for it would be the one handed to the refresh below, leaving the refresh's own
1480
+ // charge open for the end-of-step settlement to bill on top of the item already recorded.
1481
+ const provider = await startFakeProvider(1, (seen) => seen === 1 ? null : { inputTokens: 1000, outputTokens: 500 });
1482
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1483
+ const invoke = "llm.invoke --model gpt-4o --prompt Summarize";
1484
+ try {
1485
+ const { result } = await runWorkflow({
1486
+ steps: [
1487
+ { id: "no-usage", pipeline: `${invoke} | json` },
1488
+ { id: "refresh", pipeline: `${invoke} --refresh | json` },
1489
+ ],
1490
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
1491
+ assert.equal(result.status, "ok");
1492
+ assert.equal(provider.requests(), 2);
1493
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
1494
+ assert.equal(result._meta?.cost?.totalOutputTokens, 500);
1495
+ }
1496
+ finally {
1497
+ await provider.close();
1498
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1499
+ }
1500
+ });
1501
+ test("llm spend ledger reads a copy that kept no cache key by what it cost", () => {
1502
+ const ledger = createLlmSpendLedger();
1503
+ const usage = { inputTokens: 1000, outputTokens: 500 };
1504
+ ledger.record("key-a", { model: "gpt-4o", usage });
1505
+ // Without a key the cost is the only evidence, and it is enough to settle an open charge --
1506
+ // which only ever adds a record of a call, never removes one.
1507
+ assert.equal(ledger.billCopy(null, "gpt-4o", { inputTokens: 7 }), true);
1508
+ assert.equal(ledger.outstanding().length, 1);
1509
+ assert.equal(ledger.billCopy(null, "gpt-4o", { ...usage }), true);
1510
+ assert.deepEqual(ledger.outstanding(), []);
1511
+ });
1512
+ test("llm spend ledger never lets a keyless item withhold its own record", () => {
1513
+ const ledger = createLlmSpendLedger();
1514
+ const usage = { inputTokens: 1000, outputTokens: 500 };
1515
+ ledger.record("key-a", { model: "gpt-4o", usage });
1516
+ assert.ok(ledger.claim("key-a"));
1517
+ // Resembling a call this run already billed is not evidence of being a copy of it: two
1518
+ // unrelated objects can carry the same model and the same counts, and an ordinary step
1519
+ // printing `{ model, usage }` must not be able to drop out of `cost_limit` that way. With
1520
+ // a matching public key remains forgeable, so both forms are billed.
1521
+ assert.equal(ledger.billCopy(null, "gpt-4o", { ...usage }), true);
1522
+ assert.equal(ledger.billCopy("key-a", "gpt-4o", { ...usage }), true);
1523
+ });
1524
+ test("llm spend ledger keeps a copy naming one call from settling another", () => {
1525
+ const ledger = createLlmSpendLedger();
1526
+ ledger.record("key-a", { model: "gpt-4o", usage: { inputTokens: 1000, outputTokens: 500 } });
1527
+ // The key a copy still carries says which call it came from, so it is read against that
1528
+ // call rather than against whichever other charge happens to have cost the same.
1529
+ assert.equal(ledger.billCopy("key-b", "gpt-4o", { inputTokens: 1000, outputTokens: 500 }), true);
1530
+ assert.equal(ledger.outstanding().length, 1);
1531
+ });
1532
+ // A step that pays a provider and then fails is retried, and `--refresh` makes the retry pay
1533
+ // again rather than replay. Two charges under one key, and they did not cost the same: only the
1534
+ // retry produces an item, so the charge it settles decides what the failed attempt is billed as.
1535
+ test("workflow cost tracking bills a retried live call and the attempt before it at their own costs", async () => {
1536
+ const provider = await startFakeProvider(1, (seen) => seen === 1
1537
+ ? { inputTokens: 1000, outputTokens: 500 }
1538
+ : { inputTokens: 3000, outputTokens: 100 });
1539
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1540
+ const flaky = await failsOnceCommand();
1541
+ try {
1542
+ const { result } = await runWorkflow({
1543
+ steps: [
1544
+ {
1545
+ id: "pay-fail-pay",
1546
+ retry: { max: 2, delay_ms: 1 },
1547
+ parallel: {
1548
+ branches: [
1549
+ {
1550
+ id: "invoke",
1551
+ pipeline: "llm.invoke --model gpt-4o --prompt Summarize --refresh",
1552
+ },
1553
+ { id: "flaky", command: flaky },
1554
+ ],
1555
+ },
1556
+ },
1557
+ ],
1558
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
1559
+ assert.equal(result.status, "ok");
1560
+ assert.equal(provider.requests(), 2);
1561
+ // 1000 + 3000 and 500 + 100. Billing the retry's item against the failed attempt's charge
1562
+ // would report the retry twice and the attempt before it not at all.
1563
+ assert.equal(result._meta?.cost?.totalInputTokens, 4000);
1564
+ assert.equal(result._meta?.cost?.totalOutputTokens, 600);
1565
+ }
1566
+ finally {
1567
+ await provider.close();
1568
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1569
+ }
1570
+ });
1571
+ test("workflow cost tracking bills a keyless step that resembles a call already billed", async () => {
1572
+ const provider = await startFakeProvider();
1573
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1574
+ const lookalike = await emitJsonCommand({
1575
+ model: "gpt-4o",
1576
+ usage: { inputTokens: 1000, outputTokens: 500 },
1577
+ });
1578
+ try {
1579
+ // The second step prints the same model and the same counts as the call the first one
1580
+ // paid for, and names no cache key. That is not evidence of being a copy of it, so it is
1581
+ // billed on its own account -- the run is charged for both.
1582
+ const { result } = await runWorkflow({
1583
+ steps: [
1584
+ { id: "live", pipeline: "llm.invoke --model gpt-4o --prompt Summarize | json" },
1585
+ { id: "lookalike", command: lookalike },
1586
+ ],
1587
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
1588
+ assert.equal(result.status, "ok");
1589
+ assert.equal(result._meta?.cost?.totalInputTokens, 2000);
1590
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["live", "lookalike"]);
1591
+ }
1592
+ finally {
1593
+ await provider.close();
1594
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1595
+ }
1596
+ });
1597
+ // Two live calls in one step, costing the same and made under different cache keys. One branch
1598
+ // hands on a copy that lost its mark and its key, so all the ledger can read it by is the cost --
1599
+ // and until the marked branch has settled its own charge, the other call's charge answers to that
1600
+ // cost just as well.
1601
+ test("workflow cost tracking does not let a keyless copy settle another call's charge", async () => {
1602
+ const provider = await startFakeProvider(1, undefined, (prompt) => (prompt === "Beta" ? 60 : 0));
1603
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1604
+ const stateDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-state-"));
1605
+ try {
1606
+ const { result } = await runWorkflow({
1607
+ steps: [
1608
+ {
1609
+ id: "two-calls",
1610
+ parallel: {
1611
+ wait: "all",
1612
+ branches: [
1613
+ {
1614
+ // Declared first, so it is accounted first; its own call answers last.
1615
+ id: "filtered",
1616
+ pipeline: "llm.invoke --model gpt-4o --prompt Beta | state.set probe | state.get probe | pick model,usage",
1617
+ },
1618
+ { id: "marked", pipeline: "llm.invoke --model gpt-4o --prompt Alpha | json" },
1619
+ ],
1620
+ },
1621
+ },
1622
+ ],
1623
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir });
1624
+ assert.equal(result.status, "ok");
1625
+ assert.equal(provider.requests(), 2);
1626
+ // Two calls, two records. Reading the copy before the marked branch settles leaves the
1627
+ // copy on one charge, the marked branch billed regardless, and the third charge for the
1628
+ // step settlement to find.
1629
+ assert.equal(result._meta?.cost?.totalInputTokens, 2000);
1630
+ assert.equal(result._meta?.cost?.totalOutputTokens, 1000);
1631
+ }
1632
+ finally {
1633
+ await provider.close();
1634
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1635
+ await fsp.rm(stateDir, { recursive: true, force: true });
1636
+ }
1637
+ });
1638
+ test("workflow cost tracking does not bill a replay routed through state storage", async () => {
1639
+ const provider = await startFakeProvider();
1640
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1641
+ const stateDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-state-"));
1642
+ const invoke = "llm.invoke --model gpt-4o --prompt Summarize";
1643
+ const env = {
1644
+ OPENCLAW_URL: provider.url,
1645
+ LOBSTER_CACHE_DIR: cacheDir,
1646
+ LOBSTER_STATE_DIR: stateDir,
1647
+ };
1648
+ try {
1649
+ // An earlier run paid for the answer, so this run has no charge of its own to settle: it
1650
+ // is billed nothing whether or not it stores the replay on the way past. `state.get`
1651
+ // rebuilds the item from its own JSON, which carries none of the marks this process
1652
+ // attached -- but the command wrote those bytes itself a moment earlier, so it knows what
1653
+ // the value it hands back stands in for.
1654
+ const first = await runWorkflow({ steps: [{ id: "warm", pipeline: `${invoke} | json` }] }, env);
1655
+ assert.equal(first.result._meta?.cost?.totalInputTokens, 1000);
1656
+ const { result } = await runWorkflow({
1657
+ steps: [
1658
+ { id: "through-state", pipeline: `${invoke} | state.set probe | state.get probe | json` },
1659
+ ],
1660
+ }, env);
1661
+ assert.equal(result.status, "ok");
1662
+ assert.equal(provider.requests(), 1);
1663
+ assert.equal(result._meta?.cost, undefined);
1664
+ }
1665
+ finally {
1666
+ await provider.close();
1667
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1668
+ await fsp.rm(stateDir, { recursive: true, force: true });
1669
+ }
1670
+ });
1671
+ test("workflow cost tracking still bills a state value this process never wrote", async () => {
1672
+ const provider = await startFakeProvider();
1673
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1674
+ const stateDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-state-"));
1675
+ // A state file some other run -- or some other program -- left behind. It looks exactly like
1676
+ // a replay and says so, and it is still billed: the bytes on disk were never handed to this
1677
+ // process to be marked, so nothing about them can claim the call was already paid for.
1678
+ await fsp.writeFile(path.join(stateDir, "planted.json"), JSON.stringify(forgedReplayItem("cache"), null, 2) + "\n", "utf8");
1679
+ try {
1680
+ const { result } = await runWorkflow({ steps: [{ id: "planted", pipeline: "state.get planted | json" }] }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir, LOBSTER_STATE_DIR: stateDir });
1681
+ assert.equal(result.status, "ok");
1682
+ assert.equal(result._meta?.cost?.totalInputTokens, 1000);
1683
+ }
1684
+ finally {
1685
+ await provider.close();
1686
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1687
+ await fsp.rm(stateDir, { recursive: true, force: true });
1688
+ }
1689
+ });
1690
+ // A response the local validator rejects was still answered by a provider, and paid for. The
1691
+ // command asks again inside the same step, so nothing downstream ever sees the rejected attempt
1692
+ // -- the charge it opened is the only record that the call happened.
1693
+ test("workflow cost tracking bills a provider call the schema validator rejected", async () => {
1694
+ const provider = await startFakeProvider(1, (seen) => seen === 1
1695
+ ? { inputTokens: 1000, outputTokens: 500 }
1696
+ : { inputTokens: 3000, outputTokens: 100 }, () => 0, (seen) => (seen === 1 ? {} : { summary: "hello" }));
1697
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1698
+ const schema = JSON.stringify({
1699
+ type: "object",
1700
+ required: ["summary"],
1701
+ properties: { summary: { type: "string" } },
1702
+ });
1703
+ try {
1704
+ const { result } = await runWorkflow({
1705
+ steps: [
1706
+ {
1707
+ id: "validated",
1708
+ pipeline: `llm.invoke --model gpt-4o --prompt Summarize --output-schema '${schema}' | json`,
1709
+ },
1710
+ ],
1711
+ }, { OPENCLAW_URL: provider.url, LOBSTER_CACHE_DIR: cacheDir });
1712
+ assert.equal(result.status, "ok");
1713
+ assert.equal(provider.requests(), 2, "the first answer failed the schema and was asked again");
1714
+ // 1000 + 3000 and 500 + 100: both calls, not just the one that satisfied the schema.
1715
+ assert.equal(result._meta?.cost?.totalInputTokens, 4000);
1716
+ assert.equal(result._meta?.cost?.totalOutputTokens, 600);
1717
+ }
1718
+ finally {
1719
+ await provider.close();
1720
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1721
+ }
1722
+ });
1723
+ // Restoring what a paused run spent without restoring what it spent it on counts one call
1724
+ // twice. The steps come back from the resume state as JSON, so a completed `llm.invoke` output
1725
+ // among them carries no mark this process put there — only the cache key, which is an ordinary
1726
+ // field. A later step that re-emits that output is then a copy of a call the fresh ledger has
1727
+ // never heard of, and it is billed on top of the restored total.
1728
+ test("workflow cost tracking does not rebill a pre-pause call re-emitted after a resume", async () => {
1729
+ const provider = await startFakeProvider();
1730
+ const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-rebill-"));
1731
+ const cacheDir = path.join(tmpDir, "cache");
1732
+ const stateDir = path.join(tmpDir, "state");
1733
+ await fsp.mkdir(cacheDir, { recursive: true });
1734
+ const filePath = path.join(tmpDir, "workflow.lobster");
1735
+ await fsp.writeFile(filePath, JSON.stringify({
1736
+ steps: [
1737
+ { id: "live", pipeline: "llm.invoke --model gpt-4o --prompt Summarize" },
1738
+ { id: "gate", run: "echo gate", approval: "Continue?" },
1739
+ // Re-emits the answer the run paid for before the gate, as a plain JSON copy.
1740
+ { id: "echo", pipeline: "head --n 1", stdin: "$live.json" },
1741
+ ],
1742
+ }), "utf8");
1743
+ const env = {
1744
+ ...process.env,
1745
+ LOBSTER_STATE_DIR: stateDir,
1746
+ LOBSTER_CACHE_DIR: cacheDir,
1747
+ OPENCLAW_URL: provider.url,
1748
+ };
1749
+ const ctx = () => ({
1750
+ stdin: process.stdin,
1751
+ stdout: process.stdout,
1752
+ stderr: new PassThrough(),
1753
+ env,
1754
+ mode: "tool",
1755
+ registry: createDefaultRegistry(),
1756
+ });
1757
+ try {
1758
+ const paused = await runWorkflowFile({ filePath, ctx: ctx() });
1759
+ assert.equal(paused.status, "needs_approval");
1760
+ const token = paused.requiresApproval?.resumeToken;
1761
+ assert.equal(typeof token, "string");
1762
+ const resumed = await runWorkflowFile({
1763
+ filePath,
1764
+ ctx: ctx(),
1765
+ resume: decodeToken(token),
1766
+ approved: true,
1767
+ });
1768
+ assert.equal(resumed.status, "ok");
1769
+ // One provider call, so one call's worth of tokens however many steps show its answer.
1770
+ assert.equal(provider.requests(), 1);
1771
+ assert.equal(resumed._meta?.cost?.totalInputTokens, 1000);
1772
+ assert.equal(resumed._meta?.cost?.totalOutputTokens, 500);
1773
+ assert.deepEqual(resumed._meta?.cost?.byStep.map((entry) => entry.stepId), ["live"]);
1774
+ }
1775
+ finally {
1776
+ await provider.close();
1777
+ await fsp.rm(tmpDir, { recursive: true, force: true });
1778
+ }
1779
+ });
1780
+ // A settled charge is read only against the cache key it names, so restored state cannot excuse
1781
+ // a call it does not name. An entry invented for one key leaves a copy of a different call
1782
+ // billed exactly as it would have been.
1783
+ test("restored billed charges mark only the call they name", async () => {
1784
+ const ledger = createLlmSpendLedger();
1785
+ const settled = [
1786
+ {
1787
+ cacheKey: "key-a",
1788
+ count: 1,
1789
+ model: "gpt-4o",
1790
+ usage: { inputTokens: 1000, outputTokens: 500 },
1791
+ },
1792
+ ];
1793
+ ledger.restoreSettled(settled);
1794
+ const matching = {
1795
+ cacheKey: "key-a",
1796
+ model: "gpt-4o",
1797
+ usage: { inputTokens: 1000, outputTokens: 500 },
1798
+ };
1799
+ const other = {
1800
+ cacheKey: "key-b",
1801
+ model: "gpt-4o",
1802
+ usage: { inputTokens: 1000, outputTokens: 500 },
1803
+ };
1804
+ restoreLlmProvenance({ matching, other }, settled);
1805
+ assert.deepEqual(llmProvenanceOf(matching), { cacheKey: "key-a", replayed: true });
1806
+ assert.equal(llmProvenanceOf(other), null);
1807
+ // Outside the trusted restore boundary, the same public fields remain billable.
1808
+ assert.equal(ledger.billCopy("key-a", "gpt-4o", { inputTokens: 1000, outputTokens: 500 }), true);
1809
+ assert.equal(ledger.claim("key-a"), null);
1810
+ });
1811
+ // What the ledger hands to a pausing run has to be readable by the run that resumes it: a
1812
+ // charge already billed travels as billed, and one still open travels as open.
1813
+ test("a ledger's settled and open charges survive a round trip through resume state", () => {
1814
+ const source = createLlmSpendLedger();
1815
+ source.record("billed-key", {
1816
+ stepId: "one",
1817
+ model: "gpt-4o",
1818
+ usage: { inputTokens: 100, outputTokens: 20 },
1819
+ });
1820
+ source.record("open-key", {
1821
+ stepId: "two",
1822
+ model: "gpt-4o",
1823
+ usage: { inputTokens: 300, outputTokens: 40 },
1824
+ });
1825
+ source.claim("billed-key", { model: "gpt-4o", usage: { inputTokens: 100, outputTokens: 20 } });
1826
+ const carried = JSON.parse(JSON.stringify({ llmSpend: source.outstanding(), llmBilled: source.settled() }));
1827
+ assert.deepEqual(carried.llmSpend.map((charge) => charge.cacheKey), ["open-key"]);
1828
+ assert.deepEqual(carried.llmBilled.map((charge) => charge.cacheKey), ["billed-key"]);
1829
+ const resumed = createLlmSpendLedger();
1830
+ resumed.restore(carried.llmSpend);
1831
+ resumed.restoreSettled(carried.llmBilled);
1832
+ // The open charge is still the resumed run's to settle...
1833
+ assert.deepEqual(resumed.claim("open-key")?.usage, { inputTokens: 300, outputTokens: 40 });
1834
+ // ...and the trusted resume boundary restores private provenance for the billed one.
1835
+ const billedCopy = {
1836
+ cacheKey: "billed-key",
1837
+ model: "gpt-4o",
1838
+ usage: { inputTokens: 100, outputTokens: 20 },
1839
+ };
1840
+ restoreLlmProvenance(billedCopy, carried.llmBilled);
1841
+ assert.deepEqual(llmProvenanceOf(billedCopy), { cacheKey: "billed-key", replayed: true });
1842
+ });
1843
+ // A `wait: "any"` step keeps the first branch to answer and abandons the rest, but an abandoned
1844
+ // branch is not a stopped one: it can still be waiting on a provider, and it pays when the answer
1845
+ // arrives. Whether that spend reached the workflow's own total used to depend on nothing but how
1846
+ // long the run happened to live afterwards, so the same workflow over the same three calls billed
1847
+ // two of them or three. The discarded branch's output is thrown away and its cost goes with it.
1848
+ async function runDiscardedBranchRace(finishSlowDuringRun) {
1849
+ let requests = 0;
1850
+ let slowStarted;
1851
+ const slowEntered = new Promise((resolve) => (slowStarted = resolve));
1852
+ let releaseSlow;
1853
+ const slowReleased = new Promise((resolve) => (releaseSlow = resolve));
1854
+ const response = {
1855
+ ok: true,
1856
+ result: {
1857
+ model: "gpt-4o",
1858
+ output: { data: { summary: "hello" } },
1859
+ usage: { inputTokens: 1000, outputTokens: 500 },
1860
+ },
1861
+ };
1862
+ let slowFinished = false;
1863
+ const slowResponse = slowReleased.then(() => {
1864
+ slowFinished = true;
1865
+ return response;
1866
+ });
1867
+ const adapter = async ({ payload }) => {
1868
+ requests += 1;
1869
+ if (payload.prompt === "Slow") {
1870
+ slowStarted();
1871
+ return slowResponse;
1872
+ }
1873
+ if (payload.prompt === "Fast") {
1874
+ // Both calls must start, but only Fast can finish before the tail begins.
1875
+ await slowEntered;
1876
+ }
1877
+ else if (finishSlowDuringRun) {
1878
+ releaseSlow();
1879
+ await slowResponse;
1880
+ }
1881
+ return response;
1882
+ };
1883
+ const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-cost-cache-"));
1884
+ try {
1885
+ const { result } = await runWorkflow({
1886
+ steps: [
1887
+ {
1888
+ id: "race",
1889
+ parallel: {
1890
+ wait: "any",
1891
+ branches: [
1892
+ { id: "fast", pipeline: "llm.invoke --model gpt-4o --prompt Fast | json" },
1893
+ { id: "slow", pipeline: "llm.invoke --model gpt-4o --prompt Slow | json" },
1894
+ ],
1895
+ },
1896
+ },
1897
+ { id: "tail", pipeline: "llm.invoke --model gpt-4o --prompt Tail | json" },
1898
+ ],
1899
+ }, { LOBSTER_LLM_PROVIDER: "race", LOBSTER_CACHE_DIR: cacheDir }, { race: adapter });
1900
+ assert.equal(slowFinished, finishSlowDuringRun);
1901
+ return { result, requests };
1902
+ }
1903
+ finally {
1904
+ releaseSlow();
1905
+ await slowResponse;
1906
+ await fsp.rm(cacheDir, { recursive: true, force: true });
1907
+ }
1908
+ }
1909
+ test("workflow cost tracking leaves a discarded wait:any branch out of the total", async () => {
1910
+ // The run ends while the losing branch is still waiting on its provider.
1911
+ const ended = await runDiscardedBranchRace(false);
1912
+ // The run is still going when that branch pays.
1913
+ const running = await runDiscardedBranchRace(true);
1914
+ for (const { result, requests } of [ended, running]) {
1915
+ assert.equal(result.status, "ok");
1916
+ // All three calls really were made; only two of them are the workflow's.
1917
+ assert.equal(requests, 3);
1918
+ assert.equal(result._meta?.cost?.totalInputTokens, 2000);
1919
+ assert.equal(result._meta?.cost?.totalOutputTokens, 1000);
1920
+ assert.deepEqual(result._meta?.cost?.byStep.map((entry) => entry.stepId), ["fast", "tail"]);
1921
+ }
1922
+ // The total is the same either way: it does not turn on the losing branch's timing.
1923
+ assert.equal(ended.result._meta?.cost?.totalInputTokens, running.result._meta?.cost?.totalInputTokens);
1924
+ });
1925
+ for (const loop of [false, true]) {
1926
+ test(`cost_limit stops ${loop ? "for_each" : "pipeline"} retries after a paid call loses its output`, async () => {
1927
+ let calls = 0;
1928
+ const step = {
1929
+ id: "spend",
1930
+ pipeline: 'llm.invoke --provider budget-test --model gpt-4o --prompt test --refresh --disable-cache | exec --stdin json node -e "process.exit(1)"',
1931
+ };
1932
+ const retry = { max: 3, delay_ms: 1 };
1933
+ await assert.rejects(() => runWorkflow({
1934
+ cost_limit: { max_usd: 0.001, action: "stop" },
1935
+ steps: loop
1936
+ ? [
1937
+ { id: "data", pipeline: "exec --json node -e \"console.log('[1,2]')\"" },
1938
+ {
1939
+ id: "loop",
1940
+ for_each: "$data.json",
1941
+ retry,
1942
+ on_error: "continue",
1943
+ steps: [step],
1944
+ },
1945
+ ]
1946
+ : [{ ...step, retry, on_error: "continue" }],
1947
+ }, undefined, {
1948
+ "budget-test": async () => {
1949
+ calls++;
1950
+ return {
1951
+ ok: true,
1952
+ result: {
1953
+ output: { text: "paid" },
1954
+ model: "gpt-4o",
1955
+ usage: { inputTokens: 1000, outputTokens: 500 },
1956
+ },
1957
+ };
1958
+ },
1959
+ }), /Cost limit exceeded/);
1960
+ assert.equal(calls, 1, "a downstream failure must not hide paid usage from the retry budget");
1961
+ });
1962
+ }
231
1963
  //# sourceMappingURL=cost_tracker.test.js.map