Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/backend/base/langflow/api/v1/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -833,10 +833,32 @@ async def update_enabled_models(
disabled_models = normalize_model_status_entries(disabled_models, providers_by_name)
explicitly_enabled_models = normalize_model_status_entries(explicitly_enabled_models, providers_by_name)

unavailable_models: dict[tuple[str, str], str] = {}
for provider_dict in all_models_by_provider:
provider = provider_dict.get("provider")
if not isinstance(provider, str):
continue
for model in provider_dict.get("models", []):
model_name = model.get("model_name")
if not isinstance(model_name, str):
continue
metadata = model.get("metadata", {})
if metadata.get("deprecated", False):
unavailable_models[(provider, model_name)] = "deprecated"
elif metadata.get("not_supported", False):
unavailable_models[(provider, model_name)] = "not supported"

# Update model sets based on user requests
# For any model being enabled, validate the provider credentials
for update in updates:
if update.enabled:
unavailable_reason = unavailable_models.get((update.provider, update.model_id))
if unavailable_reason:
raise HTTPException(
status_code=400,
detail=f"Cannot enable {unavailable_reason} model: {update.model_id}",
)

from lfx.base.models.unified_models import get_all_variables_for_provider, validate_model_provider_key

# Get variables from DB or environment
Expand Down
13 changes: 13 additions & 0 deletions src/backend/tests/unit/api/v1/test_models_enabled_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,19 @@ async def test_enabled_providers_reflects_models_endpoint(client: AsyncClient, o
assert enabled_result["provider_status"]["OpenAI"] is True


@pytest.mark.usefixtures("active_user")
async def test_cannot_enable_deprecated_model(client: AsyncClient, logged_in_headers):
"""Deprecated models cannot be persisted as explicitly enabled."""
response = await client.post(
"api/v1/models/enabled_models",
json=[{"provider": "OpenAI", "model_id": "gpt-3.5-turbo", "enabled": True}],
headers=logged_in_headers,
)

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json()["detail"] == "Cannot enable deprecated model: gpt-3.5-turbo"


@pytest.mark.usefixtures("active_user")
async def test_security_credential_value_never_exposed_in_variables_endpoint(
client: AsyncClient, openai_credential, logged_in_headers
Expand Down
4 changes: 2 additions & 2 deletions src/backend/tests/unit/api/v1/test_variable.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,7 @@ async def test_delete_provider_credential_cleans_up_enabled_models(client: Async
enable_response = await client.post(
"api/v1/models/enabled_models",
json=[
{"provider": "OpenAI", "model_id": "gpt-4-turbo-preview", "enabled": True},
{"provider": "OpenAI", "model_id": "gpt-4.1-mini", "enabled": True},
],
headers=logged_in_headers,
)
Expand All @@ -620,7 +620,7 @@ async def test_delete_provider_credential_cleans_up_enabled_models(client: Async
import json

enabled_models = json.loads(enabled_models_var["value"])
assert "gpt-4-turbo-preview" not in enabled_models
assert "gpt-4.1-mini" not in enabled_models


@pytest.mark.usefixtures("active_user")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,26 @@ describe("ModelSelection", () => {
);
});

it("should not allow deprecated models to be enabled", async () => {
const user = userEvent.setup();
const onModelToggle = jest.fn();
render(
<ModelSelection
{...defaultProps}
availableModels={withDeprecated}
modelType="llm"
onModelToggle={onModelToggle}
/>,
);

await user.click(screen.getByTestId("llm-deprecated-summary"));
const deprecatedToggle = screen.getByTestId("llm-toggle-gpt-old");
expect(deprecatedToggle).toBeDisabled();

await user.click(deprecatedToggle);
expect(onModelToggle).not.toHaveBeenCalled();
});

it("should pluralize 'model' correctly for a single deprecated row", () => {
const single: Model[] = [
{ model_name: "gpt-4o", metadata: { model_type: "llm", icon: "Bot" } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ const ModelRow = ({
{isEnabledModel && (
<Switch
checked={enabled}
disabled={
model.metadata?.deprecated === true ||
model.metadata?.not_supported === true
}
onCheckedChange={(checked) =>
onToggle(model.model_name, checked, modelType)
}
Expand Down Expand Up @@ -308,9 +312,7 @@ const ModelSelection = ({
);

const addableDeploymentTypes =
supportsCustomDeployments &&
!!isEnabledModel &&
trimmedModelQuery.length > 0
supportsCustomDeployments && isEnabledModel && trimmedModelQuery.length > 0
? visibleDeploymentTypes.filter(
(deploymentType) =>
!modelsWithCustomDeployments.some(
Expand Down
26 changes: 12 additions & 14 deletions src/lfx/src/lfx/base/models/models_dev_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,9 @@

# Threshold for auto-deprecating models that haven't shipped a new version in
# a long time. models.dev has no deprecation field, and providers rarely
# formally deprecate models even after they ship a successor — but a model
# that hasn't been touched in 30 months is overwhelmingly legacy in practice
# (catches gpt-4 / gpt-4-turbo / text-embedding-ada-002 today, leaves
# gpt-4o / text-embedding-3-small/large active). Tuned conservatively so
# current embeddings (~28 months old) survive; nudge down only if successor
# adoption is verified.
# formally deprecate models even after they ship a successor. Apply this only
# to language models: embedding release cadences are slower, and age alone
# does not mean that a provider has deprecated an embedding model.
_AGE_DEPRECATION_DAYS = 900

MODELS_DEV_URL = "https://models.dev/api.json"
Expand Down Expand Up @@ -229,15 +226,16 @@ def _is_embedding_family(model_dict: dict[str, Any]) -> bool:


def _is_aged_out(model_dict: dict[str, Any], now: datetime) -> bool:
"""Return True if the model is older than ``_AGE_DEPRECATION_DAYS`` days.
"""Return True if a language model is older than ``_AGE_DEPRECATION_DAYS`` days.

Prefers ``release_date`` (when the model functionally shipped) over
``last_updated`` (which tracks catalog-curator edits like typo fixes, not
new model versions). The combination of preferring release_date and the
900-day threshold correctly catches ``gpt-4`` / ``gpt-4-turbo`` (924d from
their 2023-11 release) while keeping ``text-embedding-3-large`` (844d
from its 2024-01 release) active.
new model versions). Embedding models are excluded because their age does
not indicate provider deprecation.
"""
if _is_embedding_family(model_dict):
return False

raw = model_dict.get("release_date") or model_dict.get("last_updated")
epoch = _release_date_to_epoch(raw)
if epoch == 0:
Expand Down Expand Up @@ -285,7 +283,7 @@ def _translate_model_entry(
``deprecated`` kwarg (which lets callers forward the static-list
curation that models.dev itself doesn't surface):
* dated-snapshot id (:data:`_DATED_SNAPSHOT_RE`);
* stale ``last_updated`` / ``release_date`` per :data:`_AGE_DEPRECATION_DAYS`.
* stale LLM ``last_updated`` / ``release_date`` per :data:`_AGE_DEPRECATION_DAYS`.
``now`` is injected for testability — defaults to the current UTC time.
"""
now = now or datetime.now(tz=timezone.utc)
Expand Down Expand Up @@ -357,8 +355,8 @@ def apply_models_dev_overrides(
preserves the static-list curation by name: any model that was already
flagged deprecated in the bundled ``*_constants.py`` lists keeps that flag
after the override. Dated-snapshot ids
(e.g. ``claude-opus-4-5-20251101``, ``gpt-4o-2024-05-13``) and rows whose
most recent date is older than :data:`_AGE_DEPRECATION_DAYS` are also
(e.g. ``claude-opus-4-5-20251101``, ``gpt-4o-2024-05-13``) and language
models whose most recent date is older than :data:`_AGE_DEPRECATION_DAYS` are also
auto-flagged in :func:`_translate_model_entry`. ``now`` is forwarded for
testability.
"""
Expand Down
32 changes: 22 additions & 10 deletions src/lfx/tests/unit/base/models/test_models_dev_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,12 @@ def test_apply_overrides_preserves_static_deprecated_flag():
{"provider": "OpenAI", "name": "gpt-4o", "tool_calling": True, "deprecated": False},
{"provider": "OpenAI", "name": "gpt-3.5-turbo", "tool_calling": True, "deprecated": True},
{"provider": "OpenAI", "name": "gpt-4.5-preview", "tool_calling": True, "deprecated": True},
{
"provider": "OpenAI",
"name": "text-embedding-legacy",
"model_type": "embeddings",
"deprecated": True,
},
]
snapshot = {
"openai": {
Expand All @@ -465,6 +471,11 @@ def test_apply_overrides_preserves_static_deprecated_flag():
# New model not in our static list — should default to
# non-deprecated unless dated-snapshot.
"gpt-6": {"id": "gpt-6", "tool_call": True},
"text-embedding-legacy": {
"id": "text-embedding-legacy",
"family": "text-embedding",
"release_date": "2020-01-01",
},
},
}
}
Expand All @@ -475,6 +486,7 @@ def test_apply_overrides_preserves_static_deprecated_flag():
assert by_name["gpt-3.5-turbo"]["deprecated"] is True
assert by_name["gpt-4.5-preview"]["deprecated"] is True
assert by_name["gpt-6"]["deprecated"] is False
assert by_name["text-embedding-legacy"]["deprecated"] is True


def test_apply_overrides_marks_embedding_family_as_embeddings_model_type():
Expand Down Expand Up @@ -517,37 +529,37 @@ def test_apply_overrides_marks_embedding_family_as_embeddings_model_type():
assert by_name["voyage-3-embedding"]["model_type"] == "embeddings"


def test_apply_overrides_auto_deprecates_stale_models():
"""Models with last_updated older than ~30 months are auto-deprecated.
def test_apply_overrides_auto_deprecates_stale_language_models_only():
"""Language models older than ~30 months are auto-deprecated.

Catches gpt-4 / gpt-4-turbo / text-embedding-ada-002 today; leaves
gpt-4o / text-embedding-3-* / current Claudes active.
Embedding models remain active regardless of age unless the provider's
static catalog explicitly marks them deprecated.
"""
from lfx.base.models.models_dev_catalog import apply_models_dev_overrides

fixed_now = datetime(2026, 5, 18, tzinfo=timezone.utc)
fixed_now = datetime(2026, 7, 14, tzinfo=timezone.utc)
snapshot = {
"openai": {
"id": "openai",
"models": {
# 2023-11 → 924 days as of 2026-05-18 → deprecated
# 2023-11 → older than 900 days → deprecated
"gpt-4": {"id": "gpt-4", "release_date": "2023-11-06", "last_updated": "2024-04-09"},
"gpt-4-turbo": {"id": "gpt-4-turbo", "release_date": "2023-11-06", "last_updated": "2024-04-09"},
# 2022-12 → very old → deprecated
# 2022-12 → very old, but embeddings are exempt from the age heuristic
"text-embedding-ada-002": {
"id": "text-embedding-ada-002",
"family": "text-embedding",
"release_date": "2022-12-15",
"last_updated": "2022-12-15",
},
# 2024-01-25 → 844 days still active under the 900d threshold
# 2024-01-25 → over 900 days, but still active because it is an embedding
"text-embedding-3-large": {
"id": "text-embedding-3-large",
"family": "text-embedding",
"release_date": "2024-01-25",
"last_updated": "2024-01-25",
},
# 2024-08650 days → active
# 2024-05newer than 900 days → active
"gpt-4o": {"id": "gpt-4o", "release_date": "2024-05-13", "last_updated": "2024-08-06"},
# No date at all → not auto-deprecated (insufficient signal)
"gpt-future": {"id": "gpt-future"},
Expand All @@ -560,7 +572,7 @@ def test_apply_overrides_auto_deprecates_stale_models():

assert by_name["gpt-4"]["deprecated"] is True
assert by_name["gpt-4-turbo"]["deprecated"] is True
assert by_name["text-embedding-ada-002"]["deprecated"] is True
assert by_name["text-embedding-ada-002"]["deprecated"] is False
assert by_name["text-embedding-3-large"]["deprecated"] is False
assert by_name["gpt-4o"]["deprecated"] is False
assert by_name["gpt-future"]["deprecated"] is False
Expand Down
Loading