From b794e3a4bcb183a723f8b94e3faee3cacd2dfc61 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:01:07 +0700 Subject: [PATCH] fix(auto-combo): include no-auth providers declaratively (#3365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- open-sse/services/autoCombo/virtualFactory.ts | 35 ++++++--- .../095_provider_node_custom_headers.sql | 5 -- src/shared/constants/providers.ts | 16 ++++- src/shared/validation/providerSchema.ts | 2 + src/sse/services/auth.ts | 71 +++++++++++-------- .../auth-opencode-zen-noauth-fallback.test.ts | 16 +++++ tests/unit/virtual-auto-combo.test.ts | 35 +++++++++ 7 files changed, 134 insertions(+), 46 deletions(-) delete mode 100644 src/lib/db/migrations/095_provider_node_custom_headers.sql diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 90203dc81e..3fa34d62d4 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -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 | 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): 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): 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 { 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) { diff --git a/src/lib/db/migrations/095_provider_node_custom_headers.sql b/src/lib/db/migrations/095_provider_node_custom_headers.sql deleted file mode 100644 index b2952eafb2..0000000000 --- a/src/lib/db/migrations/095_provider_node_custom_headers.sql +++ /dev/null @@ -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; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index faa00d061f..67df2b560a 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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.", }, diff --git a/src/shared/validation/providerSchema.ts b/src/shared/validation/providerSchema.ts index f8615188ab..9351c621b1 100644 --- a/src/shared/validation/providerSchema.ts +++ b/src/shared/validation/providerSchema.ts @@ -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); diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 7dc74cb87b..a5fce790cb 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -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)[ + providerId + ]?.noAuth + ) || + Boolean( + (WEB_COOKIE_PROVIDERS as Record)[ + providerId + ]?.noAuth + ) + ); +} + +function maybeSyntheticNoAuthFallback(providerId: string, excludedConnectionIds: Set) { + 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(); 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; } diff --git a/tests/unit/auth-opencode-zen-noauth-fallback.test.ts b/tests/unit/auth-opencode-zen-noauth-fallback.test.ts index 453a31b67a..218e186bcb 100644 --- a/tests/unit/auth-opencode-zen-noauth-fallback.test.ts +++ b/tests/unit/auth-opencode-zen-noauth-fallback.test.ts @@ -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. diff --git a/tests/unit/virtual-auto-combo.test.ts b/tests/unit/virtual-auto-combo.test.ts index fbf58e99ae..79c77a84c2 100644 --- a/tests/unit/virtual-auto-combo.test.ts +++ b/tests/unit/virtual-auto-combo.test.ts @@ -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");