"""Which model answers one reply, and where that is decided. Until 1.6.0 it was `chat.model_id` and nothing else, while `Message.model_id` was written on every assistant placeholder and read only for display. The two could disagree, and did: `wake_chat` accepts a `model_id` override, `schedule/runner` passes `schedule.model_id or chat.model_id`, and that reached the row and never reached the request — so a schedule naming another model got the chat's model wearing the other one's name on the bubble. Half a feature, wired and unread. The row is the authority now. That is also what makes a reply survive a restart: `_follow` calls `ensure`, which starts a **new** generation against the same row, so anything the request depends on has to be durable — and the in-process registry is not. Everything that differs per model is asserted here, because each of them fails differently and three of them fail silently: * the model id sent, which is the visible one; * `vision`, where a wrong answer makes the endpoint reject the **whole request**; * the reasoning-effort vocabulary, which raises inside the model's chat template; * the tools capability, `context_length`, `{{model_name}}`, the personality, and the authored prompt's model layer. """ from __future__ import annotations import pytest from sqlalchemy import select from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Connection, Model, User from lembas.services import chat as chat_service from lembas.services import harness as harness_service from lembas.services import personas as personas_service from lembas.services import settings_store from lembas.services import tools as tools_service from lembas.services.crypto import encrypt @pytest.fixture(autouse=True) def two_models(db, registered): connection = Connection( name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("") ) db.add(connection) db.commit() db.add( Model( connection_id=connection.id, model_id="the-chats-model", display_name="Chat model", position=0, context_length=8192, reasoning_efforts=["low", "medium", "high"], system_prompt="You are the chat's model.", capabilities_json={"tools": True, "vision": True}, ) ) db.add( Model( connection_id=connection.id, model_id="the-other-model", display_name="Other model", position=1, context_length=128000, reasoning_efforts=["low", "medium", "xhigh"], system_prompt="You are the other model.", capabilities_json={"tools": False, "vision": False}, ) ) db.commit() def _user(db) -> User: return db.scalars(select(User).order_by(User.created_at)).first() def _chat(db) -> Chat: connection = db.scalars(select(Connection)).first() chat = Chat( user_id=_user(db).id, title="t", model_id="the-chats-model", connection_id=connection.id, ) db.add(chat) db.commit() return chat def _turn(db, chat, *, model_id: str = ""): """A user turn and the assistant placeholder that answers it.""" chat_service.create_message(db, chat, ROLE_USER, "Say something") return chat_service.create_message( db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=model_id or chat.model_id ) # --- Where it is decided ------------------------------------------------------ def test_the_row_names_the_model_and_the_chat_is_the_default(db): chat = _chat(db) assert chat_service.speaker_for(db, chat).model_id == "the-chats-model" placeholder = _turn(db, chat, model_id="the-other-model") assert chat_service.speaker_for(db, chat, placeholder).model_id == "the-other-model" def test_a_row_naming_no_model_falls_back_to_the_chat(db): """Every existing row names one, but a row written by an older release or by some future caller that forgot must not send an empty model id.""" chat = _chat(db) placeholder = _turn(db, chat) placeholder.model_id = "" db.commit() assert chat_service.speaker_for(db, chat, placeholder).model_id == "the-chats-model" # --- What the request carries ------------------------------------------------- def test_the_request_is_sent_to_the_model_the_row_names(db): """The bug, in one assertion. This failed before the speaker existed.""" chat = _chat(db) placeholder = _turn(db, chat, model_id="the-other-model") body = chat_service.build_request(db, chat, upto=placeholder, user=_user(db)) assert body["model"] == "the-other-model" def test_the_endpoint_is_resolved_for_the_row_s_model(db): chat = _chat(db) placeholder = _turn(db, chat, model_id="the-other-model") speaker = chat_service.speaker_for(db, chat, placeholder) _endpoint, model_id = chat_service.resolve_endpoint(db, chat, speaker) assert model_id == "the-other-model" def test_resolving_another_model_s_connection_does_not_repoint_the_chat(db): """`resolve_endpoint` writes `chat.connection_id` when the original has gone. For a speaker that is not the chat's own model that would quietly move the whole conversation to another endpoint.""" chat = _chat(db) original = chat.connection_id second = Connection(name="Second", base_url="http://127.0.0.2:1", api_key_encrypted=encrypt("")) db.add(second) db.commit() db.add(Model(connection_id=second.id, model_id="only-here", position=9)) db.commit() chat_service.resolve_endpoint(db, chat, chat_service.Speaker("only-here", None)) db.expire_all() assert db.get(Chat, chat.id).connection_id == original def test_the_effort_vocabulary_is_the_answering_model_s(db): """Not cosmetic: an effort a model does not take is rendered into its chat template and raises there, failing the whole reply. gpt-oss takes low/medium/high; a Bonsai takes low/medium/xhigh and refuses high.""" chat = _chat(db) chat.params_json = {"reasoning_effort": "high"} db.commit() placeholder = _turn(db, chat, model_id="the-other-model") body = chat_service.build_request(db, chat, upto=placeholder, user=_user(db)) # `high` is not in the other model's list, so it is not sent at all rather # than being sent to a template that raises on it. assert body.get("reasoning_effort") != "high" kwargs = body.get("chat_template_kwargs") or {} assert kwargs.get("reasoning_effort") != "high" def test_an_effort_the_answering_model_does_take_is_sent(db): chat = _chat(db) chat.params_json = {"reasoning_effort": "medium"} db.commit() placeholder = _turn(db, chat, model_id="the-other-model") body = chat_service.build_request(db, chat, upto=placeholder, user=_user(db)) assert body["reasoning_effort"] == "medium" def test_vision_follows_the_answering_model(db): """An image sent to a model without vision is not degraded gracefully: most endpoints reject the entire request.""" chat = _chat(db) assert chat_service.model_supports(db, chat, "vision") is True assert ( chat_service.model_supports( db, chat, "vision", speaker=chat_service.Speaker("the-other-model") ) is False ) def test_the_authored_prompt_uses_the_answering_model_s_layer(db): chat = _chat(db) speaker = chat_service.Speaker("the-other-model") assert "chat's model" in chat_service.effective_system_prompt(db, chat) assert "other model" in chat_service.effective_system_prompt(db, chat, speaker) def test_the_tools_capability_is_the_answering_model_s(db): """`tools` off is the first gate and returns nothing at all, so a model that cannot take a tools array must not be handed one -- its replies fail rather than degrade.""" chat = _chat(db) user = _user(db) settings_store.update(db, {"default_permissions": {"tools.web_search": True}}) assert tools_service.resolve_tools(db, chat, user).defs assert not tools_service.resolve_tools( db, chat, user, chat_service.Speaker("the-other-model") ).defs def test_the_context_limit_is_the_answering_model_s(db): chat = _chat(db) assert chat_service.model_for(db, chat).context_length == 8192 other = chat_service.model_row(db, chat_service.Speaker("the-other-model")) assert other.context_length == 128000 def test_the_model_name_variable_is_the_answering_model_s(db): """Telling a speaker it is the main model is a lie it then reasons from.""" chat = _chat(db) values = harness_service.context_variables( db, _user(db), [], chat, chat_service.Speaker("the-other-model") ) assert values["model_name"] == "Other model" def test_the_personality_is_the_answering_model_s(db): chat = _chat(db) user = _user(db) settings_store.update(db, {"default_permissions": {"tools.persona": True}}) personas_service.write(db, model_key="the-chats-model", owner=user, content="I am the chat's.") personas_service.write(db, model_key="the-other-model", owner=user, content="I am the other.") offered = [ tool.schema for tool in tools_service.registry(db).values() if tools_service.gate_of(tool.family) == "persona" ] mine = harness_service.context_variables(db, user, offered, chat) theirs = harness_service.context_variables( db, user, offered, chat, chat_service.Speaker("the-other-model") ) assert mine["persona"] == "I am the chat's." assert theirs["persona"] == "I am the other." def test_a_tool_acts_as_the_answering_model(db): """`ToolContext.model_id` is which model a tool acts *as* -- whose personality `persona_write` rewrites, and whose endpoint the image reviewer reaches for.""" chat = _chat(db) context = tools_service.context_for( db, _user(db), chat, speaker=chat_service.Speaker("the-other-model", "abc") ) assert context.model_id == "the-other-model" assert context.connection_id == "abc" # --- The half-wired feature this closes --------------------------------------- async def test_a_schedule_naming_another_model_now_sends_it(db, monkeypatch): """`wake_chat(model_id=…)` wrote the override onto the row and `_run` ignored it. End to end: the turn goes in through the documented path, and the request built for the placeholder it created names the model the caller asked for.""" from lembas.services import generation as generation_service from lembas.services import wake as wake_service chat = _chat(db) monkeypatch.setattr(generation_service, "running_for", lambda chat_id: None) monkeypatch.setattr(generation_service, "ensure", lambda chat_id, message_id: None) message_id = await wake_service.wake_chat( chat.id, "Run the nightly summary", model_id="the-other-model" ) db.expire_all() from lembas.db.models import Message placeholder = db.get(Message, message_id) assert placeholder.model_id == "the-other-model" body = chat_service.build_request( db, db.get(Chat, chat.id), upto=placeholder, user=_user(db) ) assert body["model"] == "the-other-model"