Files
2026-07-13 13:39:12 +08:00

335 lines
12 KiB
TypeScript

import { unavailableResponse } from "../../utils/error.ts";
import { selectProvider as selectAutoProvider } from "../autoCombo/engine.ts";
import {
resolveRequestModePack,
parseRequestBudgetCap,
} from "../autoCombo/requestControls.ts";
import { selectWithStrategy } from "../autoCombo/routerStrategy.ts";
import { buildComplexityRoutingHint } from "../autoCombo/complexityRouter";
import { recordComboIntent } from "../comboMetrics.ts";
import { estimateTokens } from "../contextManager.ts";
import { classifyWithConfig } from "../intentClassifier.ts";
import type { RoutingHint } from "../manifestAdapter";
import { parseModel } from "../model.ts";
import { supportsToolCalling } from "../modelCapabilities.ts";
import type { ResilienceSettings } from "../../../src/lib/resilience/settings";
import { parseAutoConfig } from "./autoConfig.ts";
import { dedupeTargetsByExecutionKey } from "./comboData.ts";
import { getModelContextLimitForModelString } from "./comboStructure.ts";
import type { ResetWindowConfig } from "./quotaScoring.ts";
import {
_registerExecutionCandidates,
expandAutoComboCandidatePool,
extractPromptForIntent,
getIntentConfig,
mapIntentToTaskType,
scoreAutoTargets,
} from "./autoStrategy.ts";
import type {
AutoProviderCandidate,
ComboLike,
ComboLogger,
ResolvedComboTarget,
} from "./types.ts";
/**
* Dependency-injected `buildAutoCandidates` — it lives in `combo.ts` (the host of
* this leaf), so importing it directly would create an import cycle. Passing it
* through `deps` keeps this module acyclic (same pattern as `buildTargetTimeoutRunner`).
*/
type BuildAutoCandidates = (
targets: ResolvedComboTarget[],
comboName: string,
sessionId?: string | null,
resetWindowConfig?: ResetWindowConfig,
resilienceSettings?: ResilienceSettings | null
) => Promise<AutoProviderCandidate[]>;
export interface ResolveAutoStrategyDeps {
orderedTargets: ResolvedComboTarget[];
body: Record<string, unknown>;
combo: ComboLike;
settings: Record<string, unknown> | null | undefined;
config: { complexityAwareRouting?: boolean };
relayOptions?: {
bypassProviderQuotaPolicy?: boolean;
sessionId?: string | null;
/** Per-request X-OmniRoute-Mode value (#6024/#6025). */
mode?: string | null;
/** Per-request X-OmniRoute-Budget value in USD (#6023). */
budgetCap?: number | null;
} | null;
resilienceSettings: ResilienceSettings;
log: ComboLogger;
buildAutoCandidates: BuildAutoCandidates;
}
export type ResolveAutoStrategyResult =
| { earlyResponse: Response }
| { orderedTargets: ResolvedComboTarget[]; autoUsedExplicitRouter: boolean };
/**
* Resolve target ordering for the `auto` combo strategy.
*
* Extracted verbatim from `handleComboChat`'s `if (strategy === "auto")` branch:
* tool-calling + context-window pre-filters, intent classification, candidate
* building (quota cutoff), explicit-router vs rules selection, complexity-aware
* scoring and final dedup ordering. Behavior is byte-identical to the previous
* inline block; the two `return unavailableResponse(...)` exits become
* `{ earlyResponse }` so the host can decide to return them, and the mutated
* `orderedTargets` / `autoUsedExplicitRouter` are returned instead of closed over.
*/
export async function resolveAutoStrategyOrder(
deps: ResolveAutoStrategyDeps
): Promise<ResolveAutoStrategyResult> {
const {
body,
combo,
settings,
config,
relayOptions,
resilienceSettings,
log,
buildAutoCandidates,
} = deps;
let orderedTargets = deps.orderedTargets;
let autoUsedExplicitRouter = false;
const requestHasTools = Array.isArray(body?.tools) && body.tools.length > 0;
let eligibleTargets = [...orderedTargets];
if (requestHasTools) {
const filtered = eligibleTargets.filter((target) => supportsToolCalling(target.modelStr));
if (filtered.length > 0) {
eligibleTargets = filtered;
} else {
log.warn(
"COMBO",
"Auto strategy: all candidates filtered by tool-calling policy, falling back to full pool"
);
}
}
// Context-window pre-filter (#1808)
// Estimate input tokens once; exclude candidates whose known context limit is too small.
// Uses the same 4-chars-per-token heuristic as contextManager.ts::compressContext().
// Null/unknown limits are treated as "include" to avoid incorrectly dropping valid targets.
const requestMessages = body.messages;
const estimatedInputTokens = estimateTokens(
typeof requestMessages === "string" ||
(requestMessages !== null && typeof requestMessages === "object")
? requestMessages
: []
);
if (estimatedInputTokens > 0) {
const filteredByContext = eligibleTargets.filter((target) => {
const limit = getModelContextLimitForModelString(target.modelStr);
if (limit === null || limit === undefined) return true; // unknown — include to be safe
return limit >= estimatedInputTokens;
});
if (filteredByContext.length > 0) {
log.debug?.(
"COMBO",
`Auto strategy: context-window filter kept ${filteredByContext.length}/${eligibleTargets.length} candidates (est. ${estimatedInputTokens} tokens)`
);
eligibleTargets = filteredByContext;
} else {
log.warn(
"COMBO",
`Auto strategy: all candidates filtered by context-window policy (est. ${estimatedInputTokens} tokens), falling back to full pool`
);
// eligibleTargets intentionally unchanged — same fallback contract as tool-calling filter
}
eligibleTargets = await expandAutoComboCandidatePool(eligibleTargets, combo);
}
const prompt = extractPromptForIntent(body);
const systemPrompt = typeof combo?.system_message === "string" ? combo.system_message : undefined;
const intentConfig = getIntentConfig(settings, combo);
const intent = classifyWithConfig(prompt, intentConfig, systemPrompt);
recordComboIntent(combo.name, intent);
const taskType = mapIntentToTaskType(intent);
const {
routingStrategy,
candidatePool,
weights,
explorationRate,
budgetCap: configBudgetCap,
modePack: configModePack,
resetWindowConfig,
slaPolicy,
} = parseAutoConfig(combo, eligibleTargets);
// Per-request overrides (#6023 / #6024 / #6025): X-OmniRoute-Budget and
// X-OmniRoute-Mode headers (threaded via relayOptions) take precedence over
// the combo's stored config for this single request. Unknown/garbage header
// values are ignored so the saved config is preserved.
const requestBudgetCap = parseRequestBudgetCap(relayOptions?.budgetCap);
const budgetCap = requestBudgetCap ?? configBudgetCap;
const requestModePack = resolveRequestModePack(relayOptions?.mode);
const modePack = requestModePack.override ? requestModePack.modePack : configModePack;
if (requestModePack.override || requestBudgetCap !== undefined) {
log.debug?.(
"COMBO",
`Auto strategy: per-request controls applied (mode=${
requestModePack.override ? (requestModePack.modePack ?? "balanced") : "—"
}, budgetCap=${requestBudgetCap ?? "—"})`
);
}
let lastKnownGoodProvider: string | undefined;
try {
const { getLKGP } = await import("../../../src/lib/localDb");
const lkgp = await getLKGP(combo.name, combo.id || combo.name);
if (lkgp) lastKnownGoodProvider = lkgp.provider;
} catch (err) {
log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err });
}
const autoCandidateResilienceSettings =
relayOptions?.bypassProviderQuotaPolicy === true
? {
...resilienceSettings,
quotaPreflight: {
...resilienceSettings.quotaPreflight,
enabled: false,
},
}
: resilienceSettings;
const candidates = await buildAutoCandidates(
eligibleTargets,
combo.name,
relayOptions?.sessionId,
resetWindowConfig,
autoCandidateResilienceSettings
);
const routableCandidates = candidates.filter(
(candidate) => candidate.quotaCutoffBlocked !== true
);
const quotaBlockedCount = candidates.length - routableCandidates.length;
if (quotaBlockedCount > 0) {
log.info(
"COMBO",
`Auto strategy: quota cutoff skipped ${quotaBlockedCount}/${candidates.length} account candidates`
);
}
// G2: Register candidates so chatCore can mark quotaSoftPenalty via setCandidateQuotaSoftPenalty.
_registerExecutionCandidates(routableCandidates);
if (candidates.length > 0 && routableCandidates.length === 0) {
return {
earlyResponse: unavailableResponse(
429,
"All auto strategy candidates are below configured quota cutoffs"
),
};
}
if (routableCandidates.length > 0) {
let selectedProvider: string | null = null;
let selectedModel: string | null = null;
let selectionReason = "";
if (routingStrategy !== "rules") {
try {
const decision = selectWithStrategy(
routableCandidates,
{
taskType,
requestHasTools,
lastKnownGoodProvider,
estimatedInputTokens,
sla: slaPolicy,
},
routingStrategy
);
selectedProvider = decision.provider;
selectedModel = decision.model;
selectionReason = decision.reason;
autoUsedExplicitRouter = true;
} catch (err) {
log.warn(
"COMBO",
`Auto strategy '${routingStrategy}' failed (${err?.message || "unknown"}), falling back to rules`
);
}
}
if (!selectedProvider || !selectedModel) {
const selection = selectAutoProvider(
{
id: combo.id || combo.name,
name: combo.name,
type: "auto",
candidatePool,
weights,
modePack,
budgetCap,
explorationRate,
},
routableCandidates,
taskType
);
selectedProvider = selection.provider;
selectedModel = selection.model;
selectionReason = `score=${selection.score.toFixed(3)}${selection.isExploration ? " (exploration)" : ""}`;
}
// Complexity-aware routing (2026, opt-in): classify the request's
// difficulty and feed a tier hint into scoring so tierAffinity /
// specificityMatch favor candidates whose tier matches the request.
const autoManifestHint: RoutingHint | null =
config.complexityAwareRouting === true
? buildComplexityRoutingHint(
eligibleTargets.filter((t) => t.kind === "model"),
body,
log
)
: null;
const scoredTargets = scoreAutoTargets(
eligibleTargets,
routableCandidates,
taskType,
weights,
autoManifestHint
);
const rankedTargets = scoredTargets.map((entry) => entry.target);
const selectedTarget =
scoredTargets.find((entry) => {
const parsed = parseModel(entry.target.modelStr);
const modelId = parsed.model || entry.target.modelStr;
return entry.target.provider === selectedProvider && modelId === selectedModel;
})?.target ||
rankedTargets[0] ||
eligibleTargets[0];
if (!selectedTarget) {
return {
earlyResponse: unavailableResponse(
429,
"No auto strategy targets remained after quota cutoff filtering"
),
};
}
// Keep eligibleTargets as the last-resort fallback tail: dedupe drops the
// routable ranked ones (and, when the cutoff is OFF, makes this identical to
// the pre-cutoff behavior), but a quota-blocked target still survives as a
// final fallback instead of vanishing — the hard cutoff only de-prioritizes.
orderedTargets = dedupeTargetsByExecutionKey(
[selectedTarget, ...rankedTargets, ...eligibleTargets].filter(
(entry): entry is ResolvedComboTarget => entry !== undefined && entry !== null
)
);
log.info(
"COMBO",
`Auto selection: ${selectedTarget?.modelStr || `${selectedProvider}/${selectedModel}`} | intent=${intent} task=${taskType} | strategy=${routingStrategy} | ${selectionReason}`
);
} else {
log.warn("COMBO", "Auto strategy has no candidates, keeping default ordering");
}
return { orderedTargets, autoUsedExplicitRouter };
}