diff --git a/CHANGELOG.md b/CHANGELOG.md index c1c7d30fa0..1852566414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,12 @@ validation falls through to the chat probe for such 403s instead of returning "Invalid API key", and `checkFallbackError` short-circuits them to no cooldown. Genuine auth failures (401 / generic 403) still fail fast. (#2929) +- **auth/opencode-zen:** the OpenCode Zen free model now works in the Playground + and combos without an API key. `opencode-zen` serves the public, signup-free + endpoint (`https://opencode.ai/zen/v1`); when no api-key connection is + configured, credential resolution now falls back to anonymous (no-auth) access + instead of failing with "No credentials for provider: opencode-zen". A + configured, active key is still used when present. (#2962) ### ✨ New Features diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 56d093edc6..cedc25bab6 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -943,6 +943,31 @@ export async function getProviderCredentials( }; } } + // #2962: opencode-zen exposes the public, signup-free OpenCode Zen endpoint + // (https://opencode.ai/zen/v1). With no usable API-key connection, fall back + // to anonymous (no-auth) access — the free tier — instead of erroring with + // "No credentials". This is what the Playground/combos hit when selecting an + // OpenCode free model. A configured, active key is still selected above; a + // rate-limited/terminal key returns its own signal before reaching here. + if (resolvedId === "opencode-zen") { + return { + apiKey: null, + accessToken: null, + refreshToken: null, + expiresAt: null, + projectId: null, + copilotToken: null, + providerSpecificData: {}, + connectionId: "noauth", + testStatus: "active", + lastError: null, + lastErrorType: null, + lastErrorSource: null, + errorCode: null, + rateLimitedUntil: null, + maxConcurrent: null, + }; + } log.warn("AUTH", `No credentials for ${provider}`); return null; } diff --git a/tests/unit/auth-opencode-zen-noauth-fallback.test.ts b/tests/unit/auth-opencode-zen-noauth-fallback.test.ts new file mode 100644 index 0000000000..fd9054d686 --- /dev/null +++ b/tests/unit/auth-opencode-zen-noauth-fallback.test.ts @@ -0,0 +1,48 @@ +/** + * Issue #2962 — Playground cannot use the OpenCode free model: + * "No credentials for the provider: opencode-zen". + * + * opencode-zen serves the public, signup-free OpenCode Zen endpoint + * (https://opencode.ai/zen/v1). When no API-key connection is configured, + * getProviderCredentials returned null → the chat handler surfaced + * "No credentials for provider: opencode-zen". It must instead fall back to + * anonymous (no-auth) credentials so the free tier works. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-opencode-zen-noauth-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { getProviderCredentials } = await import("../../src/sse/services/auth.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#2962 opencode-zen with no connection falls back to anonymous no-auth credentials", async () => { + const creds = await getProviderCredentials("opencode-zen"); + assert.ok(creds, "opencode-zen must resolve to credentials, not null (no-auth free tier)"); + assert.equal( + (creds as { connectionId?: string }).connectionId, + "noauth", + "should be synthetic no-auth credentials" + ); + assert.equal( + (creds as { apiKey?: unknown }).apiKey, + null, + "anonymous access carries no api key" + ); +}); + +test("#2962 a normal api-key provider with no connection still returns null (no over-broadening)", async () => { + const creds = await getProviderCredentials("openai"); + // Must NOT synthesize no-auth creds for a real api-key provider. + const connectionId = (creds as { connectionId?: string } | null)?.connectionId; + assert.notEqual(connectionId, "noauth", "openai must not get anonymous no-auth credentials"); +});