Fix OpenCode default model and model-unavailable diagnostics

This commit is contained in:
Dotta
2026-03-05 08:02:39 -06:00
parent e835c5cee9
commit 875924a7f3
4 changed files with 76 additions and 40 deletions

View File

@@ -1,17 +1,12 @@
export const type = "opencode_local";
export const label = "OpenCode (local)";
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.3-codex";
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
export const models = [
{ id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL },
{ id: "openai/gpt-5.3-codex-spark", label: "openai/gpt-5.3-codex-spark" },
{ id: "openai/gpt-5.2-codex", label: "openai/gpt-5.2-codex" },
{ id: "openai/gpt-5.1-codex", label: "openai/gpt-5.1-codex" },
{ id: "openai/gpt-5-codex", label: "openai/gpt-5-codex" },
{ id: "openai/codex-mini-latest", label: "openai/codex-mini-latest" },
{ id: "openai/gpt-5", label: "openai/gpt-5" },
{ id: "openai/o3", label: "openai/o3" },
{ id: "openai/o4-mini", label: "openai/o4-mini" },
{ id: "openai/gpt-5.2", label: "openai/gpt-5.2" },
{ id: "openai/gpt-5.1-codex-max", label: "openai/gpt-5.1-codex-max" },
{ id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" },
];
export const agentConfigurationDoc = `# opencode_local agent configuration
@@ -31,7 +26,7 @@ Don't use when:
Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- model (string, optional): OpenCode model id in provider/model format (for example openai/gpt-5.3-codex)
- model (string, optional): OpenCode model id in provider/model format (for example openai/gpt-5.2-codex)
- variant (string, optional): provider-specific reasoning/profile variant passed as --variant
- promptTemplate (string, optional): run prompt template
- command (string, optional): defaults to "opencode"

View File

@@ -59,28 +59,58 @@ function resolveProviderFromModel(model: string): string | null {
return trimmed.slice(0, slash).toLowerCase();
}
function resolveProviderCredentialKey(provider: string | null): string | null {
if (!provider) return null;
switch (provider) {
case "openai":
return "OPENAI_API_KEY";
case "anthropic":
return "ANTHROPIC_API_KEY";
case "openrouter":
return "OPENROUTER_API_KEY";
case "google":
case "gemini":
return "GEMINI_API_KEY";
default:
return null;
}
}
function isProviderModelNotFoundFailure(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`;
return /ProviderModelNotFoundError|provider model not found/i.test(haystack);
}
type ProviderModelNotFoundDetails = {
providerId: string | null;
modelId: string | null;
suggestions: string[];
};
function parseProviderModelNotFoundDetails(
stdout: string,
stderr: string,
): ProviderModelNotFoundDetails | null {
if (!isProviderModelNotFoundFailure(stdout, stderr)) return null;
const haystack = `${stdout}\n${stderr}`;
const providerMatch = haystack.match(/providerID:\s*"([^"]+)"/i);
const modelMatch = haystack.match(/modelID:\s*"([^"]+)"/i);
const suggestionsMatch = haystack.match(/suggestions:\s*\[([^\]]*)\]/i);
const suggestions = suggestionsMatch
? Array.from(
suggestionsMatch[1].matchAll(/"([^"]+)"/g),
(match) => match[1].trim(),
).filter((value) => value.length > 0)
: [];
return {
providerId: providerMatch?.[1]?.trim().toLowerCase() || null,
modelId: modelMatch?.[1]?.trim() || null,
suggestions,
};
}
function formatModelNotFoundError(
model: string,
providerFromModel: string | null,
details: ProviderModelNotFoundDetails | null,
): string {
const provider = details?.providerId || providerFromModel || "unknown";
const missingModel = details?.modelId || model;
const suggestions = details?.suggestions ?? [];
const suggestionText =
suggestions.length > 0 ? ` Suggested models: ${suggestions.map((value) => `\`${value}\``).join(", ")}.` : "";
return (
`OpenCode model \`${missingModel}\` is unavailable for provider \`${provider}\`.` +
` Run \`opencode models ${provider}\` and set adapterConfig.model to a supported value.` +
suggestionText
);
}
function claudeSkillsHome(): string {
return path.join(os.homedir(), ".claude", "skills");
}
@@ -372,13 +402,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
: null;
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const providerCredentialKey = resolveProviderCredentialKey(providerFromModel);
const missingProviderCredential =
providerCredentialKey !== null &&
!hasEffectiveEnvValue(env, providerCredentialKey) &&
isProviderModelNotFoundFailure(attempt.proc.stdout, attempt.proc.stderr);
const fallbackErrorMessage = missingProviderCredential
? `OpenCode provider "${providerFromModel}" is not configured. Set ${providerCredentialKey} or authenticate with \`opencode auth login\`.`
const modelNotFound = parseProviderModelNotFoundDetails(attempt.proc.stdout, attempt.proc.stderr);
const fallbackErrorMessage = modelNotFound
? formatModelNotFoundError(model, providerFromModel, modelNotFound)
: parsedError ||
stderrLine ||
`OpenCode exited with code ${attempt.proc.exitCode ?? -1}`;

View File

@@ -58,7 +58,8 @@ function summarizeProbeDetail(stdout: string, stderr: string, parsedError: strin
}
const OPENCODE_AUTH_REQUIRED_RE =
/(?:not\s+authenticated|authentication\s+required|unauthorized|forbidden|api(?:[_\s-]?key)?(?:\s+is)?\s+required|missing\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|provider\s+credentials|provider\s+model\s+not\s+found|ProviderModelNotFoundError|login\s+required)/i;
/(?:not\s+authenticated|authentication\s+required|unauthorized|forbidden|api(?:[_\s-]?key)?(?:\s+is)?\s+required|missing\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|provider\s+credentials|login\s+required)/i;
const OPENCODE_MODEL_NOT_FOUND_RE = /ProviderModelNotFoundError|provider\s+model\s+not\s+found/i;
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
@@ -123,7 +124,7 @@ export async function testEnvironment(
message: "OPENAI_API_KEY is not set. OpenCode runs may fail until authentication is configured.",
hint: configDefinesOpenAiKey
? "adapterConfig.env defines OPENAI_API_KEY but it is empty. Set a non-empty value or remove the override."
: "Set OPENAI_API_KEY in adapter env or shell environment.",
: "Set OPENAI_API_KEY in adapter env/shell, or authenticate with `opencode auth login`.",
});
}
@@ -168,6 +169,12 @@ export async function testEnvironment(
const parsed = parseOpenCodeJsonl(probe.stdout);
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
const modelNotFound = OPENCODE_MODEL_NOT_FOUND_RE.test(authEvidence);
const modelProvider = (() => {
const slash = model.indexOf("/");
if (slash <= 0) return "openai";
return model.slice(0, slash).toLowerCase();
})();
if (probe.timedOut) {
checks.push({
@@ -192,6 +199,14 @@ export async function testEnvironment(
hint: "Try `opencode run --format json \"Respond with hello\"` manually to inspect full output.",
}),
});
} else if (modelNotFound) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: `OpenCode could not run model \`${model}\`.`,
...(detail ? { detail } : {}),
hint: `Run \`opencode models ${modelProvider}\` and set adapterConfig.model to one of the available models.`,
});
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
checks.push({
code: "opencode_hello_probe_auth_required",

View File

@@ -61,7 +61,7 @@ describe("opencode_local environment diagnostics", () => {
}
});
it("classifies ProviderModelNotFoundError probe output as auth-required warning", async () => {
it("classifies ProviderModelNotFoundError probe output as model-unavailable warning", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-env-probe-cwd-"));
const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-env-probe-bin-"));
const fakeOpencode = path.join(binDir, "opencode");
@@ -86,9 +86,9 @@ describe("opencode_local environment diagnostics", () => {
},
});
const authCheck = result.checks.find((check) => check.code === "opencode_hello_probe_auth_required");
expect(authCheck).toBeTruthy();
expect(authCheck?.level).toBe("warn");
const modelCheck = result.checks.find((check) => check.code === "opencode_hello_probe_model_unavailable");
expect(modelCheck).toBeTruthy();
expect(modelCheck?.level).toBe("warn");
expect(result.status).toBe("warn");
} finally {
await fs.rm(cwd, { recursive: true, force: true });