From 42f8a26ee1ebe62b0f4176262fa46b2c8cf3ba5e Mon Sep 17 00:00:00 2001 From: Bentlybro Date: Wed, 21 Jan 2026 23:26:52 +0000 Subject: [PATCH] Allow LLM model deletion without replacement if unused Updated backend logic and API schema to permit deleting an LLM model without specifying a replacement if no workflow nodes are using it. Adjusted tests to cover both cases (with and without usage), made replacement_model_slug optional in the response model, and updated OpenAPI spec accordingly. --- .../api/features/admin/llm_routes_test.py | 52 +++++++++++++++++-- .../backend/backend/server/v2/llm/db.py | 4 +- .../backend/backend/server/v2/llm/model.py | 2 +- .../frontend/src/app/api/openapi.json | 3 +- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/autogpt_platform/backend/backend/api/features/admin/llm_routes_test.py b/autogpt_platform/backend/backend/api/features/admin/llm_routes_test.py index f11567211e..53de71b65c 100644 --- a/autogpt_platform/backend/backend/api/features/admin/llm_routes_test.py +++ b/autogpt_platform/backend/backend/api/features/admin/llm_routes_test.py @@ -439,9 +439,53 @@ def test_delete_llm_model_validation_error( assert "Replacement model 'invalid' not found" in response.json()["detail"] -def test_delete_llm_model_missing_replacement() -> None: - """Test deletion fails when replacement_model_slug is not provided""" +def test_delete_llm_model_no_replacement_with_usage( + mocker: pytest_mock.MockFixture, +) -> None: + """Test deletion fails when nodes exist but no replacement is provided""" + mocker.patch( + "backend.api.features.admin.llm_routes.llm_db.delete_model", + new=AsyncMock( + side_effect=ValueError( + "Cannot delete model 'test-model': 5 workflow node(s) are using it. " + "Please provide a replacement_model_slug to migrate them." + ) + ), + ) + response = client.delete("/admin/llm/models/model-1") - # FastAPI will return 422 for missing required query params - assert response.status_code == 422 + assert response.status_code == 400 + assert "workflow node(s) are using it" in response.json()["detail"] + + +def test_delete_llm_model_no_replacement_no_usage( + mocker: pytest_mock.MockFixture, +) -> None: + """Test deletion succeeds when no nodes use the model and no replacement is provided""" + mock_response = llm_model.DeleteLlmModelResponse( + deleted_model_slug="unused-model", + deleted_model_display_name="Unused Model", + replacement_model_slug=None, + nodes_migrated=0, + message="Successfully deleted model 'Unused Model' (unused-model). No workflows were using this model.", + ) + + mocker.patch( + "backend.api.features.admin.llm_routes.llm_db.delete_model", + new=AsyncMock(return_value=mock_response), + ) + + mock_refresh = mocker.patch( + "backend.api.features.admin.llm_routes._refresh_runtime_state", + new=AsyncMock(), + ) + + response = client.delete("/admin/llm/models/model-1") + + assert response.status_code == 200 + response_data = response.json() + assert response_data["deleted_model_slug"] == "unused-model" + assert response_data["nodes_migrated"] == 0 + assert response_data["replacement_model_slug"] is None + mock_refresh.assert_called_once() diff --git a/autogpt_platform/backend/backend/server/v2/llm/db.py b/autogpt_platform/backend/backend/server/v2/llm/db.py index 96d80573f1..273d2e1162 100644 --- a/autogpt_platform/backend/backend/server/v2/llm/db.py +++ b/autogpt_platform/backend/backend/server/v2/llm/db.py @@ -493,7 +493,9 @@ async def delete_model( deleted_display_name = model.displayName # 2. Count affected nodes first to determine if replacement is needed - count_result = await prisma.models.prisma().query_raw( + import prisma as prisma_module + + count_result = await prisma_module.get_client().query_raw( """ SELECT COUNT(*) as count FROM "AgentNode" diff --git a/autogpt_platform/backend/backend/server/v2/llm/model.py b/autogpt_platform/backend/backend/server/v2/llm/model.py index d34e7df666..02a2029a3e 100644 --- a/autogpt_platform/backend/backend/server/v2/llm/model.py +++ b/autogpt_platform/backend/backend/server/v2/llm/model.py @@ -173,7 +173,7 @@ class ToggleLlmModelResponse(pydantic.BaseModel): class DeleteLlmModelResponse(pydantic.BaseModel): deleted_model_slug: str deleted_model_display_name: str - replacement_model_slug: str + replacement_model_slug: Optional[str] = None nodes_migrated: int message: str diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index 733ba67625..783e0a3144 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -7930,7 +7930,7 @@ "title": "Deleted Model Display Name" }, "replacement_model_slug": { - "type": "string", + "anyOf": [{ "type": "string" }, { "type": "null" }], "title": "Replacement Model Slug" }, "nodes_migrated": { "type": "integer", "title": "Nodes Migrated" }, @@ -7940,7 +7940,6 @@ "required": [ "deleted_model_slug", "deleted_model_display_name", - "replacement_model_slug", "nodes_migrated", "message" ],