Files
OmniRoute/open-sse/services/combo/autoStrategy.ts
mikeiagents 39e777248c fix(auto-combo): short-circuit expandAutoComboCandidatePool when models[] is non-empty (#8598)
When an auto-combo has models[] populated by the operator but
config.auto.candidatePool is empty (the default for combos created
via the dashboard), expandAutoComboCandidatePool silently expands
the candidate pool to every model of every active provider
connection. This overrides the explicit list in models[] and lets
unintended models (e.g. gemini-3.1-flash-lite) win the auto-strategy
scoring contest.

In omniroute@3.8.48 (npm) only one guard exists before the expansion
loop (GUARD A: if (config.auto.candidatePool populated) return
eligibleTargets). The upstream release branch release/v3.8.49 added
a second guard (combo-ref check, PR #7301) but it does not cover
the common pattern where models[] holds explicit kind:"model"
entries. Both gaps share the same root mechanism and the same fix.

The new guard short-circuits whenever models[] is a non-empty array,
covering both kind:"model" entries (the dashboard default) and
kind:"combo-ref" entries (which #7301 already handles). With this
guard in place, the existing combo-ref check becomes redundant; it
is left in place for the minimal-scope surgical fix, and can be
removed in a follow-up cleanup.

Validation (in isolated Docker, 3 providers + 4 controlled combos):
  - 3 explicit models, empty candidatePool: pool 60 -> 6
  - 1 combo-ref + 2 explicit, empty candidatePool: pool 64 -> 10
  - 3 explicit models, populated candidatePool (GUARD A path): 6 -> 6
  - empty models[] virtual auto: 57 -> 57 (expansion preserved)

Closes #8597

Co-authored-by: Michael de Souza Marcos <michael.smarcos@hotmail.com>
2026-07-27 19:07:26 -03:00

520 lines
20 KiB
TypeScript

/**
* Auto-combo scoring, intent extraction, request-tag routing, candidate-pool
* expansion and the quota-soft execution-candidate registry — extracted from
* combo.ts (Quality Gate v2 / Fase 9, combo split D8 — reduced).
*
* Logic is unchanged (byte-identical move); the moved public symbols
* (QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty, scoreAutoTargets,
* expandAutoComboCandidatePool) are re-exported from combo.ts for backward
* compatibility — including chatCore.ts's dynamic `import("../services/combo")`
* which reads setCandidateQuotaSoftPenalty + QUOTA_SOFT_DEPRIORITIZE_FACTOR.
*
* The _activeExecutionCandidates registry Map MUST stay a single instance, so it
* and its three mutators live together here.
*
* NOTE: buildAutoCandidates (and its two private-only helpers
* calculateTargetContextAffinity / getBootstrapLatencyMs) deliberately stay in
* combo.ts — it is the sole user of the internal reset-window helpers
* (resolveResetWindowConfig / fetchResetAwareQuotaWithCache /
* calculateResetWindowAffinity), so keeping it there avoids a combo ⇄ autoStrategy
* import cycle. This module never imports from the combo barrel.
*/
import { isRecord } from "./comboData.ts";
import type {
AutoProviderCandidate,
ComboLike,
HistoricalLatencyStatsEntry,
ResolvedComboTarget,
} from "./types.ts";
import { extractSessionAffinityKey } from "@/sse/services/auth";
import { DEFAULT_INTENT_CONFIG, type IntentClassifierConfig } from "../intentClassifier.ts";
import { getTaskFitness } from "../autoCombo/taskFitness.ts";
import {
calculateFactors,
calculateScore,
type ProviderCandidate,
type ScoringWeights,
} from "../autoCombo/scoring.ts";
import type { RoutingHint } from "../manifestAdapter";
import { getCachedProviderConnections } from "../../../src/lib/db/readCache";
import { getProviderModels } from "../../config/providerModels.ts";
import {
getConnectionRoutingTags,
matchesRoutingTags,
resolveRequestRoutingTags,
} from "../../../src/domain/tagRouter.ts";
// Quota Share soft-policy deprioritization factor (B17).
// When a candidate has quotaSoftPenalty === true, its auto-combo score is
// multiplied by this factor so over-quota-soft keys are de-prioritized
// without being fully blocked (that is done by "hard" policy).
// Override via QUOTA_SOFT_DEPRIORITIZE_FACTOR env var (range 0..1, default 0.7).
export const QUOTA_SOFT_DEPRIORITIZE_FACTOR = Number(
process.env.QUOTA_SOFT_DEPRIORITIZE_FACTOR ?? "0.7"
);
// #4540: Status soft-deprioritization factor.
// When the quota-preflight HARD cutoff is OFF (default), a candidate whose connection
// is in a terminal/transient unavailable status (credits_exhausted / rate_limited /
// banned / expired / future-dated unavailable) is NOT hard-blocked — instead its
// auto-combo score is multiplied by this factor so an exhausted provider ranks strictly
// below an otherwise-identical healthy one, without surfacing a misleading 429.
// Override via STATUS_SOFT_DEPRIORITIZE_FACTOR env var (range 0..1, default 0.5).
export const STATUS_SOFT_DEPRIORITIZE_FACTOR = Number(
process.env.STATUS_SOFT_DEPRIORITIZE_FACTOR ?? "0.5"
);
// G2: Module-level registry of active combo execution candidates.
// Maps executionKey → Map<stepId, candidate mutable ref>.
// Populated by buildAutoCandidates registrations; cleaned up after each execution.
// This allows chatCore.ts to mark a candidate's quotaSoftPenalty flag so that
// subsequent scoring iterations (auto-combo fallback) deprioritize it.
const _activeExecutionCandidates = new Map<string, Map<string, { quotaSoftPenalty?: boolean }>>();
/**
* Mark a specific candidate (by comboExecutionKey + stepId) with soft quota penalty.
* Called from chatCore.ts when enforceQuotaShare returns a "soft deprioritize" decision.
* The flag is read on subsequent auto-combo scoring iterations (fallback chain)
* within the same combo execution via scoreAutoTargets → QUOTA_SOFT_DEPRIORITIZE_FACTOR.
*
* Guards:
* - null executionKey or stepId → no-op (non-combo or context not available).
* - unknown executionKey → no-op (candidate not yet registered or already cleaned up).
* - Idempotent: calling twice with the same (key, stepId, true) is safe.
*/
export function setCandidateQuotaSoftPenalty(
comboExecutionKey: string | null,
comboStepId: string | null,
penalty: boolean
): void {
if (!comboExecutionKey || !comboStepId) return;
const byStep = _activeExecutionCandidates.get(comboExecutionKey);
if (!byStep) return;
const candidate = byStep.get(comboStepId);
if (candidate) {
candidate.quotaSoftPenalty = penalty;
}
}
/**
* Register candidates for a combo execution so setCandidateQuotaSoftPenalty can
* locate them by (executionKey, stepId).
* Each candidate object is stored by reference — mutations via setCandidateQuotaSoftPenalty
* propagate back to the original candidate array used by scoreAutoTargets.
* @internal — not exported; only called within combo.ts by buildAutoCandidates callers.
*/
export function _registerExecutionCandidates(
candidates: Array<{ executionKey: string; stepId: string; quotaSoftPenalty?: boolean }>
): void {
for (const candidate of candidates) {
if (!candidate.executionKey) continue;
let byStep = _activeExecutionCandidates.get(candidate.executionKey);
if (!byStep) {
byStep = new Map();
_activeExecutionCandidates.set(candidate.executionKey, byStep);
}
byStep.set(candidate.stepId, candidate);
}
}
/**
* Unregister all candidates for a given execution key once the execution completes.
* Prevents unbounded memory growth.
* @internal — not exported; called after each handleComboChat iteration.
*/
export function _unregisterExecutionCandidates(executionKeys: string[]): void {
for (const key of executionKeys) {
_activeExecutionCandidates.delete(key);
}
}
function toTextContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((part) => {
if (!isRecord(part)) return "";
if (typeof part.text === "string") return part.text;
return "";
})
.join("\n");
}
export function extractPromptForIntent(body: Record<string, unknown> | null | undefined): string {
if (!body || typeof body !== "object") return "";
const fromMessages = Array.isArray(body.messages)
? [...body.messages].reverse().find((m) => isRecord(m) && m.role === "user")
: null;
if (isRecord(fromMessages)) return toTextContent(fromMessages.content);
if (typeof body.input === "string") return body.input;
if (Array.isArray(body.input)) {
const text = body.input
.map((item) => {
if (!isRecord(item)) return "";
if (typeof item.content === "string") return item.content;
if (typeof item.text === "string") return item.text;
return "";
})
.filter(Boolean)
.join("\n");
if (text) return text;
}
if (typeof body.prompt === "string") return body.prompt;
return "";
}
export function mapIntentToTaskType(intent: string): "coding" | "analysis" | "default" {
switch (intent) {
case "code":
return "coding";
case "reasoning":
return "analysis";
case "simple":
return "default";
case "medium":
default:
return "default";
}
}
function toStringArray(input: unknown): string[] {
if (Array.isArray(input)) {
return input.map((v) => (typeof v === "string" ? v.trim() : "")).filter(Boolean);
}
if (typeof input === "string") {
return input
.split(",")
.map((v) => v.trim())
.filter(Boolean);
}
return [];
}
export function getIntentConfig(
settings: Record<string, unknown> | null | undefined,
combo: ComboLike
): IntentClassifierConfig {
const resolvedSettings = settings || {};
const comboAutoConfig = combo?.autoConfig || {};
const comboConfigAuto = isRecord(combo?.config?.auto) ? combo.config.auto : {};
const comboIntentConfig =
(isRecord(comboAutoConfig.intentConfig) && comboAutoConfig.intentConfig) ||
(isRecord(comboConfigAuto.intentConfig) && comboConfigAuto.intentConfig) ||
(isRecord(combo?.config?.intentConfig) && combo.config.intentConfig) ||
{};
return {
...DEFAULT_INTENT_CONFIG,
...comboIntentConfig,
...(typeof resolvedSettings.intentDetectionEnabled === "boolean"
? { enabled: resolvedSettings.intentDetectionEnabled }
: {}),
...(Number.isFinite(Number(resolvedSettings.intentSimpleMaxWords))
? { simpleMaxWords: Number(resolvedSettings.intentSimpleMaxWords) }
: {}),
...(toStringArray(resolvedSettings.intentExtraCodeKeywords).length > 0
? { extraCodeKeywords: toStringArray(resolvedSettings.intentExtraCodeKeywords) }
: {}),
...(toStringArray(resolvedSettings.intentExtraReasoningKeywords).length > 0
? { extraReasoningKeywords: toStringArray(resolvedSettings.intentExtraReasoningKeywords) }
: {}),
...(toStringArray(resolvedSettings.intentExtraSimpleKeywords).length > 0
? { extraSimpleKeywords: toStringArray(resolvedSettings.intentExtraSimpleKeywords) }
: {}),
};
}
export async function applyRequestTagRouting(
targets: ResolvedComboTarget[],
body: Record<string, unknown> | null | undefined,
log: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }
): Promise<ResolvedComboTarget[]> {
const { tags, matchMode } = resolveRequestRoutingTags(body);
if (tags.length === 0 || targets.length === 0) {
return targets;
}
const providerIds = Array.from(
new Set(targets.map((target) => target.providerId || target.provider))
).filter(
(providerId): providerId is string => typeof providerId === "string" && providerId.length > 0
);
const providerConnections = new Map<string, Array<Record<string, unknown>>>();
await Promise.all(
providerIds.map(async (providerId) => {
try {
const connections = await getCachedProviderConnections({
provider: providerId,
isActive: true,
});
providerConnections.set(
providerId,
Array.isArray(connections) ? (connections as Array<Record<string, unknown>>) : []
);
} catch (error) {
log.warn?.(
"COMBO",
`Tag routing failed to load connections for provider=${providerId}: ${error instanceof Error ? error.message : String(error)}`
);
providerConnections.set(providerId, []);
}
})
);
const filteredTargets = targets.reduce<ResolvedComboTarget[]>((acc, target) => {
const providerKey = target.providerId || target.provider;
const candidateConnections =
providerConnections.get(providerKey)?.filter((connection) => {
const connectionId =
typeof connection.id === "string" && connection.id.trim().length > 0
? connection.id
: null;
if (!connectionId) return false;
if (target.connectionId) {
return connectionId === target.connectionId;
}
return true;
}) || [];
const matchedConnectionIds = candidateConnections
.filter((connection) =>
matchesRoutingTags(
getConnectionRoutingTags(connection.providerSpecificData),
tags,
matchMode
)
)
.map((connection) => connection.id)
.filter((connectionId): connectionId is string => typeof connectionId === "string");
if (matchedConnectionIds.length === 0) {
return acc;
}
if (target.connectionId) {
acc.push(target);
return acc;
}
// #3266: when a step already carries an account allowlist, intersect it with
// the tag-matched connections (most-restrictive wins). An empty intersection
// means no connection satisfies both constraints, so the target is dropped —
// the same outcome the `matchedConnectionIds.length === 0` guard above yields.
const tagMatched = Array.from(new Set(matchedConnectionIds));
const stepAllow = Array.isArray(target.allowedConnectionIds)
? target.allowedConnectionIds.filter(
(id): id is string => typeof id === "string" && id.length > 0
)
: null;
const effectiveAllow =
stepAllow && stepAllow.length > 0
? tagMatched.filter((id) => stepAllow.includes(id))
: tagMatched;
if (effectiveAllow.length === 0) {
return acc;
}
acc.push({
...target,
allowedConnectionIds: effectiveAllow,
});
return acc;
}, []);
if (filteredTargets.length === 0) {
log.info?.(
"COMBO",
`Tag routing matched 0/${targets.length} targets for [${tags.join(", ")}] (${matchMode}); falling back to the full target set`
);
return targets;
}
log.info?.(
"COMBO",
`Tag routing matched ${filteredTargets.length}/${targets.length} targets for [${tags.join(", ")}] (${matchMode})`
);
return filteredTargets;
}
export function scoreAutoTargets(
targets: ResolvedComboTarget[],
candidates: AutoProviderCandidate[],
taskType: string | null,
weights: ScoringWeights,
manifestHint?: RoutingHint | null
) {
const targetByExecutionKey = new Map(targets.map((target) => [target.executionKey, target]));
const activeCandidates = candidates.filter((candidate) => candidate.quotaCutoffBlocked !== true);
return activeCandidates
.map((candidate) => {
const baseTarget =
targetByExecutionKey.get(candidate.executionKey) ||
targets.find(
(target) =>
target.stepId === candidate.stepId ||
(target.provider === candidate.provider && target.modelStr === candidate.modelStr)
);
if (!baseTarget) return null;
const target: ResolvedComboTarget = {
...baseTarget,
stepId: candidate.stepId,
executionKey: candidate.executionKey,
modelStr: candidate.modelStr,
provider: candidate.provider,
connectionId: candidate.connectionId ?? baseTarget.connectionId,
};
const factors = calculateFactors(
candidate as ProviderCandidate,
activeCandidates,
taskType ?? "general",
getTaskFitness,
manifestHint ?? undefined
);
let score = calculateScore(factors, weights);
// B17: Quota Share soft-policy deprioritization
if ("quotaSoftPenalty" in candidate && candidate.quotaSoftPenalty === true) {
score *= QUOTA_SOFT_DEPRIORITIZE_FACTOR;
}
// #4540: terminal/transient connection status soft penalty (no hard block).
// A no-fetcher exhausted provider keeps quotaRemaining=100, so without this its
// score would tie a healthy provider's. The penalty pushes it strictly below.
if ("statusPenalty" in candidate && candidate.statusPenalty === true) {
score *= STATUS_SOFT_DEPRIORITIZE_FACTOR;
}
return {
target,
score,
};
})
.filter((entry): entry is { target: ResolvedComboTarget; score: number } => entry !== null)
.sort((a, b) => b.score - a.score);
}
/**
* For an auto-combo WITHOUT an explicit `candidatePool`, broaden the eligible
* targets to every model of every active provider connection so the router has
* the full pool to score over. Already-present `modelStr`s are not duplicated.
*
* Best-effort: if loading active connections or provider models throws, the
* explicitly-resolved targets are returned unchanged (the combo still runs).
* Exported for unit testing. Mutates and returns `eligibleTargets`.
*/
export async function expandAutoComboCandidatePool(
eligibleTargets: ResolvedComboTarget[],
combo: { autoConfig?: unknown; config?: unknown } | null | undefined
): Promise<ResolvedComboTarget[]> {
const localAutoConfig =
(combo?.autoConfig as Record<string, unknown> | undefined) ||
(isRecord((combo?.config as Record<string, unknown>)?.auto)
? ((combo?.config as Record<string, unknown>).auto as Record<string, unknown>)
: null) ||
(combo?.config as Record<string, unknown> | undefined) ||
{};
if (Array.isArray(localAutoConfig?.candidatePool) && localAutoConfig.candidatePool.length > 0)
return eligibleTargets;
// #COMBO-REF: if the combo references other combos via kind:"combo-ref" entries,
// the resolved eligibleTargets already represent the operator's intended pool.
// Expanding to ALL providers would defeat the purpose of the combo-ref constraint
// (e.g. an "auto" combo delegating to a "priority" sub-combo should not pull in
// every model from every active provider).
// When the operator has populated the combo's models[] (the common
// case for combos created through the dashboard multi-model editor
// with strategy=auto), the explicit list IS the candidate pool.
// Expansion to every active provider's catalog would silently
// override the operator's intent and inject models the operator
// never approved. Only fall through to the full-catalog expansion
// when the operator has not pre-populated a models[] (pure-auto
// combos that want to score every model).
const explicitModels = (combo as Record<string, unknown> | null | undefined)?.models;
if (Array.isArray(explicitModels) && explicitModels.length > 0) return eligibleTargets;
try {
const allConnections = await getCachedProviderConnections({ isActive: true });
const providerIds = [
...new Set(
(allConnections as Array<{ provider?: unknown }>)
.map((c) => c.provider)
.filter((p): p is string => typeof p === "string" && p.length > 0)
),
];
for (const providerId of providerIds) {
const providerModels = getProviderModels(providerId);
for (const model of providerModels) {
const modelStr = `${providerId}/${model.id}`;
if (!eligibleTargets.some((t) => t.modelStr === modelStr)) {
eligibleTargets.push({
kind: "model",
stepId: modelStr,
executionKey: modelStr,
provider: providerId,
providerId: providerId,
modelStr,
weight: 1,
connectionId: null,
label: null,
});
}
}
}
} catch {
// Best-effort candidate expansion only: if loading active connections or
// provider models fails, fall back to the explicitly-resolved targets
// rather than aborting the combo. The push above is the only mutation,
// so a throw leaves eligibleTargets exactly as explicit resolution built it.
}
return eligibleTargets;
}
/**
* Derive a STABLE per-conversation session key for combo context-cache pinning when
* the client did not provide an explicit session id (#3825).
*
* Most OpenAI-compatible clients send no session id, so the server-side pin added by
* #3399 (gated on `relayOptions?.sessionId`) never engaged → combos rotated every turn,
* causing upstream prompt-cache misses, cold high-reasoning starts and intermittent
* 504s. We reuse `extractSessionAffinityKey(body)` (the same conversation fingerprint
* used for codex failover affinity), which hashes the first user/system message — stable
* across turns of the same conversation and identical on turn 2 of a continued chat.
*
* Returns null when no stable fingerprint is available (e.g. empty body), in which case
* the caller falls back to NO pinning — preserving prior behavior rather than guessing.
*/
export function deriveComboSessionKey(body: Record<string, unknown>): string | null {
try {
return extractSessionAffinityKey(body) ?? null;
} catch {
return null;
}
}
/**
* Surface TTFT/E2E-latency/tokens-per-second from a historical latency-stats
* entry onto an AutoProviderCandidate's speed-telemetry fields (#6875). Pure
* projection — only positive, finite numbers pass through; anything else is
* omitted so the existing speed-ranking factor (speedRanking.ts, #6011) falls
* back to its own pool-median default instead of scoring on a bad 0/NaN.
*/
export function deriveSpeedTelemetry(
metric: HistoricalLatencyStatsEntry | null
): Pick<AutoProviderCandidate, "avgTtftMs" | "avgE2ELatencyMs" | "avgTokensPerSecond"> {
const positive = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
return {
avgTtftMs: positive(metric?.avgTtftMs),
avgE2ELatencyMs: positive(metric?.avgE2ELatencyMs),
avgTokensPerSecond: positive(metric?.avgTokensPerSecond),
};
}