/** * 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 { filterChatSelectableModels } from "../modelEndpointPolicy.ts"; import { DEFAULT_INTENT_CONFIG, type IntentClassifierConfig } from "../intentClassifier.ts"; import { getTaskFitness } from "../autoCombo/taskFitness.ts"; import { calculateFactors, calculateScore, computePoolMaxima, type ProviderCandidate, type ScoringWeights, } from "../autoCombo/scoring.ts"; import type { RoutingHint } from "../manifestAdapter"; import { getCachedProviderConnections } from "../../../src/lib/db/readCache"; import { getSyncedAvailableModels, getCustomModels, getHiddenModelsByProvider, } from "../../../src/lib/db/models"; 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. // 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>(); /** * 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 | 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 | 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 | null | undefined, log: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void } ): Promise { 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>>(); 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>) : [] ); } 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((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); // Computed once per scoring pass, not per candidate — see computePoolMaxima's // doc comment (scoring.ts) for the O(n^2) OOM this avoids on large auto-combo // candidate pools (#OOM incident, zero-config auto combo expanding to 1000s // of provider/model targets). const poolMaxima = computePoolMaxima(activeCandidates as unknown as ProviderCandidate[]); 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 as unknown as ProviderCandidate[], taskType ?? "general", getTaskFitness, manifestHint ?? undefined, poolMaxima ); 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 { const localAutoConfig = (combo?.autoConfig as Record | undefined) || (isRecord((combo?.config as Record)?.auto) ? ((combo?.config as Record).auto as Record) : null) || (combo?.config as Record | 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 | 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) ), ]; // Pre-build a Set of already-present modelStr values so candidate-pool // expansion doesn't turn into O(n^2) per provider. See #OOM incident // (zero-config auto combo expanding to 1000s of provider/model targets). const seenModelStrs = new Set(eligibleTargets.map((t) => t.modelStr)); const hiddenModelsMap = getHiddenModelsByProvider(); for (const providerId of providerIds) { // #auto-pool-visible-only: when the operator has synced/custom models for // this provider, expand ONLY those (minus hidden); fall back to the static // catalog only when the user has none. This keeps catalog-only models // (e.g. openrouter/auto) out of pure-auto pools when the operator only // synced a subset (e.g. OpenRouter with importFreeModelsOnly). // #11088 (option 1): the synced store now persists non-chat models too — // chat combo pools must keep filtering them out at read time. const [syncedModelsRaw, customModels] = await Promise.all([ getSyncedAvailableModels(providerId), getCustomModels(providerId), ]); const syncedModels = filterChatSelectableModels(providerId, syncedModelsRaw); const hiddenModels = hiddenModelsMap.get(providerId); const userVisibleIds = new Set(); for (const m of syncedModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id); for (const m of customModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id); const hasUserModels = userVisibleIds.size > 0; const expandIds = hasUserModels ? Array.from(userVisibleIds) : getProviderModels(providerId).map((m) => m.id); for (const modelId of expandIds) { const modelStr = `${providerId}/${modelId}`; if (!seenModelStrs.has(modelStr)) { seenModelStrs.add(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 | 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 { 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), }; }