Improve OpenCode auth diagnostics for model lookup failures
This commit is contained in:
@@ -34,13 +34,21 @@ function firstNonEmptyLine(text: string): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
function getEffectiveEnvValue(envOverrides: Record<string, string>, key: string): string {
|
||||||
const raw = env[key];
|
if (Object.prototype.hasOwnProperty.call(envOverrides, key)) {
|
||||||
return typeof raw === "string" && raw.trim().length > 0;
|
const raw = envOverrides[key];
|
||||||
|
return typeof raw === "string" ? raw : "";
|
||||||
|
}
|
||||||
|
const raw = process.env[key];
|
||||||
|
return typeof raw === "string" ? raw : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasEffectiveEnvValue(envOverrides: Record<string, string>, key: string): boolean {
|
||||||
|
return getEffectiveEnvValue(envOverrides, key).trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveOpenCodeBillingType(env: Record<string, string>): "api" | "subscription" {
|
function resolveOpenCodeBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||||
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
return hasEffectiveEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProviderFromModel(model: string): string | null {
|
function resolveProviderFromModel(model: string): string | null {
|
||||||
@@ -51,6 +59,28 @@ function resolveProviderFromModel(model: string): string | null {
|
|||||||
return trimmed.slice(0, slash).toLowerCase();
|
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 {
|
function claudeSkillsHome(): string {
|
||||||
return path.join(os.homedir(), ".claude", "skills");
|
return path.join(os.homedir(), ".claude", "skills");
|
||||||
}
|
}
|
||||||
@@ -342,10 +372,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
: null;
|
: null;
|
||||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||||
const fallbackErrorMessage =
|
const providerCredentialKey = resolveProviderCredentialKey(providerFromModel);
|
||||||
parsedError ||
|
const missingProviderCredential =
|
||||||
stderrLine ||
|
providerCredentialKey !== null &&
|
||||||
`OpenCode exited with code ${attempt.proc.exitCode ?? -1}`;
|
!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\`.`
|
||||||
|
: parsedError ||
|
||||||
|
stderrLine ||
|
||||||
|
`OpenCode exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exitCode: attempt.proc.exitCode,
|
exitCode: attempt.proc.exitCode,
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ function isNonEmpty(value: unknown): value is string {
|
|||||||
return typeof value === "string" && value.trim().length > 0;
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEffectiveEnvValue(envOverrides: Record<string, string>, 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 {
|
function firstNonEmptyLine(text: string): string {
|
||||||
return (
|
return (
|
||||||
text
|
text
|
||||||
@@ -49,7 +58,7 @@ function summarizeProbeDetail(stdout: string, stderr: string, parsedError: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const OPENCODE_AUTH_REQUIRED_RE =
|
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(
|
export async function testEnvironment(
|
||||||
ctx: AdapterEnvironmentTestContext,
|
ctx: AdapterEnvironmentTestContext,
|
||||||
@@ -97,10 +106,10 @@ export async function testEnvironment(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const configOpenAiKey = env.OPENAI_API_KEY;
|
const configDefinesOpenAiKey = Object.prototype.hasOwnProperty.call(env, "OPENAI_API_KEY");
|
||||||
const hostOpenAiKey = process.env.OPENAI_API_KEY;
|
const effectiveOpenAiKey = getEffectiveEnvValue(env, "OPENAI_API_KEY");
|
||||||
if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) {
|
if (isNonEmpty(effectiveOpenAiKey)) {
|
||||||
const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment";
|
const source = configDefinesOpenAiKey ? "adapter config env" : "server environment";
|
||||||
checks.push({
|
checks.push({
|
||||||
code: "opencode_openai_api_key_present",
|
code: "opencode_openai_api_key_present",
|
||||||
level: "info",
|
level: "info",
|
||||||
@@ -112,7 +121,9 @@ export async function testEnvironment(
|
|||||||
code: "opencode_openai_api_key_missing",
|
code: "opencode_openai_api_key_missing",
|
||||||
level: "warn",
|
level: "warn",
|
||||||
message: "OPENAI_API_KEY is not set. OpenCode runs may fail until authentication is configured.",
|
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.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,4 +29,70 @@ describe("opencode_local environment diagnostics", () => {
|
|||||||
expect(stats.isDirectory()).toBe(true);
|
expect(stats.isDirectory()).toBe(true);
|
||||||
await fs.rm(path.dirname(cwd), { recursive: true, force: 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user