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,205 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { resolveCliModel } from "#app/models";
|
|
3
|
+
import {
|
|
4
|
+
buildUnavailableModelError,
|
|
5
|
+
FREE_FALLBACK_SLUG,
|
|
6
|
+
hasProviderKeyForModel,
|
|
7
|
+
selectFallbackModelIfNeeded,
|
|
8
|
+
} from "#app/utils/byokFallback";
|
|
9
|
+
|
|
10
|
+
describe("FREE_FALLBACK_SLUG", () => {
|
|
11
|
+
it("resolves in the curated catalog", () => {
|
|
12
|
+
expect(resolveCliModel(FREE_FALLBACK_SLUG)).toBe("opencode/big-pickle");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("is opencode/big-pickle", () => {
|
|
16
|
+
expect(FREE_FALLBACK_SLUG).toBe("opencode/big-pickle");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("selectFallbackModelIfNeeded", () => {
|
|
21
|
+
const empty = new Set<string>();
|
|
22
|
+
|
|
23
|
+
it("falls back to free when the model is unauthorized AND no provider key is present", () => {
|
|
24
|
+
const result = selectFallbackModelIfNeeded({
|
|
25
|
+
resolvedModel: "anthropic/claude-opus-4-7",
|
|
26
|
+
authorized: empty,
|
|
27
|
+
providerKeyPresent: false,
|
|
28
|
+
agentName: "opencode",
|
|
29
|
+
});
|
|
30
|
+
expect(result).toEqual({
|
|
31
|
+
kind: "fallback",
|
|
32
|
+
from: "anthropic/claude-opus-4-7",
|
|
33
|
+
to: FREE_FALLBACK_SLUG,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("uses the resolved model when the claude harness serves it", () => {
|
|
38
|
+
// opencode models can't see CLAUDE_CODE_OAUTH_TOKEN, so `authorized` is
|
|
39
|
+
// empty for anthropic/* — and the OAuth token counts as a present provider
|
|
40
|
+
// key, which would land in `unavailable` and fail the run. resolveAgent
|
|
41
|
+
// picks the claude agent, which brings its own auth; the gate must defer.
|
|
42
|
+
const result = selectFallbackModelIfNeeded({
|
|
43
|
+
resolvedModel: "anthropic/claude-opus-4-8",
|
|
44
|
+
authorized: empty,
|
|
45
|
+
providerKeyPresent: true,
|
|
46
|
+
agentName: "claude",
|
|
47
|
+
});
|
|
48
|
+
expect(result).toEqual({ kind: "use-resolved" });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("reports unavailable (does NOT downgrade) when a provider key IS present but the model is unauthorized", () => {
|
|
52
|
+
// PR #2 scenario: Google key set, but the configured Google model id isn't
|
|
53
|
+
// one OpenCode can route → fail loudly instead of silently serving free.
|
|
54
|
+
const result = selectFallbackModelIfNeeded({
|
|
55
|
+
resolvedModel: "google/gemini-3.5-flash-lite",
|
|
56
|
+
authorized: new Set(["google/gemini-3.5-flash", "google/gemini-3.1-pro"]),
|
|
57
|
+
providerKeyPresent: true,
|
|
58
|
+
agentName: "opencode",
|
|
59
|
+
});
|
|
60
|
+
expect(result).toEqual({ kind: "unavailable", model: "google/gemini-3.5-flash-lite" });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("uses the resolved model when it IS authorized (regardless of key presence)", () => {
|
|
64
|
+
const result = selectFallbackModelIfNeeded({
|
|
65
|
+
resolvedModel: "anthropic/claude-opus-4-7",
|
|
66
|
+
authorized: new Set(["anthropic/claude-opus-4-7"]),
|
|
67
|
+
providerKeyPresent: true,
|
|
68
|
+
agentName: "opencode",
|
|
69
|
+
});
|
|
70
|
+
expect(result).toEqual({ kind: "use-resolved" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("uses the resolved model when none is resolved (auto-select path)", () => {
|
|
74
|
+
const result = selectFallbackModelIfNeeded({
|
|
75
|
+
resolvedModel: undefined,
|
|
76
|
+
authorized: empty,
|
|
77
|
+
providerKeyPresent: false,
|
|
78
|
+
agentName: "opencode",
|
|
79
|
+
});
|
|
80
|
+
expect(result).toEqual({ kind: "use-resolved" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("uses the resolved model when it is itself the free fallback", () => {
|
|
84
|
+
const result = selectFallbackModelIfNeeded({
|
|
85
|
+
resolvedModel: FREE_FALLBACK_SLUG,
|
|
86
|
+
authorized: empty,
|
|
87
|
+
providerKeyPresent: false,
|
|
88
|
+
agentName: "opencode",
|
|
89
|
+
});
|
|
90
|
+
expect(result).toEqual({ kind: "use-resolved" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("uses the resolved model for Bedrock routing (raw model ID has no slash)", () => {
|
|
94
|
+
// resolveModel({slug:"bedrock/byok"}) returns the raw BEDROCK_MODEL_ID
|
|
95
|
+
// value (e.g. "eu.anthropic.claude-opus-4-7"), which has no `/`. the
|
|
96
|
+
// routing validator (validateBedrockSetup) owns auth + region + model-id
|
|
97
|
+
// checking for this path, not the BYOK fallback gate.
|
|
98
|
+
const result = selectFallbackModelIfNeeded({
|
|
99
|
+
resolvedModel: "eu.anthropic.claude-opus-4-7",
|
|
100
|
+
authorized: empty,
|
|
101
|
+
providerKeyPresent: true,
|
|
102
|
+
agentName: "claude",
|
|
103
|
+
});
|
|
104
|
+
expect(result).toEqual({ kind: "use-resolved" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("the no-slash skip holds on its own (opencode agent, key present)", () => {
|
|
108
|
+
// distinguishes the raw-ID guard from the claude-agent guard right after
|
|
109
|
+
// it: with agentName "opencode" + a present key, deleting the no-slash
|
|
110
|
+
// check would mis-route this to `unavailable` and fail the run.
|
|
111
|
+
const result = selectFallbackModelIfNeeded({
|
|
112
|
+
resolvedModel: "amazon.titan-text-express-v1",
|
|
113
|
+
authorized: empty,
|
|
114
|
+
providerKeyPresent: true,
|
|
115
|
+
agentName: "opencode",
|
|
116
|
+
});
|
|
117
|
+
expect(result).toEqual({ kind: "use-resolved" });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("uses the resolved model when stored minimax-m2.5-free resolves to big-pickle", () => {
|
|
121
|
+
const result = selectFallbackModelIfNeeded({
|
|
122
|
+
resolvedModel: resolveCliModel("opencode/minimax-m2.5-free"),
|
|
123
|
+
authorized: empty,
|
|
124
|
+
providerKeyPresent: false,
|
|
125
|
+
agentName: "opencode",
|
|
126
|
+
});
|
|
127
|
+
expect(result).toEqual({ kind: "use-resolved" });
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("hasProviderKeyForModel", () => {
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
vi.unstubAllEnvs();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("is true when a Google key is present for a resolved Google model", () => {
|
|
137
|
+
vi.stubEnv("GOOGLE_GENERATIVE_AI_API_KEY", "xxx");
|
|
138
|
+
expect(hasProviderKeyForModel("google/gemini-3.5-flash-lite")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("is true for the alternate Google key env var", () => {
|
|
142
|
+
vi.stubEnv("GEMINI_API_KEY", "xxx");
|
|
143
|
+
expect(hasProviderKeyForModel("google/gemini-3.1-pro-preview")).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("is false when no key for that provider is present", () => {
|
|
147
|
+
vi.stubEnv("GEMINI_API_KEY", "");
|
|
148
|
+
vi.stubEnv("GOOGLE_GENERATIVE_AI_API_KEY", "");
|
|
149
|
+
expect(hasProviderKeyForModel("google/gemini-3.5-flash-lite")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("detects the Anthropic OAuth token shape as a present key", () => {
|
|
153
|
+
vi.stubEnv("ANTHROPIC_API_KEY", "");
|
|
154
|
+
vi.stubEnv("CLAUDE_CODE_OAUTH_TOKEN", "tok");
|
|
155
|
+
expect(hasProviderKeyForModel("anthropic/claude-opus-4-8")).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("is false for an unknown provider (no catalog env vars)", () => {
|
|
159
|
+
expect(hasProviderKeyForModel("madeup/model")).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("buildUnavailableModelError", () => {
|
|
164
|
+
it("lists same-provider authorized models and names the provider", () => {
|
|
165
|
+
const msg = buildUnavailableModelError({
|
|
166
|
+
model: "google/gemini-3.5-flash-lite",
|
|
167
|
+
authorized: new Set([
|
|
168
|
+
"google/gemini-3.5-flash",
|
|
169
|
+
"google/gemini-3.1-pro",
|
|
170
|
+
"anthropic/claude-opus-4-8",
|
|
171
|
+
]),
|
|
172
|
+
});
|
|
173
|
+
expect(msg).toContain(
|
|
174
|
+
'model "google/gemini-3.5-flash-lite" is not available to your Google key',
|
|
175
|
+
);
|
|
176
|
+
expect(msg).toContain(" - google/gemini-3.5-flash");
|
|
177
|
+
expect(msg).toContain(" - google/gemini-3.1-pro");
|
|
178
|
+
// unrelated provider is filtered out when same-provider matches exist
|
|
179
|
+
expect(msg).not.toContain("anthropic/claude-opus-4-8");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("falls back to the full authorized list when no same-provider model is authorized", () => {
|
|
183
|
+
const msg = buildUnavailableModelError({
|
|
184
|
+
model: "google/gemini-3.5-flash-lite",
|
|
185
|
+
authorized: new Set(["anthropic/claude-opus-4-8"]),
|
|
186
|
+
});
|
|
187
|
+
expect(msg).toContain(" - anthropic/claude-opus-4-8");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("lists the authorized models sorted, not in Set insertion order", () => {
|
|
191
|
+
const msg = buildUnavailableModelError({
|
|
192
|
+
model: "google/gemini-3.5-flash-lite",
|
|
193
|
+
authorized: new Set(["google/z-model", "google/a-model"]),
|
|
194
|
+
});
|
|
195
|
+
expect(msg.indexOf("google/a-model")).toBeLessThan(msg.indexOf("google/z-model"));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("handles an empty authorized set without throwing", () => {
|
|
199
|
+
const msg = buildUnavailableModelError({
|
|
200
|
+
model: "google/gemini-3.5-flash-lite",
|
|
201
|
+
authorized: new Set(),
|
|
202
|
+
});
|
|
203
|
+
expect(msg).toContain("does not authorize any model");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { AgentId } from "#app/external";
|
|
2
|
+
import { getModelEnvVars, getProviderDisplayName } from "#app/models";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Slug we fall back to when a BYOK-required model is configured but the
|
|
6
|
+
* runner has no provider key in env. Picked because it's free, stable, and
|
|
7
|
+
* currently served by OpenCode Zen without a key.
|
|
8
|
+
*
|
|
9
|
+
* The slug is intentionally hard-coded and not a config knob — the
|
|
10
|
+
* fallback is a safety net, not a user-facing preference, and adding a
|
|
11
|
+
* config surface here would just push the same "what to fall back to"
|
|
12
|
+
* decision into another setting that goes stale the same way.
|
|
13
|
+
*/
|
|
14
|
+
export const FREE_FALLBACK_SLUG = "opencode/big-pickle";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Outcome of the BYOK model gate.
|
|
18
|
+
*
|
|
19
|
+
* - `use-resolved`: run the configured model as-is.
|
|
20
|
+
* - `fallback`: the runner has NO provider key for this model's provider,
|
|
21
|
+
* so swap to the free OpenCode slug — a genuine no-key safety net.
|
|
22
|
+
* - `unavailable`: a provider key IS present but the configured model is
|
|
23
|
+
* not one OpenCode can route with it. This is almost always a wrong or
|
|
24
|
+
* mistyped model id (or a key scoped to other models). We must NOT
|
|
25
|
+
* silently downgrade to the free model — that hides the misconfiguration
|
|
26
|
+
* and produces a free run the user didn't ask for (see PR #2). The caller
|
|
27
|
+
* fails loudly with the authorized-model list instead.
|
|
28
|
+
*/
|
|
29
|
+
export type FallbackDecision =
|
|
30
|
+
| { kind: "use-resolved" }
|
|
31
|
+
| { kind: "fallback"; from: string; to: string }
|
|
32
|
+
| { kind: "unavailable"; model: string };
|
|
33
|
+
|
|
34
|
+
function hasEnvVar(name: string): boolean {
|
|
35
|
+
const value = process.env[name];
|
|
36
|
+
return typeof value === "string" && value.length > 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Does the runner have a provider credential for `resolvedModel`'s provider?
|
|
41
|
+
*
|
|
42
|
+
* Uses the provider→envVars map in `models.ts` (provider-level, since a
|
|
43
|
+
* resolved specifier like `google/gemini-3.5-flash-lite` won't match a catalog
|
|
44
|
+
* model key and correctly falls through to the provider's env vars). A present
|
|
45
|
+
* key is what distinguishes the two not-authorized situations: key present =
|
|
46
|
+
* wrong/unavailable model id (fail loudly); no key = genuine BYOK gap (free
|
|
47
|
+
* fallback).
|
|
48
|
+
*/
|
|
49
|
+
export function hasProviderKeyForModel(resolvedModel: string): boolean {
|
|
50
|
+
return getModelEnvVars(resolvedModel).some(hasEnvVar);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Decide whether to run the configured model, fall back to the free model, or
|
|
55
|
+
* fail because the model is unavailable to the present key.
|
|
56
|
+
*
|
|
57
|
+
* `authorized` is OpenCode's authoritative "what can I route right now"
|
|
58
|
+
* snapshot, captured after Codex auth.json is in place.
|
|
59
|
+
*
|
|
60
|
+
* Skip cases (always `use-resolved`, without consulting `authorized`):
|
|
61
|
+
* - No resolved model: auto-select handles it downstream.
|
|
62
|
+
* - Resolved model is the free fallback already.
|
|
63
|
+
* - Resolved model is a raw Bedrock / Vertex ID (no `/`): the routing
|
|
64
|
+
* validators (`validateBedrockSetup` / `validateVertexSetup`) cover
|
|
65
|
+
* auth + region/location/model-id; `opencode models` does not.
|
|
66
|
+
* - The selected agent is `claude`: the Claude Code harness brings its own
|
|
67
|
+
* auth and `resolveAgent` only returns it when that auth is present.
|
|
68
|
+
* `opencode models` can't see `CLAUDE_CODE_OAUTH_TOKEN`, so without this
|
|
69
|
+
* an OAuth-subscription run on an Anthropic model would land in
|
|
70
|
+
* `unavailable` (the token counts as a present provider key) and fail a
|
|
71
|
+
* run the claude harness serves fine. `validateAgentApiKey` still covers
|
|
72
|
+
* the claude path with its own Anthropic auth check.
|
|
73
|
+
*/
|
|
74
|
+
export function selectFallbackModelIfNeeded(input: {
|
|
75
|
+
resolvedModel: string | undefined;
|
|
76
|
+
authorized: Set<string>;
|
|
77
|
+
/** whether a provider key for the resolved model's provider is present in env */
|
|
78
|
+
providerKeyPresent: boolean;
|
|
79
|
+
/** which agent harness `resolveAgent` picks for the resolved model */
|
|
80
|
+
agentName: AgentId;
|
|
81
|
+
}): FallbackDecision {
|
|
82
|
+
if (!input.resolvedModel) return { kind: "use-resolved" };
|
|
83
|
+
if (input.resolvedModel === FREE_FALLBACK_SLUG) return { kind: "use-resolved" };
|
|
84
|
+
if (!input.resolvedModel.includes("/")) return { kind: "use-resolved" };
|
|
85
|
+
if (input.agentName === "claude") return { kind: "use-resolved" };
|
|
86
|
+
if (input.authorized.has(input.resolvedModel)) return { kind: "use-resolved" };
|
|
87
|
+
|
|
88
|
+
// resolved model is NOT in OpenCode's authorized set. split the two cases:
|
|
89
|
+
if (input.providerKeyPresent) {
|
|
90
|
+
// a key for this provider is configured — the model id is wrong or not
|
|
91
|
+
// available to this key. surface it; do not silently serve a free model.
|
|
92
|
+
return { kind: "unavailable", model: input.resolvedModel };
|
|
93
|
+
}
|
|
94
|
+
// no key at all → genuine BYOK gap → free safety net so the run still works.
|
|
95
|
+
return { kind: "fallback", from: input.resolvedModel, to: FREE_FALLBACK_SLUG };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Loud, actionable error for the `unavailable` decision: a provider key is
|
|
100
|
+
* present but the configured model isn't one the key can serve. Lists the
|
|
101
|
+
* models OpenCode CAN route (same-provider first) so the user can copy a valid
|
|
102
|
+
* slug instead of getting a silent free downgrade.
|
|
103
|
+
*/
|
|
104
|
+
export function buildUnavailableModelError(input: {
|
|
105
|
+
model: string;
|
|
106
|
+
authorized: Set<string>;
|
|
107
|
+
}): string {
|
|
108
|
+
const provider = input.model.slice(0, input.model.indexOf("/"));
|
|
109
|
+
const providerLabel = getProviderDisplayName(input.model) ?? provider;
|
|
110
|
+
const all = [...input.authorized].sort();
|
|
111
|
+
const sameProvider = all.filter((m) => m.startsWith(`${provider}/`));
|
|
112
|
+
const shown = sameProvider.length > 0 ? sameProvider : all;
|
|
113
|
+
const list =
|
|
114
|
+
shown.length > 0
|
|
115
|
+
? shown.map((m) => ` - ${m}`).join("\n")
|
|
116
|
+
: " (none — your key does not authorize any model OpenCode can route)";
|
|
117
|
+
|
|
118
|
+
return [
|
|
119
|
+
`model "${input.model}" is not available to your ${providerLabel} key.`,
|
|
120
|
+
``,
|
|
121
|
+
`A provider credential is present, so Terramend did not fall back to the free model —`,
|
|
122
|
+
`it surfaces this instead so you can pick a valid id. Models your key can serve:`,
|
|
123
|
+
``,
|
|
124
|
+
list,
|
|
125
|
+
``,
|
|
126
|
+
`Set the model (the \`model\` input, or \`TERRAMEND_MODEL\`) to one of the slugs above.`,
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { resolveModelSlug } from "#app/models";
|
|
3
|
+
import { preflightClaudeSubscription } from "#app/utils/claudeSubscription";
|
|
4
|
+
|
|
5
|
+
type FetchArgs = [input: string | URL | Request, init?: RequestInit];
|
|
6
|
+
|
|
7
|
+
function stubFetch(impl: () => Promise<Response>) {
|
|
8
|
+
const mock = vi.fn(impl);
|
|
9
|
+
vi.stubGlobal("fetch", mock);
|
|
10
|
+
return mock;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function firstCall(mock: { mock: { calls: unknown[][] } }): FetchArgs {
|
|
14
|
+
const call = mock.mock.calls[0];
|
|
15
|
+
if (!call) throw new Error("expected fetch to have been called");
|
|
16
|
+
return call as FetchArgs;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function requestBody(mock: { mock: { calls: unknown[][] } }): Record<string, unknown> {
|
|
20
|
+
const [, init] = firstCall(mock);
|
|
21
|
+
if (!init || typeof init.body !== "string") throw new Error("expected a string request body");
|
|
22
|
+
return JSON.parse(init.body) as Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("preflightClaudeSubscription", () => {
|
|
30
|
+
it("returns usable on 200 OK and probes with the run's model on the OAuth surface", async () => {
|
|
31
|
+
const mock = stubFetch(async () => new Response("{}", { status: 200 }));
|
|
32
|
+
|
|
33
|
+
const result = await preflightClaudeSubscription({
|
|
34
|
+
token: "oauth-tok",
|
|
35
|
+
model: "claude-fable-5",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(result).toEqual({ usable: true });
|
|
39
|
+
expect(mock).toHaveBeenCalledTimes(1);
|
|
40
|
+
const [url, init] = firstCall(mock);
|
|
41
|
+
expect(url).toBe("https://api.anthropic.com/v1/messages");
|
|
42
|
+
if (!init) throw new Error("expected a request init");
|
|
43
|
+
expect(init.method).toBe("POST");
|
|
44
|
+
expect(init.headers).toMatchObject({
|
|
45
|
+
authorization: "Bearer oauth-tok",
|
|
46
|
+
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
|
|
47
|
+
"anthropic-version": "2023-06-01",
|
|
48
|
+
"content-type": "application/json",
|
|
49
|
+
"x-app": "cli",
|
|
50
|
+
});
|
|
51
|
+
expect(requestBody(mock)).toMatchObject({ model: "claude-fable-5", max_tokens: 1 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("falls back to the registry-resolved haiku probe model when no model is set", async () => {
|
|
55
|
+
const mock = stubFetch(async () => new Response("{}", { status: 200 }));
|
|
56
|
+
|
|
57
|
+
await preflightClaudeSubscription({ token: "oauth-tok", model: undefined });
|
|
58
|
+
|
|
59
|
+
const resolved = resolveModelSlug("anthropic/claude-haiku");
|
|
60
|
+
if (!resolved) throw new Error("anthropic/claude-haiku missing from registry");
|
|
61
|
+
const expected = resolved.slice(resolved.indexOf("/") + 1);
|
|
62
|
+
const body = requestBody(mock);
|
|
63
|
+
expect(body.model).toBe(expected);
|
|
64
|
+
expect(String(body.model)).not.toContain("/");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("marks the token unusable on 401 with the error.message from the JSON body", async () => {
|
|
68
|
+
stubFetch(
|
|
69
|
+
async () =>
|
|
70
|
+
new Response(JSON.stringify({ error: { message: "OAuth token revoked" } }), {
|
|
71
|
+
status: 401,
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
|
|
76
|
+
|
|
77
|
+
expect(result).toEqual({ usable: false, reason: "401: OAuth token revoked" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("marks the token unusable on 429 (subscription limit hit)", async () => {
|
|
81
|
+
stubFetch(
|
|
82
|
+
async () =>
|
|
83
|
+
new Response(JSON.stringify({ error: { message: "You've hit your Opus limit" } }), {
|
|
84
|
+
status: 429,
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const result = await preflightClaudeSubscription({ token: "t", model: "claude-opus-4-8" });
|
|
89
|
+
|
|
90
|
+
expect(result).toEqual({ usable: false, reason: "429: You've hit your Opus limit" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it.each([400, 403, 500, 529])("fails open (usable) on status %d", async (status) => {
|
|
94
|
+
stubFetch(async () => new Response("nope", { status }));
|
|
95
|
+
|
|
96
|
+
const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
|
|
97
|
+
|
|
98
|
+
expect(result).toEqual({ usable: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("fails open when fetch itself throws (network error / timeout)", async () => {
|
|
102
|
+
stubFetch(async () => {
|
|
103
|
+
throw new TypeError("fetch failed");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
|
|
107
|
+
|
|
108
|
+
expect(result).toEqual({ usable: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("uses a raw excerpt of a non-JSON 401 body, capped at 200 chars", async () => {
|
|
112
|
+
const html = `<html>upstream error${"x".repeat(300)}</html>`;
|
|
113
|
+
stubFetch(async () => new Response(html, { status: 401 }));
|
|
114
|
+
|
|
115
|
+
const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
|
|
116
|
+
|
|
117
|
+
expect(result.usable).toBe(false);
|
|
118
|
+
if (result.usable) throw new Error("expected an unusable result");
|
|
119
|
+
expect(result.reason).toBe(`401: ${html.slice(0, 200)}`);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("uses the raw excerpt when the JSON body has no error.message string", async () => {
|
|
123
|
+
const body = JSON.stringify({ error: "rate_limited" });
|
|
124
|
+
stubFetch(async () => new Response(body, { status: 429 }));
|
|
125
|
+
|
|
126
|
+
const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
|
|
127
|
+
|
|
128
|
+
expect(result).toEqual({ usable: false, reason: `429: ${body}` });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("degrades to an empty reason body when reading the response body fails", async () => {
|
|
132
|
+
const fake = {
|
|
133
|
+
status: 401,
|
|
134
|
+
text: () => Promise.reject(new Error("body stream interrupted")),
|
|
135
|
+
} as unknown as Response;
|
|
136
|
+
stubFetch(async () => fake);
|
|
137
|
+
|
|
138
|
+
const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
|
|
139
|
+
|
|
140
|
+
expect(result).toEqual({ usable: false, reason: "401: " });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("sends the exact probe body Anthropic's OAuth gate validates", async () => {
|
|
144
|
+
// the system-identity block and 1-token cap are what make the probe both
|
|
145
|
+
// accepted by the OAuth surface and effectively free — pin the full shape.
|
|
146
|
+
const mock = stubFetch(async () => new Response("{}", { status: 200 }));
|
|
147
|
+
|
|
148
|
+
await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
|
|
149
|
+
|
|
150
|
+
expect(requestBody(mock)).toEqual({
|
|
151
|
+
model: "claude-fable-5",
|
|
152
|
+
max_tokens: 1,
|
|
153
|
+
system: "You are Claude Code, Anthropic's official CLI for Claude.",
|
|
154
|
+
messages: [{ role: "user", content: "ok" }],
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("uses the raw excerpt when error.message exists but is not a string", async () => {
|
|
159
|
+
const body = JSON.stringify({ error: { message: 42 } });
|
|
160
|
+
stubFetch(async () => new Response(body, { status: 401 }));
|
|
161
|
+
|
|
162
|
+
const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
|
|
163
|
+
|
|
164
|
+
expect(result).toEqual({ usable: false, reason: `401: ${body}` });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it.each([
|
|
168
|
+
"null",
|
|
169
|
+
"42",
|
|
170
|
+
'"just a string"',
|
|
171
|
+
JSON.stringify({ no_error_key: 1 }),
|
|
172
|
+
])("uses the raw excerpt for JSON body %s (no extractable error.message)", async (body) => {
|
|
173
|
+
stubFetch(async () => new Response(body, { status: 429 }));
|
|
174
|
+
|
|
175
|
+
const result = await preflightClaudeSubscription({ token: "t", model: "claude-fable-5" });
|
|
176
|
+
|
|
177
|
+
expect(result).toEqual({ usable: false, reason: `429: ${body}` });
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { resolveModelSlug } from "#app/models";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* identity block Anthropic's OAuth gate validates on `/v1/messages` calls
|
|
5
|
+
* authenticated with a Claude Code OAuth token. haiku is currently exempt
|
|
6
|
+
* but sending it costs nothing and survives the exemption being removed.
|
|
7
|
+
*/
|
|
8
|
+
const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
9
|
+
|
|
10
|
+
// fallback probe model for runs with no explicit model (claude-code picks its
|
|
11
|
+
// own default there, so per-model accuracy is moot — only whether the
|
|
12
|
+
// subscription answers at all). registry-resolved so a catalog bump keeps
|
|
13
|
+
// the id fresh.
|
|
14
|
+
const fallbackResolve = resolveModelSlug("anthropic/claude-haiku");
|
|
15
|
+
if (!fallbackResolve) {
|
|
16
|
+
throw new Error("claudeSubscription preflight: anthropic/claude-haiku missing from registry");
|
|
17
|
+
}
|
|
18
|
+
const FALLBACK_PROBE_MODEL = fallbackResolve.slice(fallbackResolve.indexOf("/") + 1);
|
|
19
|
+
|
|
20
|
+
export type SubscriptionPreflight = { usable: true } | { usable: false; reason: string };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* preflight a Claude subscription OAuth token (`CLAUDE_CODE_OAUTH_TOKEN`)
|
|
24
|
+
* with a 1-token Messages call, so the agent can fall back to
|
|
25
|
+
* `ANTHROPIC_API_KEY` when the subscription is exhausted or revoked instead
|
|
26
|
+
* of failing the whole run at its first model call. rides the same de-facto
|
|
27
|
+
* OAuth surface Claude Code itself uses: Bearer auth + the
|
|
28
|
+
* `claude-code-20250219,oauth-2025-04-20` betas + the identity system prompt.
|
|
29
|
+
*
|
|
30
|
+
* probes the run's own model when known — subscription limits can be
|
|
31
|
+
* per-model ("You've hit your Opus limit"), so a cheaper stand-in could pass
|
|
32
|
+
* preflight and still leave the run dead on arrival.
|
|
33
|
+
*
|
|
34
|
+
* fail-open by design: only 401 (revoked/expired token) and 429
|
|
35
|
+
* (session/weekly/per-model limit) mark the token unusable. network errors,
|
|
36
|
+
* 5xx, and request-shape drift (400) all keep today's subscription-first
|
|
37
|
+
* behavior, so the preflight can never fail a run that would have worked —
|
|
38
|
+
* the worst wrong answer is a run that bills the API key instead of the
|
|
39
|
+
* subscription.
|
|
40
|
+
*/
|
|
41
|
+
export async function preflightClaudeSubscription(params: {
|
|
42
|
+
token: string;
|
|
43
|
+
/** bare Anthropic model id the run will use (e.g. "claude-fable-5") */
|
|
44
|
+
model: string | undefined;
|
|
45
|
+
}): Promise<SubscriptionPreflight> {
|
|
46
|
+
let res: Response;
|
|
47
|
+
try {
|
|
48
|
+
res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
authorization: `Bearer ${params.token}`,
|
|
52
|
+
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
|
|
53
|
+
"anthropic-version": "2023-06-01",
|
|
54
|
+
"content-type": "application/json",
|
|
55
|
+
"x-app": "cli",
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
model: params.model ?? FALLBACK_PROBE_MODEL,
|
|
59
|
+
max_tokens: 1,
|
|
60
|
+
system: CLAUDE_CODE_IDENTITY,
|
|
61
|
+
messages: [{ role: "user", content: "ok" }],
|
|
62
|
+
}),
|
|
63
|
+
signal: AbortSignal.timeout(10_000),
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
// network failure / timeout says nothing about the credential
|
|
67
|
+
return { usable: true };
|
|
68
|
+
}
|
|
69
|
+
if (res.status !== 401 && res.status !== 429) return { usable: true };
|
|
70
|
+
const body = await res.text().catch(() => "");
|
|
71
|
+
return { usable: false, reason: `${res.status}: ${extractApiErrorMessage(body)}` };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** pull `error.message` out of an Anthropic error payload, else a raw excerpt */
|
|
75
|
+
function extractApiErrorMessage(body: string): string {
|
|
76
|
+
try {
|
|
77
|
+
const parsed: unknown = JSON.parse(body);
|
|
78
|
+
if (
|
|
79
|
+
typeof parsed === "object" &&
|
|
80
|
+
parsed !== null &&
|
|
81
|
+
"error" in parsed &&
|
|
82
|
+
typeof parsed.error === "object" &&
|
|
83
|
+
parsed.error !== null &&
|
|
84
|
+
"message" in parsed.error &&
|
|
85
|
+
typeof parsed.error.message === "string"
|
|
86
|
+
) {
|
|
87
|
+
return parsed.error.message;
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// not json — fall through to the raw excerpt
|
|
91
|
+
}
|
|
92
|
+
return body.slice(0, 200);
|
|
93
|
+
}
|
package/src/utils/cli.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
// re-export logging utilities for backward compatibility
|
|
9
|
+
export {
|
|
10
|
+
formatIndentedField,
|
|
11
|
+
formatJsonValue,
|
|
12
|
+
formatUsageSummary,
|
|
13
|
+
log,
|
|
14
|
+
writeSummary,
|
|
15
|
+
} from "#app/utils/log";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Finds a CLI executable path by checking if it's installed globally
|
|
19
|
+
* @param name The name of the CLI executable to find
|
|
20
|
+
* @returns The path to the CLI executable, or null if not found
|
|
21
|
+
*/
|
|
22
|
+
export function findCliPath(name: string): string | null {
|
|
23
|
+
const result = spawnSync("which", [name], { encoding: "utf-8" });
|
|
24
|
+
if (result.status === 0 && result.stdout) {
|
|
25
|
+
const cliPath = result.stdout.trim();
|
|
26
|
+
if (cliPath && existsSync(cliPath)) {
|
|
27
|
+
return cliPath;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|