Compare commits

...

2 Commits

Author SHA1 Message Date
diegosouzapw
f05f754eb0 Merge remote-tracking branch 'origin/release/v3.8.50' into HEAD 2026-08-07 21:40:28 -03:00
Hermes Agent
0838bee661 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
2026-08-07 03:50:34 +00:00
9 changed files with 513 additions and 2 deletions

View File

@@ -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))

View File

@@ -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 && (
<AnonymousFallbackToggle
providerId={providerId}
providerName={providerInfo?.name || providerId}
/>
)}
{!isUpstreamProxyProvider && !isFreeNoAuth && (
<Card>
<ProviderAccountRoutingCard

View File

@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
computeNoAuthFallbackDisabledProviders,
isNoAuthFallbackEnabled,
} from "../components/AnonymousFallbackToggle";
describe("AnonymousFallbackToggle list-update helpers", () => {
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);
});
});

View File

@@ -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<string[]>([]);
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 (
<Card>
<div className="flex items-center gap-3">
<div className="inline-flex shrink-0 items-center justify-center w-10 h-10 rounded-full bg-sky-500/10 text-sky-500">
<span className="material-symbols-outlined text-[20px]">key_off</span>
</div>
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold">{title}</h2>
<p className="text-sm text-text-muted">
{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)."
)}
</p>
</div>
<button
type="button"
aria-pressed={fallbackEnabled}
aria-label={title}
disabled={saving}
onClick={() => handleToggle(!fallbackEnabled)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
fallbackEnabled ? "bg-sky-500" : "bg-black/[0.12] dark:bg-white/[0.15]"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
fallbackEnabled ? "translate-x-[26px]" : "translate-x-[3px]"
}`}
/>
</button>
</div>
</Card>
);
}

View File

@@ -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",

View File

@@ -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(),

View File

@@ -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";
@@ -773,12 +776,43 @@ function providerCanUseSyntheticNoAuthFallback(providerId: string): boolean {
);
}
/**
* 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<string, AnonymousFallbackProviderDefinition | undefined>
)[providerId];
const webCookieProviderDef = (
WEB_COOKIE_PROVIDERS as Record<string, AnonymousFallbackProviderDefinition | undefined>
)[providerId];
return (
providerDef?.anonymousFallback === true &&
noAuthProviderDef?.noAuth !== true &&
webCookieProviderDef?.noAuth !== true
);
}
async function maybeSyntheticNoAuthFallback(
providerId: string,
excludedConnectionIds: Set<string>
) {
if (!providerCanUseSyntheticNoAuthFallback(providerId)) 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);
@@ -2030,7 +2064,10 @@ export async function markAccountUnavailable(
? "model"
: getQuotaScopeLabelForProvider(provider, model);
const antigravityFamilyInferredBaseCooldownMs =
!usesExactAntigravityLock && provider === "antigravity" && quotaScope === "family" && status === 429
!usesExactAntigravityLock &&
provider === "antigravity" &&
quotaScope === "family" &&
status === 429
? ANTIGRAVITY_FAMILY_INFERRED_BASE_COOLDOWN_MS
: null;
const lockout = recordModelLockoutFailure(

View File

@@ -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<boolean> {
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;
}
}

View File

@@ -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<void> {
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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");
});