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.
- package/LICENSE +661 -0
- package/README.md +145 -0
- package/dist/agents/claude.d.ts +73 -0
- package/dist/agents/claudePretoolGate.d.ts +99 -0
- package/dist/agents/gateServer.d.ts +7 -0
- package/dist/agents/index.d.ts +6 -0
- package/dist/agents/nativeFsDenies.d.ts +28 -0
- package/dist/agents/opencode.d.ts +231 -0
- package/dist/agents/opencodePlugin.d.ts +85 -0
- package/dist/agents/opencodeShared.d.ts +40 -0
- package/dist/agents/postRun.d.ts +132 -0
- package/dist/agents/reviewer.d.ts +38 -0
- package/dist/agents/sessionLabeler.d.ts +97 -0
- package/dist/agents/shared.d.ts +189 -0
- package/dist/agents/subagentModels.d.ts +19 -0
- package/dist/agents/subagentToolGates.d.ts +55 -0
- package/dist/cli.mjs +197426 -0
- package/dist/external.d.ts +227 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +196783 -0
- package/dist/internal/index.d.ts +18 -0
- package/dist/internal.js +1714 -0
- package/dist/lifecycle.d.ts +2 -0
- package/dist/main.d.ts +8 -0
- package/dist/mcp/arkConfig.d.ts +1 -0
- package/dist/mcp/checkSuite.d.ts +25 -0
- package/dist/mcp/checkout.d.ts +77 -0
- package/dist/mcp/comment.d.ts +119 -0
- package/dist/mcp/commitInfo.d.ts +9 -0
- package/dist/mcp/crosswalk.d.ts +105 -0
- package/dist/mcp/dependencies.d.ts +8 -0
- package/dist/mcp/geminiSanitizer.d.ts +28 -0
- package/dist/mcp/git.d.ts +46 -0
- package/dist/mcp/guardrails.d.ts +104 -0
- package/dist/mcp/issue.d.ts +18 -0
- package/dist/mcp/issueComments.d.ts +9 -0
- package/dist/mcp/issueEvents.d.ts +9 -0
- package/dist/mcp/issueInfo.d.ts +9 -0
- package/dist/mcp/labels.d.ts +12 -0
- package/dist/mcp/localContext.d.ts +19 -0
- package/dist/mcp/moduleExtraction.d.ts +71 -0
- package/dist/mcp/moduleTests.d.ts +104 -0
- package/dist/mcp/modules.d.ts +179 -0
- package/dist/mcp/output.d.ts +12 -0
- package/dist/mcp/pathSafety.d.ts +14 -0
- package/dist/mcp/policy.d.ts +48 -0
- package/dist/mcp/pr.d.ts +49 -0
- package/dist/mcp/prInfo.d.ts +9 -0
- package/dist/mcp/providerSchema.d.ts +50 -0
- package/dist/mcp/review.d.ts +199 -0
- package/dist/mcp/reviewComments.d.ts +178 -0
- package/dist/mcp/roots.d.ts +58 -0
- package/dist/mcp/scope.d.ts +15 -0
- package/dist/mcp/selectMode.d.ts +18 -0
- package/dist/mcp/server.d.ts +48 -0
- package/dist/mcp/shared.d.ts +47 -0
- package/dist/mcp/shell.d.ts +37 -0
- package/dist/mcp/staleFix.d.ts +51 -0
- package/dist/mcp/terraform/cost.d.ts +55 -0
- package/dist/mcp/terraform/currency.d.ts +94 -0
- package/dist/mcp/terraform/decisions.d.ts +178 -0
- package/dist/mcp/terraform/findings.d.ts +75 -0
- package/dist/mcp/terraform/plan.d.ts +157 -0
- package/dist/mcp/terraform/scanners.d.ts +131 -0
- package/dist/mcp/terraform/tools.d.ts +63 -0
- package/dist/mcp/terraform/types.d.ts +172 -0
- package/dist/mcp/terraform.d.ts +22 -0
- package/dist/mcp/terratest.d.ts +83 -0
- package/dist/mcp/upload.d.ts +6 -0
- package/dist/models.d.ts +171 -0
- package/dist/modes.d.ts +26 -0
- package/dist/prep/index.d.ts +7 -0
- package/dist/prep/installNodeDependencies.d.ts +2 -0
- package/dist/prep/installPythonDependencies.d.ts +2 -0
- package/dist/prep/types.d.ts +31 -0
- package/dist/reviewQuality.d.ts +64 -0
- package/dist/skills/terraform-best-practices/SKILL.md +369 -0
- package/dist/toolState.d.ts +135 -0
- package/dist/utils/activity.d.ts +40 -0
- package/dist/utils/agent.d.ts +20 -0
- package/dist/utils/agentHangReport.d.ts +38 -0
- package/dist/utils/apiFetch.d.ts +19 -0
- package/dist/utils/apiKeys.d.ts +41 -0
- package/dist/utils/apiUrl.d.ts +20 -0
- package/dist/utils/assets.d.ts +8 -0
- package/dist/utils/billingErrors.d.ts +85 -0
- package/dist/utils/body.d.ts +34 -0
- package/dist/utils/buildTerramendFooter.d.ts +25 -0
- package/dist/utils/byokFallback.d.ts +85 -0
- package/dist/utils/claudeSubscription.d.ts +30 -0
- package/dist/utils/cli.d.ts +10 -0
- package/dist/utils/codexHome.d.ts +29 -0
- package/dist/utils/codexOAuth.d.ts +60 -0
- package/dist/utils/diffCoverage.d.ts +63 -0
- package/dist/utils/errorReport.d.ts +17 -0
- package/dist/utils/exitHandler.d.ts +8 -0
- package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
- package/dist/utils/gitAuth.d.ts +84 -0
- package/dist/utils/gitAuthServer.d.ts +24 -0
- package/dist/utils/github.d.ts +78 -0
- package/dist/utils/globals.d.ts +3 -0
- package/dist/utils/install.d.ts +60 -0
- package/dist/utils/instructions.d.ts +48 -0
- package/dist/utils/leapingComment.d.ts +11 -0
- package/dist/utils/learnings.d.ts +62 -0
- package/dist/utils/learningsTruncate.d.ts +25 -0
- package/dist/utils/lifecycle.d.ts +57 -0
- package/dist/utils/log.d.ts +111 -0
- package/dist/utils/normalizeEnv.d.ts +30 -0
- package/dist/utils/openCodeModels.d.ts +11 -0
- package/dist/utils/overrides.d.ts +40 -0
- package/dist/utils/packageManager.d.ts +49 -0
- package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
- package/dist/utils/payload.d.ts +105 -0
- package/dist/utils/prSummary.d.ts +61 -0
- package/dist/utils/progressComment.d.ts +146 -0
- package/dist/utils/providerErrors.d.ts +31 -0
- package/dist/utils/rangeDiff.d.ts +51 -0
- package/dist/utils/remediationCommand.d.ts +55 -0
- package/dist/utils/retry.d.ts +13 -0
- package/dist/utils/reviewCleanup.d.ts +14 -0
- package/dist/utils/run.d.ts +9 -0
- package/dist/utils/runContext.d.ts +60 -0
- package/dist/utils/runContextData.d.ts +23 -0
- package/dist/utils/runErrorRenderer.d.ts +64 -0
- package/dist/utils/runLifecycle.d.ts +86 -0
- package/dist/utils/runStartupLog.d.ts +15 -0
- package/dist/utils/secrets.d.ts +22 -0
- package/dist/utils/setup.d.ts +90 -0
- package/dist/utils/shell.d.ts +32 -0
- package/dist/utils/skills.d.ts +10 -0
- package/dist/utils/subprocess.d.ts +80 -0
- package/dist/utils/terraformMcp.d.ts +42 -0
- package/dist/utils/time.d.ts +15 -0
- package/dist/utils/timer.d.ts +23 -0
- package/dist/utils/todoTracking.d.ts +16 -0
- package/dist/utils/token.d.ts +39 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/versioning.d.ts +7 -0
- package/dist/utils/vertex.d.ts +16 -0
- package/dist/utils/workflow.d.ts +13 -0
- package/package.json +119 -0
- package/src/agents/claude.test.ts +1016 -0
- package/src/agents/claude.ts +1246 -0
- package/src/agents/claudePretoolGate.test.ts +28 -0
- package/src/agents/claudePretoolGate.ts +173 -0
- package/src/agents/gateServer.test.ts +204 -0
- package/src/agents/gateServer.ts +124 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/nativeFsDenies.ts +82 -0
- package/src/agents/opencode.test.ts +1440 -0
- package/src/agents/opencode.ts +1312 -0
- package/src/agents/opencodePlugin.ts +222 -0
- package/src/agents/opencodeShared.test.ts +34 -0
- package/src/agents/opencodeShared.ts +121 -0
- package/src/agents/postRun.test.ts +549 -0
- package/src/agents/postRun.ts +535 -0
- package/src/agents/reviewer.ts +104 -0
- package/src/agents/sessionLabeler.test.ts +247 -0
- package/src/agents/sessionLabeler.ts +178 -0
- package/src/agents/shared.test.ts +76 -0
- package/src/agents/shared.ts +292 -0
- package/src/agents/subagentModels.test.ts +113 -0
- package/src/agents/subagentModels.ts +40 -0
- package/src/agents/subagentRegistration.test.ts +41 -0
- package/src/agents/subagentToolGates.ts +114 -0
- package/src/cli.test.ts +129 -0
- package/src/cli.ts +105 -0
- package/src/commands/gha.test.ts +192 -0
- package/src/commands/gha.ts +188 -0
- package/src/commands/mcp.ts +122 -0
- package/src/config.ts +1 -0
- package/src/entry.ts +7 -0
- package/src/entryPost.stdlibOnly.test.ts +109 -0
- package/src/entryPost.ts +99 -0
- package/src/external.test.ts +16 -0
- package/src/external.ts +302 -0
- package/src/index.ts +11 -0
- package/src/internal/index.ts +71 -0
- package/src/lifecycle.ts +2 -0
- package/src/main.test.ts +873 -0
- package/src/main.ts +712 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
- package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
- package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
- package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
- package/src/mcp/arkConfig.ts +7 -0
- package/src/mcp/checkSuite.test.ts +245 -0
- package/src/mcp/checkSuite.ts +255 -0
- package/src/mcp/checkout.test.ts +752 -0
- package/src/mcp/checkout.ts +886 -0
- package/src/mcp/comment.test.ts +772 -0
- package/src/mcp/comment.ts +582 -0
- package/src/mcp/commitInfo.test.ts +127 -0
- package/src/mcp/commitInfo.ts +61 -0
- package/src/mcp/crosswalk.test.ts +106 -0
- package/src/mcp/crosswalk.ts +339 -0
- package/src/mcp/dependencies.test.ts +309 -0
- package/src/mcp/dependencies.ts +189 -0
- package/src/mcp/geminiSanitizer.test.ts +287 -0
- package/src/mcp/geminiSanitizer.ts +207 -0
- package/src/mcp/git.test.ts +1083 -0
- package/src/mcp/git.ts +890 -0
- package/src/mcp/guardrails.test.ts +705 -0
- package/src/mcp/guardrails.ts +465 -0
- package/src/mcp/issue.test.ts +113 -0
- package/src/mcp/issue.ts +73 -0
- package/src/mcp/issueComments.test.ts +69 -0
- package/src/mcp/issueComments.ts +48 -0
- package/src/mcp/issueEvents.test.ts +134 -0
- package/src/mcp/issueEvents.ts +100 -0
- package/src/mcp/issueInfo.test.ts +104 -0
- package/src/mcp/issueInfo.ts +72 -0
- package/src/mcp/labels.test.ts +52 -0
- package/src/mcp/labels.ts +34 -0
- package/src/mcp/localContext.ts +28 -0
- package/src/mcp/localServer.test.ts +75 -0
- package/src/mcp/localServer.ts +131 -0
- package/src/mcp/moduleExtraction.test.ts +261 -0
- package/src/mcp/moduleExtraction.ts +313 -0
- package/src/mcp/moduleTests.test.ts +269 -0
- package/src/mcp/moduleTests.ts +421 -0
- package/src/mcp/modules.test.ts +640 -0
- package/src/mcp/modules.ts +696 -0
- package/src/mcp/output.test.ts +96 -0
- package/src/mcp/output.ts +70 -0
- package/src/mcp/pathSafety.test.ts +44 -0
- package/src/mcp/pathSafety.ts +28 -0
- package/src/mcp/policy.test.ts +282 -0
- package/src/mcp/policy.ts +199 -0
- package/src/mcp/pr.test.ts +387 -0
- package/src/mcp/pr.ts +194 -0
- package/src/mcp/prInfo.test.ts +96 -0
- package/src/mcp/prInfo.ts +91 -0
- package/src/mcp/providerSchema.test.ts +85 -0
- package/src/mcp/providerSchema.ts +175 -0
- package/src/mcp/review.test.ts +936 -0
- package/src/mcp/review.ts +923 -0
- package/src/mcp/reviewComments.test.ts +549 -0
- package/src/mcp/reviewComments.ts +896 -0
- package/src/mcp/roots.test.ts +175 -0
- package/src/mcp/roots.ts +217 -0
- package/src/mcp/scope.test.ts +59 -0
- package/src/mcp/scope.ts +65 -0
- package/src/mcp/security.test.ts +720 -0
- package/src/mcp/selectMode.test.ts +210 -0
- package/src/mcp/selectMode.ts +181 -0
- package/src/mcp/server.test.ts +292 -0
- package/src/mcp/server.ts +403 -0
- package/src/mcp/shared.ts +100 -0
- package/src/mcp/shell.test.ts +520 -0
- package/src/mcp/shell.ts +505 -0
- package/src/mcp/staleFix.test.ts +237 -0
- package/src/mcp/staleFix.ts +277 -0
- package/src/mcp/terraform/cost.ts +163 -0
- package/src/mcp/terraform/currency.test.ts +338 -0
- package/src/mcp/terraform/currency.ts +336 -0
- package/src/mcp/terraform/decisions.ts +527 -0
- package/src/mcp/terraform/findings.ts +333 -0
- package/src/mcp/terraform/plan.ts +348 -0
- package/src/mcp/terraform/scanners.ts +809 -0
- package/src/mcp/terraform/tools.test.ts +1071 -0
- package/src/mcp/terraform/tools.ts +908 -0
- package/src/mcp/terraform/types.ts +305 -0
- package/src/mcp/terraform.test.ts +1957 -0
- package/src/mcp/terraform.ts +23 -0
- package/src/mcp/terratest.test.ts +105 -0
- package/src/mcp/terratest.ts +196 -0
- package/src/mcp/toolFiltering.test.ts +85 -0
- package/src/mcp/upload.test.ts +180 -0
- package/src/mcp/upload.ts +112 -0
- package/src/models.test.ts +300 -0
- package/src/models.ts +708 -0
- package/src/modes.test.ts +107 -0
- package/src/modes.ts +880 -0
- package/src/prep/index.ts +43 -0
- package/src/prep/installNodeDependencies.test.ts +298 -0
- package/src/prep/installNodeDependencies.ts +196 -0
- package/src/prep/installPythonDependencies.test.ts +268 -0
- package/src/prep/installPythonDependencies.ts +199 -0
- package/src/prep/types.ts +38 -0
- package/src/reviewQuality.test.ts +63 -0
- package/src/reviewQuality.ts +134 -0
- package/src/runCli.test.ts +214 -0
- package/src/runCli.ts +282 -0
- package/src/skills/terraform-best-practices/SKILL.md +369 -0
- package/src/toolState.test.ts +45 -0
- package/src/toolState.ts +252 -0
- package/src/utils/activity.test.ts +188 -0
- package/src/utils/activity.ts +210 -0
- package/src/utils/agent.test.ts +251 -0
- package/src/utils/agent.ts +139 -0
- package/src/utils/agentHangReport.test.ts +203 -0
- package/src/utils/agentHangReport.ts +170 -0
- package/src/utils/apiFetch.test.ts +115 -0
- package/src/utils/apiFetch.ts +62 -0
- package/src/utils/apiKeys.test.ts +344 -0
- package/src/utils/apiKeys.ts +206 -0
- package/src/utils/apiUrl.test.ts +30 -0
- package/src/utils/apiUrl.ts +59 -0
- package/src/utils/assets.test.ts +153 -0
- package/src/utils/assets.ts +107 -0
- package/src/utils/billingErrors.test.ts +121 -0
- package/src/utils/billingErrors.ts +189 -0
- package/src/utils/body.test.ts +217 -0
- package/src/utils/body.ts +168 -0
- package/src/utils/buildTerramendFooter.test.ts +38 -0
- package/src/utils/buildTerramendFooter.ts +82 -0
- package/src/utils/byokFallback.test.ts +205 -0
- package/src/utils/byokFallback.ts +128 -0
- package/src/utils/claudeSubscription.test.ts +179 -0
- package/src/utils/claudeSubscription.ts +93 -0
- package/src/utils/cli.ts +31 -0
- package/src/utils/codexHome.test.ts +190 -0
- package/src/utils/codexHome.ts +191 -0
- package/src/utils/codexOAuth.ts +147 -0
- package/src/utils/codexRefreshDetect.test.ts +85 -0
- package/src/utils/codexRefreshDetect.ts +35 -0
- package/src/utils/diffCoverage.test.ts +468 -0
- package/src/utils/diffCoverage.ts +404 -0
- package/src/utils/errorReport.test.ts +135 -0
- package/src/utils/errorReport.ts +83 -0
- package/src/utils/exitHandler.ts +35 -0
- package/src/utils/fixDoubleEscapedString.ts +9 -0
- package/src/utils/ghaCore.ts +13 -0
- package/src/utils/gitAuth.test.ts +322 -0
- package/src/utils/gitAuth.ts +263 -0
- package/src/utils/gitAuthServer.test.ts +260 -0
- package/src/utils/gitAuthServer.ts +182 -0
- package/src/utils/github.test.ts +615 -0
- package/src/utils/github.ts +538 -0
- package/src/utils/globals.ts +9 -0
- package/src/utils/humanEditCapture.test.ts +100 -0
- package/src/utils/humanEditCapture.ts +193 -0
- package/src/utils/install.test.ts +768 -0
- package/src/utils/install.ts +492 -0
- package/src/utils/instructions.test.ts +240 -0
- package/src/utils/instructions.ts +543 -0
- package/src/utils/leapingComment.test.ts +51 -0
- package/src/utils/leapingComment.ts +18 -0
- package/src/utils/learnings.test.ts +87 -0
- package/src/utils/learnings.ts +138 -0
- package/src/utils/learningsTocRender.test.ts +116 -0
- package/src/utils/learningsTruncate.test.ts +39 -0
- package/src/utils/learningsTruncate.ts +42 -0
- package/src/utils/lifecycle.test.ts +195 -0
- package/src/utils/lifecycle.ts +198 -0
- package/src/utils/log.test.ts +402 -0
- package/src/utils/log.ts +432 -0
- package/src/utils/normalizeEnv.test.ts +91 -0
- package/src/utils/normalizeEnv.ts +106 -0
- package/src/utils/openCodeModels.ts +82 -0
- package/src/utils/overrides.test.ts +89 -0
- package/src/utils/overrides.ts +98 -0
- package/src/utils/packageManager.test.ts +321 -0
- package/src/utils/packageManager.ts +257 -0
- package/src/utils/patchWorkflowRunFields.test.ts +92 -0
- package/src/utils/patchWorkflowRunFields.ts +150 -0
- package/src/utils/payload.test.ts +497 -0
- package/src/utils/payload.ts +371 -0
- package/src/utils/postApiFetch.ts +51 -0
- package/src/utils/prSummary.test.ts +224 -0
- package/src/utils/prSummary.ts +147 -0
- package/src/utils/progressComment.ts +261 -0
- package/src/utils/providerErrors.test.ts +315 -0
- package/src/utils/providerErrors.ts +172 -0
- package/src/utils/rangeDiff.test.ts +236 -0
- package/src/utils/rangeDiff.ts +182 -0
- package/src/utils/remediationCommand.test.ts +163 -0
- package/src/utils/remediationCommand.ts +119 -0
- package/src/utils/retry.test.ts +153 -0
- package/src/utils/retry.ts +58 -0
- package/src/utils/reviewCleanup.ts +106 -0
- package/src/utils/run.ts +99 -0
- package/src/utils/runContext.ts +145 -0
- package/src/utils/runContextData.ts +58 -0
- package/src/utils/runErrorRenderer.test.ts +95 -0
- package/src/utils/runErrorRenderer.ts +259 -0
- package/src/utils/runFixture.ts +76 -0
- package/src/utils/runLifecycle.ts +237 -0
- package/src/utils/runStartupLog.ts +60 -0
- package/src/utils/secrets.test.ts +103 -0
- package/src/utils/secrets.ts +177 -0
- package/src/utils/setup.test.ts +509 -0
- package/src/utils/setup.ts +352 -0
- package/src/utils/shell.ts +103 -0
- package/src/utils/skills.test.ts +46 -0
- package/src/utils/skills.ts +67 -0
- package/src/utils/subprocess.test.ts +170 -0
- package/src/utils/subprocess.ts +438 -0
- package/src/utils/terraformMcp.test.ts +63 -0
- package/src/utils/terraformMcp.ts +83 -0
- package/src/utils/time.test.ts +105 -0
- package/src/utils/time.ts +59 -0
- package/src/utils/timer.test.ts +91 -0
- package/src/utils/timer.ts +72 -0
- package/src/utils/todoTracking.test.ts +223 -0
- package/src/utils/todoTracking.ts +167 -0
- package/src/utils/token.test.ts +239 -0
- package/src/utils/token.ts +186 -0
- package/src/utils/version.ts +10 -0
- package/src/utils/versioning.test.ts +34 -0
- package/src/utils/versioning.ts +44 -0
- package/src/utils/vertex.ts +85 -0
- 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
|
+
}
|