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:
Paijo
2026-06-07 21:01:07 +07:00
committed by GitHub
parent 0bb4290100
commit b794e3a4bc
7 changed files with 134 additions and 46 deletions

View File

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

View File

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

View File

@@ -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 (~25K100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use.",
freeNote:
"Free tier: 50 credits/month (~25K100K 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.",
},

View File

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

View File

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

View File

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

View File

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