Fix OpenCode default model and model-unavailable diagnostics
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user