diff --git a/backend/app/alembic/versions/047_backfill_legacy_config_completion_type.py b/backend/app/alembic/versions/047_backfill_legacy_config_completion_type.py new file mode 100644 index 00000000..bd243b81 --- /dev/null +++ b/backend/app/alembic/versions/047_backfill_legacy_config_completion_type.py @@ -0,0 +1,29 @@ +"""backfill legacy config completion type + +Revision ID: 047 +Revises: 046 +Create Date: 2026-02-17 00:00:00.000000 + +""" +from alembic import op + +revision = "047" +down_revision = "046" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + UPDATE config_version + SET config_blob = jsonb_set(config_blob, '{completion,type}', '"text"') + WHERE config_blob->'completion' IS NOT NULL + AND config_blob->'completion'->>'type' IS NULL + """ + ) + + +def downgrade() -> None: + # No-op: removing type='text' would drop a required field and break validation; NULL safely defaults to 'text'. + pass diff --git a/backend/app/crud/config/version.py b/backend/app/crud/config/version.py index b3da74f1..04a80693 100644 --- a/backend/app/crud/config/version.py +++ b/backend/app/crud/config/version.py @@ -156,6 +156,10 @@ def _validate_immutable_fields( existing_type = existing_completion.get("type") merged_type = merged_completion.get("type") + # Legacy configs predate the 'type' field; all were text-only + if existing_type is None: + existing_type = "text" + if existing_type != merged_type: raise HTTPException( status_code=400, @@ -274,7 +278,11 @@ def _validate_config_type_unchanged( version_create.config_blob.model_dump().get("completion", {}).get("type") ) - if old_type is None or new_type is None: + # Legacy configs predate the 'type' field; all were text-only + if old_type is None: + old_type = "text" + + if new_type is None: logger.error( f"[ConfigVersionCrud._validate_config_type_unchanged] Missing type field | " f"{{'config_id': '{self.config_id}', 'old_type': {old_type}, 'new_type': {new_type}}}" diff --git a/backend/app/tests/crud/config/test_version.py b/backend/app/tests/crud/config/test_version.py index dfbe137a..dccb54f4 100644 --- a/backend/app/tests/crud/config/test_version.py +++ b/backend/app/tests/crud/config/test_version.py @@ -419,6 +419,78 @@ def test_create_version_different_configs( assert version2_config2.config_id == config2.id +def test_validate_immutable_fields_legacy_config_allows_text_update( + db: Session, example_config_blob: ConfigBlob +) -> None: + """Test that a legacy config (no type field) allows updates with type='text'.""" + config = create_test_config(db) + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + # Simulate a legacy config_blob without 'type' in completion + legacy_blob = { + "completion": { + "provider": "openai-native", + "params": { + "model": "gpt-4", + "temperature": 0.7, + "max_tokens": 1000, + }, + } + } + merged_blob = { + "completion": { + "provider": "openai-native", + "type": "text", + "params": { + "model": "gpt-4", + "temperature": 0.8, + "max_tokens": 1500, + }, + } + } + + # Should NOT raise — legacy configs default to "text" + version_crud._validate_immutable_fields(legacy_blob, merged_blob) + + +def test_validate_immutable_fields_legacy_config_rejects_non_text_update( + db: Session, example_config_blob: ConfigBlob +) -> None: + """Test that a legacy config (no type field) rejects updates with type != 'text'.""" + config = create_test_config(db) + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + legacy_blob = { + "completion": { + "provider": "openai-native", + "params": { + "model": "gpt-4", + "temperature": 0.7, + }, + } + } + merged_blob = { + "completion": { + "provider": "openai-native", + "type": "stt", + "params": { + "model": "gpt-4", + "temperature": 0.8, + }, + } + } + + with pytest.raises( + HTTPException, + match="Cannot change config type from 'text' to 'stt'", + ): + version_crud._validate_immutable_fields(legacy_blob, merged_blob) + + def test_read_all_versions_config_not_found(db: Session) -> None: """Test reading versions for a non-existent config raises HTTPException.""" project = create_test_project(db)