From ccc442574413fea33afe98cc8cc2da8b9a37ae46 Mon Sep 17 00:00:00 2001 From: Wilson Date: Fri, 5 Jun 2026 00:50:08 -0300 Subject: [PATCH] fix(auto-combo): include no-auth OpenCode Free (#3189) Integrated into release/v3.8.11 --- CHANGELOG.md | 6 ++ open-sse/services/autoCombo/virtualFactory.ts | 102 ++++++++++++++---- tests/unit/virtual-auto-combo.test.ts | 31 +++++- 3 files changed, 113 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d004a25298..678c9ffd04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,12 @@ OAuth resilience & observability release: spaced/sequential quota sync for OAuth --- +## [3.8.10] — Unreleased + +_Development cycle in progress — entries are added as work merges into `release/v3.8.10` and finalized by the release flow._ + +--- + ## [3.8.9] — 2026-06-03 ### ✨ New Features diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 820f57868c..90203dc81e 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -5,6 +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 { defaultLogger as log } from "@omniroute/open-sse/utils/logger"; /** Minimal connection shape needed for virtual auto-combo factory */ @@ -16,6 +17,12 @@ interface VirtualFactoryConn extends ConnectionFields { tokenExpiresAt?: number | string | null; } +type NoAuthProviderDefinition = { + id?: string; + alias?: string; + noAuth?: boolean; +}; + export interface VirtualAutoComboCandidate { provider: string; connectionId: string; @@ -81,6 +88,53 @@ function hasUsableOAuthToken(conn: VirtualFactoryConn): boolean { return expiryMs === null || expiryMs > Date.now(); } +const SYNTHETIC_NOAUTH_CONNECTION_ID = "noauth"; +const ZERO_CONFIG_NOAUTH_CHAT_PROVIDERS = new Set(["opencode"]); + +function getFirstRegistryModelId(providerInfo: { models?: Array<{ id?: string }> } | undefined) { + const firstModel = Array.isArray(providerInfo?.models) ? providerInfo.models[0] : undefined; + return typeof firstModel?.id === "string" && firstModel.id.trim().length > 0 + ? firstModel.id + : undefined; +} + +function getNoAuthCandidates(excludedProviders: Set): VirtualAutoComboCandidate[] { + const registry = getProviderRegistry(); + const candidates: VirtualAutoComboCandidate[] = []; + + for (const providerDef of Object.values(NOAUTH_PROVIDERS) as NoAuthProviderDefinition[]) { + if (providerDef?.noAuth !== true) 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); + if (!modelId) continue; + + // 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. + const registryAlias = + typeof providerInfo?.alias === "string" && providerInfo.alias.trim().length > 0 + ? providerInfo.alias + : null; + const routingPrefix = providerDef.alias || registryAlias || providerId; + candidates.push({ + provider: providerId, + connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID, + model: modelId, + modelStr: `${routingPrefix}/${modelId}`, + costPer1MTokens: 0, + }); + } + + return candidates; +} + /** * Creates a virtual AutoCombo configuration dynamically based on connected providers and a specified variant. * This combo is not persisted in the DB. @@ -95,7 +149,32 @@ export async function createVirtualAutoCombo( return hasApiKey || hasUsableOAuthToken(conn); }); - if (validConnections.length === 0) { + const candidatePool: VirtualAutoComboCandidate[] = []; + for (const conn of validConnections) { + const providerInfo = getProviderRegistry()[conn.provider]; + if (!providerInfo) continue; // Skip unknown providers + + let modelId: string | undefined = conn.defaultModel; + if (!modelId) { + const firstModel = providerInfo.models[0]; + modelId = firstModel?.id; + } + if (!modelId) continue; // Skip providers without a model + + candidatePool.push({ + provider: conn.provider, + connectionId: conn.id, + model: modelId, + modelStr: `${conn.provider}/${modelId}`, + costPer1MTokens: 0, // Not used in virtual auto-combo (LKGP uses session stickiness) + }); + } + + candidatePool.push( + ...getNoAuthCandidates(new Set(validConnections.map((conn) => conn.provider))) + ); + + if (candidatePool.length === 0) { log.warn("AUTO", "No connected providers with valid credentials for virtual auto-combo"); const emptyPool: string[] = []; const autoConfig = { @@ -119,27 +198,6 @@ export async function createVirtualAutoCombo( }; } - const candidatePool: VirtualAutoComboCandidate[] = []; - for (const conn of validConnections) { - const providerInfo = getProviderRegistry()[conn.provider]; - if (!providerInfo) continue; // Skip unknown providers - - let modelId: string | undefined = conn.defaultModel; - if (!modelId) { - const firstModel = providerInfo.models[0]; - modelId = firstModel?.id; - } - if (!modelId) continue; // Skip providers without a model - - candidatePool.push({ - provider: conn.provider, - connectionId: conn.id, - model: modelId, - modelStr: `${conn.provider}/${modelId}`, - costPer1MTokens: 0, // Not used in virtual auto-combo (LKGP uses session stickiness) - }); - } - let weights: ScoringWeights = { ...DEFAULT_WEIGHTS }; let explorationRate = 0.05; // Default exploration rate let routerStrategy = "lkgp"; // All auto variants use LKGP diff --git a/tests/unit/virtual-auto-combo.test.ts b/tests/unit/virtual-auto-combo.test.ts index a303c1d365..fbf58e99ae 100644 --- a/tests/unit/virtual-auto-combo.test.ts +++ b/tests/unit/virtual-auto-combo.test.ts @@ -48,12 +48,12 @@ test("createVirtualAutoCombo returns an executable auto combo for API-key connec const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("fast"); assert.equal(combo.strategy, "auto"); - assert.equal(combo.models.length, 1); + assert.ok(combo.models.length >= 1); assert.equal(combo.models[0].kind, "model"); assert.equal(combo.models[0].model, "openai/gpt-4o-mini"); assert.equal(combo.models[0].providerId, "openai"); assert.equal(combo.autoConfig.routerStrategy, "lkgp"); - assert.deepEqual(combo.autoConfig.candidatePool, ["openai"]); + assert.ok(combo.autoConfig.candidatePool.includes("openai")); }); test("createVirtualAutoCombo includes OAuth accessToken connections with real expiry fields", async () => { @@ -69,7 +69,30 @@ test("createVirtualAutoCombo includes OAuth accessToken connections with real ex const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("coding"); assert.equal(combo.strategy, "auto"); - assert.equal(combo.models.length, 1); + assert.ok(combo.models.length >= 1); assert.equal(combo.models[0].model, "anthropic/claude-sonnet-4-5"); - assert.deepEqual(combo.autoConfig.candidatePool, ["anthropic"]); + assert.ok(combo.autoConfig.candidatePool.includes("anthropic")); +}); + +test("createVirtualAutoCombo includes no-auth OpenCode Free without provider_connections rows", async () => { + const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("fast"); + + const opencode = combo.models.find((model) => model.providerId === "opencode"); + assert.ok( + opencode, + "OpenCode Free should appear in auto/* even when it has no provider_connections row" + ); + assert.equal(opencode.connectionId, "noauth"); + assert.equal(opencode.model, "oc/big-pickle"); + assert.ok(combo.autoConfig.candidatePool.includes("opencode")); +}); + +test("createVirtualAutoCombo keeps credential-required providers out when disconnected", async () => { + const combo: VirtualComboResult = await virtualFactory.createVirtualAutoCombo("fast"); + + assert.equal( + combo.models.some((model) => model.providerId === "openai"), + false, + "OpenAI should still require a real active connection" + ); });