mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
fix(auto-combo): include no-auth providers declaratively (#3365)
Integrated into release/v3.8.15. Cleanup applied on contributor's branch: removed duplicate migration 095 (already exists from PR #3338), reverted CHANGELOG.md and i18n changelogs to release versions (release process owns these), dropped package version-bump noise from stale fork base. Core feature — declarative no-auth via serviceKinds metadata, declarative VEO as 'video' provider, anonymousFallback flag for opencode-zen/opencode-go — integrated cleanly.
This commit is contained in:
@@ -5,7 +5,7 @@ import { AutoVariant } from "./autoPrefix";
|
||||
import { getProviderConnections } from "@/lib/db/providers";
|
||||
import { getProviderRegistry } from "./providerRegistryAccessor";
|
||||
import type { ConnectionFields } from "@/lib/db/encryption";
|
||||
import { NOAUTH_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { NOAUTH_PROVIDERS, WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { defaultLogger as log } from "@omniroute/open-sse/utils/logger";
|
||||
|
||||
/** Minimal connection shape needed for virtual auto-combo factory */
|
||||
@@ -15,12 +15,14 @@ interface VirtualFactoryConn extends ConnectionFields {
|
||||
defaultModel?: string;
|
||||
expiresAt?: number | string | null;
|
||||
tokenExpiresAt?: number | string | null;
|
||||
providerSpecificData?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
type NoAuthProviderDefinition = {
|
||||
id?: string;
|
||||
alias?: string;
|
||||
noAuth?: boolean;
|
||||
serviceKinds?: string[];
|
||||
};
|
||||
|
||||
export interface VirtualAutoComboCandidate {
|
||||
@@ -88,8 +90,25 @@ function hasUsableOAuthToken(conn: VirtualFactoryConn): boolean {
|
||||
return expiryMs === null || expiryMs > Date.now();
|
||||
}
|
||||
|
||||
function hasProviderSpecificSessionData(conn: VirtualFactoryConn): boolean {
|
||||
if (!(conn.provider in WEB_COOKIE_PROVIDERS)) return false;
|
||||
const data = conn.providerSpecificData;
|
||||
return Boolean(data && typeof data === "object" && Object.keys(data).length > 0);
|
||||
}
|
||||
|
||||
function hasUsableConnectionCredential(conn: VirtualFactoryConn): boolean {
|
||||
const hasApiKey = typeof conn.apiKey === "string" && conn.apiKey.trim().length > 0;
|
||||
return hasApiKey || hasUsableOAuthToken(conn) || hasProviderSpecificSessionData(conn);
|
||||
}
|
||||
|
||||
const SYNTHETIC_NOAUTH_CONNECTION_ID = "noauth";
|
||||
const ZERO_CONFIG_NOAUTH_CHAT_PROVIDERS = new Set(["opencode"]);
|
||||
|
||||
function isChatAutoComboNoAuthProvider(providerDef: NoAuthProviderDefinition): boolean {
|
||||
if (providerDef.noAuth !== true) return false;
|
||||
if (!Array.isArray(providerDef.serviceKinds) || providerDef.serviceKinds.length === 0)
|
||||
return true;
|
||||
return providerDef.serviceKinds.includes("llm");
|
||||
}
|
||||
|
||||
function getFirstRegistryModelId(providerInfo: { models?: Array<{ id?: string }> } | undefined) {
|
||||
const firstModel = Array.isArray(providerInfo?.models) ? providerInfo.models[0] : undefined;
|
||||
@@ -103,11 +122,10 @@ function getNoAuthCandidates(excludedProviders: Set<string>): VirtualAutoComboCa
|
||||
const candidates: VirtualAutoComboCandidate[] = [];
|
||||
|
||||
for (const providerDef of Object.values(NOAUTH_PROVIDERS) as NoAuthProviderDefinition[]) {
|
||||
if (providerDef?.noAuth !== true) continue;
|
||||
if (!isChatAutoComboNoAuthProvider(providerDef)) continue;
|
||||
|
||||
const providerId = providerDef.id;
|
||||
if (!providerId || excludedProviders.has(providerId)) continue;
|
||||
if (!ZERO_CONFIG_NOAUTH_CHAT_PROVIDERS.has(providerId)) continue;
|
||||
|
||||
const providerInfo = registry[providerId];
|
||||
const modelId = getFirstRegistryModelId(providerInfo);
|
||||
@@ -116,8 +134,8 @@ function getNoAuthCandidates(excludedProviders: Set<string>): VirtualAutoComboCa
|
||||
// No-auth providers do not have provider_connections rows. Use the same
|
||||
// synthetic connection id returned by getProviderCredentials() so the
|
||||
// downstream combo path can still carry a stable target/account identity.
|
||||
// For OpenCode Free specifically, route through its alias (oc/...) because
|
||||
// opencode/... is a compatibility alias for the opencode-zen API-key tier.
|
||||
// Prefer provider aliases because some canonical provider IDs are reserved
|
||||
// for credentialed tiers with different routing semantics.
|
||||
const registryAlias =
|
||||
typeof providerInfo?.alias === "string" && providerInfo.alias.trim().length > 0
|
||||
? providerInfo.alias
|
||||
@@ -144,10 +162,7 @@ export async function createVirtualAutoCombo(
|
||||
): Promise<VirtualAutoCombo> {
|
||||
const connections = (await getProviderConnections({ isActive: true })) as VirtualFactoryConn[];
|
||||
|
||||
const validConnections = connections.filter((conn) => {
|
||||
const hasApiKey = typeof conn.apiKey === "string" && conn.apiKey.trim().length > 0;
|
||||
return hasApiKey || hasUsableOAuthToken(conn);
|
||||
});
|
||||
const validConnections = connections.filter(hasUsableConnectionCredential);
|
||||
|
||||
const candidatePool: VirtualAutoComboCandidate[] = [];
|
||||
for (const conn of validConnections) {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Add custom_headers_json column to provider_nodes
|
||||
-- Stores JSON object of custom HTTP headers to send with requests to this provider
|
||||
-- NULL = no custom headers (backward compatible)
|
||||
-- Column uses _json suffix so rowToCamel auto-parses it
|
||||
ALTER TABLE provider_nodes ADD COLUMN custom_headers_json TEXT;
|
||||
@@ -38,6 +38,7 @@ export const NOAUTH_PROVIDERS = {
|
||||
website: "https://opencode.ai",
|
||||
noAuth: true,
|
||||
hasFree: true,
|
||||
serviceKinds: ["llm"],
|
||||
authHint: "No API key required — uses OpenCode's public free endpoint.",
|
||||
freeNote:
|
||||
"No API key required — public OpenCode endpoint with Kimi, GLM, Qwen, MiMo, MiniMax models.",
|
||||
@@ -55,6 +56,7 @@ export const NOAUTH_PROVIDERS = {
|
||||
website: "https://duckduckgo.com/duckchat",
|
||||
noAuth: true,
|
||||
hasFree: true,
|
||||
serviceKinds: ["llm"],
|
||||
freeNote: "Free — anonymous access to multiple AI models via DuckDuckGo.",
|
||||
authHint: "No credentials required — DuckDuckGo AI Chat is anonymous and free.",
|
||||
},
|
||||
@@ -68,6 +70,7 @@ export const NOAUTH_PROVIDERS = {
|
||||
website: "https://theoldllm.vercel.app",
|
||||
noAuth: true,
|
||||
hasFree: true,
|
||||
serviceKinds: ["llm"],
|
||||
freeNote:
|
||||
"Free — GPT-5.4, Claude 4.6 Opus/Sonnet/Haiku, + more. No API key — tokens auto-generated via browser.",
|
||||
authHint:
|
||||
@@ -83,6 +86,7 @@ export const NOAUTH_PROVIDERS = {
|
||||
website: "https://amelia.chipotle.com",
|
||||
noAuth: true,
|
||||
hasFree: true,
|
||||
serviceKinds: ["llm"],
|
||||
freeNote:
|
||||
"Free — Chipotle's Pepper AI (IPsoft Amelia). Anonymous sessions, no API key. Rate-limited.",
|
||||
authHint:
|
||||
@@ -98,6 +102,7 @@ export const NOAUTH_PROVIDERS = {
|
||||
website: "https://veoaifree.com",
|
||||
noAuth: true,
|
||||
hasFree: true,
|
||||
serviceKinds: ["video"],
|
||||
freeNote: "Free video generation — VEO 3.1, Seedance. 6 requests/hour.",
|
||||
authHint: "No auth required. Rate limited to 6 requests/hour per IP.",
|
||||
},
|
||||
@@ -168,7 +173,8 @@ export const OAUTH_PROVIDERS = {
|
||||
subscriptionRisk: true,
|
||||
riskNoticeVariant: "deprecated",
|
||||
hasFree: true,
|
||||
freeNote: "Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use.",
|
||||
freeNote:
|
||||
"Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use.",
|
||||
},
|
||||
"amazon-q": {
|
||||
id: "amazon-q",
|
||||
@@ -567,7 +573,7 @@ export const WEB_COOKIE_PROVIDERS = {
|
||||
"Open chat.qwen.ai, log in, then open DevTools → Application → Local Storage → " +
|
||||
'copy the "token" value (or use tongyi_sso_ticket cookie as Bearer token).',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// API Key Providers
|
||||
export const APIKEY_PROVIDERS = {
|
||||
@@ -1139,6 +1145,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "opencode",
|
||||
color: "#6366f1",
|
||||
website: "https://opencode.ai/zen",
|
||||
anonymousFallback: true,
|
||||
},
|
||||
"opencode-go": {
|
||||
id: "opencode-go",
|
||||
@@ -1147,6 +1154,7 @@ export const APIKEY_PROVIDERS = {
|
||||
icon: "opencode",
|
||||
color: "#6366f1",
|
||||
website: "https://opencode.ai/go",
|
||||
anonymousFallback: true,
|
||||
},
|
||||
alibaba: {
|
||||
id: "alibaba",
|
||||
@@ -1177,7 +1185,8 @@ export const APIKEY_PROVIDERS = {
|
||||
textIcon: "LC",
|
||||
website: "https://longcat.chat/platform/docs",
|
||||
hasFree: true,
|
||||
freeNote: "Free: 5M tokens/day on LongCat-2.0-Preview (Flash models retired 2026-05-29); up to 120M/day via feedback.",
|
||||
freeNote:
|
||||
"Free: 5M tokens/day on LongCat-2.0-Preview (Flash models retired 2026-05-29); up to 120M/day via feedback.",
|
||||
},
|
||||
pollinations: {
|
||||
id: "pollinations",
|
||||
@@ -1188,6 +1197,7 @@ export const APIKEY_PROVIDERS = {
|
||||
textIcon: "PO",
|
||||
website: "https://pollinations.ai",
|
||||
hasFree: true,
|
||||
anonymousFallback: true,
|
||||
freeNote:
|
||||
"No API key required for free public endpoint. Optional Spore tier: ~0.01 pollen/hour.",
|
||||
},
|
||||
|
||||
@@ -42,6 +42,8 @@ export const ProviderSchema = z.object({
|
||||
authHint: z.string().optional(),
|
||||
apiHint: z.string().optional(),
|
||||
serviceKinds: z.array(z.enum(SERVICE_KIND_VALUES)).optional(),
|
||||
noAuth: z.boolean().optional(),
|
||||
anonymousFallback: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const ProvidersMapSchema = z.record(z.string(), ProviderSchema);
|
||||
|
||||
@@ -718,12 +718,17 @@ async function selectSessionAffinityConnection(
|
||||
|
||||
/**
|
||||
* Sentinel connection id used for the synthetic credentials of no-auth /
|
||||
* keyless providers (opencode / opencode-zen). It is NOT a real DB row, so it
|
||||
* keyless providers. It is NOT a real DB row, so it
|
||||
* cannot carry cooldown state — the account-fallback loop must be able to
|
||||
* exclude it (#3061), otherwise it gets re-selected forever.
|
||||
*/
|
||||
const SYNTHETIC_NOAUTH_CONNECTION_ID = "noauth";
|
||||
|
||||
type AnonymousFallbackProviderDefinition = {
|
||||
anonymousFallback?: boolean;
|
||||
noAuth?: boolean;
|
||||
};
|
||||
|
||||
function buildSyntheticNoAuthCredentials(): {
|
||||
apiKey: null;
|
||||
accessToken: null;
|
||||
@@ -764,6 +769,31 @@ function buildSyntheticNoAuthCredentials(): {
|
||||
};
|
||||
}
|
||||
|
||||
function providerCanUseSyntheticNoAuthFallback(providerId: string): boolean {
|
||||
const providerDef = getProviderById(providerId) as
|
||||
| AnonymousFallbackProviderDefinition
|
||||
| undefined;
|
||||
return (
|
||||
providerDef?.anonymousFallback === true ||
|
||||
Boolean(
|
||||
(NOAUTH_PROVIDERS as Record<string, AnonymousFallbackProviderDefinition | undefined>)[
|
||||
providerId
|
||||
]?.noAuth
|
||||
) ||
|
||||
Boolean(
|
||||
(WEB_COOKIE_PROVIDERS as Record<string, AnonymousFallbackProviderDefinition | undefined>)[
|
||||
providerId
|
||||
]?.noAuth
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function maybeSyntheticNoAuthFallback(providerId: string, excludedConnectionIds: Set<string>) {
|
||||
if (!providerCanUseSyntheticNoAuthFallback(providerId)) return null;
|
||||
if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) return null;
|
||||
return buildSyntheticNoAuthCredentials();
|
||||
}
|
||||
|
||||
function normalizeExcludedConnectionIds(
|
||||
excludeConnectionId: string | null,
|
||||
extraExcludedConnectionIds: string[] | null | undefined
|
||||
@@ -940,10 +970,7 @@ export async function getProviderCredentials(
|
||||
excludeConnectionId,
|
||||
options.excludeConnectionIds
|
||||
);
|
||||
if (excludedForNoAuth.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) {
|
||||
return null;
|
||||
}
|
||||
return buildSyntheticNoAuthCredentials();
|
||||
return maybeSyntheticNoAuthFallback(resolvedId, excludedForNoAuth);
|
||||
}
|
||||
|
||||
const allowSuppressedConnections = options.allowSuppressedConnections === true;
|
||||
@@ -1028,6 +1055,9 @@ export async function getProviderCredentials(
|
||||
// the dashboard sees a misleading "bad_request" code.
|
||||
const terminalConnections = allConnections.filter(isTerminalConnectionStatus);
|
||||
if (terminalConnections.length === allConnections.length) {
|
||||
const syntheticFallback = maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds);
|
||||
if (syntheticFallback) return syntheticFallback;
|
||||
|
||||
const statusCounts = new Map<string, number>();
|
||||
for (const c of terminalConnections) {
|
||||
const key = normalizeStatus(c.testStatus) || "expired";
|
||||
@@ -1042,21 +1072,8 @@ 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") {
|
||||
// #3061: same loop guard as the NOAUTH_PROVIDERS path above — once the
|
||||
// single synthetic "noauth" connection has been excluded by the chat
|
||||
// fallback loop, return null instead of re-handing it back forever.
|
||||
if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) {
|
||||
return null;
|
||||
}
|
||||
return buildSyntheticNoAuthCredentials();
|
||||
}
|
||||
const syntheticFallback = maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds);
|
||||
if (syntheticFallback) return syntheticFallback;
|
||||
log.warn("AUTH", `No credentials for ${provider}`);
|
||||
return null;
|
||||
}
|
||||
@@ -1226,13 +1243,8 @@ export async function getProviderCredentials(
|
||||
cooldownModel: allBlockedByModelCooldown ? requestedModel : null,
|
||||
};
|
||||
}
|
||||
if (resolvedId === "opencode-zen") {
|
||||
if (excludedConnectionIds.has(SYNTHETIC_NOAUTH_CONNECTION_ID)) {
|
||||
return null;
|
||||
}
|
||||
return buildSyntheticNoAuthCredentials();
|
||||
}
|
||||
|
||||
const syntheticFallback = maybeSyntheticNoAuthFallback(resolvedId, excludedConnectionIds);
|
||||
if (syntheticFallback) return syntheticFallback;
|
||||
log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`);
|
||||
return null;
|
||||
}
|
||||
@@ -1586,7 +1598,10 @@ export async function getProviderCredentialsWithQuotaPreflight(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (credentials.allRateLimited || credentials.allExpired) {
|
||||
if (
|
||||
("allRateLimited" in credentials && credentials.allRateLimited) ||
|
||||
("allExpired" in credentials && credentials.allExpired)
|
||||
) {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,22 @@ test("#2962 opencode-zen with no connection falls back to anonymous no-auth cred
|
||||
assert.equal((creds as { apiKey?: unknown }).apiKey, null, "anonymous access carries no api key");
|
||||
});
|
||||
|
||||
test("apikey providers with anonymous fallback use no-auth when saved rows are terminal", async () => {
|
||||
await createProviderConnection({
|
||||
provider: "pollinations",
|
||||
authType: "apikey",
|
||||
name: "expired-pollinations-key",
|
||||
apiKey: "pollinations-expired",
|
||||
isActive: true,
|
||||
testStatus: "expired",
|
||||
});
|
||||
|
||||
const creds = await getProviderCredentials("pollinations");
|
||||
assert.ok(creds, "pollinations should fall back to anonymous credentials");
|
||||
assert.equal((creds as { connectionId?: string }).connectionId, "noauth");
|
||||
assert.equal((creds as { apiKey?: unknown }).apiKey, null);
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
@@ -74,6 +74,23 @@ test("createVirtualAutoCombo includes OAuth accessToken connections with real ex
|
||||
assert.ok(combo.autoConfig.candidatePool.includes("anthropic"));
|
||||
});
|
||||
|
||||
test("createVirtualAutoCombo includes configured web-session providers without apiKey fields", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "qwen-web",
|
||||
authType: "apikey",
|
||||
name: "Qwen Web Session",
|
||||
providerSpecificData: { token: "qwen-web-session-token" },
|
||||
defaultModel: "qwen3-coder-plus",
|
||||
});
|
||||
|
||||
const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("coding");
|
||||
|
||||
const qwenWeb = combo.models.find((model) => model.providerId === "qwen-web");
|
||||
assert.ok(qwenWeb, "configured web-session providers should be auto-combo candidates");
|
||||
assert.equal(qwenWeb.model, "qwen-web/qwen3-coder-plus");
|
||||
assert.ok(combo.autoConfig.candidatePool.includes("qwen-web"));
|
||||
});
|
||||
|
||||
test("createVirtualAutoCombo includes no-auth OpenCode Free without provider_connections rows", async () => {
|
||||
const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("fast");
|
||||
|
||||
@@ -87,6 +104,24 @@ test("createVirtualAutoCombo includes no-auth OpenCode Free without provider_con
|
||||
assert.ok(combo.autoConfig.candidatePool.includes("opencode"));
|
||||
});
|
||||
|
||||
test("createVirtualAutoCombo includes all chat-capable no-auth providers without connections", async () => {
|
||||
const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("fast");
|
||||
|
||||
const byProvider = new Map(combo.models.map((model) => [model.providerId, model]));
|
||||
|
||||
assert.equal(byProvider.get("duckduckgo-web")?.connectionId, "noauth");
|
||||
assert.equal(byProvider.get("duckduckgo-web")?.model, "ddgw/gpt-4o-mini");
|
||||
assert.equal(byProvider.get("theoldllm")?.connectionId, "noauth");
|
||||
assert.equal(byProvider.get("theoldllm")?.model, "tllm/GPT_5_4");
|
||||
assert.equal(byProvider.get("chipotle")?.connectionId, "noauth");
|
||||
assert.equal(byProvider.get("chipotle")?.model, "pepper/pepper-1");
|
||||
assert.equal(
|
||||
byProvider.has("veoaifree-web"),
|
||||
false,
|
||||
"video-only no-auth providers must not be inserted into chat auto-combos"
|
||||
);
|
||||
});
|
||||
|
||||
test("createVirtualAutoCombo keeps credential-required providers out when disconnected", async () => {
|
||||
const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("fast");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user