From 22c517a54fec6ddacfc7b016ea3580cfad3f174b Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:03:44 -0400 Subject: [PATCH] fix: stale admin model defaults after model upgrades (#1709) (#200) * fix: migrate stale admin model defaults Normalize retired model IDs before validation and in settings responses so full admin updates remain saveable after model upgrades. * fix: restrict retired model migration Only migrate explicitly retired model IDs so malformed provider model names and efforts continue to fail validation. (cherry picked from commit 62e0ca2d4898ebc3c2ae8887030a364779d907cb) Co-authored-by: Johannes du Plessis --- agent/dashboard/team_settings.py | 82 ++++++++++++++++++++++++- tests/test_model_fallback_resolution.py | 69 ++++++++++++++++++++- 2 files changed, 149 insertions(+), 2 deletions(-) diff --git a/agent/dashboard/team_settings.py b/agent/dashboard/team_settings.py index 5c65b971..a484af47 100644 --- a/agent/dashboard/team_settings.py +++ b/agent/dashboard/team_settings.py @@ -110,6 +110,39 @@ class TeamSettingsUpdate(BaseModel): @model_validator(mode="after") def _validate_model_pairs(self) -> TeamSettingsUpdate: + self.default_agent_model, self.default_agent_reasoning_effort = _normalize_stale_model_pair( + self.default_agent_model, + self.default_agent_reasoning_effort, + ) + self.default_agent_subagent_model, self.default_agent_subagent_reasoning_effort = ( + _normalize_stale_model_pair( + self.default_agent_subagent_model, + self.default_agent_subagent_reasoning_effort, + ) + ) + self.default_reviewer_model, self.default_reviewer_reasoning_effort = ( + _normalize_stale_model_pair( + self.default_reviewer_model, + self.default_reviewer_reasoning_effort, + ) + ) + ( + self.default_reviewer_subagent_model, + self.default_reviewer_subagent_reasoning_effort, + ) = _normalize_stale_model_pair( + self.default_reviewer_subagent_model, + self.default_reviewer_subagent_reasoning_effort, + ) + self.default_grouping_model, self.default_grouping_reasoning_effort = ( + _normalize_stale_model_pair( + self.default_grouping_model, + self.default_grouping_reasoning_effort, + ) + ) + self.default_chat_model, self.default_chat_reasoning_effort = _normalize_stale_model_pair( + self.default_chat_model, + self.default_chat_reasoning_effort, + ) _validate_model_effort_pair( self.default_agent_model, self.default_agent_reasoning_effort, "agent" ) @@ -168,6 +201,53 @@ def _validate_model_effort_pair(model: str | None, effort: str | None, role: str raise ValueError(f"effort {effort!r} not supported by {role} model {model!r}") +# Model ids retired from SUPPORTED_MODELS mapped to their current successors, +# so stored admin defaults stay saveable/readable across model upgrades. Direct +# anthropic: ids moved to the Bedrock inference profile in the provider +# migration (#62); OpenAI/Google were dropped entirely and take the global +# default. anthropic:claude-fable-5 deliberately maps to Opus, not Bedrock +# Fable: Fable is gated behind the provider-data-share opt-in and a stale +# record must never resurface it. +_RETIRED_MODEL_REPLACEMENTS: dict[str, str] = { + "anthropic:claude-opus-4-7": "bedrock_converse:us.anthropic.claude-opus-4-8", + "anthropic:claude-opus-4-8": "bedrock_converse:us.anthropic.claude-opus-4-8", + "anthropic:claude-fable-5": "bedrock_converse:us.anthropic.claude-opus-4-8", + "openai:gpt-5.5": "bedrock_converse:us.anthropic.claude-opus-4-8", + "google_genai:gemini-3.5-flash": "bedrock_converse:us.anthropic.claude-opus-4-8", +} + + +def _normalize_stale_model_pair( + model: str | None, effort: str | None +) -> tuple[str | None, str | None]: + if model is None: + return model, effort + return _RETIRED_MODEL_REPLACEMENTS.get(model, model), effort + + +_MODEL_PAIR_FIELDS: tuple[tuple[str, str], ...] = ( + ("default_agent_model", "default_agent_reasoning_effort"), + ("default_agent_subagent_model", "default_agent_subagent_reasoning_effort"), + ("default_reviewer_model", "default_reviewer_reasoning_effort"), + ("default_reviewer_subagent_model", "default_reviewer_subagent_reasoning_effort"), + ("default_grouping_model", "default_grouping_reasoning_effort"), + ("default_chat_model", "default_chat_reasoning_effort"), +) + + +def normalize_team_settings_for_response(settings: dict[str, Any]) -> dict[str, Any]: + value = dict(settings) + for model_field, effort_field in _MODEL_PAIR_FIELDS: + model = value.get(model_field) + effort = value.get(effort_field) + if isinstance(model, str): + value[model_field], value[effort_field] = _normalize_stale_model_pair( + model, + effort if isinstance(effort, str) else None, + ) + return value + + def _client(): return get_client() @@ -241,7 +321,7 @@ async def get_team_settings() -> dict[str, Any]: "review_author_context_enabled", ): merged.pop(stale_field, None) - return merged + return normalize_team_settings_for_response(merged) async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]: diff --git a/tests/test_model_fallback_resolution.py b/tests/test_model_fallback_resolution.py index 8f0305f3..5b08ae3e 100644 --- a/tests/test_model_fallback_resolution.py +++ b/tests/test_model_fallback_resolution.py @@ -11,12 +11,18 @@ from agent.dashboard.options import ( gate_fable_model, provider_fallback_pair, ) -from agent.dashboard.team_settings import get_team_default_model +from agent.dashboard.team_settings import ( + TeamSettingsUpdate, + get_team_default_model, + normalize_team_settings_for_response, +) STALE_ANTHROPIC = "bedrock_converse:us.anthropic.claude-opus-4-7" SUPPORTED_ANTHROPIC = "bedrock_converse:us.anthropic.claude-opus-4-8" STALE_SONNET = "bedrock_converse:us.anthropic.claude-sonnet-4-9" SUPPORTED_SONNET = "bedrock_converse:us.anthropic.claude-sonnet-5" +RETIRED_DIRECT_ANTHROPIC = "anthropic:claude-opus-4-8" +RETIRED_FABLE = "anthropic:claude-fable-5" def test_provider_fallback_preserves_provider_and_effort() -> None: @@ -79,6 +85,67 @@ def test_profile_stale_anthropic_upgrades_to_supported() -> None: assert normalize_profile_overrides(profile) == (SUPPORTED_ANTHROPIC, "high") +def test_team_settings_update_normalizes_retired_models() -> None: + update = TeamSettingsUpdate( + default_agent_model=SUPPORTED_ANTHROPIC, + default_agent_reasoning_effort="medium", + default_agent_subagent_model=RETIRED_DIRECT_ANTHROPIC, + default_agent_subagent_reasoning_effort="medium", + default_reviewer_model=RETIRED_DIRECT_ANTHROPIC, + default_reviewer_reasoning_effort="medium", + default_reviewer_subagent_model=RETIRED_DIRECT_ANTHROPIC, + default_reviewer_subagent_reasoning_effort="low", + ) + + assert update.default_agent_subagent_model == SUPPORTED_ANTHROPIC + assert update.default_reviewer_model == SUPPORTED_ANTHROPIC + assert update.default_reviewer_subagent_model == SUPPORTED_ANTHROPIC + + +def test_team_settings_update_maps_retired_fable_to_opus_not_fable() -> None: + update = TeamSettingsUpdate( + fable_enabled=True, + default_agent_model=RETIRED_FABLE, + default_agent_reasoning_effort="high", + ) + + assert update.default_agent_model == SUPPORTED_ANTHROPIC + assert update.default_agent_model not in FABLE_MODEL_IDS + + +def test_team_settings_update_rejects_unknown_model() -> None: + with pytest.raises(ValueError, match="unsupported agent model"): + TeamSettingsUpdate( + default_agent_model="openai:gpt-6", + default_agent_reasoning_effort="medium", + ) + + +def test_team_settings_update_rejects_invalid_effort_for_retired_model() -> None: + with pytest.raises(ValueError, match="effort 'bogus' not supported"): + TeamSettingsUpdate( + default_agent_model=RETIRED_DIRECT_ANTHROPIC, + default_agent_reasoning_effort="bogus", + ) + + +def test_team_settings_response_normalizes_retired_models() -> None: + settings = normalize_team_settings_for_response( + { + "default_agent_subagent_model": RETIRED_DIRECT_ANTHROPIC, + "default_agent_subagent_reasoning_effort": "medium", + "default_reviewer_model": "openai:gpt-5.5", + "default_reviewer_reasoning_effort": "medium", + "default_reviewer_subagent_model": RETIRED_FABLE, + "default_reviewer_subagent_reasoning_effort": "low", + } + ) + + assert settings["default_agent_subagent_model"] == SUPPORTED_ANTHROPIC + assert settings["default_reviewer_model"] == SUPPORTED_ANTHROPIC + assert settings["default_reviewer_subagent_model"] == SUPPORTED_ANTHROPIC + + def test_profile_without_model_defers_to_team_default() -> None: assert normalize_profile_overrides({"reasoning_effort": "high"}) == (None, None)