From 6d1fc18c30c1ecff7f4a5372dbfc09b6aa6883af Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:39:26 +0530 Subject: [PATCH 1/3] Config: Fix legacy configs missing completion type field --- ..._backfill_legacy_config_completion_type.py | 34 ++++ backend/app/crud/config/version.py | 10 +- backend/app/tests/crud/config/test_version.py | 145 +++++++++++++++++- 3 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 backend/app/alembic/versions/047_backfill_legacy_config_completion_type.py 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..33694e75 --- /dev/null +++ b/backend/app/alembic/versions/047_backfill_legacy_config_completion_type.py @@ -0,0 +1,34 @@ +"""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: + op.execute( + """ + UPDATE config_version + SET config_blob = config_blob #- '{completion,type}' + WHERE config_blob->'completion'->>'type' = 'text' + """ + ) diff --git a/backend/app/crud/config/version.py b/backend/app/crud/config/version.py index 915d1b18..3b8b0a6e 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..53f7946d 100644 --- a/backend/app/tests/crud/config/test_version.py +++ b/backend/app/tests/crud/config/test_version.py @@ -4,7 +4,7 @@ from sqlmodel import Session from fastapi import HTTPException -from app.models import ConfigVersionUpdate, ConfigBlob +from app.models import ConfigVersionUpdate, ConfigBlob, ConfigVersionCreate from app.models.llm.request import NativeCompletionConfig from app.crud.config import ConfigVersionCrud from app.tests.utils.test_data import ( @@ -419,6 +419,149 @@ 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_validate_config_type_unchanged_legacy_config_allows_text( + db: Session, +) -> None: + """Test that _validate_config_type_unchanged defaults legacy (null type) to 'text'.""" + config = create_test_config(db) + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + # Directly patch the latest version's config_blob to remove 'type' (simulate legacy) + latest_version = version_crud._get_latest_version() + blob = dict(latest_version.config_blob) + blob["completion"] = {k: v for k, v in blob["completion"].items() if k != "type"} + latest_version.config_blob = blob + db.add(latest_version) + db.commit() + + # Creating a new version with type="text" should succeed + new_blob = ConfigBlob( + completion=NativeCompletionConfig( + provider="openai-native", + type="text", + params={"model": "gpt-4", "temperature": 0.8, "max_tokens": 1500}, + ) + ) + version_create = ConfigVersionCreate( + config_blob=new_blob, + commit_message="Update after legacy", + ) + + # Should NOT raise + version_crud._validate_config_type_unchanged(version_create) + + +def test_validate_config_type_unchanged_legacy_config_rejects_non_text( + db: Session, +) -> None: + """Test that _validate_config_type_unchanged rejects non-text type for legacy configs.""" + config = create_test_config(db) + version_crud = ConfigVersionCrud( + session=db, project_id=config.project_id, config_id=config.id + ) + + # Directly patch the latest version's config_blob to remove 'type' (simulate legacy) + latest_version = version_crud._get_latest_version() + blob = dict(latest_version.config_blob) + blob["completion"] = {k: v for k, v in blob["completion"].items() if k != "type"} + latest_version.config_blob = blob + db.add(latest_version) + db.commit() + + # Creating a new version with type="stt" should fail + new_blob = ConfigBlob( + completion=NativeCompletionConfig( + provider="openai-native", + type="stt", + params={"model": "gpt-4", "temperature": 0.8, "max_tokens": 1500}, + ) + ) + version_create = ConfigVersionCreate( + config_blob=new_blob, + commit_message="Change type after legacy", + ) + + with pytest.raises( + HTTPException, + match="Cannot change config type from 'text' to 'stt'", + ): + version_crud._validate_config_type_unchanged(version_create) + + 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) From 6ba9039cf2d8cdd637daee866b395ee9ac7c76bd Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:55:00 +0530 Subject: [PATCH 2/3] migration: made downgrade a no-op to preserve required completion.type field --- .../047_backfill_legacy_config_completion_type.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) 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 index 33694e75..bd243b81 100644 --- a/backend/app/alembic/versions/047_backfill_legacy_config_completion_type.py +++ b/backend/app/alembic/versions/047_backfill_legacy_config_completion_type.py @@ -25,10 +25,5 @@ def upgrade() -> None: def downgrade() -> None: - op.execute( - """ - UPDATE config_version - SET config_blob = config_blob #- '{completion,type}' - WHERE config_blob->'completion'->>'type' = 'text' - """ - ) + # No-op: removing type='text' would drop a required field and break validation; NULL safely defaults to 'text'. + pass From 0ac3dcb0f43b047bb40bb21af7b23be1b1c9b461 Mon Sep 17 00:00:00 2001 From: Prashant Vasudevan <71649489+vprashrex@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:57:51 +0530 Subject: [PATCH 3/3] removed dead code from test --- backend/app/tests/crud/config/test_version.py | 73 +------------------ 1 file changed, 1 insertion(+), 72 deletions(-) diff --git a/backend/app/tests/crud/config/test_version.py b/backend/app/tests/crud/config/test_version.py index 53f7946d..dccb54f4 100644 --- a/backend/app/tests/crud/config/test_version.py +++ b/backend/app/tests/crud/config/test_version.py @@ -4,7 +4,7 @@ from sqlmodel import Session from fastapi import HTTPException -from app.models import ConfigVersionUpdate, ConfigBlob, ConfigVersionCreate +from app.models import ConfigVersionUpdate, ConfigBlob from app.models.llm.request import NativeCompletionConfig from app.crud.config import ConfigVersionCrud from app.tests.utils.test_data import ( @@ -491,77 +491,6 @@ def test_validate_immutable_fields_legacy_config_rejects_non_text_update( version_crud._validate_immutable_fields(legacy_blob, merged_blob) -def test_validate_config_type_unchanged_legacy_config_allows_text( - db: Session, -) -> None: - """Test that _validate_config_type_unchanged defaults legacy (null type) to 'text'.""" - config = create_test_config(db) - version_crud = ConfigVersionCrud( - session=db, project_id=config.project_id, config_id=config.id - ) - - # Directly patch the latest version's config_blob to remove 'type' (simulate legacy) - latest_version = version_crud._get_latest_version() - blob = dict(latest_version.config_blob) - blob["completion"] = {k: v for k, v in blob["completion"].items() if k != "type"} - latest_version.config_blob = blob - db.add(latest_version) - db.commit() - - # Creating a new version with type="text" should succeed - new_blob = ConfigBlob( - completion=NativeCompletionConfig( - provider="openai-native", - type="text", - params={"model": "gpt-4", "temperature": 0.8, "max_tokens": 1500}, - ) - ) - version_create = ConfigVersionCreate( - config_blob=new_blob, - commit_message="Update after legacy", - ) - - # Should NOT raise - version_crud._validate_config_type_unchanged(version_create) - - -def test_validate_config_type_unchanged_legacy_config_rejects_non_text( - db: Session, -) -> None: - """Test that _validate_config_type_unchanged rejects non-text type for legacy configs.""" - config = create_test_config(db) - version_crud = ConfigVersionCrud( - session=db, project_id=config.project_id, config_id=config.id - ) - - # Directly patch the latest version's config_blob to remove 'type' (simulate legacy) - latest_version = version_crud._get_latest_version() - blob = dict(latest_version.config_blob) - blob["completion"] = {k: v for k, v in blob["completion"].items() if k != "type"} - latest_version.config_blob = blob - db.add(latest_version) - db.commit() - - # Creating a new version with type="stt" should fail - new_blob = ConfigBlob( - completion=NativeCompletionConfig( - provider="openai-native", - type="stt", - params={"model": "gpt-4", "temperature": 0.8, "max_tokens": 1500}, - ) - ) - version_create = ConfigVersionCreate( - config_blob=new_blob, - commit_message="Change type after legacy", - ) - - with pytest.raises( - HTTPException, - match="Cannot change config type from 'text' to 'stt'", - ): - version_crud._validate_config_type_unchanged(version_create) - - 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)