fix(moa): reject half-filled MoA saves at the API boundary and hold desktop autosave until slots are complete

Follow-up hardening on top of #64158 (@DavidMetcalfe):

Backend (the root-cause fix):
- hermes_cli/moa_config.py: add validate_moa_payload() — strict write-time
  counterpart to the deliberately tolerant normalize_moa_config(). Flags
  half-filled slots, empty reference lists, recursive moa slots, naming the
  exact preset/slot.
- hermes_cli/web_server.py: PUT /api/model/moa validates before normalizing
  and returns 422 with the specific problems instead of silently swapping the
  user's preset for hardcoded defaults (#64156). Also declares
  fanout / reference_max_tokens / reasoning_effort on the Pydantic payload so
  client round-trips no longer erase hand-set values.

Desktop:
- Replace sanitize-then-send with hold-while-incomplete: the debounced
  autosave is deferred (not repaired) while any slot is half-filled, and
  flushes once the model pick completes the edit. Mid-edit UI state is never
  repainted by a save response (generation guard covers held edits too).
- updateMoaSlot only clears the model when the provider actually changed.
- Explicit preset ops (set default / add / delete) cancel the pending
  autosave and invalidate in-flight responses so the two writers can't race.
- Stable row keys (preset+index) so mid-edit rows don't remount; cleared
  model shows the 'Model' placeholder instead of vanishing.

Both TS clients' MoaConfigResponse types now declare the round-tripped
fields (fanout, reference_max_tokens, reasoning_effort).

Tests: 12 new backend unit tests (validate_moa_payload contract incl.
validate/normalize agreement), 3 new web_server endpoint tests (422 on
half-filled ref/aggregator, fanout round-trip), 3 new desktop vitest cases
(autosave held while half-filled, flush on completion, same-provider
reselect no-op). E2E validated against a live TestClient with isolated
HERMES_HOME: bug sequence now 422s with config untouched.

Fixes #64156
This commit is contained in:
Teknium
2026-07-15 05:15:14 -07:00
parent a61a0bc019
commit fcdc10a0f3
8 changed files with 500 additions and 54 deletions
@@ -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<typeof moaConfig>
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()
}
})
})
@@ -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<typeof currentMoaPreset>) => NonNullable<typeof currentMoaPreset>) => {
@@ -402,7 +402,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const updateMoaSlot = useCallback((slot: MoaModelSlot, patch: Partial<MoaModelSlot>): 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) {
<SelectContent>
{withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), slot.provider).map(slug => {
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
return (
<SelectItem key={slug} value={slug}>
{provider?.name || slug}
@@ -1083,10 +1097,10 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
}
description={
<span className="font-mono text-[0.68rem]">
{slot.provider} · {slot.model}
{slot.provider} · {slot.model || m.model}
</span>
}
key={`${selectedMoaPreset}-${slot.provider}-${slot.model}-${index}`}
key={`${selectedMoaPreset}-${index}`}
title={`Reference ${index + 1}`}
/>
))}
@@ -1118,6 +1132,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
<SelectContent>
{withActive(moaSlotProviderOptions.map(p => p.slug || 'none'), currentMoaPreset.aggregator.provider).map(slug => {
const provider = moaSlotProviderOptions.find(p => (p.slug || 'none') === slug)
return (
<SelectItem key={slug} value={slug}>
{provider?.name || slug}
+6
View File
@@ -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
+71
View File
@@ -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),
+54 -20
View File
@@ -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)
+112
View File
@@ -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"}})
+66
View File
@@ -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):
+6
View File
@@ -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[];