From 44b2fd3e1c113b6cd673e2f8613905861b5d30fe Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 11 Nov 2026 11:47:57 -0600 Subject: [PATCH] fix(opencode): report a provider 401 from the hello probe as missing authentication OpenRouter answers an empty or unknown key with HTTP 401 and messages such as "Missing Authentication header" or "User not found.", which the probe's wording regex missed, so readiness showed a generic probe error instead of the missing-account state. Classify the structured APIError status 401 as opencode_hello_probe_auth_required. Co-Authored-By: Claude Opus 5.5 --- .../opencode-local/src/server/parse.ts | 12 ++++++++++ .../src/server/test.remote.test.ts | 24 +++++++++++++++++++ .../opencode-local/src/server/test.ts | 7 ++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/parse.ts b/packages/adapters/opencode-local/src/server/parse.ts index c244fa0e102..35a8dbe6730 100644 --- a/packages/adapters/opencode-local/src/server/parse.ts +++ b/packages/adapters/opencode-local/src/server/parse.ts @@ -106,6 +106,18 @@ export function createOpenCodeJsonlParser() { }; } +/** HTTP statuses that OpenCode reports on provider API error events. */ +export function openCodeErrorStatusCodes(stdout: string): number[] { + const statuses: number[] = []; + for (const rawLine of stdout.split(/\r?\n/)) { + const event = parseJson(rawLine.trim()); + if (!event || asString(event.type, "") !== "error") continue; + const status = parseObject(parseObject(event.error).data).statusCode; + if (typeof status === "number" && Number.isInteger(status)) statuses.push(status); + } + return statuses; +} + export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): boolean { const haystack = `${stdout}\n${stderr}` .split(/\r?\n/) diff --git a/packages/adapters/opencode-local/src/server/test.remote.test.ts b/packages/adapters/opencode-local/src/server/test.remote.test.ts index e83848e3f15..4d83b852d89 100644 --- a/packages/adapters/opencode-local/src/server/test.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/test.remote.test.ts @@ -119,6 +119,30 @@ describe("opencode remote environment diagnostics", () => { }, ); + // Recorded from OpenCode 1.18.34 against OpenRouter with an empty and an unknown key. + it.each(["Missing Authentication header", "User not found."])( + "reports a provider 401 as missing authentication, not a generic failure (%s)", async message => { + const successfulProbe = await runAdapterExecutionTargetProcess.getMockImplementation()!(); + runAdapterExecutionTargetProcess.mockResolvedValueOnce({ ...successfulProbe, exitCode: 1, stderr: "", + stdout: JSON.stringify({ type: "error", sessionID: "session-1", error: { name: "APIError", + data: { message, statusCode: 401, isRetryable: false, responseHeaders: { "access-control-expose-headers": "WWW-Authenticate" } } } }) }); + const result = await testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", + config: { model: "openrouter/deepseek/deepseek-v4-flash-0731", env: { XDG_CONFIG_HOME: configHome } } }); + expect(result.status).toBe("warn"); + expect(result.checks.map(check => [check.code, check.level])).toContainEqual(["opencode_hello_probe_auth_required", "warn"]); + expect(result.checks.some(check => check.level === "error")).toBe(false); + }, + ); + + it.each([500, 429])("keeps other provider statuses a generic probe failure (%s)", async statusCode => { + const successfulProbe = await runAdapterExecutionTargetProcess.getMockImplementation()!(); + runAdapterExecutionTargetProcess.mockResolvedValueOnce({ ...successfulProbe, exitCode: 1, stderr: "", + stdout: JSON.stringify({ type: "error", error: { name: "APIError", data: { message: "Upstream error", statusCode } } }) }); + const result = await testEnvironment({ companyId: "company-1", adapterType: "paperclip_runner", + config: { model: "openrouter/deepseek/deepseek-v4-flash-0731", env: { XDG_CONFIG_HOME: configHome } } }); + expect(result.checks).toContainEqual(expect.objectContaining({ code: "opencode_hello_probe_failed", level: "error" })); + }); + it("gives each concurrent native probe its own OpenCode data directory", async () => { vi.stubEnv("HOME", "/host/user"); vi.stubEnv("XDG_DATA_HOME", "/host/user/.local/share"); diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index aad64db7234..d25e1c8b0f7 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -27,7 +27,7 @@ import { overrideAdapterExecutionTargetRemoteCwd, } from "@paperclipai/adapter-utils/execution-target"; import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable, requireOpenCodeModelId } from "./models.js"; -import { parseOpenCodeJsonl } from "./parse.js"; +import { openCodeErrorStatusCodes, parseOpenCodeJsonl } from "./parse.js"; import { SANDBOX_INSTALL_COMMAND } from "../index.js"; import { prepareOpenCodeRuntimeConfig, prepareManagedOpenCodeRemoteHomes } from "./runtime-config.js"; @@ -418,6 +418,9 @@ 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(); + // A provider 401 is an authentication rejection whatever its wording + // (OpenRouter says "Missing Authentication header" or "User not found."). + const providerRejectedAuth = openCodeErrorStatusCodes(probe.stdout).includes(401); if (probe.timedOut) { checks.push({ @@ -450,7 +453,7 @@ export async function testEnvironment( ...(detail ? { detail } : {}), hint: "Run `opencode models` and choose an available provider/model ID.", }); - } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { + } else if (providerRejectedAuth || OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { checks.push({ code: "opencode_hello_probe_auth_required", level: "warn",