From 3ae112acff4410c6b91fefb7fd4d45078698ffbb Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 07:29:31 -0600 Subject: [PATCH] Improve OpenCode auth diagnostics for model lookup failures --- .../opencode-local/src/server/execute.ts | 52 ++++++++++++--- .../opencode-local/src/server/test.ts | 23 +++++-- ...opencode-local-adapter-environment.test.ts | 66 +++++++++++++++++++ 3 files changed, 127 insertions(+), 14 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 24cbcefe..2bb5437e 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -34,13 +34,21 @@ function firstNonEmptyLine(text: string): string { ); } -function hasNonEmptyEnvValue(env: Record, key: string): boolean { - const raw = env[key]; - return typeof raw === "string" && raw.trim().length > 0; +function getEffectiveEnvValue(envOverrides: Record, key: string): string { + if (Object.prototype.hasOwnProperty.call(envOverrides, key)) { + const raw = envOverrides[key]; + return typeof raw === "string" ? raw : ""; + } + const raw = process.env[key]; + return typeof raw === "string" ? raw : ""; +} + +function hasEffectiveEnvValue(envOverrides: Record, key: string): boolean { + return getEffectiveEnvValue(envOverrides, key).trim().length > 0; } function resolveOpenCodeBillingType(env: Record): "api" | "subscription" { - return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription"; + return hasEffectiveEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription"; } function resolveProviderFromModel(model: string): string | null { @@ -51,6 +59,28 @@ 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); +} + function claudeSkillsHome(): string { return path.join(os.homedir(), ".claude", "skills"); } @@ -342,10 +372,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; } +function getEffectiveEnvValue(envOverrides: Record, key: string): string { + if (Object.prototype.hasOwnProperty.call(envOverrides, key)) { + const raw = envOverrides[key]; + return typeof raw === "string" ? raw : ""; + } + const raw = process.env[key]; + return typeof raw === "string" ? raw : ""; +} + function firstNonEmptyLine(text: string): string { return ( text @@ -49,7 +58,7 @@ 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|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|provider\s+model\s+not\s+found|ProviderModelNotFoundError|login\s+required)/i; export async function testEnvironment( ctx: AdapterEnvironmentTestContext, @@ -97,10 +106,10 @@ export async function testEnvironment( }); } - const configOpenAiKey = env.OPENAI_API_KEY; - const hostOpenAiKey = process.env.OPENAI_API_KEY; - if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) { - const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment"; + const configDefinesOpenAiKey = Object.prototype.hasOwnProperty.call(env, "OPENAI_API_KEY"); + const effectiveOpenAiKey = getEffectiveEnvValue(env, "OPENAI_API_KEY"); + if (isNonEmpty(effectiveOpenAiKey)) { + const source = configDefinesOpenAiKey ? "adapter config env" : "server environment"; checks.push({ code: "opencode_openai_api_key_present", level: "info", @@ -112,7 +121,9 @@ export async function testEnvironment( code: "opencode_openai_api_key_missing", level: "warn", message: "OPENAI_API_KEY is not set. OpenCode runs may fail until authentication is configured.", - hint: "Set OPENAI_API_KEY in adapter env or shell environment.", + 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.", }); } diff --git a/server/src/__tests__/opencode-local-adapter-environment.test.ts b/server/src/__tests__/opencode-local-adapter-environment.test.ts index f92de1de..1a6285ee 100644 --- a/server/src/__tests__/opencode-local-adapter-environment.test.ts +++ b/server/src/__tests__/opencode-local-adapter-environment.test.ts @@ -29,4 +29,70 @@ describe("opencode_local environment diagnostics", () => { expect(stats.isDirectory()).toBe(true); await fs.rm(path.dirname(cwd), { recursive: true, force: true }); }); + + it("treats an empty OPENAI_API_KEY override as missing", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-env-empty-key-")); + const originalOpenAiKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = "sk-host-value"; + + try { + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "opencode_local", + config: { + command: process.execPath, + cwd, + env: { + OPENAI_API_KEY: "", + }, + }, + }); + + const missingCheck = result.checks.find((check) => check.code === "opencode_openai_api_key_missing"); + expect(missingCheck).toBeTruthy(); + expect(missingCheck?.hint).toContain("empty"); + } finally { + if (originalOpenAiKey === undefined) { + delete process.env.OPENAI_API_KEY; + } else { + process.env.OPENAI_API_KEY = originalOpenAiKey; + } + await fs.rm(cwd, { recursive: true, force: true }); + } + }); + + it("classifies ProviderModelNotFoundError probe output as auth-required 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"); + const script = [ + "#!/bin/sh", + "echo 'ProviderModelNotFoundError: ProviderModelNotFoundError' 1>&2", + "echo 'data: { providerID: \"openai\", modelID: \"gpt-5.3-codex\", suggestions: [] }' 1>&2", + "exit 1", + "", + ].join("\n"); + + try { + await fs.writeFile(fakeOpencode, script, "utf8"); + await fs.chmod(fakeOpencode, 0o755); + + const result = await testEnvironment({ + companyId: "company-1", + adapterType: "opencode_local", + config: { + command: fakeOpencode, + cwd, + }, + }); + + const authCheck = result.checks.find((check) => check.code === "opencode_hello_probe_auth_required"); + expect(authCheck).toBeTruthy(); + expect(authCheck?.level).toBe("warn"); + expect(result.status).toBe("warn"); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + await fs.rm(binDir, { recursive: true, force: true }); + } + }); });