terramend 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (406) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +145 -0
  3. package/dist/agents/claude.d.ts +73 -0
  4. package/dist/agents/claudePretoolGate.d.ts +99 -0
  5. package/dist/agents/gateServer.d.ts +7 -0
  6. package/dist/agents/index.d.ts +6 -0
  7. package/dist/agents/nativeFsDenies.d.ts +28 -0
  8. package/dist/agents/opencode.d.ts +231 -0
  9. package/dist/agents/opencodePlugin.d.ts +85 -0
  10. package/dist/agents/opencodeShared.d.ts +40 -0
  11. package/dist/agents/postRun.d.ts +132 -0
  12. package/dist/agents/reviewer.d.ts +38 -0
  13. package/dist/agents/sessionLabeler.d.ts +97 -0
  14. package/dist/agents/shared.d.ts +189 -0
  15. package/dist/agents/subagentModels.d.ts +19 -0
  16. package/dist/agents/subagentToolGates.d.ts +55 -0
  17. package/dist/cli.mjs +197426 -0
  18. package/dist/external.d.ts +227 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +196783 -0
  21. package/dist/internal/index.d.ts +18 -0
  22. package/dist/internal.js +1714 -0
  23. package/dist/lifecycle.d.ts +2 -0
  24. package/dist/main.d.ts +8 -0
  25. package/dist/mcp/arkConfig.d.ts +1 -0
  26. package/dist/mcp/checkSuite.d.ts +25 -0
  27. package/dist/mcp/checkout.d.ts +77 -0
  28. package/dist/mcp/comment.d.ts +119 -0
  29. package/dist/mcp/commitInfo.d.ts +9 -0
  30. package/dist/mcp/crosswalk.d.ts +105 -0
  31. package/dist/mcp/dependencies.d.ts +8 -0
  32. package/dist/mcp/geminiSanitizer.d.ts +28 -0
  33. package/dist/mcp/git.d.ts +46 -0
  34. package/dist/mcp/guardrails.d.ts +104 -0
  35. package/dist/mcp/issue.d.ts +18 -0
  36. package/dist/mcp/issueComments.d.ts +9 -0
  37. package/dist/mcp/issueEvents.d.ts +9 -0
  38. package/dist/mcp/issueInfo.d.ts +9 -0
  39. package/dist/mcp/labels.d.ts +12 -0
  40. package/dist/mcp/localContext.d.ts +19 -0
  41. package/dist/mcp/moduleExtraction.d.ts +71 -0
  42. package/dist/mcp/moduleTests.d.ts +104 -0
  43. package/dist/mcp/modules.d.ts +179 -0
  44. package/dist/mcp/output.d.ts +12 -0
  45. package/dist/mcp/pathSafety.d.ts +14 -0
  46. package/dist/mcp/policy.d.ts +48 -0
  47. package/dist/mcp/pr.d.ts +49 -0
  48. package/dist/mcp/prInfo.d.ts +9 -0
  49. package/dist/mcp/providerSchema.d.ts +50 -0
  50. package/dist/mcp/review.d.ts +199 -0
  51. package/dist/mcp/reviewComments.d.ts +178 -0
  52. package/dist/mcp/roots.d.ts +58 -0
  53. package/dist/mcp/scope.d.ts +15 -0
  54. package/dist/mcp/selectMode.d.ts +18 -0
  55. package/dist/mcp/server.d.ts +48 -0
  56. package/dist/mcp/shared.d.ts +47 -0
  57. package/dist/mcp/shell.d.ts +37 -0
  58. package/dist/mcp/staleFix.d.ts +51 -0
  59. package/dist/mcp/terraform/cost.d.ts +55 -0
  60. package/dist/mcp/terraform/currency.d.ts +94 -0
  61. package/dist/mcp/terraform/decisions.d.ts +178 -0
  62. package/dist/mcp/terraform/findings.d.ts +75 -0
  63. package/dist/mcp/terraform/plan.d.ts +157 -0
  64. package/dist/mcp/terraform/scanners.d.ts +131 -0
  65. package/dist/mcp/terraform/tools.d.ts +63 -0
  66. package/dist/mcp/terraform/types.d.ts +172 -0
  67. package/dist/mcp/terraform.d.ts +22 -0
  68. package/dist/mcp/terratest.d.ts +83 -0
  69. package/dist/mcp/upload.d.ts +6 -0
  70. package/dist/models.d.ts +171 -0
  71. package/dist/modes.d.ts +26 -0
  72. package/dist/prep/index.d.ts +7 -0
  73. package/dist/prep/installNodeDependencies.d.ts +2 -0
  74. package/dist/prep/installPythonDependencies.d.ts +2 -0
  75. package/dist/prep/types.d.ts +31 -0
  76. package/dist/reviewQuality.d.ts +64 -0
  77. package/dist/skills/terraform-best-practices/SKILL.md +369 -0
  78. package/dist/toolState.d.ts +135 -0
  79. package/dist/utils/activity.d.ts +40 -0
  80. package/dist/utils/agent.d.ts +20 -0
  81. package/dist/utils/agentHangReport.d.ts +38 -0
  82. package/dist/utils/apiFetch.d.ts +19 -0
  83. package/dist/utils/apiKeys.d.ts +41 -0
  84. package/dist/utils/apiUrl.d.ts +20 -0
  85. package/dist/utils/assets.d.ts +8 -0
  86. package/dist/utils/billingErrors.d.ts +85 -0
  87. package/dist/utils/body.d.ts +34 -0
  88. package/dist/utils/buildTerramendFooter.d.ts +25 -0
  89. package/dist/utils/byokFallback.d.ts +85 -0
  90. package/dist/utils/claudeSubscription.d.ts +30 -0
  91. package/dist/utils/cli.d.ts +10 -0
  92. package/dist/utils/codexHome.d.ts +29 -0
  93. package/dist/utils/codexOAuth.d.ts +60 -0
  94. package/dist/utils/diffCoverage.d.ts +63 -0
  95. package/dist/utils/errorReport.d.ts +17 -0
  96. package/dist/utils/exitHandler.d.ts +8 -0
  97. package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
  98. package/dist/utils/gitAuth.d.ts +84 -0
  99. package/dist/utils/gitAuthServer.d.ts +24 -0
  100. package/dist/utils/github.d.ts +78 -0
  101. package/dist/utils/globals.d.ts +3 -0
  102. package/dist/utils/install.d.ts +60 -0
  103. package/dist/utils/instructions.d.ts +48 -0
  104. package/dist/utils/leapingComment.d.ts +11 -0
  105. package/dist/utils/learnings.d.ts +62 -0
  106. package/dist/utils/learningsTruncate.d.ts +25 -0
  107. package/dist/utils/lifecycle.d.ts +57 -0
  108. package/dist/utils/log.d.ts +111 -0
  109. package/dist/utils/normalizeEnv.d.ts +30 -0
  110. package/dist/utils/openCodeModels.d.ts +11 -0
  111. package/dist/utils/overrides.d.ts +40 -0
  112. package/dist/utils/packageManager.d.ts +49 -0
  113. package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
  114. package/dist/utils/payload.d.ts +105 -0
  115. package/dist/utils/prSummary.d.ts +61 -0
  116. package/dist/utils/progressComment.d.ts +146 -0
  117. package/dist/utils/providerErrors.d.ts +31 -0
  118. package/dist/utils/rangeDiff.d.ts +51 -0
  119. package/dist/utils/remediationCommand.d.ts +55 -0
  120. package/dist/utils/retry.d.ts +13 -0
  121. package/dist/utils/reviewCleanup.d.ts +14 -0
  122. package/dist/utils/run.d.ts +9 -0
  123. package/dist/utils/runContext.d.ts +60 -0
  124. package/dist/utils/runContextData.d.ts +23 -0
  125. package/dist/utils/runErrorRenderer.d.ts +64 -0
  126. package/dist/utils/runLifecycle.d.ts +86 -0
  127. package/dist/utils/runStartupLog.d.ts +15 -0
  128. package/dist/utils/secrets.d.ts +22 -0
  129. package/dist/utils/setup.d.ts +90 -0
  130. package/dist/utils/shell.d.ts +32 -0
  131. package/dist/utils/skills.d.ts +10 -0
  132. package/dist/utils/subprocess.d.ts +80 -0
  133. package/dist/utils/terraformMcp.d.ts +42 -0
  134. package/dist/utils/time.d.ts +15 -0
  135. package/dist/utils/timer.d.ts +23 -0
  136. package/dist/utils/todoTracking.d.ts +16 -0
  137. package/dist/utils/token.d.ts +39 -0
  138. package/dist/utils/version.d.ts +2 -0
  139. package/dist/utils/versioning.d.ts +7 -0
  140. package/dist/utils/vertex.d.ts +16 -0
  141. package/dist/utils/workflow.d.ts +13 -0
  142. package/package.json +119 -0
  143. package/src/agents/claude.test.ts +1016 -0
  144. package/src/agents/claude.ts +1246 -0
  145. package/src/agents/claudePretoolGate.test.ts +28 -0
  146. package/src/agents/claudePretoolGate.ts +173 -0
  147. package/src/agents/gateServer.test.ts +204 -0
  148. package/src/agents/gateServer.ts +124 -0
  149. package/src/agents/index.ts +10 -0
  150. package/src/agents/nativeFsDenies.ts +82 -0
  151. package/src/agents/opencode.test.ts +1440 -0
  152. package/src/agents/opencode.ts +1312 -0
  153. package/src/agents/opencodePlugin.ts +222 -0
  154. package/src/agents/opencodeShared.test.ts +34 -0
  155. package/src/agents/opencodeShared.ts +121 -0
  156. package/src/agents/postRun.test.ts +549 -0
  157. package/src/agents/postRun.ts +535 -0
  158. package/src/agents/reviewer.ts +104 -0
  159. package/src/agents/sessionLabeler.test.ts +247 -0
  160. package/src/agents/sessionLabeler.ts +178 -0
  161. package/src/agents/shared.test.ts +76 -0
  162. package/src/agents/shared.ts +292 -0
  163. package/src/agents/subagentModels.test.ts +113 -0
  164. package/src/agents/subagentModels.ts +40 -0
  165. package/src/agents/subagentRegistration.test.ts +41 -0
  166. package/src/agents/subagentToolGates.ts +114 -0
  167. package/src/cli.test.ts +129 -0
  168. package/src/cli.ts +105 -0
  169. package/src/commands/gha.test.ts +192 -0
  170. package/src/commands/gha.ts +188 -0
  171. package/src/commands/mcp.ts +122 -0
  172. package/src/config.ts +1 -0
  173. package/src/entry.ts +7 -0
  174. package/src/entryPost.stdlibOnly.test.ts +109 -0
  175. package/src/entryPost.ts +99 -0
  176. package/src/external.test.ts +16 -0
  177. package/src/external.ts +302 -0
  178. package/src/index.ts +11 -0
  179. package/src/internal/index.ts +71 -0
  180. package/src/lifecycle.ts +2 -0
  181. package/src/main.test.ts +873 -0
  182. package/src/main.ts +712 -0
  183. package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
  184. package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
  185. package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
  186. package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
  187. package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
  188. package/src/mcp/arkConfig.ts +7 -0
  189. package/src/mcp/checkSuite.test.ts +245 -0
  190. package/src/mcp/checkSuite.ts +255 -0
  191. package/src/mcp/checkout.test.ts +752 -0
  192. package/src/mcp/checkout.ts +886 -0
  193. package/src/mcp/comment.test.ts +772 -0
  194. package/src/mcp/comment.ts +582 -0
  195. package/src/mcp/commitInfo.test.ts +127 -0
  196. package/src/mcp/commitInfo.ts +61 -0
  197. package/src/mcp/crosswalk.test.ts +106 -0
  198. package/src/mcp/crosswalk.ts +339 -0
  199. package/src/mcp/dependencies.test.ts +309 -0
  200. package/src/mcp/dependencies.ts +189 -0
  201. package/src/mcp/geminiSanitizer.test.ts +287 -0
  202. package/src/mcp/geminiSanitizer.ts +207 -0
  203. package/src/mcp/git.test.ts +1083 -0
  204. package/src/mcp/git.ts +890 -0
  205. package/src/mcp/guardrails.test.ts +705 -0
  206. package/src/mcp/guardrails.ts +465 -0
  207. package/src/mcp/issue.test.ts +113 -0
  208. package/src/mcp/issue.ts +73 -0
  209. package/src/mcp/issueComments.test.ts +69 -0
  210. package/src/mcp/issueComments.ts +48 -0
  211. package/src/mcp/issueEvents.test.ts +134 -0
  212. package/src/mcp/issueEvents.ts +100 -0
  213. package/src/mcp/issueInfo.test.ts +104 -0
  214. package/src/mcp/issueInfo.ts +72 -0
  215. package/src/mcp/labels.test.ts +52 -0
  216. package/src/mcp/labels.ts +34 -0
  217. package/src/mcp/localContext.ts +28 -0
  218. package/src/mcp/localServer.test.ts +75 -0
  219. package/src/mcp/localServer.ts +131 -0
  220. package/src/mcp/moduleExtraction.test.ts +261 -0
  221. package/src/mcp/moduleExtraction.ts +313 -0
  222. package/src/mcp/moduleTests.test.ts +269 -0
  223. package/src/mcp/moduleTests.ts +421 -0
  224. package/src/mcp/modules.test.ts +640 -0
  225. package/src/mcp/modules.ts +696 -0
  226. package/src/mcp/output.test.ts +96 -0
  227. package/src/mcp/output.ts +70 -0
  228. package/src/mcp/pathSafety.test.ts +44 -0
  229. package/src/mcp/pathSafety.ts +28 -0
  230. package/src/mcp/policy.test.ts +282 -0
  231. package/src/mcp/policy.ts +199 -0
  232. package/src/mcp/pr.test.ts +387 -0
  233. package/src/mcp/pr.ts +194 -0
  234. package/src/mcp/prInfo.test.ts +96 -0
  235. package/src/mcp/prInfo.ts +91 -0
  236. package/src/mcp/providerSchema.test.ts +85 -0
  237. package/src/mcp/providerSchema.ts +175 -0
  238. package/src/mcp/review.test.ts +936 -0
  239. package/src/mcp/review.ts +923 -0
  240. package/src/mcp/reviewComments.test.ts +549 -0
  241. package/src/mcp/reviewComments.ts +896 -0
  242. package/src/mcp/roots.test.ts +175 -0
  243. package/src/mcp/roots.ts +217 -0
  244. package/src/mcp/scope.test.ts +59 -0
  245. package/src/mcp/scope.ts +65 -0
  246. package/src/mcp/security.test.ts +720 -0
  247. package/src/mcp/selectMode.test.ts +210 -0
  248. package/src/mcp/selectMode.ts +181 -0
  249. package/src/mcp/server.test.ts +292 -0
  250. package/src/mcp/server.ts +403 -0
  251. package/src/mcp/shared.ts +100 -0
  252. package/src/mcp/shell.test.ts +520 -0
  253. package/src/mcp/shell.ts +505 -0
  254. package/src/mcp/staleFix.test.ts +237 -0
  255. package/src/mcp/staleFix.ts +277 -0
  256. package/src/mcp/terraform/cost.ts +163 -0
  257. package/src/mcp/terraform/currency.test.ts +338 -0
  258. package/src/mcp/terraform/currency.ts +336 -0
  259. package/src/mcp/terraform/decisions.ts +527 -0
  260. package/src/mcp/terraform/findings.ts +333 -0
  261. package/src/mcp/terraform/plan.ts +348 -0
  262. package/src/mcp/terraform/scanners.ts +809 -0
  263. package/src/mcp/terraform/tools.test.ts +1071 -0
  264. package/src/mcp/terraform/tools.ts +908 -0
  265. package/src/mcp/terraform/types.ts +305 -0
  266. package/src/mcp/terraform.test.ts +1957 -0
  267. package/src/mcp/terraform.ts +23 -0
  268. package/src/mcp/terratest.test.ts +105 -0
  269. package/src/mcp/terratest.ts +196 -0
  270. package/src/mcp/toolFiltering.test.ts +85 -0
  271. package/src/mcp/upload.test.ts +180 -0
  272. package/src/mcp/upload.ts +112 -0
  273. package/src/models.test.ts +300 -0
  274. package/src/models.ts +708 -0
  275. package/src/modes.test.ts +107 -0
  276. package/src/modes.ts +880 -0
  277. package/src/prep/index.ts +43 -0
  278. package/src/prep/installNodeDependencies.test.ts +298 -0
  279. package/src/prep/installNodeDependencies.ts +196 -0
  280. package/src/prep/installPythonDependencies.test.ts +268 -0
  281. package/src/prep/installPythonDependencies.ts +199 -0
  282. package/src/prep/types.ts +38 -0
  283. package/src/reviewQuality.test.ts +63 -0
  284. package/src/reviewQuality.ts +134 -0
  285. package/src/runCli.test.ts +214 -0
  286. package/src/runCli.ts +282 -0
  287. package/src/skills/terraform-best-practices/SKILL.md +369 -0
  288. package/src/toolState.test.ts +45 -0
  289. package/src/toolState.ts +252 -0
  290. package/src/utils/activity.test.ts +188 -0
  291. package/src/utils/activity.ts +210 -0
  292. package/src/utils/agent.test.ts +251 -0
  293. package/src/utils/agent.ts +139 -0
  294. package/src/utils/agentHangReport.test.ts +203 -0
  295. package/src/utils/agentHangReport.ts +170 -0
  296. package/src/utils/apiFetch.test.ts +115 -0
  297. package/src/utils/apiFetch.ts +62 -0
  298. package/src/utils/apiKeys.test.ts +344 -0
  299. package/src/utils/apiKeys.ts +206 -0
  300. package/src/utils/apiUrl.test.ts +30 -0
  301. package/src/utils/apiUrl.ts +59 -0
  302. package/src/utils/assets.test.ts +153 -0
  303. package/src/utils/assets.ts +107 -0
  304. package/src/utils/billingErrors.test.ts +121 -0
  305. package/src/utils/billingErrors.ts +189 -0
  306. package/src/utils/body.test.ts +217 -0
  307. package/src/utils/body.ts +168 -0
  308. package/src/utils/buildTerramendFooter.test.ts +38 -0
  309. package/src/utils/buildTerramendFooter.ts +82 -0
  310. package/src/utils/byokFallback.test.ts +205 -0
  311. package/src/utils/byokFallback.ts +128 -0
  312. package/src/utils/claudeSubscription.test.ts +179 -0
  313. package/src/utils/claudeSubscription.ts +93 -0
  314. package/src/utils/cli.ts +31 -0
  315. package/src/utils/codexHome.test.ts +190 -0
  316. package/src/utils/codexHome.ts +191 -0
  317. package/src/utils/codexOAuth.ts +147 -0
  318. package/src/utils/codexRefreshDetect.test.ts +85 -0
  319. package/src/utils/codexRefreshDetect.ts +35 -0
  320. package/src/utils/diffCoverage.test.ts +468 -0
  321. package/src/utils/diffCoverage.ts +404 -0
  322. package/src/utils/errorReport.test.ts +135 -0
  323. package/src/utils/errorReport.ts +83 -0
  324. package/src/utils/exitHandler.ts +35 -0
  325. package/src/utils/fixDoubleEscapedString.ts +9 -0
  326. package/src/utils/ghaCore.ts +13 -0
  327. package/src/utils/gitAuth.test.ts +322 -0
  328. package/src/utils/gitAuth.ts +263 -0
  329. package/src/utils/gitAuthServer.test.ts +260 -0
  330. package/src/utils/gitAuthServer.ts +182 -0
  331. package/src/utils/github.test.ts +615 -0
  332. package/src/utils/github.ts +538 -0
  333. package/src/utils/globals.ts +9 -0
  334. package/src/utils/humanEditCapture.test.ts +100 -0
  335. package/src/utils/humanEditCapture.ts +193 -0
  336. package/src/utils/install.test.ts +768 -0
  337. package/src/utils/install.ts +492 -0
  338. package/src/utils/instructions.test.ts +240 -0
  339. package/src/utils/instructions.ts +543 -0
  340. package/src/utils/leapingComment.test.ts +51 -0
  341. package/src/utils/leapingComment.ts +18 -0
  342. package/src/utils/learnings.test.ts +87 -0
  343. package/src/utils/learnings.ts +138 -0
  344. package/src/utils/learningsTocRender.test.ts +116 -0
  345. package/src/utils/learningsTruncate.test.ts +39 -0
  346. package/src/utils/learningsTruncate.ts +42 -0
  347. package/src/utils/lifecycle.test.ts +195 -0
  348. package/src/utils/lifecycle.ts +198 -0
  349. package/src/utils/log.test.ts +402 -0
  350. package/src/utils/log.ts +432 -0
  351. package/src/utils/normalizeEnv.test.ts +91 -0
  352. package/src/utils/normalizeEnv.ts +106 -0
  353. package/src/utils/openCodeModels.ts +82 -0
  354. package/src/utils/overrides.test.ts +89 -0
  355. package/src/utils/overrides.ts +98 -0
  356. package/src/utils/packageManager.test.ts +321 -0
  357. package/src/utils/packageManager.ts +257 -0
  358. package/src/utils/patchWorkflowRunFields.test.ts +92 -0
  359. package/src/utils/patchWorkflowRunFields.ts +150 -0
  360. package/src/utils/payload.test.ts +497 -0
  361. package/src/utils/payload.ts +371 -0
  362. package/src/utils/postApiFetch.ts +51 -0
  363. package/src/utils/prSummary.test.ts +224 -0
  364. package/src/utils/prSummary.ts +147 -0
  365. package/src/utils/progressComment.ts +261 -0
  366. package/src/utils/providerErrors.test.ts +315 -0
  367. package/src/utils/providerErrors.ts +172 -0
  368. package/src/utils/rangeDiff.test.ts +236 -0
  369. package/src/utils/rangeDiff.ts +182 -0
  370. package/src/utils/remediationCommand.test.ts +163 -0
  371. package/src/utils/remediationCommand.ts +119 -0
  372. package/src/utils/retry.test.ts +153 -0
  373. package/src/utils/retry.ts +58 -0
  374. package/src/utils/reviewCleanup.ts +106 -0
  375. package/src/utils/run.ts +99 -0
  376. package/src/utils/runContext.ts +145 -0
  377. package/src/utils/runContextData.ts +58 -0
  378. package/src/utils/runErrorRenderer.test.ts +95 -0
  379. package/src/utils/runErrorRenderer.ts +259 -0
  380. package/src/utils/runFixture.ts +76 -0
  381. package/src/utils/runLifecycle.ts +237 -0
  382. package/src/utils/runStartupLog.ts +60 -0
  383. package/src/utils/secrets.test.ts +103 -0
  384. package/src/utils/secrets.ts +177 -0
  385. package/src/utils/setup.test.ts +509 -0
  386. package/src/utils/setup.ts +352 -0
  387. package/src/utils/shell.ts +103 -0
  388. package/src/utils/skills.test.ts +46 -0
  389. package/src/utils/skills.ts +67 -0
  390. package/src/utils/subprocess.test.ts +170 -0
  391. package/src/utils/subprocess.ts +438 -0
  392. package/src/utils/terraformMcp.test.ts +63 -0
  393. package/src/utils/terraformMcp.ts +83 -0
  394. package/src/utils/time.test.ts +105 -0
  395. package/src/utils/time.ts +59 -0
  396. package/src/utils/timer.test.ts +91 -0
  397. package/src/utils/timer.ts +72 -0
  398. package/src/utils/todoTracking.test.ts +223 -0
  399. package/src/utils/todoTracking.ts +167 -0
  400. package/src/utils/token.test.ts +239 -0
  401. package/src/utils/token.ts +186 -0
  402. package/src/utils/version.ts +10 -0
  403. package/src/utils/versioning.test.ts +34 -0
  404. package/src/utils/versioning.ts +44 -0
  405. package/src/utils/vertex.ts +85 -0
  406. package/src/utils/workflow.ts +25 -0
@@ -0,0 +1,315 @@
1
+ import {
2
+ detectProviderError,
3
+ extractProviderId,
4
+ findProviderErrorMatch,
5
+ isProviderBillingExhausted,
6
+ isRouterKeylimitExhaustedError,
7
+ } from "#app/utils/providerErrors";
8
+
9
+ describe("detectProviderError", () => {
10
+ describe("false positives previously seen in production", () => {
11
+ it("returns null for commit SHAs containing 429", () => {
12
+ expect(detectProviderError("hash=7a46d89f505b36df49b4f54429daffa1a9459b11")).toBeNull();
13
+ expect(detectProviderError("commit f609cc89e84596ab125d60dac568bfb2ef398396 429")).toBeNull();
14
+ });
15
+
16
+ it("classifies 401 + x-ratelimit-* headers as auth, not rate-limited", () => {
17
+ // OpenRouter 401 responses bundle `x-ratelimit-*` rate-limit headers
18
+ // alongside the auth error. the auth patterns must win — pre-fix this
19
+ // got tagged as `rate limited` because of the loose `\brate[_ ]limit`
20
+ // match against header names like `ratelimit-limit-requests`. note: in
21
+ // OpenRouter's actual format the header name is `ratelimit` (one word),
22
+ // but the dumped JSON sometimes contains `rate-limit` separators too.
23
+ const stderr = JSON.stringify({
24
+ error: { name: "APIError", statusCode: 401, message: "Invalid authentication credentials" },
25
+ headers: {
26
+ "x-ratelimit-limit-requests": 50,
27
+ "x-ratelimit-remaining-requests": 49,
28
+ "x-ratelimit-reset-tokens": "2025-01-01T00:00:00Z",
29
+ },
30
+ });
31
+ expect(detectProviderError(stderr)).toBe("auth error (401)");
32
+ });
33
+
34
+ it("returns null for INTERNAL_SERVER_ERROR substring", () => {
35
+ expect(detectProviderError("HTTP/1.1 500 INTERNAL_SERVER_ERROR")).toBeNull();
36
+ expect(detectProviderError("expected: not INTERNAL_SERVER_ERROR")).toBeNull();
37
+ });
38
+
39
+ it("returns null for INTERNALS substring", () => {
40
+ expect(detectProviderError("debugging INTERNALS of the parser")).toBeNull();
41
+ });
42
+ });
43
+
44
+ describe("auth errors", () => {
45
+ it("detects 401 / 403 status codes as auth errors", () => {
46
+ expect(detectProviderError('{"statusCode": 401}')).toBe("auth error (401)");
47
+ expect(detectProviderError('{"statusCode": 403}')).toBe("auth error (403)");
48
+ expect(detectProviderError("status_code: 401")).toBe("auth error (401)");
49
+ });
50
+
51
+ it("detects OpenRouter 'User not found' (disabled/invalid key)", () => {
52
+ // bare `"code":401` lacks a status-key prefix so the 401 status pattern
53
+ // intentionally doesn't fire; the User-not-found pattern catches it.
54
+ expect(detectProviderError('{"error":{"message":"User not found","code":401}}')).toBe(
55
+ "auth error (invalid/disabled key)",
56
+ );
57
+ expect(detectProviderError("APIError: User not found.")).toBe(
58
+ "auth error (invalid/disabled key)",
59
+ );
60
+ });
61
+
62
+ it("detects 'Invalid authentication' phrasing", () => {
63
+ expect(detectProviderError("Invalid authentication credentials")).toBe(
64
+ "auth error (invalid credentials)",
65
+ );
66
+ });
67
+
68
+ it("detects 'No auth credentials found' phrasing", () => {
69
+ expect(detectProviderError("AI_APICallError: No auth credentials found")).toBe(
70
+ "auth error (missing credentials)",
71
+ );
72
+ });
73
+ });
74
+
75
+ describe("billing exhaustion", () => {
76
+ // see #778 — providers return 401 / 429 for billing/quota exhaustion
77
+ // (OpenCode Zen `CreditsError` / `FreeUsageLimitError`, Gemini
78
+ // `RESOURCE_EXHAUSTED` + spending cap, "Insufficient balance"). these
79
+ // are non-retryable; status-code patterns must NOT win and surface the
80
+ // misleading "auth error (401)" / "rate limited (429)" labels.
81
+ it("classifies OpenCode Zen CreditsError as billing exhausted, not 401", () => {
82
+ const stderr = JSON.stringify({
83
+ statusCode: 401,
84
+ responseBody:
85
+ '{"type":"error","error":{"type":"CreditsError","message":"Insufficient balance. Manage your billing here: https://opencode.ai/workspace/x/billing"}}',
86
+ });
87
+ expect(detectProviderError(stderr)).toBe("provider billing exhausted");
88
+ });
89
+
90
+ it("classifies OpenCode Zen FreeUsageLimitError as billing exhausted, not 429", () => {
91
+ const stderr = JSON.stringify({
92
+ statusCode: 429,
93
+ responseBody:
94
+ '{"type":"error","error":{"type":"FreeUsageLimitError","message":"Rate limit exceeded. Please try again later."}}',
95
+ });
96
+ expect(detectProviderError(stderr)).toBe("provider billing exhausted");
97
+ });
98
+
99
+ it("classifies Gemini spending-cap RESOURCE_EXHAUSTED as billing exhausted, not 429", () => {
100
+ const stderr =
101
+ 'statusCode: 429, body: {"code": 429, "status": "RESOURCE_EXHAUSTED", "message": "Your project has exceeded its monthly spending cap..."}';
102
+ expect(detectProviderError(stderr)).toBe("provider billing exhausted");
103
+ });
104
+
105
+ it("classifies bare 'Insufficient balance' as billing exhausted", () => {
106
+ expect(detectProviderError("error: Insufficient balance")).toBe("provider billing exhausted");
107
+ });
108
+
109
+ it("classifies Anthropic 'credit balance is too low' as billing exhausted (#835)", () => {
110
+ // Anthropic-direct BYOK returns this string verbatim when the user's
111
+ // Anthropic console credit balance can't cover the request. distinct
112
+ // wording from "Insufficient balance" used by DeepSeek / OpenCode Zen.
113
+ const stderr =
114
+ "APIError: 400 Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits.";
115
+ expect(detectProviderError(stderr)).toBe("provider billing exhausted");
116
+ });
117
+ });
118
+
119
+ describe("real provider errors", () => {
120
+ it("detects 429 only when adjacent to a status key", () => {
121
+ expect(detectProviderError('{"statusCode": 429}')).toBe("rate limited (429)");
122
+ expect(detectProviderError('{"status_code": 429, "message": "..."}')).toBe(
123
+ "rate limited (429)",
124
+ );
125
+ expect(detectProviderError("http_status: 429")).toBe("rate limited (429)");
126
+ expect(detectProviderError("status=429")).toBe("rate limited (429)");
127
+ });
128
+
129
+ it("detects rate_limit_error and rate_limit_exceeded", () => {
130
+ expect(detectProviderError('{"type":"rate_limit_error"}')).toBe("rate limited");
131
+ expect(detectProviderError("rate_limit_exceeded")).toBe("rate limited");
132
+ expect(detectProviderError("plain rate limit reached")).toBe("rate limited");
133
+ });
134
+
135
+ it("detects rate-limit phrasing with trailing inflection", () => {
136
+ expect(detectProviderError("Error: rate limited by provider")).toBe("rate limited");
137
+ expect(detectProviderError("rate limits exceeded for this key")).toBe("rate limited");
138
+ });
139
+
140
+ it("detects RESOURCE_EXHAUSTED", () => {
141
+ expect(detectProviderError('"status": "RESOURCE_EXHAUSTED"')).toBe("quota exhausted");
142
+ });
143
+
144
+ it("detects gRPC INTERNAL status as a whole word", () => {
145
+ expect(detectProviderError('"status": "INTERNAL"')).toBe("provider internal error");
146
+ });
147
+
148
+ it("detects UNAVAILABLE as a whole word", () => {
149
+ expect(detectProviderError('"status": "UNAVAILABLE"')).toBe("provider unavailable");
150
+ });
151
+
152
+ it("detects 500 / 503 only when adjacent to a status key", () => {
153
+ expect(detectProviderError('"statusCode": 500')).toBe("provider 500 error");
154
+ expect(detectProviderError('"statusCode": 503')).toBe("provider unavailable (503)");
155
+ expect(detectProviderError("v1.503.0 release notes")).toBeNull();
156
+ });
157
+
158
+ it("detects quota and zero-quota responses", () => {
159
+ expect(detectProviderError('"message": "quota exceeded"')).toBe("quota error");
160
+ expect(detectProviderError('{"code":"insufficient_quota"}')).toBe("quota error");
161
+ expect(detectProviderError('"error":"quota_exceeded"')).toBe("quota error");
162
+ expect(detectProviderError('{"reason":"quotaExceeded"}')).toBe("quota error");
163
+ expect(detectProviderError('{"limit": 0, "remaining": 0}')).toBe("zero quota");
164
+ expect(detectProviderError('"time_limit": 0')).toBeNull();
165
+ });
166
+ });
167
+ });
168
+
169
+ describe("findProviderErrorMatch", () => {
170
+ // regression for issue #703: when stderr arrives as a multi-KB buffer
171
+ // (mcp tool-schema dump + the actual error message), the old
172
+ // `chunk.substring(0, 500)` excerpt showed the head of the buffer
173
+ // (schema) instead of the matched error text. the windowed excerpt
174
+ // must center on the matched line.
175
+ it("excerpt centers on the matched line, not the head of the buffer", () => {
176
+ const schemaDump =
177
+ "{".repeat(2000) +
178
+ '"name":"terramend_create_pull_request_review","description":"Submit a review..."';
179
+ const errorLine = "ERROR 2026-05-13 service=session error=rate_limit_exceeded retry-after=30";
180
+ const chunk = `${schemaDump}\n${errorLine}\ncaller stack at handler.ts:42`;
181
+
182
+ const match = findProviderErrorMatch(chunk);
183
+ expect(match).not.toBeNull();
184
+ expect(match?.label).toBe("rate limited");
185
+ expect(match?.excerpt).toContain("rate_limit_exceeded");
186
+ expect(match?.excerpt).toContain("retry-after=30");
187
+ expect(match?.excerpt).not.toContain("terramend_create_pull_request_review");
188
+ });
189
+
190
+ it("includes a small surrounding-line window for stack-trace context", () => {
191
+ const chunk =
192
+ "» about to call session.processor\n" +
193
+ "ERROR rate_limit_exceeded for key=abc\n" +
194
+ "at handler.ts:42\n" +
195
+ "at runtime.ts:88";
196
+ const match = findProviderErrorMatch(chunk);
197
+ expect(match?.excerpt).toContain("about to call session.processor");
198
+ expect(match?.excerpt).toContain("rate_limit_exceeded");
199
+ expect(match?.excerpt).toContain("handler.ts:42");
200
+ expect(match?.excerpt).toContain("runtime.ts:88");
201
+ });
202
+
203
+ it("falls back to the matched line alone when adjacent lines are huge", () => {
204
+ const giantPrefix = "x".repeat(5000);
205
+ const errorLine = '"statusCode": 429, "message": "slow down"';
206
+ const giantSuffix = "y".repeat(5000);
207
+ const chunk = `${giantPrefix}\n${errorLine}\n${giantSuffix}`;
208
+
209
+ const match = findProviderErrorMatch(chunk);
210
+ expect(match?.label).toBe("rate limited (429)");
211
+ expect(match?.excerpt).toBe(errorLine);
212
+ });
213
+
214
+ it("head-truncates the matched line if it alone exceeds the byte cap", () => {
215
+ const padding = "z".repeat(700);
216
+ const chunk = `${padding} "statusCode": 429 ${padding}`;
217
+ const match = findProviderErrorMatch(chunk);
218
+ expect(match?.label).toBe("rate limited (429)");
219
+ expect(match?.excerpt.length).toBeLessThanOrEqual(600);
220
+ });
221
+
222
+ it("returns null when no pattern matches", () => {
223
+ expect(findProviderErrorMatch("just some normal log line\nnothing wrong here")).toBeNull();
224
+ });
225
+ });
226
+
227
+ describe("isProviderBillingExhausted (#835)", () => {
228
+ it("matches DeepSeek 'Insufficient Balance' payloads", () => {
229
+ expect(isProviderBillingExhausted("AI_APICallError: Insufficient Balance")).toBe(true);
230
+ });
231
+
232
+ it("matches Anthropic 'credit balance is too low' payloads", () => {
233
+ expect(
234
+ isProviderBillingExhausted("Your credit balance is too low to access the Anthropic API"),
235
+ ).toBe(true);
236
+ });
237
+
238
+ it("matches OpenCode Zen CreditsError / FreeUsageLimitError", () => {
239
+ expect(isProviderBillingExhausted("CreditsError: out of credit")).toBe(true);
240
+ expect(isProviderBillingExhausted("FreeUsageLimitError: limit hit")).toBe(true);
241
+ });
242
+
243
+ it("returns false for unrelated provider errors", () => {
244
+ expect(isProviderBillingExhausted('{"statusCode": 401}')).toBe(false);
245
+ expect(isProviderBillingExhausted("rate_limit_exceeded")).toBe(false);
246
+ expect(isProviderBillingExhausted("just some log noise")).toBe(false);
247
+ });
248
+ });
249
+
250
+ describe("extractProviderId", () => {
251
+ it("parses providerID= from OpenCode harness logs", () => {
252
+ expect(
253
+ extractProviderId(
254
+ 'ERROR providerID=deepseek modelID=deepseek-v4-pro error={"name":"AI_APICallError"}',
255
+ ),
256
+ ).toBe("deepseek");
257
+ });
258
+
259
+ it("lowercases the captured slug", () => {
260
+ expect(extractProviderId("providerID=Anthropic modelID=claude")).toBe("anthropic");
261
+ });
262
+
263
+ it("returns null when providerID is absent", () => {
264
+ expect(extractProviderId("APIError: Insufficient Balance")).toBeNull();
265
+ });
266
+ });
267
+
268
+ describe("isRouterKeylimitExhaustedError", () => {
269
+ it("matches the canonical OpenRouter mid-run error", () => {
270
+ expect(
271
+ isRouterKeylimitExhaustedError(
272
+ "APIError: This request requires more credits, or fewer max_tokens. " +
273
+ "You requested up to 32000 tokens, but can only afford 22800. " +
274
+ "To increase, visit https://openrouter.ai/settings/keys and create a key with a higher total limit",
275
+ ),
276
+ ).toBe(true);
277
+ });
278
+
279
+ it("matches the 'requires more credits' phrasing on its own", () => {
280
+ expect(
281
+ isRouterKeylimitExhaustedError("This request requires more credits, or fewer max_tokens."),
282
+ ).toBe(true);
283
+ });
284
+
285
+ it("matches the 'requested up to ... can only afford' phrasing on its own", () => {
286
+ expect(
287
+ isRouterKeylimitExhaustedError("You requested up to 8000 tokens but can only afford 1234"),
288
+ ).toBe(true);
289
+ });
290
+
291
+ it("does not match generic out-of-credit text", () => {
292
+ expect(isRouterKeylimitExhaustedError("Your account has insufficient credits")).toBe(false);
293
+ expect(isRouterKeylimitExhaustedError("rate_limit_exceeded")).toBe(false);
294
+ expect(isRouterKeylimitExhaustedError('{"limit": 0}')).toBe(false);
295
+ });
296
+
297
+ it("does not match unrelated mentions of max_tokens", () => {
298
+ expect(isRouterKeylimitExhaustedError("max_tokens parameter must be a positive integer")).toBe(
299
+ false,
300
+ );
301
+ });
302
+
303
+ it("matches across newlines (defends against upstream wrapping/reformatting)", () => {
304
+ expect(
305
+ isRouterKeylimitExhaustedError(
306
+ "APIError: This request requires more credits, or\nfewer max_tokens. You requested up to 32000 tokens",
307
+ ),
308
+ ).toBe(true);
309
+ expect(
310
+ isRouterKeylimitExhaustedError(
311
+ "You requested up to 32000 tokens,\nbut can only afford 22800",
312
+ ),
313
+ ).toBe(true);
314
+ });
315
+ });
@@ -0,0 +1,172 @@
1
+ type ProviderErrorPattern = { regex: RegExp; label: string };
2
+
3
+ /** Stable label for the BYOK provider-billing-exhausted classification. */
4
+ export const PROVIDER_BILLING_EXHAUSTED_LABEL = "provider billing exhausted";
5
+
6
+ // status codes are only treated as provider errors when they are adjacent to
7
+ // a recognised status key. this rejects commit SHAs that happen to contain
8
+ // "429", version strings, file hashes, etc.
9
+ const statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`;
10
+
11
+ const PROVIDER_ERROR_PATTERNS: ProviderErrorPattern[] = [
12
+ // billing-payload patterns come BEFORE bare status-code patterns. providers
13
+ // commonly return 401 / 429 for billing/quota exhaustion (OpenCode Zen
14
+ // `CreditsError` / `FreeUsageLimitError`, Gemini `RESOURCE_EXHAUSTED` +
15
+ // "spending cap", Anthropic "Insufficient balance" / "credit balance is
16
+ // too low"). these are non-retryable and require user-billing action —
17
+ // distinct from a transient auth error or rate-limit. status-code patterns
18
+ // would otherwise win and surface "auth error (401)" / "rate limited (429)"
19
+ // with no billing hint. see #778, #835.
20
+ { regex: /\bCreditsError\b/, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
21
+ { regex: /\bFreeUsageLimitError\b/, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
22
+ { regex: /Insufficient balance/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
23
+ { regex: /credit balance is too low/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
24
+ { regex: /spending cap/i, label: PROVIDER_BILLING_EXHAUSTED_LABEL },
25
+ // auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error
26
+ // payloads carry `x-ratelimit-*` response headers in the dump, and the
27
+ // free-form rate-limit regex below would otherwise win on word-boundary
28
+ // matches inside header names. canonical 401 messages: OpenRouter returns
29
+ // `{"error":{"message":"User not found","code":401}}` for disabled or
30
+ // invalid keys (https://openai.luzhipeng.com/docs/api/reference/errors-and-debugging).
31
+ { regex: new RegExp(`${statusKey}401\\b`, "i"), label: "auth error (401)" },
32
+ { regex: new RegExp(`${statusKey}403\\b`, "i"), label: "auth error (403)" },
33
+ { regex: /\bUser not found\b/i, label: "auth error (invalid/disabled key)" },
34
+ { regex: /\bInvalid authentication\b/i, label: "auth error (invalid credentials)" },
35
+ { regex: /\bNo auth credentials found\b/i, label: "auth error (missing credentials)" },
36
+ { regex: new RegExp(`${statusKey}429\\b`, "i"), label: "rate limited (429)" },
37
+ { regex: new RegExp(`${statusKey}500\\b`, "i"), label: "provider 500 error" },
38
+ { regex: new RegExp(`${statusKey}503\\b`, "i"), label: "provider unavailable (503)" },
39
+ // matches `rate limit`, `rate limited`, `rate limits exceeded`,
40
+ // `rate_limit_error`, `rate_limit_exceeded`. the leading `\b` + `[_ ]`
41
+ // separator rejects `x-ratelimit-*` / `anthropic-ratelimit-*` response
42
+ // headers (no separator between "rate" and "limit") which routinely
43
+ // appear in dumped 401 / 4xx error JSON.
44
+ { regex: /\brate[_ ]limit/i, label: "rate limited" },
45
+ { regex: /\bRESOURCE_EXHAUSTED\b/, label: "quota exhausted" },
46
+ // Google gRPC `INTERNAL` status. word-boundary anchors reject
47
+ // `INTERNAL_SERVER_ERROR` (HTTP 500 message that may appear in unrelated
48
+ // log lines) and identifiers like `INTERNALS`.
49
+ { regex: /\bINTERNAL\b/, label: "provider internal error" },
50
+ { regex: /\bUNAVAILABLE\b/, label: "provider unavailable" },
51
+ // matches `quota`, `insufficient_quota`, `quota_exceeded`, `quotaExceeded`.
52
+ // word-character lookarounds would reject `_quota` / `quotaX`; `quota` is
53
+ // specific enough that a plain substring match is safe.
54
+ { regex: /quota/i, label: "quota error" },
55
+ // explicit zero-quota response, e.g. `{"limit": 0}`. the `\b` anchor
56
+ // around `limit` rejects keys like `time_limit` or `field_limit`.
57
+ { regex: /["']?\blimit\b["']?\s*:\s*0\b/, label: "zero quota" },
58
+ ];
59
+
60
+ /**
61
+ * Result of a provider-error scan: the classification label plus a
62
+ * human-readable excerpt centered on the matched line. The excerpt is what
63
+ * gets surfaced in `» provider error detected (...)` log lines — see
64
+ * `extractExcerpt` for the windowing/byte-cap policy.
65
+ */
66
+ export type ProviderErrorMatch = {
67
+ label: string;
68
+ excerpt: string;
69
+ };
70
+
71
+ // roughly half a wide terminal line by 4–5 lines of context; large enough
72
+ // to capture a structured error payload (request id, retry-after, model)
73
+ // plus its immediate stack/headers, small enough to not flood the log.
74
+ const EXCERPT_MAX_BYTES = 600;
75
+ const LINES_BEFORE = 1;
76
+ const LINES_AFTER = 2;
77
+
78
+ export function findProviderErrorMatch(text: string): ProviderErrorMatch | null {
79
+ for (const entry of PROVIDER_ERROR_PATTERNS) {
80
+ const m = entry.regex.exec(text);
81
+ if (!m) continue;
82
+ return { label: entry.label, excerpt: extractExcerpt(text, m.index) };
83
+ }
84
+ return null;
85
+ }
86
+
87
+ export function detectProviderError(text: string): string | null {
88
+ return findProviderErrorMatch(text)?.label ?? null;
89
+ }
90
+
91
+ /**
92
+ * Slice a context window around `matchIndex`: the matched line plus
93
+ * `LINES_BEFORE`/`LINES_AFTER` neighbours. If the windowed slice exceeds
94
+ * `EXCERPT_MAX_BYTES` (giant adjacent lines, e.g. JSON tool-schema dumps),
95
+ * fall back to the matched line alone, head-truncated if still too long.
96
+ * Replaces the old `chunk.substring(0, 500)` head-anchored excerpt which
97
+ * surfaced whatever happened to be at the front of the stderr buffer
98
+ * instead of the error itself. See issue #703.
99
+ */
100
+ function extractExcerpt(text: string, matchIndex: number): string {
101
+ const lineStart = text.lastIndexOf("\n", matchIndex - 1) + 1;
102
+ const lineEndRaw = text.indexOf("\n", matchIndex);
103
+ const lineEnd = lineEndRaw === -1 ? text.length : lineEndRaw;
104
+
105
+ let start = lineStart;
106
+ for (let i = 0; i < LINES_BEFORE && start > 0; i++) {
107
+ const prev = text.lastIndexOf("\n", start - 2);
108
+ start = prev < 0 ? 0 : prev + 1;
109
+ }
110
+
111
+ let end = lineEnd;
112
+ for (let i = 0; i < LINES_AFTER && end < text.length; i++) {
113
+ const next = text.indexOf("\n", end + 1);
114
+ end = next < 0 ? text.length : next;
115
+ }
116
+
117
+ let excerpt = text.slice(start, end);
118
+ if (excerpt.length > EXCERPT_MAX_BYTES) {
119
+ excerpt = text.slice(lineStart, lineEnd);
120
+ if (excerpt.length > EXCERPT_MAX_BYTES) excerpt = excerpt.slice(0, EXCERPT_MAX_BYTES);
121
+ }
122
+ return excerpt.trim();
123
+ }
124
+
125
+ /**
126
+ * OpenRouter's response when the per-run key's remaining budget can't cover
127
+ * the agent's `max_tokens` reservation. Distinct from a generic provider error
128
+ * because it's a Terramend billing concern, not an upstream outage — the user's
129
+ * Router wallet ran out (or the key budget was undersized at mint time and the
130
+ * agent ran out of headroom partway through).
131
+ *
132
+ * Match must be specific to this exact OpenRouter error class. Generic "credits"
133
+ * or "limit" text shows up in unrelated errors and would mis-classify them.
134
+ *
135
+ * Sample:
136
+ * `APIError: This request requires more credits, or fewer max_tokens.
137
+ * You requested up to 32000 tokens, but can only afford 22800.`
138
+ */
139
+ // `/s` (dotAll) lets `.*?` cross newlines so we still detect the error if any
140
+ // upstream layer reformats the message onto multiple lines. Without it, a
141
+ // single inserted `\n` would silently bypass the BillingError reclassification
142
+ // and the user would see the generic `❌ Terramend failed` dump instead of the
143
+ // actionable top-up CTA.
144
+ const ROUTER_KEYLIMIT_EXHAUSTED_PATTERN =
145
+ /requires more credits.*?fewer max_tokens|requested up to \d+ tokens.*?can only afford/is;
146
+
147
+ export function isRouterKeylimitExhaustedError(text: string): boolean {
148
+ return ROUTER_KEYLIMIT_EXHAUSTED_PATTERN.test(text);
149
+ }
150
+
151
+ /**
152
+ * BYOK billing-exhausted: provider rejected the request because the user's
153
+ * provider wallet is empty (DeepSeek "Insufficient Balance", Anthropic
154
+ * "credit balance is too low", OpenCode Zen `CreditsError` /
155
+ * `FreeUsageLimitError`, Gemini "spending cap"). Distinct from
156
+ * `isRouterKeylimitExhaustedError` — that's Terramend's Router wallet, this
157
+ * is the user's own provider account.
158
+ */
159
+ export function isProviderBillingExhausted(text: string): boolean {
160
+ return findProviderErrorMatch(text)?.label === PROVIDER_BILLING_EXHAUSTED_LABEL;
161
+ }
162
+
163
+ /**
164
+ * Extract `providerID=foo` from agent error logs (OpenCode emits this on
165
+ * `provider error detected (...)` lines). Returns the lowercase provider
166
+ * slug, or null when absent. Used to render a provider-specific dashboard
167
+ * link in the BYOK billing-exhausted summary.
168
+ */
169
+ export function extractProviderId(text: string): string | null {
170
+ const match = text.match(/\bproviderID=([a-z0-9_-]+)/i);
171
+ return match ? match[1]!.toLowerCase() : null;
172
+ }