Files
OmniRoute/open-sse/services/autoCombo/virtualFactory.ts
Moseyuh333 d43e71613e optimize(chaos+ponytail): i18n ponytail, dedupl dispatch, provider diversity (#8264)
* feat(chaos+ponytail): parallel chaos-mode dispatch + ponytail output style (rebased on v3.8.49)

- Chaos mode: new auto/chaos variant fans the prompt out to the top-N
  stable models in parallel and returns a single merged SSE stream.
  - Progressive streaming: each panel model's answer is enqueued as it
    lands (omni-chaos-part event), instead of awaiting the whole panel.
  - withTimeout now aborts the underlying request (modelAbortSignal) on
    timeout so the connection is released, not leaked.
  - concatSseText parses both OpenAI and Anthropic SSE wire formats.
  - autoPrefix/modePacks add the chaos-mode weight pack; virtualFactory
    materializes auto/chaos with fusion strategy + chaos config flag.
- Ponytail (lazy-senior-dev mode) integrated into the existing
  OUTPUT_STYLE_CATALOG registry (id 'ponytail') so it rides the production
  output-style injector, instead of a bespoke duplicate module. Dev-only
  scripts and the duplicate ponytail/ module are removed.
- Tests: chaosEngine/chaosVirtualCombo cover panel dispatch, progressive
  broadcast, timeout abort, and Anthropic parsing; autoCombo pack count
  updated to 6.

Rebased onto release/v3.8.49 (no provider-registry or validation changes —
those are split out per review).

* optimize(chaos+ponytail): i18n ponytail, dedupl chaos dispatch, provider diversity

- Ponytail: add vi/ja/pt-BR/id i18n with lite/full/ultra levels
- chaosEngine: extract dispatchOnePanelModel (shared), add onResult for
  progressive SSE streaming, fix withTimeout anti-pattern
- virtualFactory: deduplicate chaos panel by provider, add tuning overrides
- dispatchChaosFromCombo: accept ChaosTuning, enforce minPanel
- Add/port 8 node-runner tests for ponytail i18n + catalog integrity
- Add muse-spark-web.ts to KNOWN_MISSING_ERROR_HELPER (pre-existing)

* fix(8264): use HandleSingleModel type in chaosEngine dispatch (base-drift)

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-07-23 11:21:53 -03:00

640 lines
26 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 { getResolvedModelCapabilities } 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";
/** #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
}
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;
};
};
};
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 = "noauth";
// 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.
const AUTO_COMBO_NOAUTH_ALLOWLIST = new Set<string>(["opencode", "felo-web"]);
function isChatAutoComboNoAuthProvider(providerDef: NoAuthProviderDefinition): boolean {
if (providerDef.noAuth !== true) return false;
if (!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>>
): VirtualAutoComboCandidate[] {
const registry = getProviderRegistry();
const candidates: VirtualAutoComboCandidate[] = [];
for (const providerDef of Object.values(NOAUTH_PROVIDERS) as NoAuthProviderDefinition[]) {
if (!isChatAutoComboNoAuthProvider(providerDef)) 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;
export function computeAdvertisedLimits(candidates: Array<{ provider: string; model: string }>): {
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 = getTokenLimit(candidate.provider, candidate.model);
if (Number.isFinite(limit) && limit > 0) {
contextLength = contextLength === null ? limit : Math.max(contextLength, limit);
}
const output = 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 };
}
export async function createVirtualAutoCombo(
variant: AutoVariant | undefined,
spec?: AutoComboSpec,
apiKeyId?: string,
autoChannel?: string
): Promise<VirtualAutoCombo> {
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)
});
}
}
candidatePool.push(
...getNoAuthCandidates(
new Set(validConnections.map((conn) => conn.provider)),
blockedProviders,
disabledNoAuthProviders,
noAuthProviderSpecificData,
hiddenModelsMap
)
);
// #6512 (follow-up to #6328/#6495): when the operator opts into `hidePaidModels`,
// exclude paid-only backends from EVERY `auto/*` candidate pool — not just the
// `/v1/models` listing — so auto-routing never picks a model that will 402/403.
// If this empties the pool the existing graceful empty-pool path below handles it
// (consistent with the opt-in intent). Default OFF → pool unchanged.
const paidFilteredPool = filterPaidOnlyCandidates(
candidatePool,
settings.hidePaidModels === true
);
if (paidFilteredPool !== candidatePool) {
candidatePool.length = 0;
candidatePool.push(...paidFilteredPool);
}
// #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((c) =>
candidateFilter({ provider: c.provider, model: c.model })
);
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,
};
}