Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ee24068
chore(release): open v3.8.14 development cycle
diegosouzapw Jun 7, 2026
ec3a2af
feat: add per-provider custom headers support for OpenAI/Anthropic-co…
pizzav-xyz Jun 7, 2026
2c67b56
fix: Kiro Builder ID token import fails with Bad credentials (#3333)
quanturbo Jun 7, 2026
54364d1
Improve code quality: auto-pr/docstrings-1780792063 (#3337)
Lang-Qiu Jun 7, 2026
bb55a80
fix(catalog): remove minimaxai/minimax-m3 from NVIDIA NIM tier (404 u…
diegosouzapw Jun 7, 2026
23d7bd1
fix(cli): write OpenCode config to ~/.config on all platforms incl. W…
diegosouzapw Jun 7, 2026
f89b5c5
fix(proxy): make auto-selection fallback opt-in (#3332) (#3344)
diegosouzapw Jun 7, 2026
1306e7b
fix(sse): treat MiniMax M3 as multimodal so vision isn't stripped (#3…
diegosouzapw Jun 7, 2026
a955e02
fix(duckduckgo): restore bare Response contract and rebase onto relea…
Jun 7, 2026
559b97c
docs(i18n): translate 25 core documentation files to Indonesian (#3348)
KrisnaSantosa15 Jun 7, 2026
9c2beb7
fix(review): resolve /review-reviews battery findings (LEDGER-1..11) …
diegosouzapw Jun 7, 2026
da16a17
fix(provider-proxy): honor per-account proxy toggles (#3349)
rdself Jun 7, 2026
4317b43
fix(dashboard): remove duplicate Distribute Proxies button on provide…
diegosouzapw Jun 7, 2026
31c9d5b
fix(providers): reduce proxy label noise (#3346)
wilsonicdev Jun 7, 2026
888414c
Merge remote-tracking branch 'origin/release/v3.8.14' into fix/web-co…
diegosouzapw Jun 7, 2026
d6df606
fix(auto-combo): include no-auth providers declaratively
oyi77 Jun 7, 2026
7b901a2
fix(auto-combo): include configured web-session providers
oyi77 Jun 7, 2026
1a723b2
fix(review): merge release/v3.8.15 + cleanup scope
oyi77 Jun 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions open-sse/services/autoCombo/virtualFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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) {
Expand Down
5 changes: 0 additions & 5 deletions src/lib/db/migrations/095_provider_node_custom_headers.sql

This file was deleted.

16 changes: 13 additions & 3 deletions src/shared/constants/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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.",
},
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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.",
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -1139,6 +1145,7 @@ export const APIKEY_PROVIDERS = {
icon: "opencode",
color: "#6366f1",
website: "https://opencode.ai/zen",
anonymousFallback: true,
},
"opencode-go": {
id: "opencode-go",
Expand All @@ -1147,6 +1154,7 @@ export const APIKEY_PROVIDERS = {
icon: "opencode",
color: "#6366f1",
website: "https://opencode.ai/go",
anonymousFallback: true,
},
alibaba: {
id: "alibaba",
Expand Down Expand Up @@ -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",
Expand All @@ -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.",
},
Expand Down
2 changes: 2 additions & 0 deletions src/shared/validation/providerSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
71 changes: 43 additions & 28 deletions src/sse/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down
16 changes: 16 additions & 0 deletions tests/unit/auth-opencode-zen-noauth-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/virtual-auto-combo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

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

Expand Down