diff --git a/changelog.d/features/11812-combo-sort-methods.md b/changelog.d/features/11812-combo-sort-methods.md new file mode 100644 index 0000000000..93605bf594 --- /dev/null +++ b/changelog.d/features/11812-combo-sort-methods.md @@ -0,0 +1 @@ +- **feat(combo):** choose how combo models are ordered — manual, provider, score, or name — via a sort control in the dashboard builder, persisted in `config.modelSort` and re-applied on load and after add ([#11812](https://github.com/diegosouzapw/OmniRoute/pull/11812)) — thanks @maxmad64bis diff --git a/src/app/(dashboard)/dashboard/combos/ComboSortSelect.tsx b/src/app/(dashboard)/dashboard/combos/ComboSortSelect.tsx new file mode 100644 index 0000000000..efb0766b1e --- /dev/null +++ b/src/app/(dashboard)/dashboard/combos/ComboSortSelect.tsx @@ -0,0 +1,60 @@ +"use client"; +import type { SortMethod } from "@/lib/combos/comboSort"; + +function getI18nOrFallback( + t: ((k: string, f: string) => string) & { has?: (k: string) => boolean }, + key: string, + fallback: string +): string { + try { + if (typeof t.has === "function" && !t.has(key)) return fallback; + } catch {} + const out = t(key, fallback); + return typeof out === "string" && out.length > 0 ? out : fallback; +} + +const OPTIONS: { value: SortMethod; key: string; fallback: string }[] = [ + { value: "manual", key: "combo.sort.method.manual", fallback: "Manual" }, + { value: "provider", key: "combo.sort.method.provider", fallback: "Provider" }, + { value: "score", key: "combo.sort.method.score", fallback: "Score" }, + { value: "name", key: "combo.sort.method.name", fallback: "Name" }, +]; + +export function ComboSortSelect({ + value, + onChange, + t, +}: { + value: SortMethod; + onChange: (m: SortMethod) => void; + t: ((k: string, f: string) => string) & { has?: (k: string) => boolean }; +}) { + const scoreHint = getI18nOrFallback( + t, + "combo.sort.scoreHint", + "Score ranking applies to free providers only; others stay in place." + ); + const isScore = value === "score"; + return ( + + ); +} diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index df2d30f9a8..ec2c0836b7 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -54,6 +54,16 @@ import KimiComboPresetCard from "./KimiComboPresetCard"; import { KIMI_CODING_PRESET, hasKimiCodingPreset } from "./kimiComboPreset"; import BuilderIntelligentStep from "./BuilderIntelligentStep"; import IntelligentComboPanel from "./IntelligentComboPanel"; +import { ComboSortSelect } from "./ComboSortSelect"; +import { + sortComboStepsSync, + sortComboStepsByScore, + fetchProviderRankings, + normalizeSortMethod, + isValidSortMethod, + type SortMethod, +} from "@/lib/combos/comboSort"; +import type { ComboStep } from "@/lib/combos/steps"; import { filterCombosByStrategyCategory, getStrategyCategory, @@ -2026,6 +2036,25 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo const [builderStage, setBuilderStage] = useState(COMBO_BUILDER_STAGES[0]); const [showAdvanced, setShowAdvanced] = useState(false); const [config, setConfig] = useState(sanitizeComboRuntimeConfig(combo?.config)); + // Validate persisted enum; ensure reset on combo change not just first mount. + const initialSortMethod = normalizeSortMethod(config.modelSort?.method); + const [sortMethod, setSortMethod] = useState(initialSortMethod); + useEffect(() => { + // Sync point: when the combo identity changes, re-derive sort method. + // Manual edits via handleSortChange already set sortMethod inside resetFormForCombo, + // but this guards the case where the modal is reused (edit-A→close→edit-B without unmount). + setSortMethod(normalizeSortMethod(combo?.config?.modelSort?.method)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [combo?.id]); + const modelsRef = useRef(models); + const sortMethodRef = useRef(sortMethod); + const resetSortGenerationRef = useRef(0); + useEffect(() => { + modelsRef.current = models; + }, [models]); + useEffect(() => { + sortMethodRef.current = sortMethod; + }, [sortMethod]); const [showStrategyNudge, setShowStrategyNudge] = useState(false); const strategyChangeMountedRef = useRef(false); // Agent features (#399 / #401 / #454) @@ -2060,9 +2089,34 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo Object.fromEntries(Object.entries(nextDefaults).filter(([key]) => key !== "strategy")) ); + // Validate persisted enum; tolerate hand-edited DB values. + const loadedMethod = normalizeSortMethod(nextCombo?.config?.modelSort?.method); + // Generation guard so a stale score fetch can't clobber the next combo. + const myGen = ++resetSortGenerationRef.current; + setSortMethod(loadedMethod); + sortMethodRef.current = loadedMethod; setName(nextCombo?.name || ""); setDescription(nextCombo?.description || ""); - setModels((nextCombo?.models || []).map((m) => normalizeModelEntry(m))); + // Branch so only one setModels runs (no raw-then-sorted double set). + // Score branch is async; guard with generation + cancelled from the caller's effect. + if (loadedMethod === "manual") { + setModels((nextCombo?.models || []).map((m) => normalizeModelEntry(m))); + } else if (loadedMethod === "score") { + const base = (nextCombo?.models || []).map((mm) => normalizeModelEntry(mm)) as ComboStep[]; + fetchProviderRankings() + .then((rk) => sortComboStepsByScore(base, rk)) + .then((sorted) => { + if (resetSortGenerationRef.current !== myGen) return; + setModels(sorted as typeof base); + }) + .catch(() => { + if (resetSortGenerationRef.current !== myGen) return; + setModels(base); + }); + } else { + const base = (nextCombo?.models || []).map((mm) => normalizeModelEntry(mm)) as ComboStep[]; + setModels(sortComboStepsSync(base, loadedMethod)); + } setStrategy(nextCombo?.strategy || comboDefaults?.strategy || "priority"); setConfig(nextConfig); setShowAdvanced(isExpertMode); @@ -2597,7 +2651,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo setBuilderError(""); }; - const handleAddModel = (model) => { + const handleAddModel = async (model) => { + // Use refs to avoid stale closure when awaiting a score fetch. + const currentModels = (modelsRef.current ?? models) as typeof models; + const currentMethod = sortMethodRef.current; const qualifiedModel = typeof model?.value === "string" ? model.value : ""; const parsedModel = parseQualifiedModel(qualifiedModel); const resolvedProviderId = @@ -2611,7 +2668,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo ...(resolvedProviderId ? { providerId: resolvedProviderId } : {}), weight: 0, }; - if (hasExactModelStepDuplicate(models, nextEntry)) { + if (hasExactModelStepDuplicate(currentModels, nextEntry)) { setBuilderError( getI18nOrFallback( t, @@ -2621,7 +2678,21 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo ); return; } - setModels([...models, nextEntry]); + const added = [...currentModels, nextEntry]; + if (currentMethod === "manual") { + setModels(added); + } else if (currentMethod === "score") { + try { + const rankings = await fetchProviderRankings(); + // Single-user modal; rapid double-add while fetch is in flight is low-probability. + const sorted = await sortComboStepsByScore(added, rankings); + setModels(sorted); + } catch { + setModels(added); + } + } else { + setModels(sortComboStepsSync(added, currentMethod as "provider" | "name")); + } setBuilderError(""); }; @@ -2649,10 +2720,27 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo // would each close over the same stale `models` snapshot and keep only the // last entry. Extracted so tests exercise this real implementation instead // of a hand-maintained mirror (#8526). - const handleAddModels = (selected) => { - const { next, addedAny } = computeBatchAddModelSteps(models, selected, builderProviders); + const handleAddModels = async (selected) => { + // Same ref discipline as handleAddModel — don't rely on closed-over render snapshot. + const currentModels = (modelsRef.current ?? models) as typeof models; + const currentMethod = sortMethodRef.current; + const { next, addedAny } = computeBatchAddModelSteps(currentModels, selected, builderProviders); if (!addedAny) return; - setModels(next); + if (currentMethod === "manual") { + setModels(next); + } else if (currentMethod === "score") { + try { + const rankings = await fetchProviderRankings(); + // Functional note: `next` is the post-batch snapshot. Concurrent single-add + // racing this batch is low-probability single-user; last write wins. + const sorted = await sortComboStepsByScore(next, rankings); + setModels(sorted); + } catch { + setModels(next); + } + } else { + setModels(sortComboStepsSync(next, currentMethod as "provider" | "name")); + } setBuilderError(""); }; @@ -2785,6 +2873,30 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo setModels(newModels); }; + const handleSortChange = async (next: SortMethod) => { + if (!isValidSortMethod(next)) return; + setSortMethod(next); + sortMethodRef.current = next; + setConfig((prev) => ({ ...prev, modelSort: { method: next } })); + if (next === "manual") return; + if (next === "score") { + try { + const rankings = await fetchProviderRankings(); + // Capture snapshot; if a concurrent add lands while rankings fetch + // is in flight, modelsRef has the freshest value — prefer it at + // sort time. Single-user UI, low-probability race; fallback keeps + // previous models if the rankings fetch fails (mirrors load path). + const snapshot = (modelsRef.current ?? models) as ComboStep[]; + const sorted = await sortComboStepsByScore(snapshot, rankings); + setModels(sorted as typeof models); + } catch { + // Keep previous models; same silent-fallback precedent as load path. + } + return; + } + setModels((prev) => sortComboStepsSync(prev as ComboStep[], next) as typeof prev); + }; + // Drag and Drop handlers const handleDragStart = (e, index) => { setDragIndex(index); @@ -3484,6 +3596,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo )} +
+ +
+ {models.length === 0 ? (
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index aac36f9279..fb967dadef 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -12918,6 +12918,18 @@ } } }, + "combo": { + "sort": { + "label": "Sort by", + "method": { + "manual": "Manual", + "provider": "Provider", + "score": "Score (free models)", + "name": "Name" + }, + "scoreHint": "Score ranking applies to free providers only; others stay in place." + } + }, "comboControl": { "title": "Combo Control Center", "unavailable": "Combo Control Center unavailable", diff --git a/src/lib/combos/comboSort.ts b/src/lib/combos/comboSort.ts new file mode 100644 index 0000000000..977c45b93c --- /dev/null +++ b/src/lib/combos/comboSort.ts @@ -0,0 +1,118 @@ +// src/lib/combos/comboSort.ts +import { OAUTH_PROVIDERS, NOAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/providers"; +import type { ComboStep } from "@/lib/combos/steps"; + +export type { ComboStep }; + +export type SortMethod = "manual" | "provider" | "score" | "name"; + +export const SORT_METHODS: readonly SortMethod[] = ["manual", "provider", "score", "name"] as const; +const VALID_SORT_METHODS = new Set(SORT_METHODS as readonly string[]); + +export function normalizeSortMethod(raw: unknown): SortMethod { + return VALID_SORT_METHODS.has(raw as string) ? (raw as SortMethod) : "manual"; +} + +export function isValidSortMethod(raw: unknown): raw is SortMethod { + return VALID_SORT_METHODS.has(raw as string); +} + +// Mirrors CANONICAL_PROVIDER_ORDER from src/app/api/v1/models/catalogOrder.ts — keep in sync. +// Both are derived from OAUTH+NOAUTH+APIKEY keys; drift would silently diverge catalog vs combo order. +export const PROVIDER_ORDER: readonly string[] = [ + ...Object.keys(OAUTH_PROVIDERS), + ...Object.keys(NOAUTH_PROVIDERS), + ...Object.keys(APIKEY_PROVIDERS), +]; + +const REFERENCE_SENTINEL = " combo-ref"; // sorts after any real provider id + +function providerKey(step: ComboStep): string { + if (step.kind === "model" || step.kind === "provider-wildcard") { + return step.providerId ?? REFERENCE_SENTINEL; + } + return REFERENCE_SENTINEL; // combo-ref: no providerId +} + +function nameKey(step: ComboStep): string { + if (step.kind === "model") return step.model; + if (step.kind === "provider-wildcard") return `${step.providerId}/${step.modelPattern}`; + return step.comboName; // combo-ref +} + +/** Stable index for provider ordering; unknown providers go after the known list. */ +function providerRank(providerId: string): number { + const idx = PROVIDER_ORDER.indexOf(providerId); + return idx === -1 ? PROVIDER_ORDER.length : idx; +} + +/** Synchronous sorts: manual (noop), provider, name. Stable. */ +export function sortComboStepsSync( + steps: ComboStep[], + method: "manual" | "provider" | "name" +): ComboStep[] { + if (method === "manual") return steps; + const indexed = steps.map((step, i) => ({ step, i })); + indexed.sort((a, b) => { + if (method === "provider") { + const ra = providerRank(providerKey(a.step)); + const rb = providerRank(providerKey(b.step)); + if (ra !== rb) return ra - rb; + } else { + const na = nameKey(a.step); + const nb = nameKey(b.step); + if (na !== nb) return na < nb ? -1 : 1; + } + return a.i - b.i; // stable tiebreak preserves original order + }); + return indexed.map((x) => x.step); +} + +export type Rankings = Map | Record; + +function toMap(rankings: Rankings): Map { + return rankings instanceof Map ? rankings : new Map(Object.entries(rankings)); +} + +/** Steps with a ranking sort descending by score; steps without a score stay + * stable at the end (including combo-ref, which has no providerId). */ +export async function sortComboStepsByScore( + steps: ComboStep[], + rankings: Rankings +): Promise { + const map = toMap(rankings); + const indexed = steps.map((step, i) => { + const pid = + step.kind === "model" || step.kind === "provider-wildcard" ? step.providerId : undefined; + const score = pid ? map.get(pid) : undefined; + return { step, i, score: score ?? -1 }; + }); + indexed.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; // desc, -1 (unscored) last + return a.i - b.i; + }); + return indexed.map((x) => x.step); +} + +/** Client-side rankings source for the dashboard (provider-level averageScore). */ +export async function fetchProviderRankings(): Promise> { + const res = await fetch("/api/free-provider-rankings"); + if (!res.ok) throw new Error(`free-provider-rankings ${res.status}`); + const data = (await res.json()) as { rankings: Array<{ id: string; averageScore: number }> }; + return new Map(data.rankings.map((r) => [r.id, r.averageScore])); +} + +/** Re-apply the current method to a models array. Sync for manual/provider/name, + * async for score (fetches rankings when getRankings is provided). */ +export async function reapplyCurrentSort( + steps: ComboStep[], + method: SortMethod, + getRankings?: () => Promise +): Promise { + if (method === "manual") return steps; + if (method === "score") { + const rankings = getRankings ? await getRankings() : new Map(); + return sortComboStepsByScore(steps, rankings); + } + return sortComboStepsSync(steps, method); +} diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts index b6587705e1..7182d92213 100644 --- a/src/shared/validation/schemas/combo.ts +++ b/src/shared/validation/schemas/combo.ts @@ -260,6 +260,12 @@ export const comboRuntimeConfigSchema = z }) .strict() .optional(), + // Optional client-side sort hint for combo models. + // Honored in the dashboard builder; reserved for future server-side use. Inert on execution. + modelSort: z + .object({ method: z.enum(["manual", "provider", "score", "name"]) }) + .passthrough() + .optional(), }) .passthrough() .transform((config) => { diff --git a/tests/_setup/jsdomGlobal.ts b/tests/_setup/jsdomGlobal.ts new file mode 100644 index 0000000000..9fce703955 --- /dev/null +++ b/tests/_setup/jsdomGlobal.ts @@ -0,0 +1,49 @@ +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { url: "http://localhost" }); + +for (const key of [ + "window", + "document", + "HTMLElement", + "Element", + "Node", + "Event", + "CustomEvent", + "MouseEvent", + "KeyboardEvent", +] as const) { + try { + (globalThis as unknown as Record)[key] = ( + dom.window as unknown as Record + )[key]; + } catch {} +} +try { + Object.defineProperty(globalThis, "navigator", { + value: dom.window.navigator, + configurable: true, + writable: true, + }); +} catch {} +try { + (globalThis as unknown as Record).getComputedStyle = + dom.window.getComputedStyle.bind(dom.window); +} catch {} + +if (typeof (globalThis as unknown as { matchMedia?: unknown }).matchMedia !== "function") { + (globalThis as unknown as { window: { matchMedia?: unknown } }).window.matchMedia = () => + ({ + matches: false, + media: "", + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as unknown as MediaQueryList; + (globalThis as unknown as Record).matchMedia = ( + globalThis as unknown as { window: { matchMedia: unknown } } + ).window.matchMedia; +} diff --git a/tests/unit/combo/comboSort.test.ts b/tests/unit/combo/comboSort.test.ts new file mode 100644 index 0000000000..8d0b7cc67e --- /dev/null +++ b/tests/unit/combo/comboSort.test.ts @@ -0,0 +1,112 @@ +// tests/unit/combo/comboSort.test.ts +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + PROVIDER_ORDER, + sortComboStepsSync, + sortComboStepsByScore, + reapplyCurrentSort, + type ComboStep, +} from "@/lib/combos/comboSort"; + +const m = (id: string, providerId: string, model: string): ComboStep => ({ + id, + kind: "model", + model, + providerId, + weight: 0, +}); +const ref = (id: string, comboName: string): ComboStep => ({ + id, + kind: "combo-ref", + comboName, + weight: 0, +}); + +describe("sortComboStepsSync", () => { + it("manual returns the input unchanged", () => { + const steps = [m("a", "openai", "gpt"), m("b", "anthropic", "claude")]; + assert.equal(sortComboStepsSync(steps, "manual"), steps); + }); + + it("provider groups by PROVIDER_ORDER, stable intra-group, combo-ref at end", () => { + const steps = [ + m("x", "anthropic", "claude-3"), + m("y", "openai", "gpt-4"), + ref("r", "other-combo"), + m("z", "anthropic", "claude-2"), + ]; + const out = sortComboStepsSync(steps, "provider"); + // combo-ref always last (no providerId). + assert.equal(out[out.length - 1].id, "r"); + const nonRef = out.filter((s) => s.id !== "r").map((s) => s.id); + // all three model steps are present, and the two anthropic steps stay grouped. + assert.deepEqual(nonRef.slice().sort(), ["x", "y", "z"]); + const ai = nonRef.indexOf("x"); + const zi = nonRef.indexOf("z"); + assert.ok(Math.abs(ai - zi) === 1, "steps of the same provider are adjacent"); + }); + + it("name sorts alphabetically with a stable tiebreak", () => { + const steps = [m("a", "openai", "zeta"), m("b", "openai", "alpha"), m("c", "openai", "alpha")]; + const out = sortComboStepsSync(steps, "name"); + assert.deepEqual( + out.map((s) => s.id), + ["b", "c", "a"] + ); + }); + + it("PROVIDER_ORDER is non-empty and stable", () => { + assert.ok(PROVIDER_ORDER.length > 0); + }); +}); + +describe("score sort", () => { + it("sorts scored steps descending, unscored stable at end", async () => { + const steps = [ + m("a", "openai", "gpt"), // score 90 + m("b", "anthropic", "claude"), // no score + m("c", "google", "gemini"), // score 70 + ref("r", "other"), // no providerId + ]; + const rankings = new Map([ + ["openai", 90], + ["google", 70], + ]); + const out = await sortComboStepsByScore(steps, rankings); + assert.deepEqual( + out.map((s) => s.id), + ["a", "c", "b", "r"] + ); + }); + + it("reapplyCurrentSort applies score async and sync methods", async () => { + const steps = [m("a", "openai", "gpt"), m("b", "claude", "claude")]; + const syncOut = await reapplyCurrentSort(steps, "provider"); + assert.deepEqual( + syncOut.map((s) => s.id), + ["b", "a"] + ); // claude (anthropic) before openai + const rankOut = await reapplyCurrentSort(steps, "score", async () => new Map([["openai", 5]])); + assert.deepEqual( + rankOut.map((s) => s.id), + ["a", "b"] + ); + }); + + it("reapplyCurrentSort after an add keeps provider grouping", async () => { + const base = [m("a", "anthropic", "claude")]; + const added = [...base, m("b", "openai", "gpt")]; + const out = await reapplyCurrentSort(added, "provider"); + // Expected order derived from PROVIDER_ORDER (robust to provider precedence). + const expected = ["a", "b"].sort((x, y) => { + const px = x === "a" ? "anthropic" : "openai"; + const py = y === "a" ? "anthropic" : "openai"; + return PROVIDER_ORDER.indexOf(px) - PROVIDER_ORDER.indexOf(py); + }); + assert.deepEqual( + out.map((s) => s.id), + expected + ); + }); +}); diff --git a/tests/unit/dashboard/ComboSortSelect.test.tsx b/tests/unit/dashboard/ComboSortSelect.test.tsx new file mode 100644 index 0000000000..60a07b0e69 --- /dev/null +++ b/tests/unit/dashboard/ComboSortSelect.test.tsx @@ -0,0 +1,18 @@ +import "../../_setup/jsdomGlobal.ts"; +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ComboSortSelect } from "@/app/(dashboard)/dashboard/combos/ComboSortSelect"; + +const t = (_k: string, f: string) => f; + +describe("ComboSortSelect", () => { + it("renders four options and emits the chosen method", () => { + let chosen = ""; + render( (chosen = mm)} t={t} />); + const select = screen.getByRole("combobox"); + assert.equal((select as HTMLSelectElement).options.length, 4); + fireEvent.change(select, { target: { value: "provider" } }); + assert.equal(chosen, "provider"); + }); +}); diff --git a/tests/unit/shared/comboSchema.test.ts b/tests/unit/shared/comboSchema.test.ts new file mode 100644 index 0000000000..b077409ca0 --- /dev/null +++ b/tests/unit/shared/comboSchema.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { comboRuntimeConfigSchema, updateComboSchema } from "@/shared/validation/schemas/combo"; + +describe("comboRuntimeConfigSchema.modelSort", () => { + it("accepts a valid modelSort method", () => { + const parsed = comboRuntimeConfigSchema.parse({ modelSort: { method: "provider" } }); + assert.deepEqual(parsed.modelSort, { method: "provider" }); + }); + + it("rejects an invalid method", () => { + assert.throws(() => comboRuntimeConfigSchema.parse({ modelSort: { method: "bogus" } })); + }); + + it("allows extra keys on the modelSort sub-object (passthrough)", () => { + const parsed = comboRuntimeConfigSchema.parse({ modelSort: { method: "name", future: 1 } }); + assert.equal(parsed.modelSort?.method, "name"); + }); + + it("is optional", () => { + const parsed = comboRuntimeConfigSchema.parse({}); + assert.equal(parsed.modelSort, undefined); + }); +}); + +describe("modelSort persistence", () => { + it("round-trips through updateComboSchema (models + config)", () => { + const parsed = updateComboSchema.parse({ + models: [{ id: "m1", kind: "model", model: "openai/gpt", providerId: "openai", weight: 0 }], + config: { modelSort: { method: "score" } }, + }); + assert.equal(parsed.config?.modelSort?.method, "score"); + }); + + it("passes through comboRuntimeConfigSchema", () => { + const parsed = comboRuntimeConfigSchema.parse({ modelSort: { method: "provider" } }); + assert.equal(parsed.modelSort?.method, "provider"); + }); +});