diff --git a/routes/model_routes.py b/routes/model_routes.py index 4535bbafe..c79049b36 100644 --- a/routes/model_routes.py +++ b/routes/model_routes.py @@ -659,7 +659,7 @@ _NON_CHAT_CONTAINS = ( "gliner", "riva", "-parse", "-embedqa", "-nemoretriever", "topic-control", "calibration", "ai-synthetic-video", "cosmos-reason2", - "bge", "llama-guard", + "bge", "llama-guard", "-embed", "rerank", ) _NON_CHAT_EXACT_PREFIXES = ( "gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat) @@ -2846,11 +2846,12 @@ def setup_model_routes(model_discovery): return {"endpoint_id": "", "endpoint_url": "", "model": ""} base = _normalize_base(ep.base_url) chat_url = build_chat_url(base) + if model and not _is_chat_model(model): + model = "" if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)): try: visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None)) - if visible: - model = visible[0] + model = next((mid for mid in visible if _is_chat_model(mid)), "") except Exception: pass return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model} diff --git a/tests/test_default_chat_model_filter.py b/tests/test_default_chat_model_filter.py new file mode 100644 index 000000000..72dcfc077 --- /dev/null +++ b/tests/test_default_chat_model_filter.py @@ -0,0 +1,86 @@ +"""Issue #2137: never preselect an embedding or other non-chat model.""" + +import json +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from core.database import ModelEndpoint +from routes import model_routes, prefs_routes +from tests.helpers.database import disposable_database + + +@pytest.fixture +def defaults_api(monkeypatch, tmp_path): + defaults = {} + with disposable_database(tmp_path) as factory: + monkeypatch.setattr(model_routes, "SessionLocal", factory) + monkeypatch.setattr(model_routes, "_load_settings", lambda: {}) + monkeypatch.setattr(prefs_routes, "_load_for_user", lambda owner: defaults) + app = FastAPI() + app.state.auth_manager = SimpleNamespace(is_admin=lambda owner: False) + + @app.middleware("http") + async def owner(request, call_next): + request.state.current_user = "alice" + return await call_next(request) + + app.include_router(model_routes.setup_model_routes(model_discovery=None)) + with TestClient(app) as client: + yield client, factory, defaults + + +def _endpoint(factory, *, cached=(), pinned=(), hidden=(), owner="alice", endpoint_id="ep"): + with factory() as db: + db.add(ModelEndpoint( + id=endpoint_id, name="Fixture", base_url="http://model.invalid/v1", owner=owner, + is_enabled=True, cached_models=json.dumps(cached), + pinned_models=json.dumps(pinned), hidden_models=json.dumps(hidden), + )) + db.commit() + + +@pytest.mark.parametrize("non_chat", [ + "text-embedding-3-small", "nomic-embed-text:latest", "jina-reranker-v2-base", + "tts-1", "whisper-1", "gpt-image-1", +]) +def test_automatic_default_skips_non_chat_models(defaults_api, non_chat): + client, factory, _ = defaults_api + _endpoint(factory, cached=[non_chat, "chat-model"]) + assert client.get("/api/default-chat").json()["model"] == "chat-model" + + +def test_non_chat_saved_default_falls_back_to_visible_pinned_chat(defaults_api): + client, factory, defaults = defaults_api + _endpoint(factory, cached=["text-embedding-3-small", "hidden-chat"], + pinned=["jina-reranker-v2-base", "pinned-chat"], hidden=["hidden-chat"]) + defaults.update(default_endpoint_id="ep", default_model="text-embedding-3-small") + assert client.get("/api/default-chat").json()["model"] == "pinned-chat" + + +def test_no_chat_candidate_leaves_model_empty(defaults_api): + client, factory, defaults = defaults_api + _endpoint(factory, cached=["text-embedding-3-small", "hidden-chat"], + pinned=["jina-reranker-v2-base"], hidden=["hidden-chat"]) + defaults.update(default_endpoint_id="ep", default_model="text-embedding-3-small") + result = client.get("/api/default-chat").json() + assert result == {"endpoint_id": "ep", "endpoint_url": "http://model.invalid/v1/chat/completions", "model": ""} + + +def test_valid_explicit_model_survives_stale_catalog(defaults_api): + client, factory, defaults = defaults_api + _endpoint(factory, cached=["text-embedding-3-small", "other-chat"]) + defaults.update(default_endpoint_id="ep", default_model="my-custom-chat") + assert client.get("/api/default-chat").json()["model"] == "my-custom-chat" + + +def test_model_filter_preserves_owner_scope(defaults_api): + client, factory, defaults = defaults_api + _endpoint(factory, owner="bob", endpoint_id="bobs-endpoint", cached=["bobs-chat"]) + _endpoint(factory, cached=["text-embedding-3-small", "alices-chat"]) + defaults.update(default_endpoint_id="bobs-endpoint") + result = client.get("/api/default-chat").json() + assert result["endpoint_id"] == "ep" + assert result["model"] == "alices-chat"