diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 51c48103388c..3f81d238f33e 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -186,3 +186,139 @@ describe('ModelSettings', () => { expect(await screen.findByText(/still run on/)).toBeTruthy() }) }) + +describe('ModelSettings MoA preset editor', () => { + const moaConfig = () => ({ + default_preset: 'default', + active_preset: '', + presets: { + default: { + reference_models: [ + { provider: 'nous', model: 'hermes-4' }, + { provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' } + ], + aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' }, + reference_temperature: 0, + aggregator_temperature: 0, + max_tokens: 4096, + enabled: true + } + }, + reference_models: [ + { provider: 'nous', model: 'hermes-4' }, + { provider: 'openrouter', model: 'deepseek/deepseek-v4-pro' } + ], + aggregator: { provider: 'openrouter', model: 'anthropic/claude-opus-4.8' }, + reference_temperature: 0, + aggregator_temperature: 0, + max_tokens: 4096, + enabled: true + }) + + beforeEach(() => { + getGlobalModelOptions.mockResolvedValue({ + providers: [ + { + name: 'Nous', + slug: 'nous', + models: ['hermes-4', 'hermes-4-mini'], + authenticated: true, + capabilities: { 'hermes-4': { reasoning: true, fast: true } } + }, + { + name: 'OpenRouter', + slug: 'openrouter', + models: ['deepseek/deepseek-v4-pro', 'anthropic/claude-opus-4.8'], + authenticated: true + } + ] + }) + getMoaModels.mockResolvedValue(moaConfig()) + saveMoaModels.mockImplementation((body: unknown) => Promise.resolve(body)) + }) + + async function openReferenceEditor() { + await renderModelSettings() + expect(await screen.findByText('Reference 1')).toBeTruthy() + } + + function slotSelects() { + // Combobox order in the MoA section (last 7 on the page): preset select, + // then provider+model per reference (2 refs), then aggregator + // provider+model. Reference 1's pair is therefore at -6 / -5. + const all = screen.getAllByRole('combobox') + + return { ref1Provider: all.at(-6)!, ref1Model: all.at(-5)! } + } + + it('holds the autosave while a slot is half-filled (provider changed, model pending)', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + try { + await openReferenceEditor() + + fireEvent.click(slotSelects().ref1Provider) + fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' })) + + // Model was cleared by the provider change → config incomplete → the + // debounced autosave must NOT fire, even well past the 600ms window. + await vi.advanceTimersByTimeAsync(2000) + expect(saveMoaModels).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('saves once the model pick completes the slot', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + try { + await openReferenceEditor() + + fireEvent.click(slotSelects().ref1Provider) + fireEvent.click(await screen.findByRole('option', { name: 'OpenRouter' })) + await vi.advanceTimersByTimeAsync(700) + + fireEvent.click(slotSelects().ref1Model) + fireEvent.click(await screen.findByRole('option', { name: 'anthropic/claude-opus-4.8' })) + await vi.advanceTimersByTimeAsync(700) + + expect(saveMoaModels).toHaveBeenCalledTimes(1) + const sent = saveMoaModels.mock.calls[0][0] as ReturnType + expect(sent.presets.default.reference_models[0]).toEqual({ + provider: 'openrouter', + model: 'anthropic/claude-opus-4.8' + }) + // The untouched slots ride along unchanged — nothing reverts to defaults. + expect(sent.presets.default.reference_models[1]).toEqual({ + provider: 'openrouter', + model: 'deepseek/deepseek-v4-pro' + }) + expect(sent.presets.default.aggregator).toEqual({ + provider: 'openrouter', + model: 'anthropic/claude-opus-4.8' + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not clear the model or save when the same provider is re-selected', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + try { + await openReferenceEditor() + + fireEvent.click(slotSelects().ref1Provider) + fireEvent.click(await screen.findByRole('option', { name: 'Nous' })) + await vi.advanceTimersByTimeAsync(700) + + // Radix treats re-picking the current value as a no-op (no + // onValueChange), so nothing changes: no save, model still shown. + expect(saveMoaModels).not.toHaveBeenCalled() + expect(screen.getByText('nous · hermes-4')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index fc761c97dbdd..f11820a9f529 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -130,6 +130,24 @@ const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', m export const withActive = (models: readonly string[], active: string): readonly string[] => active && !models.includes(active) ? [active, ...models] : models +// A slot is complete when both halves are chosen. Changing a slot's provider +// intentionally clears its model (see updateMoaSlot), so every provider change +// passes through an incomplete state while the user picks the new model. +export const moaSlotComplete = (slot: MoaModelSlot): boolean => !!(slot.provider.trim() && slot.model.trim()) + +// True when every slot in every preset is fully specified — the only state +// that is safe to persist. The backend rejects configs with half-filled slots +// (HTTP 422) instead of silently swapping the preset for hardcoded defaults +// (#64156), so the autosave must simply wait for the edit to finish rather +// than trying to "repair" the payload. +export const moaConfigComplete = (config: MoaConfigResponse): boolean => + Object.values(config.presets).every( + preset => + preset.reference_models.length > 0 && + preset.reference_models.every(moaSlotComplete) && + moaSlotComplete(preset.aggregator) + ) + interface StaleAuxWarningProps { applying: boolean onReset: () => void @@ -320,49 +338,31 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { // Guard against stale save responses overwriting newer state. const moaSaveGeneration = useRef(0) - // Strip slots with an empty model from every preset before autosave, so a - // half-filled slot (provider selected but no model yet) is never sent to the - // backend. Without this guard, _clean_slot rejects the empty-model slot and - // _normalize_preset falls back to hardcoded defaults. Both reference and - // aggregator slots are sanitized. Presets keep their empty reference_models - // array rather than being dropped. - const sanitizeMoaRefsForSave = useCallback((config: MoaConfigResponse): MoaConfigResponse => { - const presets: MoaConfigResponse['presets'] = {} - let changed = false - - for (const [name, preset] of Object.entries(config.presets)) { - const refs = preset.reference_models.filter(slot => slot.provider.trim() && slot.model.trim()) - if (refs.length !== preset.reference_models.length) { - changed = true - } - const agg = preset.aggregator - const aggValid = agg && agg.provider.trim() && agg.model.trim() - const cleanAgg = aggValid ? agg : { provider: agg?.provider ?? '', model: agg?.model ?? '' } - - presets[name] = { - ...preset, - reference_models: refs, - aggregator: cleanAgg - } - } - - return changed ? { ...config, presets } : config - }, []) - // Quiet debounced persist for inline MoA edits — mirrors the config page's // autosave so slot/aggregator tweaks save themselves, matching the // preset-level ops (set default / add / delete) that already persist on // click. No `applying` spinner, so selecting stays responsive. + // + // While any slot is half-filled (provider picked, model pending) the save is + // HELD, not sent: the previous complete config stays on disk and the next + // edit that completes the slot flushes the whole preset. Every edit bumps + // the generation so an in-flight response from an older save can never + // repaint over the user's mid-edit state. const scheduleMoaSave = useCallback((next: MoaConfigResponse) => { if (moaSaveTimer.current) { window.clearTimeout(moaSaveTimer.current) + moaSaveTimer.current = null } const generation = moaSaveGeneration.current + 1 moaSaveGeneration.current = generation + if (!moaConfigComplete(next)) { + return + } + moaSaveTimer.current = window.setTimeout(() => { - void saveMoaModels(sanitizeMoaRefsForSave(next)) + void saveMoaModels(next) .then(saved => { if (moaSaveGeneration.current === generation) { setMoa(saved) @@ -374,7 +374,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { } }) }, 600) - }, [sanitizeMoaRefsForSave]) + }, []) const updateMoaPreset = useCallback( (updater: (preset: NonNullable) => NonNullable) => { @@ -402,7 +402,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { const updateMoaSlot = useCallback((slot: MoaModelSlot, patch: Partial): MoaModelSlot => { const next = { ...slot, ...patch } - if (patch.provider) { + // Picking a new provider invalidates the model choice (models are + // per-provider). A same-provider update must not wipe the model — Radix + // filters same-value changes, but programmatic callers may not. + if (patch.provider && patch.provider !== slot.provider) { next.model = '' } @@ -411,6 +414,16 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { const saveMoa = useCallback(async (next: MoaConfigResponse) => { const epoch = profileEpoch.current + + // Explicit preset ops (set default / add / delete) supersede any pending + // debounced slot autosave — cancel it and invalidate in-flight responses + // so the two writers can't race each other's state. + if (moaSaveTimer.current) { + window.clearTimeout(moaSaveTimer.current) + moaSaveTimer.current = null + } + + moaSaveGeneration.current += 1 setApplying(true) setError('') @@ -1036,6 +1049,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { {withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), slot.provider).map(slug => { const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug) + return ( {provider?.name || slug} @@ -1083,10 +1097,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { } description={ - {slot.provider} · {slot.model} + {slot.provider} · {slot.model || m.model} } - key={`${selectedMoaPreset}-${slot.provider}-${slot.model}-${index}`} + key={`${selectedMoaPreset}-${index}`} title={`Reference ${index + 1}`} /> ))} @@ -1118,6 +1132,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { {withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), currentMoaPreset.aggregator.provider).map(slug => { const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug) + return ( {provider?.name || slug} diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 24c29d1c5f3a..0c4a4f8f788c 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -857,6 +857,8 @@ export interface AuxiliaryModelsResponse { export interface MoaModelSlot { provider: string model: string + /** Optional per-slot reasoning effort — round-tripped, not edited here. */ + reasoning_effort?: string } export interface MoaConfigResponse { @@ -871,6 +873,10 @@ export interface MoaConfigResponse { max_tokens: number reference_models: MoaModelSlot[] reference_temperature: number + /** Optional advisor output cap — round-tripped, not edited here. */ + reference_max_tokens?: number | null + /** Fan-out cadence (per_iteration | user_turn) — round-tripped. */ + fanout?: string } > aggregator: MoaModelSlot diff --git a/hermes_cli/moa_config.py b/hermes_cli/moa_config.py index 242b45f59e7b..e3fc77f7439d 100644 --- a/hermes_cli/moa_config.py +++ b/hermes_cli/moa_config.py @@ -110,6 +110,77 @@ def _clean_slot(slot: Any) -> dict[str, Any] | None: return clean +def _slot_problem(slot: Any) -> str | None: + """Return a human-readable problem for a slot ``_clean_slot`` would drop. + + None means the slot is complete and valid. Mirrors ``_clean_slot`` exactly + so the write-boundary validator (``validate_moa_payload``) and the + tolerant runtime normalizer can never disagree about what is acceptable. + """ + if not isinstance(slot, dict): + return "must be an object with 'provider' and 'model'" + provider = str(slot.get("provider") or "").strip() + model = str(slot.get("model") or "").strip() + if not provider and not model: + return "provider and model are required" + if not provider: + return "provider is required" + if not model: + return f"model is required (provider '{provider}' has no model selected)" + if provider.lower() == "moa": + return "the Mixture of Agents provider cannot be used inside a preset (recursive MoA)" + return None + + +def validate_moa_payload(raw: Any) -> list[str]: + """Return the problems ``normalize_moa_config`` would silently paper over. + + ``normalize_moa_config`` is deliberately tolerant: at *read* time a + hand-edited config must degrade to defaults rather than crash the agent. + That same tolerance at *write* time is a corruption engine — a client that + sends a half-filled slot gets its whole preset silently replaced with the + hardcoded defaults (#64156). API write paths call this first and reject + invalid payloads loudly instead of saving something the user never chose. + + Returns a list of human-readable problems; empty means safe to save. + """ + if not isinstance(raw, dict): + return ["MoA config must be an object"] + + presets_raw = raw.get("presets") + if isinstance(presets_raw, dict) and presets_raw: + presets: dict[Any, Any] = presets_raw + else: + # Legacy flat payload: the top-level object is the default preset. + presets = {DEFAULT_MOA_PRESET_NAME: raw} + + problems: list[str] = [] + for name, preset in presets.items(): + label = str(name or "").strip() or "(unnamed)" + if not isinstance(preset, dict): + problems.append(f"preset '{label}': must be an object") + continue + + refs = preset.get("reference_models") + if not isinstance(refs, list): + refs = [refs] if isinstance(refs, dict) else [] + complete_refs = 0 + for index, slot in enumerate(refs): + issue = _slot_problem(slot) + if issue: + problems.append(f"preset '{label}' reference {index + 1}: {issue}") + else: + complete_refs += 1 + if not complete_refs: + problems.append(f"preset '{label}': needs at least one complete reference model") + + agg_issue = _slot_problem(preset.get("aggregator")) + if agg_issue: + problems.append(f"preset '{label}' aggregator: {agg_issue}") + + return problems + + def _default_preset() -> dict[str, Any]: return { "reference_models": deepcopy(DEFAULT_MOA_REFERENCE_MODELS), diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 406e18f5ca31..6c4b16982a8b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -996,6 +996,9 @@ class ModelAssignment(BaseModel): class MoaModelSlot(BaseModel): provider: str = "" model: str = "" + # Optional per-slot reasoning effort. Declared so a client round-tripping + # the GET payload doesn't have it stripped at parse time and wiped on save. + reasoning_effort: Optional[str] = None class MoaPresetPayload(BaseModel): @@ -1006,6 +1009,11 @@ class MoaPresetPayload(BaseModel): reference_temperature: Optional[float] = None aggregator_temperature: Optional[float] = None max_tokens: int = 4096 + # Newer per-preset knobs (see moa_config._normalize_preset). Optional so + # older clients that never send them keep working; declared so clients + # that round-trip the GET payload don't silently erase hand-set values. + reference_max_tokens: Optional[int] = None + fanout: Optional[str] = None enabled: bool = True @@ -1020,6 +1028,8 @@ class MoaConfigPayload(BaseModel): reference_temperature: Optional[float] = None aggregator_temperature: Optional[float] = None max_tokens: int = 4096 + reference_max_tokens: Optional[int] = None + fanout: Optional[str] = None enabled: bool = True profile: Optional[str] = None @@ -5701,7 +5711,23 @@ def get_moa_models(profile: Optional[str] = None): def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None): """Persist the Mixture-of-Agents provider/model slots.""" try: - from hermes_cli.moa_config import normalize_moa_config + from hermes_cli.moa_config import normalize_moa_config, validate_moa_payload + + def _slot_dict(slot: MoaModelSlot) -> dict: + # Drop unset optionals so saved slots stay minimal ({provider, model}). + return {k: v for k, v in slot.dict().items() if v is not None} + + def _preset_dict(preset: MoaPresetPayload) -> dict: + return { + "reference_models": [_slot_dict(slot) for slot in preset.reference_models], + "aggregator": _slot_dict(preset.aggregator), + "reference_temperature": preset.reference_temperature, + "aggregator_temperature": preset.aggregator_temperature, + "max_tokens": preset.max_tokens, + "reference_max_tokens": preset.reference_max_tokens, + "fanout": preset.fanout, + "enabled": preset.enabled, + } with _profile_scope(body.profile or profile): cfg = load_config() @@ -5709,27 +5735,35 @@ def set_moa_models(body: MoaConfigPayload, profile: Optional[str] = None): raw = { "default_preset": body.default_preset, "active_preset": body.active_preset, - "presets": { - name: { - "reference_models": [slot.dict() for slot in preset.reference_models], - "aggregator": preset.aggregator.dict(), - "reference_temperature": preset.reference_temperature, - "aggregator_temperature": preset.aggregator_temperature, - "max_tokens": preset.max_tokens, - "enabled": preset.enabled, - } - for name, preset in body.presets.items() - }, + "presets": {name: _preset_dict(preset) for name, preset in body.presets.items()}, } else: - raw = { - "reference_models": [slot.dict() for slot in body.reference_models], - "aggregator": body.aggregator.dict(), - "reference_temperature": body.reference_temperature, - "aggregator_temperature": body.aggregator_temperature, - "max_tokens": body.max_tokens, - "enabled": body.enabled, - } + raw = _preset_dict( + MoaPresetPayload( + reference_models=body.reference_models, + aggregator=body.aggregator, + reference_temperature=body.reference_temperature, + aggregator_temperature=body.aggregator_temperature, + max_tokens=body.max_tokens, + reference_max_tokens=body.reference_max_tokens, + fanout=body.fanout, + enabled=body.enabled, + ) + ) + + # Reject-don't-repair: normalize_moa_config() silently swaps any + # preset containing incomplete slots for the hardcoded defaults — + # correct tolerance for hand-edited configs at READ time, silent + # data loss at WRITE time (#64156: desktop autosave of a + # half-filled slot replaced the user's whole preset). Refuse the + # save loudly so no client can corrupt config through this route. + problems = validate_moa_payload(raw) + if problems: + raise HTTPException( + status_code=422, + detail="Invalid MoA config: " + "; ".join(problems), + ) + normalized = normalize_moa_config(raw) cfg["moa"] = normalized save_config(cfg) diff --git a/tests/hermes_cli/test_moa_config.py b/tests/hermes_cli/test_moa_config.py index 7b5dde2e8f62..69b07e89fdf6 100644 --- a/tests/hermes_cli/test_moa_config.py +++ b/tests/hermes_cli/test_moa_config.py @@ -304,3 +304,115 @@ def test_reference_max_tokens_in_flattened_view(): active preset's reference_max_tokens.""" cfg = normalize_moa_config(_preset(reference_max_tokens=750)) assert cfg["reference_max_tokens"] == 750 + + +# ── validate_moa_payload (write-boundary validation, #64156) ───────────────── +# +# normalize_moa_config is deliberately tolerant at READ time (hand-edited +# configs degrade to defaults). validate_moa_payload is the strict WRITE-time +# counterpart: it must flag exactly the payloads normalize would silently +# repair, so API save paths reject them instead of corrupting user config. + + +def _valid_preset_payload(): + return { + "reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}], + "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + } + + +def test_validate_moa_payload_accepts_complete_presets(): + from hermes_cli.moa_config import validate_moa_payload + + assert validate_moa_payload({"presets": {"default": _valid_preset_payload()}}) == [] + + +def test_validate_moa_payload_accepts_legacy_flat_payload(): + from hermes_cli.moa_config import validate_moa_payload + + assert validate_moa_payload(_valid_preset_payload()) == [] + + +def test_validate_moa_payload_flags_half_filled_reference_slot(): + """The #64156 shape: provider picked, model still empty (mid-edit autosave).""" + from hermes_cli.moa_config import validate_moa_payload + + preset = _valid_preset_payload() + preset["reference_models"].append({"provider": "kilo", "model": ""}) + problems = validate_moa_payload({"presets": {"default": preset}}) + + assert problems + assert any("reference 2" in p and "model is required" in p for p in problems) + + +def test_validate_moa_payload_flags_half_filled_aggregator(): + from hermes_cli.moa_config import validate_moa_payload + + preset = _valid_preset_payload() + preset["aggregator"] = {"provider": "openrouter", "model": ""} + problems = validate_moa_payload({"presets": {"default": preset}}) + + assert any("aggregator" in p and "model is required" in p for p in problems) + + +def test_validate_moa_payload_flags_empty_references(): + from hermes_cli.moa_config import validate_moa_payload + + preset = _valid_preset_payload() + preset["reference_models"] = [] + problems = validate_moa_payload({"presets": {"default": preset}}) + + assert any("at least one complete reference model" in p for p in problems) + + +def test_validate_moa_payload_flags_recursive_moa_slot(): + from hermes_cli.moa_config import validate_moa_payload + + preset = _valid_preset_payload() + preset["aggregator"] = {"provider": "MoA", "model": "default"} + problems = validate_moa_payload({"presets": {"default": preset}}) + + assert any("recursive MoA" in p for p in problems) + + +def test_validate_moa_payload_names_the_broken_preset(): + """Multi-preset payloads must say WHICH preset is broken.""" + from hermes_cli.moa_config import validate_moa_payload + + problems = validate_moa_payload( + { + "presets": { + "good": _valid_preset_payload(), + "broken": { + "reference_models": [{"provider": "", "model": ""}], + "aggregator": {"provider": "a", "model": "b"}, + }, + } + } + ) + + assert problems + assert all("'broken'" in p for p in problems) + assert not any("'good'" in p for p in problems) + + +def test_validate_moa_payload_agrees_with_clean_slot(): + """Contract: a payload validate accepts must survive normalize UNCHANGED in + its slots — validate and _clean_slot can never disagree (else a payload + could pass validation and still be swapped for defaults).""" + from hermes_cli.moa_config import validate_moa_payload + + payload = {"presets": {"p": _valid_preset_payload()}} + assert validate_moa_payload(payload) == [] + + cfg = normalize_moa_config(payload) + assert cfg["presets"]["p"]["reference_models"] == payload["presets"]["p"]["reference_models"] + assert cfg["presets"]["p"]["aggregator"] == payload["presets"]["p"]["aggregator"] + + +def test_validate_moa_payload_rejects_non_dict(): + from hermes_cli.moa_config import validate_moa_payload + + assert validate_moa_payload(None) + assert validate_moa_payload([1, 2]) + assert validate_moa_payload({"presets": {"p": "not-a-dict"}}) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 17e6d2054c47..1d27bf6c37a8 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -861,6 +861,72 @@ class TestWebServerEndpoints: assert cfg["moa"]["reference_models"] == payload["reference_models"] assert cfg["moa"]["aggregator"] == payload["aggregator"] + def test_put_moa_models_rejects_half_filled_slot_with_422(self): + """#64156: a mid-edit autosave (provider picked, model empty) used to be + silently normalized into the hardcoded default preset — the user's + config was replaced without any error. The write path must reject it.""" + from hermes_cli.config import load_config + + original = load_config().get("moa") + + payload = { + "presets": { + "default": { + "reference_models": [{"provider": "kilo", "model": ""}], + "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + } + } + } + + resp = self.client.put("/api/model/moa", json=payload) + assert resp.status_code == 422 + assert "model is required" in resp.json()["detail"] + # Config untouched — not swapped for defaults. + assert load_config().get("moa") == original + + def test_put_moa_models_rejects_half_filled_aggregator_with_422(self): + payload = { + "presets": { + "default": { + "reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}], + "aggregator": {"provider": "openrouter", "model": ""}, + } + } + } + + resp = self.client.put("/api/model/moa", json=payload) + assert resp.status_code == 422 + assert "aggregator" in resp.json()["detail"] + + def test_put_moa_models_round_trips_fanout_and_reference_max_tokens(self): + """GET → PUT round-trip must not erase newer per-preset knobs. The old + Pydantic payload didn't declare fanout / reference_max_tokens, so any + client save silently wiped hand-set values back to defaults.""" + from hermes_cli.config import load_config + + payload = { + "presets": { + "default": { + "reference_models": [{"provider": "openrouter", "model": "deepseek/deepseek-v4-pro"}], + "aggregator": {"provider": "openrouter", "model": "anthropic/claude-opus-4.8"}, + "fanout": "user_turn", + "reference_max_tokens": 600, + } + } + } + + resp = self.client.put("/api/model/moa", json=payload) + assert resp.status_code == 200 + + saved = load_config()["moa"]["presets"]["default"] + assert saved["fanout"] == "user_turn" + assert saved["reference_max_tokens"] == 600 + + # And the GET view carries them back to the client. + fetched = self.client.get("/api/model/moa").json() + assert fetched["presets"]["default"]["fanout"] == "user_turn" + assert fetched["presets"]["default"]["reference_max_tokens"] == 600 + # ── GET /api/media (remote image display) ─────────────────────────── def test_get_media_serves_image_in_root(self): diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1adcd184e3a3..2d1521454bfd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -2301,6 +2301,8 @@ export interface AuxiliaryModelsResponse { export interface MoaModelSlot { provider: string; model: string; + /** Optional per-slot reasoning effort — round-tripped, not edited here. */ + reasoning_effort?: string; } export interface MoaConfigResponse { @@ -2312,6 +2314,10 @@ export interface MoaConfigResponse { reference_temperature: number; aggregator_temperature: number; max_tokens: number; + /** Optional advisor output cap — round-tripped, not edited here. */ + reference_max_tokens?: number | null; + /** Fan-out cadence (per_iteration | user_turn) — round-tripped. */ + fanout?: string; enabled: boolean; }>; reference_models: MoaModelSlot[];