From 58f0ff1b41c4ff43e88a579131a9cccf7e07239d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 9 Aug 2026 10:06:50 -0300 Subject: [PATCH] cherry-pick(pr-9675): fix(providers): per-provider opt-out for anonymous no-auth fallback (opencode-go/zen 401s) (#9873) * fix(providers): add per-provider opt-out for anonymous no-auth fallback API-key providers with anonymousFallback: true (opencode-go, opencode-zen, pollinations, kilocode) receive a synthetic "noauth" connection whenever all real connections are terminal (credits_exhausted/banned/expired) or unavailable. The opencode upstream now rejects anonymous requests with 401 Missing API key, so the fallback adds a guaranteed-failing round trip and health/reconnect noise before the combo moves on. Add a noAuthFallbackDisabledProviders settings array (zod-validated, persisted via /api/settings, following the blockedProviders pattern). When a provider is listed, maybeSyntheticNoAuthFallback returns null for anonymousFallback-only providers, so exhausted providers are skipped immediately as allExpired/allRateLimited while real keyed connections keep working and recover automatically once quota state clears. True no-auth providers are unaffected; blockedProviders remains their disable mechanism. Default (absent/empty list) preserves current behavior. Provider detail pages for anonymousFallback providers gain an "Anonymous fallback" toggle (default ON) backed by the new setting. Refs #9674 * fix(auth): reduce file size Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Hermes Agent --- .../9675-anonymous-fallback-disable-toggle.md | 1 + .../[id]/ProviderDetailPageClient.tsx | 7 + .../anonymousFallbackToggle.test.tsx | 56 +++++ .../components/AnonymousFallbackToggle.tsx | 196 ++++++++++++++++++ src/i18n/messages/en.json | 5 + src/shared/validation/settingsSchemas.ts | 1 + src/sse/services/auth.ts | 36 +++- src/sse/services/noAuthProviderSettings.ts | 27 +++ .../auth-anonymous-fallback-toggle.test.ts | 181 ++++++++++++++++ 9 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/9675-anonymous-fallback-disable-toggle.md create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/__tests__/anonymousFallbackToggle.test.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/AnonymousFallbackToggle.tsx create mode 100644 tests/unit/auth-anonymous-fallback-toggle.test.ts diff --git a/changelog.d/fixes/9675-anonymous-fallback-disable-toggle.md b/changelog.d/fixes/9675-anonymous-fallback-disable-toggle.md new file mode 100644 index 0000000000..1a1b802eb3 --- /dev/null +++ b/changelog.d/fixes/9675-anonymous-fallback-disable-toggle.md @@ -0,0 +1 @@ +- **fix(providers):** new per-provider `noAuthFallbackDisabledProviders` setting lets operators disable the synthetic anonymous (no-auth) credential fallback for API-key providers whose static definition declares `anonymousFallback: true` (e.g. `opencode-go`, `opencode-zen`) — upstream endpoints now reject anonymous requests with `401 Missing API key`, so the fallback added latency and caused UI health/reconnect churn. Real keyed connections keep working and recover automatically once quota state clears; true no-auth providers (`opencode`, `mimocode`, …) are unaffected, with `blockedProviders` remaining their disable mechanism. Default behavior is unchanged ([#9675](https://github.com/diegosouzapw/OmniRoute/pull/9675)) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 42dcef1987..24699207d3 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -59,6 +59,7 @@ import EmptyConnectionsPlaceholder from "./components/EmptyConnectionsPlaceholde import UpstreamProxyCard from "./components/UpstreamProxyCard"; import SearchProviderCard from "./components/SearchProviderCard"; import NoAuthProviderControls from "./components/NoAuthProviderControls"; +import AnonymousFallbackToggle from "./components/AnonymousFallbackToggle"; // providerText used by UpstreamProxyCard (Phase 1t.7) export default function ProviderDetailPageClient() { @@ -538,6 +539,12 @@ export default function ProviderDetailPageClient() { } /> )} + {!isUpstreamProxyProvider && !isFreeNoAuth && ( + + )} {!isUpstreamProxyProvider && !isFreeNoAuth && ( { + it("disabling adds the providerId exactly once and dedupes existing entries", () => { + const next = computeNoAuthFallbackDisabledProviders( + ["openai", "openai", "opencode-go"], + "opencode-go", + "opencode", + true + ); + expect(next).toEqual(["openai", "opencode-go"]); + expect(next.filter((id) => id === "opencode-go")).toHaveLength(1); + }); + + it("enabling removes both the providerId and its alias", () => { + const next = computeNoAuthFallbackDisabledProviders( + ["openai", "opencode-go", "opencode"], + "opencode-go", + "opencode", + false + ); + expect(next).toEqual(["openai"]); + }); + + it("enabling with only the alias present also removes it", () => { + const next = computeNoAuthFallbackDisabledProviders( + ["opencode"], + "opencode-go", + "opencode", + false + ); + expect(next).toEqual([]); + }); + + it("is enabled by default when the disabled list is absent", () => { + expect(isNoAuthFallbackEnabled("opencode-go", "opencode", undefined)).toBe(true); + }); + + it("is disabled when the providerId is in the list", () => { + expect(isNoAuthFallbackEnabled("opencode-go", "opencode", ["opencode-go"])).toBe(false); + }); + + it("is disabled when only the alias is in the list", () => { + expect(isNoAuthFallbackEnabled("opencode-go", "opencode", ["opencode"])).toBe(false); + }); + + it("is enabled when the list is present but does not contain the provider", () => { + expect(isNoAuthFallbackEnabled("opencode-go", "opencode", ["openai"])).toBe(true); + }); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/AnonymousFallbackToggle.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/AnonymousFallbackToggle.tsx new file mode 100644 index 0000000000..4ae64dde7f --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/AnonymousFallbackToggle.tsx @@ -0,0 +1,196 @@ +"use client"; + +// Issue #8935 — per-provider opt-out for the synthetic anonymous (no-auth) +// credential fallback on API-key providers whose static definition declares +// anonymousFallback: true (opencode-go, opencode-zen, pollinations, kilocode). +// Default ON (fallback enabled) when the setting is absent, so existing +// behavior is preserved for everyone who does not opt out. True no-auth +// providers (NOAUTH_PROVIDERS / WEB_COOKIE_PROVIDERS) never see this control — +// their synthetic credential is the only credential path and is governed by +// blockedProviders instead. + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/shared/components"; +import { getProviderAlias, getProviderById } from "@/shared/constants/providers"; +import { useNotificationStore } from "@/store/notificationStore"; +import { providerText } from "../providerPageHelpers"; + +export function computeNoAuthFallbackDisabledProviders( + current: string[], + providerId: string, + providerAlias: string | undefined, + disabling: boolean +): string[] { + const keysToRemove = new Set([providerId, providerAlias].filter(Boolean)); + if (!disabling) { + return current.filter((item) => !keysToRemove.has(item)); + } + return Array.from(new Set([...current.filter((item) => !keysToRemove.has(item)), providerId])); +} + +export function isNoAuthFallbackEnabled( + providerId: string, + providerAlias: string | undefined, + disabledProviders: string[] | undefined +): boolean { + if (!Array.isArray(disabledProviders)) return true; + return ( + !disabledProviders.includes(providerId) && + !(typeof providerAlias === "string" && disabledProviders.includes(providerAlias)) + ); +} + +interface AnonymousFallbackToggleProps { + providerId: string; + providerName: string; +} + +export default function AnonymousFallbackToggle({ + providerId, + providerName, +}: AnonymousFallbackToggleProps) { + const t = useTranslations("providers"); + const notify = useNotificationStore(); + const [disabledProviders, setDisabledProviders] = useState([]); + const [saving, setSaving] = useState(false); + + const providerDef = getProviderById(providerId) as { anonymousFallback?: boolean } | undefined; + const providerAlias = getProviderAlias(providerId); + const fallbackEnabled = isNoAuthFallbackEnabled(providerId, providerAlias, disabledProviders); + + useEffect(() => { + let cancelled = false; + + async function fetchDisabledProviders() { + try { + const response = await fetch("/api/settings", { cache: "no-store" }); + if (!response.ok) return; + const data = await response.json(); + if (!cancelled && Array.isArray(data.noAuthFallbackDisabledProviders)) { + setDisabledProviders(data.noAuthFallbackDisabledProviders); + } + } catch (error) { + console.error("Failed to fetch provider settings:", error); + } + } + + void fetchDisabledProviders(); + return () => { + cancelled = true; + }; + }, []); + + const handleToggle = useCallback( + async (nextEnabled: boolean) => { + const previous = disabledProviders; + const next = computeNoAuthFallbackDisabledProviders( + previous, + providerId, + providerAlias, + !nextEnabled + ); + setDisabledProviders(next); + setSaving(true); + try { + const response = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ noAuthFallbackDisabledProviders: next }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error( + data?.error?.message || + data?.error || + providerText( + t, + "anonymousFallbackUpdateFailed", + "Failed to update anonymous fallback setting" + ) + ); + } + setDisabledProviders( + Array.isArray(data.noAuthFallbackDisabledProviders) + ? data.noAuthFallbackDisabledProviders + : next + ); + notify.success( + nextEnabled + ? providerText( + t, + "anonymousFallbackEnabled", + "Anonymous fallback enabled for {provider}", + { + provider: providerName, + } + ) + : providerText( + t, + "anonymousFallbackDisabled", + "Anonymous fallback disabled for {provider} — exhausted connections will skip this provider", + { provider: providerName } + ) + ); + } catch (error) { + setDisabledProviders(previous); + notify.error( + error instanceof Error + ? error.message + : providerText( + t, + "anonymousFallbackUpdateFailed", + "Failed to update anonymous fallback setting" + ) + ); + } finally { + setSaving(false); + } + }, + [disabledProviders, notify, providerAlias, providerId, providerName, t] + ); + + // Only API-key providers whose static definition opts into the anonymous + // fallback get this control; everything else self-hides. + if (providerDef?.anonymousFallback !== true) { + return null; + } + + const title = providerText(t, "anonymousFallbackTitle", "Anonymous fallback"); + + return ( + +
+
+ key_off +
+
+

{title}

+

+ {providerText( + t, + "anonymousFallbackDesc", + "When all configured connections are exhausted (quota, credits, or expiry), temporarily use this provider's keyless tier. Turn off to skip this provider instead of sending anonymous requests — recommended when the keyless tier rejects them (401)." + )} +

+
+ +
+
+ ); +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d6cc93ec84..819d6c94bc 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5027,6 +5027,11 @@ "hideFailedAuto": "Auto-hide failed models", "selectAllModels": "Select all", "hideAllModels": "Hide all", + "anonymousFallbackTitle": "Anonymous fallback", + "anonymousFallbackDesc": "When all configured connections are exhausted (quota, credits, or expiry), temporarily use this provider's keyless tier. Turn off to skip this provider instead of sending anonymous requests — recommended when the keyless tier rejects them (401).", + "anonymousFallbackEnabled": "Anonymous fallback enabled for {provider}", + "anonymousFallbackDisabled": "Anonymous fallback disabled for {provider} — exhausted connections will skip this provider", + "anonymousFallbackUpdateFailed": "Failed to update anonymous fallback setting", "modelsActive": "Active", "showModel": "Show model", "hideModel": "Hide model", diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index bd4dea2a1d..32a2d1cebb 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -118,6 +118,7 @@ export const updateSettingsSchema = z.object({ baseUrl: z.string().max(500).optional(), setupComplete: z.boolean().optional(), blockedProviders: z.array(z.string().max(100)).optional(), + noAuthFallbackDisabledProviders: z.array(z.string().max(100)).optional(), hidePaidModels: z.boolean().optional(), hideHealthCheckLogs: z.boolean().optional(), hideEndpointCloudflaredTunnel: z.boolean().optional(), diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 3243a0316a..4a30d23b0f 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -76,7 +76,10 @@ import { resolveSessionAffinityTtlMs, selectSessionAffinityConnection, } from "./sessionAffinityPin"; -import { isNoAuthProviderBlockedBySettings } from "./noAuthProviderSettings"; +import { + isAnonymousFallbackDisabledBySettings, + isNoAuthProviderBlockedBySettings, +} from "./noAuthProviderSettings"; import { resolveAccountProxiesFromRegistry } from "./noAuthProxyResolution"; import { getNoAuthHydrationProviderIds } from "./noAuthProviderSiblings"; import { getResource404Bypass } from "./requestResourceHealth"; @@ -728,6 +731,30 @@ function providerCanUseSyntheticNoAuthFallback(providerId: string): boolean { webCookieProviderDef?.noAuth === true ); } + +/** + * True only for API-key gateway providers whose synthetic anonymous fallback + * eligibility comes from `anonymousFallback: true` on the static definition — + * NOT for true no-auth providers (NOAUTH_PROVIDERS / WEB_COOKIE_PROVIDERS), + * where the synthetic credential is the only credential path (blockedProviders + * is the disable mechanism for those). `noAuthFallbackDisabledProviders` gates + * exactly this subset. + */ +function isAnonymousFallbackOnlyProvider(providerId: string): boolean { + const providerDef = getProviderById(providerId) as + AnonymousFallbackProviderDefinition | undefined; + const noAuthProviderDef = ( + NOAUTH_PROVIDERS as Record + )[providerId]; + const webCookieProviderDef = ( + WEB_COOKIE_PROVIDERS as Record + )[providerId]; + return ( + providerDef?.anonymousFallback === true && + noAuthProviderDef?.noAuth !== true && + webCookieProviderDef?.noAuth !== true + ); +} async function maybeSyntheticNoAuthFallback( providerId: string, excludedConnectionIds: Set, @@ -740,6 +767,13 @@ async function maybeSyntheticNoAuthFallback( // key reach free providers (felo-chat, etc.) that it should not access. if (Array.isArray(allowedConnections) && allowedConnections.length > 0) return null; if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) return null; + if ( + isAnonymousFallbackOnlyProvider(providerId) && + (await isAnonymousFallbackDisabledBySettings(providerId)) + ) { + log.info("AUTH", `${providerId} | anonymous no-auth fallback disabled by settings`); + return null; + } // #4954: hydrate per-account proxy/rotation config off the connection row so // no-auth executors (opencode, mimocode) actually honor configured proxies. const providerSpecificData = await loadNoAuthProviderSpecificData(providerId); diff --git a/src/sse/services/noAuthProviderSettings.ts b/src/sse/services/noAuthProviderSettings.ts index 205fd9154f..dee365c5f1 100644 --- a/src/sse/services/noAuthProviderSettings.ts +++ b/src/sse/services/noAuthProviderSettings.ts @@ -16,3 +16,30 @@ export async function isNoAuthProviderBlockedBySettings(providerId: string): Pro return false; } } + +/** + * Per-provider opt-out for the synthetic anonymous (no-auth) credential + * fallback. Only applies to API-key gateway providers whose fallback + * eligibility comes from `anonymousFallback: true` on the static provider + * definition (e.g. opencode-go, opencode-zen). True no-auth providers + * (NOAUTH_PROVIDERS / WEB_COOKIE_PROVIDERS) are NOT matched here — for them + * the synthetic credential is the only credential path, and `blockedProviders` + * remains the disable mechanism. + * + * Fail-open: any settings-read error returns false, preserving current + * behavior (anonymous fallback keeps working). + */ +export async function isAnonymousFallbackDisabledBySettings(providerId: string): Promise { + try { + const settings = await getSettings(); + return isProviderBlockedByIdOrAlias(providerId, settings.noAuthFallbackDisabledProviders); + } catch (error) { + log.warn( + "AUTH", + `Could not read no-auth fallback disabled settings for ${providerId}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return false; + } +} diff --git a/tests/unit/auth-anonymous-fallback-toggle.test.ts b/tests/unit/auth-anonymous-fallback-toggle.test.ts new file mode 100644 index 0000000000..6795a24c97 --- /dev/null +++ b/tests/unit/auth-anonymous-fallback-toggle.test.ts @@ -0,0 +1,181 @@ +/** + * Per-provider opt-out for the synthetic anonymous (no-auth) credential + * fallback (`noAuthFallbackDisabledProviders`). + * + * API-key gateway providers whose static definition declares + * `anonymousFallback: true` (opencode-go, opencode-zen, pollinations, …) get a + * synthetic "noauth" connection whenever all real configured connections are + * terminal (expired/banned/credits_exhausted) or all unavailable. Upstream + * endpoints now reject anonymous requests with 401 Missing API key, so operators + * need a per-provider toggle to disable that fallback while keeping the provider + * enabled and real keyed connections working. + * + * The gate applies ONLY to `anonymousFallback: true` API-key providers. True + * no-auth providers (NOAUTH_PROVIDERS / WEB_COOKIE_PROVIDERS entries with + * `noAuth: true`, e.g. opencode, mimocode) are NOT affected — for them the + * synthetic credential is the only credential path and `blockedProviders` is + * the disable mechanism. + */ +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-anon-fallback-toggle-")); +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"); +const { createProviderConnection, updateProviderConnection, deleteProviderConnectionsByProvider } = + await import("../../src/lib/db/providers.ts"); +const { updateSettings } = await import("../../src/lib/db/settings.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +/** Set the opt-out list; pass null to remove the key entirely (absent setting). */ +async function setNoAuthFallbackDisabledProviders(providers: string[] | null): Promise { + if (providers === null) { + const db = core.getDbInstance(); + db.prepare( + "DELETE FROM key_value WHERE namespace = 'settings' AND key = 'noAuthFallbackDisabledProviders'" + ).run(); + return; + } + await updateSettings({ noAuthFallbackDisabledProviders: providers }); +} + +function assertSyntheticNoAuth(creds: unknown, providerId: string): void { + assert.ok(creds, `${providerId} must resolve to synthetic no-auth credentials`); + assert.equal( + (creds as { connectionId?: string }).connectionId, + "noauth", + `${providerId} should return the synthetic "noauth" connection` + ); + assert.equal((creds as { apiKey?: unknown }).apiKey, null, "anonymous access carries no api key"); +} + +test("a. backward compat default: no setting → terminal opencode-go falls back to noauth", async () => { + await setNoAuthFallbackDisabledProviders(null); + await deleteProviderConnectionsByProvider("opencode-go"); + await createProviderConnection({ + provider: "opencode-go", + authType: "apikey", + name: "expired-key-default", + apiKey: "sk-expired-default", + isActive: false, + testStatus: "expired", + }); + + const creds = await getProviderCredentials("opencode-go"); + assertSyntheticNoAuth(creds, "opencode-go"); +}); + +test("b. disabled + all terminal → allExpired result, never noauth", async () => { + await setNoAuthFallbackDisabledProviders(["opencode-go"]); + await deleteProviderConnectionsByProvider("opencode-go"); + await createProviderConnection({ + provider: "opencode-go", + authType: "apikey", + name: "expired-key-disabled", + apiKey: "sk-expired-disabled", + isActive: false, + testStatus: "expired", + }); + + const result = (await getProviderCredentials("opencode-go")) as Record | null; + assert.ok(result, "must return a structured result (allExpired), not null"); + assert.equal(result.allExpired, true, "terminal connections should surface as allExpired"); + assert.notEqual( + result.connectionId, + "noauth", + "disabled provider must never receive synthetic no-auth credentials" + ); +}); + +test("c. disabled + zero connections → null, never noauth", async () => { + await setNoAuthFallbackDisabledProviders(["opencode-go", "opencode-zen"]); + await deleteProviderConnectionsByProvider("opencode-zen"); + + const result = await getProviderCredentials("opencode-zen"); + assert.equal(result, null, "disabled provider with no connections must not fall back to noauth"); +}); + +test("d. disabled + healthy real connection → real credentials still selected", async () => { + await setNoAuthFallbackDisabledProviders(["opencode-go"]); + await deleteProviderConnectionsByProvider("opencode-go"); + const created = await createProviderConnection({ + provider: "opencode-go", + authType: "apikey", + name: "healthy-key", + apiKey: "sk-opencode-go-live", + isActive: true, + testStatus: "active", + }); + + const result = (await getProviderCredentials("opencode-go")) as Record | null; + assert.ok(result, "healthy keyed connection must still resolve to credentials"); + assert.equal(result.connectionId, created.id, "must select the real DB connection"); + assert.equal(result.apiKey, "sk-opencode-go-live", "real api key must be returned"); + assert.notEqual(result.connectionId, "noauth"); +}); + +test("e. recovery: rate-limited → allRateLimited; quota recovered → real connection again", async () => { + await setNoAuthFallbackDisabledProviders(["opencode-go"]); + await deleteProviderConnectionsByProvider("opencode-go"); + const created = await createProviderConnection({ + provider: "opencode-go", + authType: "apikey", + name: "recovering-key", + apiKey: "sk-opencode-go-recover", + isActive: true, + testStatus: "active", + rateLimitedUntil: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + + const exhausted = (await getProviderCredentials("opencode-go")) as Record | null; + assert.ok(exhausted, "rate-limited connection must produce a structured result"); + assert.equal( + exhausted.allRateLimited, + true, + "while rate limited the provider should surface allRateLimited, not noauth" + ); + assert.notEqual(exhausted.connectionId, "noauth"); + + await updateProviderConnection(created.id as string, { rateLimitedUntil: null }); + + const recovered = (await getProviderCredentials("opencode-go")) as Record | null; + assert.ok(recovered, "recovered connection must resolve to credentials again"); + assert.equal( + recovered.connectionId, + created.id, + "once quota recovers the real connection must be selected again" + ); + assert.equal(recovered.apiKey, "sk-opencode-go-recover"); +}); + +test("f. true no-auth provider unaffected: opencode still returns synthetic noauth", async () => { + await setNoAuthFallbackDisabledProviders(["opencode", "opencode-go", "opencode-zen"]); + + const creds = await getProviderCredentials("opencode"); + assertSyntheticNoAuth(creds, "opencode"); +}); + +test("g. re-enable: removing provider from the list restores the fallback", async () => { + await setNoAuthFallbackDisabledProviders(["opencode-zen"]); + await deleteProviderConnectionsByProvider("opencode-go"); + await createProviderConnection({ + provider: "opencode-go", + authType: "apikey", + name: "expired-key-reenabled", + apiKey: "sk-expired-reenabled", + isActive: false, + testStatus: "expired", + }); + + const creds = await getProviderCredentials("opencode-go"); + assertSyntheticNoAuth(creds, "opencode-go"); +});