Files
OmniRoute/open-sse/services/autoCombo/virtualFactory.ts
Diego Rodrigues de Sa e Souza f6ccd3cf9f fix(quality): green release/v3.8.50 base-reds round 2 (#9985) (#10131)
* fix(quality): green release/v3.8.50 base-reds round 2 — gateways/conol/deepai corruption, migrations, docs, ratchets, dashboard-typecheck

Base-red fix for issue #9985 after the 2026-08-11 merge storm (99 PRs).

Real defects fixed:
- gateways.ts: close regolo entry (was swallowing naga-ac + chatanywhere from #9421), drop stale duplicate chatanywhere entry (#9594)
- conol-web + deepai registry: correct ../shared import depth + deepai executor:default
- modelSelectModalHelpers: close isProviderModelHidden (#9011)
- driverFactory.test.ts: restore eaten test-closing brace (#9173)
- usageTracking: remove duplicate cache_* props
- modelCapability{Overrides,ResolutionSnapshot,Capabilities}: max_token -> max_output_tokens (#9199 vs #8908) + test align
- videoGeneration: drop duplicate handleFalVideoGeneration import (mediaGeneration/fal canonical, #9982)
- responseSanitizer: cast input_tokens_details before .cached_tokens access
- EditConnectionModal: missing alibaba code fields, hoist validationPsd, providerPageHelpers Badge variant union
- FreeBudgetCard: t() -> labels.noApiKey
- peerRouting + cliRuntime: ProcessEnv typing
- image-combo.test.ts: type any -> unknown
- fal.test.ts: moved to tests/unit/services (collected path) 14 tests green
- remove duplicate 143_job_registry.sql (146 canonical), KNOWN_GAPS fix

Docs/ratchets (owner-authorized rebaselines, annotated):
- CHANGELOG 3.8.50 living section restored + 42 i18n mirrors
- MCP-SERVER.md 104->105 tools + i18n
- ENVIRONMENT.md/.env.example: ADOBE_FIREFLY_CHROME_HEADED + DEBUG_CLAUDE_NONSTREAM
- fabricated-docs allowlist: TELEGRAM proposal env vars
- file-size: 5 grown files + proxyFetch 1207->1220
- dead-code 230->248, codeql 2->9 (drift from merged PRs, not this PR)
- untrack _tasks symlink; agent-skills-sync --apply (config-codex-cli)

* fix(changelog): reformat two feature fragments to the bullet convention (#9239, #9490)

* fix(quality): prune stale ESLint suppressions (base-red)

* fix(quality): resolve open-sse type errors + catalog/build regressions (base-red round 3)

Storm-merge splices repaired in the base-fix PR #10131:
- doctor.ts: AppConfig missing brokerSocketPath
- conol-web.ts: Buffer not assignable to BodyInit (Uint8Array)
- tinycms.ts: TinyCmsExecutor.execute return matches BaseExecutor (response/url/transformedBody)
- tinycmsSigner.ts: encodeInto never-narrowing guard + dead wasm URL fallback (Turbopack)
- virtualFactory.ts: options slot for resolutionSnapshot
- bottleneckPatch.ts: insufficient-overlap casts (as unknown as)
- imageCombo.ts: narrow handleImageGeneration union result
- browser-worker.ts: AppConfig + turn.capabilities splice
- conolDiscovery.ts: getProviderOutboundGuard from Policy module
- catalog.ts: drop removed SWR hooks (getCatalogStaleWhileRevalidateMs + accessors), CatalogCachePolicy -> inline settings, resolve 4-arg call
- catalogCache.ts: remove dead inFlight/promise refs
- chat.ts: add isProviderBreakerFailureStatus import
- model-catalog-cache-swr-8728.test.ts: align to #9199 new API (policy injection removed)

* fix(quality): align UI test fixtures to current component contracts (base-red vitest)

- setup-wizard: provide required serverState prop (component gained it in a merged PR)
- grok-device-oauth-modal: next-intl stub resolves grok flow keys to EN labels
- provider-quota-widget: label now inline (PR #8916 removed AutoRefreshButtonLabel extraction) — test the widget
- use-provider-connections-cursor-refresh + phase1f: match /api/providers?provider=<id> query form; hoist heavy dynamic imports to module scope (timeout flake)
- home-topology: mock next/navigation useRouter (component added node-click navigation)
- cooling/lobe/AutoComboCatalog: raise cold-import describe timeouts to 30-60s
- request-logger-*: align to current detail-view contract

* fix(search): guard params.token undefined in serper headers (typecheck base-red)

* fix(search): guard token headers + non-null providerConfig (typecheck base-red)

* fix(changelog): restore base CHANGELOGs eaten by merge auto-resolve (43 files)

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: backryun <bakryun0718@proton.me>
2026-08-12 16:09:34 -03:00

826 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AutoComboConfig } from "./engine";
import { MODE_PACKS } from "./modePacks";
import { DEFAULT_WEIGHTS, ScoringWeights } from "./scoring";
import { getCachedProviderConnections } from "@/lib/db/readCache";
import { getSettings } from "@/lib/db/settings";
import { getProviderRegistry } from "./providerRegistryAccessor";
import type { ConnectionFields } from "@/lib/db/encryption";
import { NOAUTH_PROVIDERS } from "@/shared/constants/providers";
import { hasUsableWebSessionCredential } from "@/shared/providers/webSessionCredentials";
import { defaultLogger as log } from "@omniroute/open-sse/utils/logger";
import { getTokenLimit } from "../contextManager";
import {
createModelCapabilityResolutionSnapshot,
getResolvedModelCapabilities,
type ModelCapabilityResolutionSnapshot,
} from "@/lib/modelCapabilities";
import {
buildAutoCandidateFilter,
tierToWeightVariant,
type AutoCategory,
type AutoTier,
} from "./suffixComposition";
import type { AutoVariant } from "./autoPrefix";
import { buildFamilyCandidateFilter, type ModelFamily } from "./modelFamily";
import { getHiddenModelsByProvider } from "@/models";
import { filterPaidOnlyCandidates } from "./paidModelFilter";
import { isModelExcludedByConnection } from "@/domain/connectionModelRules";
import { filterExcludedCandidates } from "./candidateOverrides";
import { getExcludedConnectionIds } from "@/lib/db/autoCandidateOverrides";
import {
filterResilienceBlockedCandidates,
SYNTHETIC_NOAUTH_CONNECTION_ID as RESILIENCE_NOAUTH_CONNECTION_ID,
type ConnectionResilienceView,
} from "./resilienceCandidateFilter";
import type { ChaosTuning } from "./chaosEngine";
/** #4235 Phase B: optional category/tier overlay for `auto/<category>:<tier>` combos.
* #6453: optional `family` overlay for `auto/<family>` combos (e.g. `auto/glm`) —
* mutually exclusive with category/tier, applied instead of them when present. */
export interface AutoComboSpec {
category?: AutoCategory;
tier?: AutoTier;
family?: ModelFamily;
}
/** Minimal connection shape needed for virtual auto-combo factory */
interface VirtualFactoryConn extends ConnectionFields {
id: string;
provider: string;
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 {
provider: string;
/** A concrete connection for synthetic/no-auth candidates; null for a logical provider/model candidate. */
connectionId: string | null;
/** Credentialed accounts that are eligible to serve this provider/model pair. */
allowedConnectionIds?: string[];
model: string;
modelStr: string; // e.g., 'openai/gpt-4o'
costPer1MTokens: number; // from providerRegistry
/** Build-local capability snapshot. Runtime calls rebuild it; catalog entries reuse it. */
resolvedContextLength?: number | null;
resolvedMaxOutputTokens?: number | null;
resolvedSupportsVision?: boolean;
resolvedReasoning?: boolean;
resolvedSupportsThinking?: boolean;
}
type VirtualAutoCombo = AutoComboConfig & {
strategy: "auto";
models: Array<{
id: string;
kind: "model";
model: string;
providerId: string;
connectionId: string | null;
allowedConnectionIds?: string[];
weight: number;
label: string;
}>;
/** MAX of candidates' context windows — safe to advertise because the
* auto-combo context pre-filter routes oversized requests to large-window
* candidates. null when the pool is empty. */
advertisedContextLength: number | null;
advertisedMaxOutputTokens: number | null;
autoConfig: {
candidatePool: string[];
weights: ScoringWeights;
explorationRate: number;
routerStrategy: string;
};
config: {
auto: {
candidatePool: string[];
weights: ScoringWeights;
explorationRate: number;
routerStrategy: string;
};
chaos?: {
enabled: true;
panelSize: number;
judgeModel?: string;
tuning: ChaosTuning;
};
};
};
/**
* Build-local candidate snapshots shared by the built-in entries in one model-catalog build.
* Runtime routing does not retain or reuse this object across requests.
*/
export interface PreparedVirtualAutoComboInputs {
readonly regularCandidates: readonly VirtualAutoComboCandidate[];
readonly familyCandidates: readonly VirtualAutoComboCandidate[];
}
function toExpiryMs(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
const parsed =
typeof value === "number"
? value
: typeof value === "string" && value.trim().length > 0
? Number(value)
: Number.NaN;
if (Number.isFinite(parsed) && parsed > 0) {
return parsed < 10_000_000_000 ? parsed * 1000 : parsed;
}
if (typeof value === "string") {
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : null;
}
return null;
}
function hasUsableOAuthToken(conn: VirtualFactoryConn): boolean {
if (typeof conn.accessToken !== "string" || conn.accessToken.trim().length === 0) return false;
const expiryMs = toExpiryMs(conn.tokenExpiresAt) ?? toExpiryMs(conn.expiresAt);
return expiryMs === null || expiryMs > Date.now();
}
function hasProviderSpecificSessionData(conn: VirtualFactoryConn): boolean {
return hasUsableWebSessionCredential(conn.provider, conn.providerSpecificData);
}
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 = RESILIENCE_NOAUTH_CONNECTION_ID;
// Allowlist of no-auth (keyless) providers permitted to enter the `auto`/`auto-*`
// candidate pool. Narrowed to the backends verified to answer without any
// configuration on our reference egress (VPS .15): `opencode` and `felo-web`
// both return 200 there, while duckduckgo-web (429/VQD rate limit), theoldllm
// (403 Vercel egress block), chipotle (502), aihorde (401, anon key rejected)
// and the others are unreliable. The excluded providers stay fully usable via
// direct `<alias>/<model>` calls — they are just kept OUT of auto-routing until
// re-verified. Re-add an id here to bring it back into every auto/* pool.
//
// Scope (operator decision 2026-07-24, refs #8183/#6453/#7032): this allowlist
// targets public-HTTP-egress reliability for the category/tier and flat-variant
// `auto/*` pools (auto/best-free, auto/coding:fast, ...). It does NOT apply to
// `auto/<family>` pools (auto/glm, auto/zai, ...) — a family combo is an
// identity selector ("whatever genuinely serves GLM"), not a reliability-curated
// pool, so it admits any no-auth backend that genuinely serves the family (e.g.
// auggie, a local CLI subprocess with zero HTTP egress, belongs in auto/glm
// regardless of this list). See the `bypassAllowlist` param below.
const AUTO_COMBO_NOAUTH_ALLOWLIST = new Set<string>(["opencode", "felo-web"]);
function isChatAutoComboNoAuthProvider(
providerDef: NoAuthProviderDefinition,
bypassAllowlist: boolean
): boolean {
if (providerDef.noAuth !== true) return false;
if (!bypassAllowlist && !AUTO_COMBO_NOAUTH_ALLOWLIST.has(providerDef.id)) return false;
if (!Array.isArray(providerDef.serviceKinds) || providerDef.serviceKinds.length === 0)
return true;
return providerDef.serviceKinds.includes("llm");
}
function getNoAuthCandidates(
excludedProviders: Set<string>,
blockedProviders: Set<string>,
disabledNoAuthProviders: Set<string>,
noAuthProviderSpecificData: Map<string, Record<string, unknown> | null | undefined>,
hiddenModelsMap: Map<string, Set<string>>,
bypassAllowlist: boolean
): VirtualAutoComboCandidate[] {
const registry = getProviderRegistry();
const candidates: VirtualAutoComboCandidate[] = [];
for (const providerDef of Object.values(NOAUTH_PROVIDERS) as NoAuthProviderDefinition[]) {
if (!isChatAutoComboNoAuthProvider(providerDef, bypassAllowlist)) continue;
const providerId = providerDef.id;
if (!providerId || excludedProviders.has(providerId)) continue;
if (
blockedProviders.has(providerId) ||
(typeof providerDef.alias === "string" && blockedProviders.has(providerDef.alias))
)
continue;
// #6557: a no-auth provider with its OWN provider_connections row explicitly
// disabled (isActive=false, the toggle on the main Providers grid card once an
// Account/fingerprint exists) must not be routed to, even though it has no
// entry in the separate `settings.blockedProviders` list.
if (
disabledNoAuthProviders.has(providerId) ||
(typeof providerDef.alias === "string" && disabledNoAuthProviders.has(providerDef.alias))
)
continue;
const providerInfo = registry[providerId];
const registryModels = Array.isArray(providerInfo?.models) ? providerInfo.models : [];
if (registryModels.length === 0) 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.
// 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
: null;
const routingPrefix = providerDef.alias || registryAlias || providerId;
// #7622: honor the "Excluded Models" field (`providerSpecificData.excludedModels`)
// already enforced at dispatch time (src/sse/services/auth.ts) for no-auth
// providers' own provider_connections row (#6557), so an excluded model never
// enters the auto-combo/fusion candidate pool in the first place.
const providerSpecificData =
noAuthProviderSpecificData.get(providerId) ??
(typeof providerDef.alias === "string"
? noAuthProviderSpecificData.get(providerDef.alias)
: undefined);
// #7620: honor the eye-icon "hidden" flag (isHidden, from the
// modelCompatOverrides/customModels key_value namespaces) the same way the
// credentialed-connection loop below does, so a hidden no-auth model never
// enters the auto-combo/fusion candidate pool either.
const hiddenModels =
hiddenModelsMap.get(providerId) ??
(typeof providerDef.alias === "string" ? hiddenModelsMap.get(providerDef.alias) : undefined);
for (const model of registryModels) {
const modelId = typeof model?.id === "string" && model.id.trim().length > 0 ? model.id : null;
if (!modelId) continue;
if (isModelExcludedByConnection(modelId, providerSpecificData)) continue;
if (hiddenModels?.has(modelId)) continue;
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.
*/
/**
* Aggregate the context window / max output to ADVERTISE for an auto combo.
*
* MAX across candidates (not min): the auto-combo context pre-filter
* (combo.ts::filterTargetsByRequestCompatibility + the estimated-tokens
* pre-filter) already routes oversized requests away from small-window
* candidates, so advertising the largest window lets clients (e.g. opencode)
* keep their smart auto-compaction calibrated to the best candidate instead
* of compacting prematurely — or, worse, receiving 0 and disabling
* compaction entirely (the "agent keeps forgetting things" bug).
*
* Unknown candidates resolve through getTokenLimit()'s fallback chain, so a
* non-empty pool always yields a positive contextLength.
*
* maxOutputTokens has no such guaranteed fallback in getResolvedModelCapabilities()
* — registry entries and models.dev sync data are both optional per model, so a
* candidate pool whose members all lack that specific field (e.g. #6453's
* provider-family combos, `auto/llama` and friends, over no-auth/free-tier
* registry entries that were never annotated with maxOutputTokens) would
* otherwise advertise `null`, which mirrors the `context: 0` bug this module's
* docstring describes for contextLength (opencode disables smart auto-compaction
* entirely when a limit is falsy). Fall back to a conservative generic default so
* a non-empty pool always yields a positive maxOutputTokens too.
*/
const DEFAULT_ADVERTISED_MAX_OUTPUT_TOKENS = 8192;
type AdvertisedLimitCandidate = {
provider: string;
model: string;
resolvedContextLength?: number | null;
resolvedMaxOutputTokens?: number | null;
};
export function computeAdvertisedLimits(candidates: AdvertisedLimitCandidate[]): {
contextLength: number | null;
maxOutputTokens: number | null;
} {
if (!Array.isArray(candidates) || candidates.length === 0) {
return { contextLength: null, maxOutputTokens: null };
}
let contextLength: number | null = null;
let maxOutputTokens: number | null = null;
for (const candidate of candidates) {
const limit =
candidate.resolvedContextLength !== undefined
? candidate.resolvedContextLength
: getTokenLimit(candidate.provider, candidate.model);
if (typeof limit === "number" && Number.isFinite(limit) && limit > 0) {
contextLength = contextLength === null ? limit : Math.max(contextLength, limit);
}
const output =
candidate.resolvedMaxOutputTokens !== undefined
? candidate.resolvedMaxOutputTokens
: getResolvedModelCapabilities({
provider: candidate.provider,
model: candidate.model,
}).maxOutputTokens;
if (typeof output === "number" && Number.isFinite(output) && output > 0) {
maxOutputTokens = maxOutputTokens === null ? output : Math.max(maxOutputTokens, output);
}
}
if (maxOutputTokens === null) {
maxOutputTokens = DEFAULT_ADVERTISED_MAX_OUTPUT_TOKENS;
}
return { contextLength, maxOutputTokens };
}
const PREPARED_CAPABILITY_YIELD_INTERVAL = 16;
type PreparedCapabilityValues = {
resolvedContextLength: number | null;
resolvedMaxOutputTokens: number | null;
resolvedSupportsVision: boolean;
resolvedReasoning: boolean;
resolvedSupportsThinking: boolean;
};
type PreparedCapabilityState = {
/** Nested provider → model memo; collision-free for arbitrary model ids. */
byTarget: Map<string, Map<string, PreparedCapabilityValues>>;
resolvedSinceYield: number;
/** Build-local bulk maps; one per catalog prepare, never retained at runtime. */
resolutionSnapshot: ModelCapabilityResolutionSnapshot;
};
function yieldVirtualAutoPreparationTurn(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
async function attachPreparedCapabilityValues(
candidates: readonly VirtualAutoComboCandidate[],
state: PreparedCapabilityState
): Promise<VirtualAutoComboCandidate[]> {
const prepared: VirtualAutoComboCandidate[] = [];
for (const candidate of candidates) {
let byModel = state.byTarget.get(candidate.provider);
if (!byModel) {
byModel = new Map();
state.byTarget.set(candidate.provider, byModel);
}
let values = byModel.get(candidate.model);
if (!values) {
const contextLength = getTokenLimit(
candidate.provider,
candidate.model,
state.resolutionSnapshot
);
const capabilities = getResolvedModelCapabilities(
{
provider: candidate.provider,
model: candidate.model,
},
undefined,
state.resolutionSnapshot
);
const maxOutputTokens = capabilities.maxOutputTokens;
values = {
resolvedContextLength:
Number.isFinite(contextLength) && contextLength > 0 ? contextLength : null,
resolvedMaxOutputTokens:
typeof maxOutputTokens === "number" &&
Number.isFinite(maxOutputTokens) &&
maxOutputTokens > 0
? maxOutputTokens
: null,
resolvedSupportsVision: capabilities.supportsVision === true,
resolvedReasoning: capabilities.reasoning === true,
resolvedSupportsThinking: capabilities.supportsThinking === true,
};
byModel.set(candidate.model, values);
state.resolvedSinceYield++;
if (state.resolvedSinceYield >= PREPARED_CAPABILITY_YIELD_INTERVAL) {
state.resolvedSinceYield = 0;
await yieldVirtualAutoPreparationTurn();
}
}
prepared.push({ ...candidate, ...values });
}
return prepared;
}
export async function prepareVirtualAutoComboInputs(
options: { includeResolvedCapabilities?: boolean } = {}
): Promise<PreparedVirtualAutoComboInputs> {
const [connections, disabledNoAuthConnections, settings] = await Promise.all([
getCachedProviderConnections({ isActive: true }) as Promise<VirtualFactoryConn[]>,
// #6557: no-auth providers (opencode/mimocode/etc.) don't get an isActive
// filter applied above since their credential is synthetic, but a real
// provider_connections row CAN exist for them (created via "Add Account")
// and its own isActive=false must gate the auto-combo pool too — not just
getCachedProviderConnections({ isActive: false }) as Promise<VirtualFactoryConn[]>,
getSettings().catch(() => ({}) as Record<string, unknown>),
]);
const blockedProviders = new Set(
Array.isArray(settings.blockedProviders) ? (settings.blockedProviders as string[]) : []
);
const disabledNoAuthProviders = new Set(
disabledNoAuthConnections
.filter((conn) => conn.provider in NOAUTH_PROVIDERS)
.map((conn) => conn.provider)
);
const hiddenModelsMap = getHiddenModelsByProvider();
// #7622: a no-auth provider's own provider_connections row (#6557) can carry
// `providerSpecificData.excludedModels` regardless of its isActive state (the
// dispatch-time enforcement in auth.ts does not gate on isActive either), so
// gather it from BOTH the active and disabled connection lists.
const noAuthProviderSpecificData = new Map<string, Record<string, unknown> | null | undefined>();
for (const conn of [...connections, ...disabledNoAuthConnections]) {
if (conn.provider in NOAUTH_PROVIDERS) {
noAuthProviderSpecificData.set(conn.provider, conn.providerSpecificData);
}
}
const validConnections = connections.filter(hasUsableConnectionCredential);
const candidatePool: VirtualAutoComboCandidate[] = [];
const registry = getProviderRegistry();
const connectionsByProvider = new Map<string, VirtualFactoryConn[]>();
for (const conn of validConnections) {
const providerConnections = connectionsByProvider.get(conn.provider) ?? [];
providerConnections.push(conn);
connectionsByProvider.set(conn.provider, providerConnections);
}
// Build one logical candidate per provider/model and keep account fallback as an
// allowlist on that candidate. This avoids both the old "first registry model per
// connection" blind spot and a connections × models Cartesian candidate pool.
for (const [providerId, providerConnections] of connectionsByProvider) {
const providerInfo = registry[providerId];
const registryModelIds = Array.isArray(providerInfo?.models)
? providerInfo.models
.map((model) => (typeof model?.id === "string" ? model.id.trim() : ""))
.filter(Boolean)
: [];
const registryModelIdSet = new Set(registryModelIds);
const defaultModelIds = providerConnections
.map((conn) => (typeof conn.defaultModel === "string" ? conn.defaultModel.trim() : ""))
.filter(Boolean);
const modelIds = Array.from(new Set([...registryModelIds, ...defaultModelIds]));
const hiddenModels = hiddenModelsMap.get(providerId);
for (const modelId of modelIds) {
if (hiddenModels?.has(modelId)) continue;
const allowedConnectionIds = providerConnections
.filter((conn) => {
if (isModelExcludedByConnection(modelId, conn.providerSpecificData)) return false;
// Registry models are provider-wide. A non-registry default (for a custom
// or passthrough model) is scoped only to connections that selected it.
return registryModelIdSet.has(modelId) || conn.defaultModel?.trim() === modelId;
})
.map((conn) => conn.id);
if (allowedConnectionIds.length === 0) continue;
candidatePool.push({
provider: providerId,
connectionId: null,
allowedConnectionIds,
model: modelId,
modelStr: `${providerId}/${modelId}`,
costPer1MTokens: 0, // Not used in virtual auto-combo (LKGP uses session stickiness)
});
}
}
// #7623: honor existing model lockouts + connection cooldown/terminal state so
// auto/* never advertises models the dispatch path would immediately skip.
const connectionsById = new Map<string, ConnectionResilienceView>();
for (const conn of [...connections, ...disabledNoAuthConnections]) {
connectionsById.set(conn.id, conn);
}
const connectedProviders = new Set(validConnections.map((conn) => conn.provider));
const buildPreparedPool = (bypassNoAuthAllowlist: boolean) => {
let pool = [
...candidatePool,
...getNoAuthCandidates(
connectedProviders,
blockedProviders,
disabledNoAuthProviders,
noAuthProviderSpecificData,
hiddenModelsMap,
bypassNoAuthAllowlist
),
];
const resilienceFilteredPool = filterResilienceBlockedCandidates(pool, connectionsById);
if (resilienceFilteredPool !== pool) pool = resilienceFilteredPool;
// #6512 (follow-up to #6328/#6495): when the operator opts into `hidePaidModels`,
// exclude paid-only backends from EVERY `auto/*` candidate pool.
const paidFilteredPool = filterPaidOnlyCandidates(pool, settings.hidePaidModels === true);
if (paidFilteredPool !== pool) pool = paidFilteredPool;
return pool;
};
const regularCandidates = buildPreparedPool(false);
// #6453/#8183: family selectors bypass the reliability-curated no-auth allowlist.
const familyCandidates = buildPreparedPool(true);
if (!options.includeResolvedCapabilities) {
return { regularCandidates, familyCandidates };
}
// One uninterrupted bulk read of all three capability tables for this prepare only.
// Do not yield between the three loads; later cooperative yields remain fine because
// catalog generation guards already prevent publishing across intervening writes.
const capabilityState: PreparedCapabilityState = {
byTarget: new Map(),
resolvedSinceYield: 0,
resolutionSnapshot: createModelCapabilityResolutionSnapshot(),
};
return {
regularCandidates: await attachPreparedCapabilityValues(regularCandidates, capabilityState),
familyCandidates: await attachPreparedCapabilityValues(familyCandidates, capabilityState),
};
}
function clonePreparedCandidates(
candidates: readonly VirtualAutoComboCandidate[]
): VirtualAutoComboCandidate[] {
return candidates.map((candidate) => ({
...candidate,
...(candidate.allowedConnectionIds
? { allowedConnectionIds: [...candidate.allowedConnectionIds] }
: {}),
}));
}
export async function createVirtualAutoComboFromPrepared(
prepared: PreparedVirtualAutoComboInputs,
variant: AutoVariant | undefined,
spec?: AutoComboSpec,
apiKeyId?: string,
autoChannel?: string
): Promise<VirtualAutoCombo> {
let candidatePool = clonePreparedCandidates(
spec?.family ? prepared.familyCandidates : prepared.regularCandidates
);
// #7819 (Level 2): per-API-key candidate exclusions. Fail-open — an absent
// apiKeyId/autoChannel (every caller before #7819) or a DB lookup failure
// both leave the pool untouched, so default (unconfigured) routing stays
// byte-identical to pre-#7819 behavior.
let excludedConnectionIds: Set<string> = new Set();
if (apiKeyId && autoChannel) {
try {
excludedConnectionIds = await getExcludedConnectionIds(apiKeyId, autoChannel);
} catch (err) {
log.warn("AUTO", "Failed to load auto-candidate overrides; routing unfiltered", { err });
}
}
const overrideFilteredPool = filterExcludedCandidates(candidatePool, excludedConnectionIds);
if (overrideFilteredPool !== candidatePool) {
candidatePool.length = 0;
candidatePool.push(...overrideFilteredPool);
}
if (candidatePool.length === 0) {
log.warn("AUTO", "No connected providers with valid credentials for virtual auto-combo");
const emptyPool: string[] = [];
const autoConfig = {
candidatePool: emptyPool,
weights: { ...DEFAULT_WEIGHTS },
explorationRate: 0.05,
routerStrategy: "lkgp",
};
return {
id: `virtual-auto-${variant || "default"}`,
name: `Auto ${variant || "Default"}`,
type: "auto" as const,
strategy: "auto",
models: [],
candidatePool: emptyPool,
weights: autoConfig.weights,
explorationRate: autoConfig.explorationRate,
routerStrategy: autoConfig.routerStrategy,
autoConfig,
config: { auto: autoConfig },
advertisedContextLength: null,
advertisedMaxOutputTokens: null,
};
}
// #4235 Phase B: narrow the pool by the `auto/<category>:<tier>` overlay
// (vision/reasoning capability, free/premium model tier).
//
// Default behavior: when the filter yields zero candidates, return an EMPTY
// pool — never silently fall back to the full pool. This makes
// `auto/coding:free` actually mean "free tier only" and prevents a paid
// expensive model from being picked just because no free provider is
// connected. Operators who want the old "never break routing, lose the bias"
// behavior can opt back in via the env var below.
let effectivePool = candidatePool;
// #6453: `auto/<family>` narrows by model family instead of category/tier. The
// two overlays are mutually exclusive on the spec (family takes precedence when
// both are somehow present, which callers never do in practice).
const candidateFilter = spec?.family
? buildFamilyCandidateFilter(spec.family)
: spec
? buildAutoCandidateFilter(spec.category, spec.tier)
: null;
if (candidateFilter) {
const narrowed = candidatePool.filter((candidate) => candidateFilter(candidate));
const label = spec?.family
? `auto/${spec.family}`
: `auto/${spec?.category ?? ""}${spec?.tier ? `:${spec.tier}` : ""}`;
if (narrowed.length > 0) {
effectivePool = narrowed;
} else if (
!spec?.family &&
(process.env.OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL === "true" ||
process.env.OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL === "1")
) {
// Opt-in legacy behavior (category/tier only): warn loudly, then keep the full pool.
log.warn(
"AUTO",
`${label} matched no connected models; falling back to the full pool (OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=true)`
);
} else {
// Family combos always degrade to an empty pool when unavailable — a family
// is a hard identity constraint, not a soft optimization bias, so there is
// no sensible "fall back to the full pool" behavior for it.
log.warn(
"AUTO",
`${label} matched no connected models; returning an empty pool.${spec?.family ? "" : ' Set OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=true to restore the legacy "use full pool" behavior.'}`
);
effectivePool = [];
}
}
let weights: ScoringWeights = { ...DEFAULT_WEIGHTS };
let explorationRate = 0.05; // Default exploration rate
let routerStrategy = "lkgp"; // All auto variants use LKGP
switch (variant) {
case "coding":
weights = { ...MODE_PACKS["quality-first"] };
break;
case "fast":
weights = { ...MODE_PACKS["ship-fast"] };
break;
case "cheap":
weights = { ...MODE_PACKS["cost-saver"] };
break;
case "offline":
weights = { ...MODE_PACKS["offline-friendly"] };
break;
case "smart":
weights = { ...MODE_PACKS["quality-first"] };
explorationRate = 0.1; // Override default exploration rate
break;
case "lkgp":
// LKGP is default for all auto variants, this variant just explicitly names it.
// Use default weights.
break;
case "chaos":
// Chaos mode: select top-N most stable models and fan them out in parallel
// (strategy "fusion"). Prioritize health + stability via the chaos-mode pack.
weights = { ...MODE_PACKS["chaos-mode"] };
explorationRate = 0; // no exploration — only the proven-stable set
break;
case undefined: // Default auto
// Use default weights
break;
}
// #4235 Phase B: category/tier weight overlay. A non-chat category leans
// quality-first; the tier then refines toward latency (fast), cost (cheap/floor)
// or availability (reliable). free/pro keep the base weights — their bias is the
// candidate filter above (free → free-tier models, pro → premium models).
if (spec) {
if (spec.category && spec.category !== "chat") {
weights = { ...MODE_PACKS["quality-first"] };
}
const weightVariant = tierToWeightVariant(spec.tier);
if (weightVariant === "fast") {
weights = { ...MODE_PACKS["ship-fast"] };
} else if (weightVariant === "cheap") {
weights = { ...MODE_PACKS["cost-saver"] };
} else if (weightVariant === "reliability") {
weights = { ...MODE_PACKS["reliability-first"] };
}
}
const providerPool = [...new Set(effectivePool.map((c) => c.provider))];
const models = effectivePool.map((candidate, index) => ({
id: `virtual-auto-${variant || "default"}-${index + 1}-${candidate.provider}`,
kind: "model" as const,
model: candidate.modelStr,
providerId: candidate.provider,
connectionId: candidate.connectionId,
...(candidate.allowedConnectionIds
? { allowedConnectionIds: candidate.allowedConnectionIds }
: {}),
weight: 1,
label: candidate.provider,
}));
const autoConfig = {
candidatePool: providerPool,
weights,
explorationRate,
routerStrategy,
};
// Chaos mode fans out to the top-N most stable models in parallel. Panel size
// is capped to keep a single IDE request from fanning out to dozens of providers;
// operators can override via env var OMNIROUTE_CHAOS_MAX_PANEL (default 5).
//
// Provider diversity: when multiple candidates from the same provider exist, only
// the highest-scored model per provider is included. This prevents a single
// provider from monopolizing the panel and gives the IDE truly diverse answers.
const isChaos = variant === "chaos";
const CHAOS_MAX_PANEL = (() => {
const env = process.env.OMNIROUTE_CHAOS_MAX_PANEL;
const parsed = env ? parseInt(env, 10) : 5;
return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 10) : 5;
})();
let chaosModels: typeof models;
if (isChaos) {
// Deduplicate by provider: keep first occurrence per provider (models are
// already scored/sorted by health + stability from scoring).
const seenProviders = new Set<string>();
const diverse: typeof models = [];
for (const m of models) {
if (seenProviders.has(m.providerId)) continue;
seenProviders.add(m.providerId);
diverse.push(m);
if (diverse.length >= CHAOS_MAX_PANEL) break;
}
chaosModels = diverse.length > 0 ? diverse : models.slice(0, CHAOS_MAX_PANEL);
} else {
chaosModels = models;
}
const advertisedLimits = computeAdvertisedLimits(effectivePool);
return {
id: `virtual-auto-${variant || "default"}`,
name: `Auto ${variant || "Default"}`,
type: "auto",
strategy: "auto",
models: chaosModels,
candidatePool: providerPool,
weights,
explorationRate,
routerStrategy,
autoConfig,
// For chaos, stash the panel size + a flag so downstream handlers can detect
// the broadcast mode and stream each panel model back to IDEs that opt in.
config: {
auto: autoConfig,
...(isChaos
? {
chaos: {
enabled: true,
panelSize: chaosModels.length,
judgeModel: chaosModels[0]?.model,
tuning: {
panelHardTimeoutMs:
Number(process.env.OMNIROUTE_CHAOS_PANEL_TIMEOUT_MS) || undefined,
minPanel: Number(process.env.OMNIROUTE_CHAOS_MIN_PANEL) || undefined,
},
},
}
: {}),
},
advertisedContextLength: advertisedLimits.contextLength,
advertisedMaxOutputTokens: advertisedLimits.maxOutputTokens,
};
}
export async function createVirtualAutoCombo(
variant: AutoVariant | undefined,
spec?: AutoComboSpec,
apiKeyId?: string,
autoChannel?: string
): Promise<VirtualAutoCombo> {
const prepared = await prepareVirtualAutoComboInputs();
return createVirtualAutoComboFromPrepared(prepared, variant, spec, apiKeyId, autoChannel);
}