From 82520c144f3f04a2b0bfe68a93f6e333e8caaeb3 Mon Sep 17 00:00:00 2001 From: -LAN- Date: Thu, 27 Aug 2026 02:00:33 +0000 Subject: [PATCH] fix(api): migrate legacy model types during upgrade (#41326) --- api/core/helper/model_provider_cache.py | 2 +- api/core/provider_manager.py | 2 +- ...5578e028b2f2_migrate_legacy_model_types.py | 267 ++++++++++++++++++ .../test_legacy_model_type_migration.py | 192 +++++++++++++ .../core/helper/test_model_provider_cache.py | 1 + .../unit_tests/core/test_provider_manager.py | 4 +- .../test_migrate_legacy_model_types.py | 86 ++++++ 7 files changed, 550 insertions(+), 4 deletions(-) create mode 100644 api/migrations/versions/2026_08_27_1200-5578e028b2f2_migrate_legacy_model_types.py create mode 100644 api/tests/unit_tests/migrations/test_migrate_legacy_model_types.py diff --git a/api/core/helper/model_provider_cache.py b/api/core/helper/model_provider_cache.py index 2b7d0d951f1..c4b95c80232 100644 --- a/api/core/helper/model_provider_cache.py +++ b/api/core/helper/model_provider_cache.py @@ -14,7 +14,7 @@ class ProviderCredentialsCacheType(StrEnum): class ProviderCredentialsCache: def __init__(self, tenant_id: str, identity_id: str, cache_type: ProviderCredentialsCacheType): - self.cache_key = f"{cache_type}_credentials:tenant_id:{tenant_id}:id:{identity_id}" + self.cache_key = f"{cache_type}_credentials:v2:tenant_id:{tenant_id}:id:{identity_id}" def get(self) -> dict[str, Any] | None: """ diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py index 4baeaed95bf..e4b39ff7189 100644 --- a/api/core/provider_manager.py +++ b/api/core/provider_manager.py @@ -73,7 +73,7 @@ _credentials_adapter: TypeAdapter[dict[str, Any]] = TypeAdapter(dict[str, Any]) _PROVIDER_CONFIGURATION_CACHE_TTL_SECONDS = 300 _PROVIDER_CONFIGURATION_CACHE_VERSION_TTL_SECONDS = 360 _PROVIDER_CONFIGURATION_CACHE_VERSION_KEY = "provider_configurations:tenant:{tenant_id}:source:{source}:version" -_PROVIDER_CONFIGURATION_CACHE_SOURCE_KEY = "provider_configurations:tenant:{tenant_id}:source:{source}:v:{version}" +_PROVIDER_CONFIGURATION_CACHE_SOURCE_KEY = "provider_configurations:v2:tenant:{tenant_id}:source:{source}:v:{version}" class ProviderConfigurationCacheSource(StrEnum): diff --git a/api/migrations/versions/2026_08_27_1200-5578e028b2f2_migrate_legacy_model_types.py b/api/migrations/versions/2026_08_27_1200-5578e028b2f2_migrate_legacy_model_types.py new file mode 100644 index 00000000000..a2966ff8571 --- /dev/null +++ b/api/migrations/versions/2026_08_27_1200-5578e028b2f2_migrate_legacy_model_types.py @@ -0,0 +1,267 @@ +"""migrate legacy model types + +Revision ID: 5578e028b2f2 +Revises: 9b7c6d5e4f3a +Create Date: 2026-08-27 12:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "5578e028b2f2" +down_revision = "9b7c6d5e4f3a" +branch_labels = None +depends_on = None + +_AFFECTED_TABLES = ( + "provider_models", + "provider_model_credentials", + "tenant_default_models", + "provider_model_settings", + "load_balancing_model_configs", +) +_LEGACY_MODEL_TYPES = ("text-generation", "embeddings", "reranking") +_MAPPED_MODEL_TYPES = ( + "text-generation", + "llm", + "embeddings", + "text-embedding", + "reranking", + "rerank", +) +_CREDENTIAL_MERGES_TABLE = "tmp_5578e028b2f2_credential_merges" + + +def _canonical_model_type(alias: str) -> str: + return f"""CASE {alias}.model_type + WHEN 'text-generation' THEN 'llm' + WHEN 'embeddings' THEN 'text-embedding' + WHEN 'reranking' THEN 'rerank' + ELSE {alias}.model_type + END""" + + +def _mapped_model_types_sql() -> str: + return ", ".join(f"'{model_type}'" for model_type in _MAPPED_MODEL_TYPES) + + +def _legacy_model_types_sql() -> str: + return ", ".join(f"'{model_type}'" for model_type in _LEGACY_MODEL_TYPES) + + +def _same_business_key(left_alias: str, right_alias: str, key_columns: tuple[str, ...]) -> str: + key_condition = "\n AND ".join(f"{left_alias}.{column} = {right_alias}.{column}" for column in key_columns) + return f"""{key_condition} + AND {_canonical_model_type(left_alias)} = {_canonical_model_type(right_alias)}""" + + +def _delete_duplicates( + table_name: str, + key_columns: tuple[str, ...], + *, + extra_condition: str | None = None, + require_legacy_row: bool = True, +) -> None: + dialect_name = op.get_context().dialect.name + scoped_condition = f"\n AND {extra_condition}" if extra_condition else "" + common_condition = f"""{_same_business_key("loser", "winner", key_columns)} + AND loser.model_type IN ({_mapped_model_types_sql()}) + AND winner.model_type IN ({_mapped_model_types_sql()}) + AND ( + loser.updated_at < winner.updated_at + OR (loser.updated_at = winner.updated_at AND loser.id < winner.id) + ){scoped_condition}""" + legacy_table = f", {table_name} AS legacy" if require_legacy_row else "" + legacy_join = "" + if require_legacy_row: + legacy_join = f""" + AND {_same_business_key("loser", "legacy", key_columns)} + AND legacy.model_type IN ({_legacy_model_types_sql()})""" + + if dialect_name == "postgresql": + op.execute( + sa.text( + f"""DELETE FROM {table_name} AS loser + USING {table_name} AS winner{legacy_table} + WHERE {common_condition}{legacy_join}""" + ) + ) + return + + if dialect_name in {"mysql", "mariadb"}: + legacy_table = "" + if require_legacy_row: + legacy_table = f""" + INNER JOIN {table_name} AS legacy + ON {_same_business_key("loser", "legacy", key_columns)} + AND legacy.model_type IN ({_legacy_model_types_sql()})""" + op.execute( + sa.text( + f"""DELETE loser + FROM {table_name} AS loser + INNER JOIN {table_name} AS winner + ON {common_condition}{legacy_table}""" + ) + ) + return + + raise RuntimeError(f"unsupported database dialect: {dialect_name}") + + +def _create_credential_merges() -> None: + dialect_name = op.get_context().dialect.name + if dialect_name == "postgresql": + table_options = "ON COMMIT DROP" + elif dialect_name in {"mysql", "mariadb"}: + # MySQL does not roll back temporary-table DDL. Remove a table left by + # an in-process retry without risking a permanent table of the same name. + op.execute(sa.text(f"DROP TEMPORARY TABLE IF EXISTS {_CREDENTIAL_MERGES_TABLE}")) + table_options = "" + else: + raise RuntimeError(f"unsupported database dialect: {dialect_name}") + + op.execute( + sa.text( + f"""CREATE TEMPORARY TABLE {_CREDENTIAL_MERGES_TABLE} {table_options} AS + SELECT id AS loser_id, winner_id + FROM ( + SELECT + id, + FIRST_VALUE(id) OVER ( + PARTITION BY + tenant_id, + provider_name, + model_name, + credential_name, + {_canonical_model_type("provider_model_credentials")} + ORDER BY updated_at DESC, id DESC + ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING + ) AS winner_id, + SUM(CASE WHEN model_type IN ({_legacy_model_types_sql()}) THEN 1 ELSE 0 END) OVER ( + PARTITION BY + tenant_id, + provider_name, + model_name, + credential_name, + {_canonical_model_type("provider_model_credentials")} + ) AS legacy_count + FROM provider_model_credentials + WHERE model_type IN ({_mapped_model_types_sql()}) + ) AS ranked_credentials + WHERE id <> winner_id AND legacy_count > 0""" + ) + ) + + +def _rewrite_credential_references() -> None: + dialect_name = op.get_context().dialect.name + if dialect_name == "postgresql": + op.execute( + sa.text( + f"""UPDATE provider_models AS model + SET credential_id = merges.winner_id + FROM {_CREDENTIAL_MERGES_TABLE} AS merges + WHERE model.credential_id = merges.loser_id""" + ) + ) + op.execute( + sa.text( + f"""UPDATE load_balancing_model_configs AS config + SET + credential_id = merges.winner_id, + name = winner.credential_name, + encrypted_config = winner.encrypted_config + FROM {_CREDENTIAL_MERGES_TABLE} AS merges + INNER JOIN provider_model_credentials AS winner ON winner.id = merges.winner_id + WHERE config.credential_id = merges.loser_id""" + ) + ) + return + + if dialect_name in {"mysql", "mariadb"}: + op.execute( + sa.text( + f"""UPDATE provider_models AS model + INNER JOIN {_CREDENTIAL_MERGES_TABLE} AS merges ON model.credential_id = merges.loser_id + SET model.credential_id = merges.winner_id""" + ) + ) + op.execute( + sa.text( + f"""UPDATE load_balancing_model_configs AS config + INNER JOIN {_CREDENTIAL_MERGES_TABLE} AS merges ON config.credential_id = merges.loser_id + INNER JOIN provider_model_credentials AS winner ON winner.id = merges.winner_id + SET + config.credential_id = merges.winner_id, + config.name = winner.credential_name, + config.encrypted_config = winner.encrypted_config""" + ) + ) + return + + raise RuntimeError(f"unsupported database dialect: {dialect_name}") + + +def _delete_merged_credentials() -> None: + dialect_name = op.get_context().dialect.name + if dialect_name == "postgresql": + op.execute( + sa.text( + f"""DELETE FROM provider_model_credentials AS credential + USING {_CREDENTIAL_MERGES_TABLE} AS merges + WHERE credential.id = merges.loser_id""" + ) + ) + op.execute(sa.text(f"DROP TABLE {_CREDENTIAL_MERGES_TABLE}")) + return + + if dialect_name in {"mysql", "mariadb"}: + op.execute( + sa.text( + f"""DELETE credential + FROM provider_model_credentials AS credential + INNER JOIN {_CREDENTIAL_MERGES_TABLE} AS merges ON credential.id = merges.loser_id""" + ) + ) + op.execute(sa.text(f"DROP TEMPORARY TABLE {_CREDENTIAL_MERGES_TABLE}")) + return + + raise RuntimeError(f"unsupported database dialect: {dialect_name}") + + +def _canonicalize_model_types() -> None: + for table_name in _AFFECTED_TABLES: + op.execute( + sa.text( + f"""UPDATE {table_name} + SET model_type = {_canonical_model_type(table_name)} + WHERE model_type IN ('text-generation', 'embeddings', 'reranking')""" + ) + ) + + +def upgrade() -> None: + # Preserve the established manual migration policy: the newest row wins + # when legacy and canonical values collapse onto the same business key. + _delete_duplicates("provider_models", ("tenant_id", "provider_name", "model_name")) + _delete_duplicates("tenant_default_models", ("tenant_id",)) + _delete_duplicates("provider_model_settings", ("tenant_id", "provider_name", "model_name")) + _delete_duplicates( + "load_balancing_model_configs", + ("tenant_id", "provider_name", "model_name"), + extra_condition="loser.name = '__inherit__' AND winner.name = '__inherit__'", + require_legacy_row=False, + ) + + _create_credential_merges() + _rewrite_credential_references() + _delete_merged_credentials() + _canonicalize_model_types() + + +def downgrade() -> None: + # Canonical rows created after the enum rename cannot be distinguished from + # rows changed here, so reversing this data migration would corrupt valid data. + pass diff --git a/api/tests/test_containers_integration_tests/commands/test_legacy_model_type_migration.py b/api/tests/test_containers_integration_tests/commands/test_legacy_model_type_migration.py index 401696d5cad..76a5862224f 100644 --- a/api/tests/test_containers_integration_tests/commands/test_legacy_model_type_migration.py +++ b/api/tests/test_containers_integration_tests/commands/test_legacy_model_type_migration.py @@ -1,13 +1,17 @@ from __future__ import annotations import importlib +import importlib.util import io import json from collections.abc import Generator from datetime import datetime, timedelta +from pathlib import Path import pytest import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations from tests.helpers.legacy_model_type_migration import ( assert_tenant_rows_use_only_canonical_model_types, @@ -16,6 +20,28 @@ from tests.helpers.legacy_model_type_migration import ( seed_legacy_model_type_dirty_data, ) +_ALEMBIC_MIGRATION_PATH = ( + Path(__file__).resolve().parents[3] + / "migrations/versions/2026_08_27_1200-5578e028b2f2_migrate_legacy_model_types.py" +) + + +def _run_legacy_model_type_alembic_upgrade(engine: sa.Engine) -> None: + spec = importlib.util.spec_from_file_location("migrate_legacy_model_types", _ALEMBIC_MIGRATION_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("failed to load legacy model type migration") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + with engine.begin() as connection: + operations = Operations(MigrationContext.configure(connection)) + original_op = module.__dict__["op"] + module.__dict__["op"] = operations + try: + module.__dict__["upgrade"]() + finally: + module.__dict__["op"] = original_op + def _parse_json_lines(output: io.StringIO) -> list[dict[str, object]]: return [json.loads(line) for line in output.getvalue().splitlines() if line.strip()] @@ -223,6 +249,172 @@ def test_legacy_model_type_migration_end_to_end_across_supported_backends( assert second_apply_state == first_apply_state +def test_legacy_model_type_alembic_upgrade_across_supported_backends( + container_engine: tuple[str, sa.Engine], +) -> None: + _, engine = container_engine + helper_module = importlib.import_module("tests.helpers.legacy_model_type_migration") + helper_module.drop_minimal_legacy_model_type_schema(engine) + fixture = seed_legacy_model_type_dirty_data(engine) + + canonical_provider_model_id = "00000000-0000-0000-0000-00000000ca01" + canonical_default_model_id = "00000000-0000-0000-0000-00000000ca02" + canonical_credential_ids = { + "00000000-0000-0000-0000-00000000ca03", + "00000000-0000-0000-0000-00000000ca04", + } + older_inherit_id = "00000000-0000-0000-0000-00000000ca05" + newer_inherit_id = "00000000-0000-0000-0000-00000000ca06" + now = datetime(2025, 1, 1, 12, 0, 0) + with engine.begin() as connection: + connection.execute( + sa.text( + """ + INSERT INTO provider_models + ( + id, tenant_id, provider_name, model_name, model_type, + credential_id, is_valid, created_at, updated_at + ) + VALUES + ( + :id, :tenant_id, 'openai', 'gpt-4o-mini', 'llm', + :credential_id, :is_valid, :created_at, :updated_at + ) + """ + ), + { + "id": canonical_provider_model_id, + "tenant_id": fixture.primary.tenant_id, + "credential_id": fixture.primary.winner_credential_id, + "is_valid": True, + "created_at": now - timedelta(days=2), + "updated_at": now - timedelta(hours=7), + }, + ) + connection.execute( + sa.text( + """ + INSERT INTO tenant_default_models + (id, tenant_id, provider_name, model_name, model_type, created_at, updated_at) + VALUES + (:id, :tenant_id, 'openai', 'gpt-4o-mini', 'llm', :created_at, :updated_at) + """ + ), + { + "id": canonical_default_model_id, + "tenant_id": fixture.primary.tenant_id, + "created_at": now - timedelta(days=2), + "updated_at": now, + }, + ) + connection.execute( + sa.text( + """ + INSERT INTO provider_model_credentials + ( + id, tenant_id, provider_name, model_name, model_type, + credential_name, encrypted_config, created_at, updated_at + ) + VALUES + (:older_id, :tenant_id, 'openai', 'gpt-4o-mini', 'llm', + 'canonical-only', '{"api_key":"older"}', :created_at, :older_updated_at), + (:newer_id, :tenant_id, 'openai', 'gpt-4o-mini', 'llm', + 'canonical-only', '{"api_key":"newer"}', :created_at, :newer_updated_at) + """ + ), + { + "older_id": min(canonical_credential_ids), + "newer_id": max(canonical_credential_ids), + "tenant_id": fixture.primary.tenant_id, + "created_at": now - timedelta(days=2), + "older_updated_at": now - timedelta(hours=2), + "newer_updated_at": now - timedelta(hours=1), + }, + ) + + _insert_load_balancing_model_config( + engine, + row_id=older_inherit_id, + tenant_id=fixture.primary.tenant_id, + provider_name="openai", + model_name="gpt-4o-mini", + model_type="llm", + name="__inherit__", + encrypted_config='{"api_key":"older-inherit"}', + credential_id=fixture.primary.winner_credential_id, + enabled=True, + created_at=now - timedelta(days=2), + updated_at=now - timedelta(hours=2), + ) + _insert_load_balancing_model_config( + engine, + row_id=newer_inherit_id, + tenant_id=fixture.primary.tenant_id, + provider_name="openai", + model_name="gpt-4o-mini", + model_type="text-generation", + name="__inherit__", + encrypted_config='{"api_key":"newer-inherit"}', + credential_id=fixture.primary.distinct_credential_id, + enabled=True, + created_at=now - timedelta(days=2), + updated_at=now - timedelta(hours=1), + ) + + _run_legacy_model_type_alembic_upgrade(engine) + + for tenant_id in (fixture.primary.tenant_id, fixture.secondary.tenant_id): + assert_tenant_rows_use_only_canonical_model_types(engine, tenant_id) + + table_names = ( + "provider_models", + "tenant_default_models", + "provider_model_settings", + "load_balancing_model_configs", + "provider_model_credentials", + ) + first_apply_state = {table_name: fetch_table_rows(engine, table_name) for table_name in table_names} + primary_provider_models = [ + row + for row in first_apply_state["provider_models"] + if row["tenant_id"] == fixture.primary.tenant_id and row["model_name"] == "gpt-4o-mini" + ] + assert [row["id"] for row in primary_provider_models] == [fixture.primary.provider_model_id] + assert primary_provider_models[0]["credential_id"] == fixture.primary.winner_credential_id + + primary_defaults = [ + row + for row in first_apply_state["tenant_default_models"] + if row["tenant_id"] == fixture.primary.tenant_id and row["model_type"] == "llm" + ] + assert [row["id"] for row in primary_defaults] == [canonical_default_model_id] + primary_credential_ids = { + row["id"] + for row in first_apply_state["provider_model_credentials"] + if row["tenant_id"] == fixture.primary.tenant_id + } + assert canonical_credential_ids <= primary_credential_ids + assert count_rows(engine, "provider_model_credentials", tenant_id=fixture.primary.tenant_id) == 4 + + primary_load_balancing_config = next( + row + for row in first_apply_state["load_balancing_model_configs"] + if row["id"] == fixture.primary.load_balancing_config_id + ) + assert primary_load_balancing_config["credential_id"] == fixture.primary.winner_credential_id + assert primary_load_balancing_config["encrypted_config"] == fixture.primary.winner_encrypted_config + primary_inherit_ids = { + row["id"] + for row in first_apply_state["load_balancing_model_configs"] + if row["tenant_id"] == fixture.primary.tenant_id and row["name"] == "__inherit__" + } + assert primary_inherit_ids == {newer_inherit_id} + + _run_legacy_model_type_alembic_upgrade(engine) + second_apply_state = {table_name: fetch_table_rows(engine, table_name) for table_name in table_names} + assert second_apply_state == first_apply_state + + def test_load_balancing_inherit_deduplication_is_applied_consistently_across_supported_backends( migration_module, container_engine: tuple[str, sa.Engine], diff --git a/api/tests/unit_tests/core/helper/test_model_provider_cache.py b/api/tests/unit_tests/core/helper/test_model_provider_cache.py index e66d58a4e9f..64ad5a13d39 100644 --- a/api/tests/unit_tests/core/helper/test_model_provider_cache.py +++ b/api/tests/unit_tests/core/helper/test_model_provider_cache.py @@ -16,6 +16,7 @@ def test_model_provider_credentials_cache_get_returns_decoded_dict(mocker: Mocke redis_client_mock.get.return_value = json.dumps(payload).encode("utf-8") + assert cache.cache_key == "provider_credentials:v2:tenant_id:tenant:id:identity" assert cache.get() == payload diff --git a/api/tests/unit_tests/core/test_provider_manager.py b/api/tests/unit_tests/core/test_provider_manager.py index bf805e761ab..935b983decd 100644 --- a/api/tests/unit_tests/core/test_provider_manager.py +++ b/api/tests/unit_tests/core/test_provider_manager.py @@ -1143,8 +1143,8 @@ def test_provider_configuration_cache_skips_write_when_version_changes_during_lo assert version_bumped is True assert fake_redis.store[version_key] == "1" - assert "provider_configurations:tenant:tenant-id:source:provider_model_credentials:v:0" not in fake_redis.store - assert "provider_configurations:tenant:tenant-id:source:provider_model_credentials:v:1" not in fake_redis.store + assert "provider_configurations:v2:tenant:tenant-id:source:provider_model_credentials:v:0" not in fake_redis.store + assert "provider_configurations:v2:tenant:tenant-id:source:provider_model_credentials:v:1" not in fake_redis.store assert result["openai"][0].credential_name == "primary" diff --git a/api/tests/unit_tests/migrations/test_migrate_legacy_model_types.py b/api/tests/unit_tests/migrations/test_migrate_legacy_model_types.py new file mode 100644 index 00000000000..608747965ef --- /dev/null +++ b/api/tests/unit_tests/migrations/test_migrate_legacy_model_types.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import importlib.util +from io import StringIO +from pathlib import Path +from types import ModuleType + +import pytest +from alembic.migration import MigrationContext +from alembic.operations import Operations + +_MIGRATION_PATH = ( + Path(__file__).resolve().parents[3] + / "migrations/versions/2026_08_27_1200-5578e028b2f2_migrate_legacy_model_types.py" +) + + +def _load_migration_module() -> ModuleType: + spec = importlib.util.spec_from_file_location("migrate_legacy_model_types", _MIGRATION_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("failed to load migration module") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize( + ("dialect_name", "duplicate_delete", "reference_update", "temporary_drop"), + [ + ( + "postgresql", + "DELETE FROM provider_models AS loser USING provider_models AS winner", + "UPDATE provider_models AS model SET credential_id = merges.winner_id FROM", + "DROP TABLE tmp_5578e028b2f2_credential_merges", + ), + ( + "mysql", + "DELETE loser FROM provider_models AS loser INNER JOIN provider_models AS winner", + "UPDATE provider_models AS model INNER JOIN tmp_5578e028b2f2_credential_merges", + "DROP TEMPORARY TABLE tmp_5578e028b2f2_credential_merges", + ), + ], +) +def test_upgrade_emits_collision_safe_sql_for_supported_databases( + dialect_name: str, + duplicate_delete: str, + reference_update: str, + temporary_drop: str, +) -> None: + module = _load_migration_module() + output = StringIO() + migration_context = MigrationContext.configure( + dialect_name=dialect_name, + opts={"as_sql": True, "literal_binds": True, "output_buffer": output}, + ) + operations = Operations(migration_context) + original_op = module.__dict__["op"] + module.__dict__["op"] = operations + try: + module.__dict__["upgrade"]() + finally: + module.__dict__["op"] = original_op + + generated_sql = " ".join(output.getvalue().split()) + assert duplicate_delete in generated_sql + assert reference_update in generated_sql + assert temporary_drop in generated_sql + assert "ORDER BY updated_at DESC, id DESC" in generated_sql + assert "legacy.model_type IN ('text-generation', 'embeddings', 'reranking')" in generated_sql + assert "WHERE id <> winner_id AND legacy_count > 0" in generated_sql + + for table_name in ( + "provider_models", + "provider_model_credentials", + "tenant_default_models", + "provider_model_settings", + "load_balancing_model_configs", + ): + assert f"UPDATE {table_name} SET model_type = CASE {table_name}.model_type" in generated_sql + + for old_value, new_value in ( + ("text-generation", "llm"), + ("embeddings", "text-embedding"), + ("reranking", "rerank"), + ): + assert f"WHEN '{old_value}' THEN '{new_value}'" in generated_sql