From e07f49284cf30c983a4f4b218c14852dfce094a4 Mon Sep 17 00:00:00 2001 From: QueryPlanner Date: Fri, 23 Oct 2026 10:17:06 +0530 Subject: [PATCH 1/2] feat: select Telegram models by OpenRouter ID - Accept exact OpenRouter IDs in Telegram model commands - Replace preset buttons with usage and current settings - Preserve chat history and the configured deployment default - Test model routing, reset, isolation, and failure behavior --- CHANGELOG.md | 2 + docs/telegram-setup.md | 26 ++- src/blacki/telegram/bot.py | 22 +- src/blacki/telegram/settings_menu.py | 110 +++++----- tests/test_telegram_model_command.py | 289 +++++++++++++++++++++++++++ tests/test_telegram_settings_menu.py | 65 ++---- 6 files changed, 413 insertions(+), 101 deletions(-) create mode 100644 tests/test_telegram_model_command.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f38fb9..e0a384a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Select an exact OpenRouter model with Telegram `/model ` - Add a tested Material for MkDocs documentation site - Add a safe first-VPS deployment and operations contract - Add private Kokoro speech delivery for Telegram replies ### Changed +- Replace Telegram model presets with current settings and command usage - Make Docker Compose private, persistent, and health-checked by default - Enable the same-privilege ADK task worker by default - Restrict owner infrastructure deployment to the QueryPlanner repository diff --git a/docs/telegram-setup.md b/docs/telegram-setup.md index 19c7fce..f79cafa 100644 --- a/docs/telegram-setup.md +++ b/docs/telegram-setup.md @@ -170,7 +170,9 @@ Open the bot in Telegram and send: | --- | --- | | `/start` | Show the welcome message | | `/help` | Show the supported command summary | -| `/model` | Open the model and thinking settings panel | +| `/model ` | Use an exact OpenRouter model ID for the next turn | +| `/model default` | Restore the configured deployment model | +| `/model` or `/models` | Show the active model, command usage, and thinking settings | | `/thinking` | Open the supported reasoning-effort choices for the active model | | `/reset` | Start a fresh conversation session | | `/connect_health` | Send a Google Health authorization link and consent for optional meal sync | @@ -185,8 +187,26 @@ If Kokoro speech is configured, ask the bot to “send that as audio” and conf Telegram receives an MP3 in its native audio player. Kokoro failures return a text error and do not create a local audio file. -The `/model` panel stores one profile for the Telegram chat. Choose a model, -then select a thinking effort supported by that model. `Default` uses +Send `/model author/model-id` with the exact ID from +[OpenRouter](https://openrouter.ai/docs/api_reference/overview), for example +`/model openai/gpt-5.2`. The optional `openrouter/` transport prefix is also +accepted, as in `/model openrouter/openai/gpt-5.2`. Variant suffixes such as +`:free` are preserved. There is no preset model list. `/model` and `/models` +show the complete active ID and the thinking and reset controls. + +Blacki checks the ID's syntax and saves the selection; OpenRouter checks model +availability and account access when the next message runs. Custom selections +require `OPENROUTER_API_KEY` on the Blacki host. They reuse that configured +account and may change model costs, context limits, and tool or image support. + +`/model default` clears the chat override. The default remains `ROOT_AGENT_MODEL`, +which the deployment workflow takes from the GitHub Actions secret of the same +name. Reset settings also clears the model and explicit thinking choice. +Neither command writes a new deployment default. + +Blacki stores one profile for the Telegram chat, shared across its topics. +After selecting a model, choose a thinking effort supported by that model. +The thinking option `Default` uses `ROOT_AGENT_REASONING_EFFORT` when configured and otherwise leaves the provider's reasoning setting unchanged. `Off` is shown only when the model advertises that it can disable reasoning. Changes apply to the next turn and diff --git a/src/blacki/telegram/bot.py b/src/blacki/telegram/bot.py index 09aec8e..3284ae0 100644 --- a/src/blacki/telegram/bot.py +++ b/src/blacki/telegram/bot.py @@ -1422,10 +1422,16 @@ async def _handle_command(self, message: Message, command: str) -> None: await self._send_help_message(chat_id) elif command == "/reset": await self._handle_reset(chat_id, message.message_thread_id) - elif command == "/model": - await self._settings_menu.send_model_menu( - chat_id, message.message_thread_id - ) + elif command.split(maxsplit=1)[0].split("@", 1)[0] in {"/model", "/models"}: + parts = command.split(maxsplit=1) + if len(parts) == 2: + await self._settings_menu.set_model( + chat_id, message.message_thread_id, parts[1].strip() + ) + else: + await self._settings_menu.send_model_menu( + chat_id, message.message_thread_id + ) elif command == "/thinking": await self._settings_menu.send_thinking_menu( chat_id, message.message_thread_id @@ -1897,7 +1903,9 @@ async def _send_start_message(self, chat_id: int) -> None: "Commands:\n" "/help - Show available commands\n" "/reset - Start a fresh conversation session\n" - "/model - Choose the model and thinking settings\n" + "/model - Use an OpenRouter model\n" + "/model default - Restore the configured default\n" + "/models - Show model and thinking settings\n" "/thinking - Choose supported reasoning effort" f"{gmail_commands}" f"{health_commands}" @@ -1932,7 +1940,9 @@ async def _send_help_message(self, chat_id: int) -> None: "• /start \\- Start a conversation\n" "• /help \\- Show this help message\n" "• /reset \\- Start a fresh conversation session\n" - "• /model \\- Choose the model and thinking settings\n" + "• /model author/model\\-id \\- Use an OpenRouter model\n" + "• /model default \\- Restore the configured default\n" + "• /models \\- Show model and thinking settings\n" "• /thinking \\- Choose supported reasoning effort\n" f"{gmail_commands}" f"{health_commands}\n" diff --git a/src/blacki/telegram/settings_menu.py b/src/blacki/telegram/settings_menu.py index 71d194f..4c98be4 100644 --- a/src/blacki/telegram/settings_menu.py +++ b/src/blacki/telegram/settings_menu.py @@ -3,8 +3,8 @@ import contextlib import logging import os +import re from collections.abc import Awaitable, Callable, Sequence -from typing import cast from blacki.models.capabilities import ( ModelCapabilities, @@ -16,6 +16,7 @@ ReasoningEffort, update_inference_profile, ) +from blacki.models.openrouter_ids import canonical_openrouter_model_id from blacki.utils.preferences import get_preferences_storage from .api import TelegramApiClient @@ -24,20 +25,10 @@ logger = logging.getLogger(__name__) -MODEL_CHOICES = { - "m1": ("openrouter/openai/gpt-oss-120b", "GPT-OSS 120B"), - "m2": ("openrouter/x-ai/grok-4.3", "Grok 4.3"), - "m3": ("google/gemini-flash-latest", "Gemini Flash"), - "m4": ("openrouter/deepseek/deepseek-v4-pro", "DeepSeek v4 Pro"), - "m5": ("openrouter/deepseek/deepseek-v4-flash", "DeepSeek v4 Flash"), - "m6": ("google/gemini-pro-latest", "Gemini Pro"), - "m7": ("moonshotai/kimi-latest", "Kimi Latest"), - "m8": ("openrouter/minimax/minimax-m2.7", "MiniMax m2.7"), - "m9": ("openrouter/nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"), - "m10": ("openrouter/z-ai/glm-5", "GLM 5"), - "m11": ("openrouter/openai/gpt-5.6-luna", "GPT-5.6 Luna"), - "m_default": ("default", "System Default"), -} +_OPENROUTER_MODEL_ID = re.compile( + r"[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._:-]*" +) +_MODEL_USAGE = "Use /model or /model default." _SETTINGS_MODEL_PREFIX = "s:m:" _SETTINGS_REASONING_PREFIX = "s:r:" @@ -122,6 +113,55 @@ async def send_thinking_menu( except Exception: logger.exception("Failed to send thinking menu") + async def set_model( + self, chat_id: int, message_thread_id: int | None, model_id: str + ) -> None: + """Save an exact OpenRouter ID for subsequent turns in this chat.""" + selected: str | None = None + if model_id != "default": + canonical = canonical_openrouter_model_id(model_id) + if _OPENROUTER_MODEL_ID.fullmatch(canonical) is None: + await self._send_model_result(chat_id, message_thread_id, _MODEL_USAGE) + return + if not os.getenv("OPENROUTER_API_KEY", "").strip(): + await self._send_model_result( + chat_id, + message_thread_id, + "OpenRouter is not configured. Set OPENROUTER_API_KEY " + "before selecting an OpenRouter model.", + ) + return + selected = f"openrouter/{canonical}" + + try: + await update_inference_profile( + get_preferences_storage(), + str(chat_id), + {"model": selected, "reasoning": None}, + ) + except Exception: + logger.exception("Failed to save Telegram model selection") + text = "Could not save settings. Please try again." + else: + effective = self._effective_model(InferenceProfile(model=selected)) + display_name = self._model_display_name(effective) + text = ( + f"Model saved for the next turn: {display_name}\n" + "Conversation history is preserved. Thinking uses the default setting." + ) + await self._send_model_result(chat_id, message_thread_id, text) + + async def _send_model_result( + self, chat_id: int, message_thread_id: int | None, text: str + ) -> None: + """Report command results without confusing delivery and save failures.""" + try: + await self._api.send_message( + chat_id=chat_id, text=text, message_thread_id=message_thread_id + ) + except Exception: + logger.exception("Failed to send model selection result") + @staticmethod def _chunk_buttons( buttons: Sequence[InlineKeyboardButton], per_row: int = 2 @@ -137,14 +177,7 @@ def _build_model_menu( current_display_name = self._model_display_name(effective_model) current_thinking = self._reasoning_display(profile) - model_buttons = [ - InlineKeyboardButton( - text=display_name, - callback_data=f"{_SETTINGS_MODEL_PREFIX}{key}", - ) - for key, (_, display_name) in MODEL_CHOICES.items() - ] - buttons = self._chunk_buttons(model_buttons) + buttons: list[list[InlineKeyboardButton]] = [] buttons.append( [ @@ -166,7 +199,8 @@ def _build_model_menu( "⚙️ **Inference settings**\n\n" f"Model: **{current_display_name}**\n" f"Thinking: **{current_thinking}**\n\n" - "Choose a model or adjust Thinking. Changes apply to the next turn." + f"{_MODEL_USAGE}\n" + "Copy the exact ID from OpenRouter. Changes apply to the next turn." ) return text, InlineKeyboardMarkup(inline_keyboard=buttons) @@ -220,8 +254,10 @@ async def handle_callback(self, query: CallbackQuery) -> None: await self._api.answer_callback_query(query.id, text="Unknown action") return - if action == "model" and (value is None or value not in MODEL_CHOICES): - await self._api.answer_callback_query(query.id, text="Unknown model") + if action == "model" and value != "m_default": + await self._api.answer_callback_query( + query.id, text="Model preset expired. Use /model ." + ) return if action == "reasoning" and value not in { _INHERIT_REASONING, @@ -241,19 +277,6 @@ async def handle_callback(self, query: CallbackQuery) -> None: try: storage = get_preferences_storage() - if action == "model": - model_id, _ = MODEL_CHOICES[cast(str, value)] - await update_inference_profile( - storage, - str(chat_id), - { - "model": None if model_id == "default" else model_id, - "reasoning": None, - }, - ) - await self._edit_model_menu(query, chat_id) - return - if action == "reasoning": profile = await self._load_profile(chat_id) capability = await self._resolve_capabilities( @@ -279,7 +302,7 @@ async def handle_callback(self, query: CallbackQuery) -> None: await self._edit_model_menu(query, chat_id) return - if action == "reset": + if action in {"reset", "model"}: await update_inference_profile( storage, str(chat_id), @@ -389,13 +412,10 @@ def _effective_model(profile: InferenceProfile) -> str: @staticmethod def _model_display_name(model_id: str) -> str: - """Return a friendly label while preserving unknown model IDs.""" - for configured_id, display_name in MODEL_CHOICES.values(): - if configured_id == model_id: - return display_name + """Show the exact API ID, including its provider and variant.""" if model_id == "default": return "System Default" - return model_id.rsplit("/", 1)[-1] + return canonical_openrouter_model_id(model_id) @staticmethod def _effort_value(value: object) -> str | None: diff --git a/tests/test_telegram_model_command.py b/tests/test_telegram_model_command.py new file mode 100644 index 0000000..0266796 --- /dev/null +++ b/tests/test_telegram_model_command.py @@ -0,0 +1,289 @@ +# mypy: ignore-errors +"""Telegram model commands through real preferences, ADK, and provider transport.""" + +import asyncio +import json +from collections.abc import AsyncIterator +from pathlib import Path +from unittest.mock import create_autospec + +import aiosqlite +import httpx +import pytest +from google.adk.agents import LlmAgent +from google.adk.apps import App +from google.adk.sessions import InMemorySessionService +from openai import AsyncOpenAI + +from blacki.agent import TelegramModelOverridePlugin +from blacki.models.inference import ( + INFERENCE_PROFILE_PREFERENCE_KEY, + LEGACY_MODEL_PREFERENCE_KEY, + load_inference_profile, +) +from blacki.models.openrouter import OpenRouterLlm +from blacki.runtime.adk import AdkRuntime, SessionLocator +from blacki.telegram import TelegramConfig +from blacki.telegram.api import TelegramApiClient +from blacki.telegram.bot import TelegramBot +from blacki.telegram.types import CallbackQuery, Chat, Message, Update, User +from blacki.utils.preferences import SqlitePreferencesStorage + + +@pytest.fixture +async def command_harness( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[tuple[TelegramBot, SqlitePreferencesStorage, list[dict]]]: + monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") + monkeypatch.setenv("ROOT_AGENT_MODEL", "openrouter/acme/deployment-default") + monkeypatch.delenv("ROOT_AGENT_REASONING_EFFORT", raising=False) + records: list[dict] = [] + + def handle(request: httpx.Request) -> httpx.Response: + records.append(json.loads(request.content)) + return httpx.Response( + 200, + json={ + "id": "completion-test", + "model": records[-1]["model"], + "choices": [ + { + "message": {"role": "assistant", "content": "Remembered."}, + "finish_reason": "stop", + } + ], + }, + ) + + def client_factory(timeout: float, max_retries: int) -> AsyncOpenAI: + return AsyncOpenAI( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + timeout=timeout, + max_retries=max_retries, + http_client=httpx.AsyncClient(transport=httpx.MockTransport(handle)), + ) + + model = OpenRouterLlm( + model="openrouter/acme/deployment-default", + api_key="test-key", + ledger_path=tmp_path / "usage.db", + client_factory=client_factory, + ) + runtime = AdkRuntime( + InMemorySessionService(), + agent_app=App( + name="model_command_test", + root_agent=LlmAgent(name="root_agent", model=model), + plugins=[TelegramModelOverridePlugin()], + ), + ) + bot = TelegramBot( + TelegramConfig(telegram_enabled=True, telegram_bot_token="test-token"), runtime + ) + bot._api = create_autospec(TelegramApiClient, instance=True, spec_set=True) + async with aiosqlite.connect( + tmp_path / "preferences.db", isolation_level=None + ) as conn: + conn.row_factory = aiosqlite.Row + storage = SqlitePreferencesStorage(conn, asyncio.Lock()) + await storage.initialize() + monkeypatch.setattr( + "blacki.telegram.bot.get_preferences_storage", lambda: storage + ) + monkeypatch.setattr( + "blacki.telegram.settings_menu.get_preferences_storage", lambda: storage + ) + yield bot, storage, records + await bot._settings_menu.aclose() + await storage.close() + + +async def send(bot: TelegramBot, text: str, *, chat_id: int = 123) -> None: + await bot._handle_update( + Update( + update_id=1, + message=Message( + message_id=42, + date="2026-10-23T00:00:00Z", + chat=Chat(id=chat_id, type="private"), + message_thread_id=7, + text=text, + ), + ) + ) + + +@pytest.mark.parametrize( + ("command", "api_model"), + [ + ("/model acme/new-model", "acme/new-model"), + ("/model openrouter/acme/new-model:free", "acme/new-model:free"), + ("/model openrouter/auto", "openrouter/auto"), + ("/model openrouter/free", "openrouter/free"), + ("/model@blacki_bot\t Acme/Model-v1.2:extended ", "Acme/Model-v1.2:extended"), + ], +) +async def test_selection_reaches_provider_with_existing_history( + command_harness, command: str, api_model: str +) -> None: + bot, storage, records = command_harness + await send(bot, "Remember the word apricot") + locator = SessionLocator( + user_id="telegram-chat-123-thread-7", + session_id_prefix="telegram-chat-123-thread-7", + ) + original = await bot.runtime.get_or_create_session(locator=locator) + await storage.set( + "123", INFERENCE_PROFILE_PREFERENCE_KEY, {"reasoning": {"effort": "high"}} + ) + + await send(bot, command) + saved = await load_inference_profile(storage, "123") + assert saved.model == f"openrouter/{api_model}" + assert saved.reasoning is None + assert (await load_inference_profile(storage, "456")).model is None + assert "next turn" in bot.api.send_message.await_args.kwargs["text"] + assert bot.api.send_message.await_args.kwargs["message_thread_id"] == 7 + + await send(bot, "What word did I ask you to remember?") + + assert [request["model"] for request in records] == [ + "acme/deployment-default", + api_model, + ] + assert any( + message["role"] == "user" and "apricot" in str(message["content"]) + for message in records[-1]["messages"] + ) + assert (await bot.runtime.get_or_create_session(locator=locator)).id == original.id + + +@pytest.mark.parametrize("command", ["/model", "/models", "/model "]) +async def test_panel_has_usage_and_no_preset_models( + command_harness, command: str +) -> None: + bot, storage, records = command_harness + await send(bot, command) + reply = bot.api.send_message.await_args.kwargs + assert "acme/deployment" in reply["text"] + assert "/model" in reply["text"] + assert [ + button.callback_data + for row in reply["reply_markup"].inline_keyboard + for button in row + ] == ["s:t", "s:x"] + assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) is None + assert records == [] + + +@pytest.mark.parametrize( + "model_id", + [ + "model-name", + "acme/", + "/model", + "acme/model extra", + "acme/model\nextra", + "https://example.com/model", + "acme/model/extra", + "../model", + "acme/..", + "acme/model\x00", + "acme/model?key=value", + ], +) +async def test_invalid_model_leaves_preferences_unchanged( + command_harness, model_id +) -> None: + bot, storage, records = command_harness + await send(bot, f"/model {model_id}") + assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) is None + assert "Use /model" in bot.api.send_message.await_args.kwargs["text"] + assert records == [] + + +async def test_default_uses_deployment_model_and_suppresses_legacy_override( + command_harness, monkeypatch: pytest.MonkeyPatch +) -> None: + bot, storage, records = command_harness + await storage.set("123", LEGACY_MODEL_PREFERENCE_KEY, "openrouter/acme/legacy") + await send(bot, "/model acme/selected") + monkeypatch.setenv("ROOT_AGENT_REASONING_EFFORT", "low") + await send(bot, "/model default") + assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) == { + "model": None, + "reasoning": None, + } + profile = await load_inference_profile(storage, "123") + assert profile.model is None + assert profile.reasoning.effort == "low" + await send(bot, "Use the configured default") + assert records[-1]["model"] == "acme/deployment-default" + + +@pytest.mark.parametrize("key", ["", " "]) +async def test_missing_openrouter_key_preserves_selection_and_allows_default( + command_harness, monkeypatch: pytest.MonkeyPatch, key: str +) -> None: + bot, storage, _ = command_harness + await send(bot, "/model acme/selected") + monkeypatch.setenv("OPENROUTER_API_KEY", key) + await send(bot, "/model acme/unusable") + assert ( + await load_inference_profile(storage, "123") + ).model == "openrouter/acme/selected" + assert "OPENROUTER_API_KEY" in bot.api.send_message.await_args.kwargs["text"] + await send(bot, "/model default") + assert (await load_inference_profile(storage, "123")).model is None + + +async def test_save_failure_reports_error(command_harness) -> None: + bot, storage, _ = command_harness + await storage.conn.execute("PRAGMA query_only = ON") + await send(bot, "/model acme/selected") + assert "Could not save" in bot.api.send_message.await_args.kwargs["text"] + assert (await load_inference_profile(storage, "123")).model is None + + +async def test_reply_failure_does_not_undo_saved_model(command_harness, caplog) -> None: + bot, storage, _ = command_harness + bot.api.send_message.side_effect = RuntimeError("Telegram unavailable") + await send(bot, "/model acme/selected") + assert ( + await load_inference_profile(storage, "123") + ).model == "openrouter/acme/selected" + assert "Could not save" not in caplog.text + assert "Failed to send model selection result" in caplog.text + + +@pytest.mark.parametrize("data", ["mod:m1", "s:m:m11", "s:m:unknown"]) +async def test_expired_preset_cannot_replace_current_selection( + command_harness, data +) -> None: + bot, storage, _ = command_harness + await send(bot, "/model acme/selected") + await bot._settings_menu.handle_callback( + CallbackQuery( + id="old-preset", + from_user=User(id=123, is_bot=False, first_name="Test"), + chat_instance="instance", + data=data, + message=Message( + message_id=1, + date="2026-10-23T00:00:00Z", + chat=Chat(id=123, type="private"), + ), + ) + ) + assert ( + await load_inference_profile(storage, "123") + ).model == "openrouter/acme/selected" + assert "expired" in bot.api.answer_callback_query.await_args.kwargs["text"] + + +async def test_similar_command_does_not_select_model(command_harness) -> None: + bot, storage, _ = command_harness + await send(bot, "/modelx acme/selected") + assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) is None + bot.api.send_message.assert_not_awaited() diff --git a/tests/test_telegram_settings_menu.py b/tests/test_telegram_settings_menu.py index 97a70e7..7df5428 100644 --- a/tests/test_telegram_settings_menu.py +++ b/tests/test_telegram_settings_menu.py @@ -33,7 +33,7 @@ @pytest.fixture def mock_api() -> MagicMock: - return create_autospec(TelegramApiClient, instance=True) + return create_autospec(TelegramApiClient, instance=True, spec_set=True) @pytest.fixture @@ -72,19 +72,6 @@ async def test_send_model_menu_exception( @pytest.mark.asyncio -@patch( - "blacki.telegram.settings_menu.MODEL_CHOICES", - {"m1": ("m1", "M1"), "m2": ("m2", "M2")}, -) -async def test_send_model_menu_even_choices( - menu: SettingsMenu, mock_api: MagicMock -) -> None: - await menu.send_model_menu(chat_id=123, message_thread_id=None) - mock_api.send_message.assert_called_once() - - -@pytest.mark.asyncio -@patch("blacki.telegram.settings_menu.MODEL_CHOICES", {"m1": ("m1", "M1")}) async def test_send_model_menu_unknown_current_model( menu: SettingsMenu, mock_api: MagicMock, load_profile: AsyncMock ) -> None: @@ -131,34 +118,9 @@ async def test_handle_callback_query_unknown_model( id="cq1", from_user=user, chat_instance="inst", data="mod:unknown" ) await menu.handle_callback(cq) - mock_api.answer_callback_query.assert_called_once_with("cq1", text="Unknown model") - - -@pytest.mark.asyncio -@patch("blacki.telegram.settings_menu.get_preferences_storage") -async def test_handle_callback_query_valid_model( - mock_get_prefs, menu: SettingsMenu, mock_api: MagicMock -) -> None: - mock_storage = AsyncMock() - mock_get_prefs.return_value = mock_storage - user = User(id=1, is_bot=False, first_name="Test") - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=msg - ) - - with patch( - "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() - ) as mock_update: - await menu.handle_callback(cq) - mock_update.assert_awaited_once_with( - mock_storage, - "123", - {"model": "openrouter/openai/gpt-oss-120b", "reasoning": None}, + mock_api.answer_callback_query.assert_called_once_with( + "cq1", text="Model preset expired. Use /model ." ) - mock_api.answer_callback_query.assert_called_once() - mock_api.edit_message_text.assert_called_once() @pytest.mark.asyncio @@ -201,7 +163,11 @@ async def test_handle_callback_query_edit_msg_exception( chat = Chat(id=123, type="private") msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=msg + id="cq1", + from_user=user, + chat_instance="inst", + data="mod:m_default", + message=msg, ) mock_api.edit_message_text.side_effect = Exception("fail") @@ -231,7 +197,11 @@ async def test_message_less_settings_mutation_does_not_write( ) -> None: user = User(id=1, is_bot=False, first_name="Test") cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="s:m:m1", message=None + id="cq1", + from_user=user, + chat_instance="inst", + data="s:m:m_default", + message=None, ) with patch( @@ -593,7 +563,7 @@ async def test_callback_model_none_value_is_rejected_without_write( update.assert_not_awaited() assert mock_api.answer_callback_query.await_args_list[-1].kwargs["text"] == ( - "Unknown model" + "Model preset expired. Use /model ." ) @@ -803,12 +773,13 @@ async def test_resolve_capabilities_uses_cached_resolver(menu: SettingsMenu) -> def test_model_display_name_handles_unknown_future_model(menu: SettingsMenu) -> None: - assert menu._model_display_name("openrouter/acme/future-model") == "future-model" + assert ( + menu._model_display_name("openrouter/acme/future-model") == "acme/future-model" + ) def test_model_display_name_handles_system_default(menu: SettingsMenu) -> None: - with patch("blacki.telegram.settings_menu.MODEL_CHOICES", {}): - assert menu._model_display_name("default") == "System Default" + assert menu._model_display_name("default") == "System Default" def test_reasoning_display_inherits_when_only_token_budget_is_set( From 577717d687089f7aecc728f1e1a7deca8343fb23 Mon Sep 17 00:00:00 2001 From: QueryPlanner Date: Fri, 23 Oct 2026 10:28:42 +0530 Subject: [PATCH 2/2] fix: preserve queued Telegram model updates - Protect model-setting commands from prompt supersession - Close queued turn coroutines when cancellation skips them - Verify rapid prompts wait for model selection and reset --- src/blacki/telegram/bot.py | 21 +++++++-- tests/test_telegram_model_command.py | 68 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/blacki/telegram/bot.py b/src/blacki/telegram/bot.py index 3284ae0..0c60d22 100644 --- a/src/blacki/telegram/bot.py +++ b/src/blacki/telegram/bot.py @@ -90,6 +90,14 @@ def _is_image_document(mime_type: str | None) -> bool: return (mime_type or "").strip().lower().startswith("image/") +def _model_command_argument(text: str) -> str | None: + """Parse model commands consistently for dispatch and turn ordering.""" + parts = text.split(maxsplit=1) + if parts and parts[0].split("@", 1)[0] in {"/model", "/models"}: + return parts[1].strip() if len(parts) == 2 else "" + return None + + def _format_google_health_sync_counts(value: object) -> str: """Render safe durable meal-sync counts without provider details.""" if not isinstance(value, Mapping): @@ -402,6 +410,8 @@ async def _safe_handle_update(self, update: Update) -> None: ) else "text" ) + if _model_command_argument(update.message.text or ""): + turn_kind = "settings" for active_album in self._album_buffer.get_active(chat_id, message_thread_id): if active_album.future is not None: @@ -607,6 +617,7 @@ async def _run_sequenced_turn( existing_task is not None and not existing_task.done() and seq >= existing_seq + and existing_kind != "settings" and not (turn_kind == "media" and existing_kind == "media") ) if supersedes_existing and existing_task is not None: @@ -636,6 +647,8 @@ async def _run_sequenced_turn( logger.info("Message turn superseded for conversation %s", conversation_key) raise finally: + # A queued turn may be cancelled before the lock lets us await it. + turn.close() if self._conversation_tasks.get(conversation_key) is current_task: self._conversation_tasks.pop(conversation_key, None) self._conversation_task_seqs.pop(conversation_key, None) @@ -1415,6 +1428,7 @@ async def _download_and_validate_photo(self, file_id: str) -> bytes: async def _handle_command(self, message: Message, command: str) -> None: """Handle a command message.""" chat_id = message.chat.id + model_argument = _model_command_argument(command) if command == "/start": await self._send_start_message(chat_id) @@ -1422,11 +1436,10 @@ async def _handle_command(self, message: Message, command: str) -> None: await self._send_help_message(chat_id) elif command == "/reset": await self._handle_reset(chat_id, message.message_thread_id) - elif command.split(maxsplit=1)[0].split("@", 1)[0] in {"/model", "/models"}: - parts = command.split(maxsplit=1) - if len(parts) == 2: + elif model_argument is not None: + if model_argument: await self._settings_menu.set_model( - chat_id, message.message_thread_id, parts[1].strip() + chat_id, message.message_thread_id, model_argument ) else: await self._settings_menu.send_model_menu( diff --git a/tests/test_telegram_model_command.py b/tests/test_telegram_model_command.py index 0266796..989e1ea 100644 --- a/tests/test_telegram_model_command.py +++ b/tests/test_telegram_model_command.py @@ -287,3 +287,71 @@ async def test_similar_command_does_not_select_model(command_harness) -> None: await send(bot, "/modelx acme/selected") assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) is None bot.api.send_message.assert_not_awaited() + + +@pytest.mark.parametrize("prompt_count", [1, 2]) +@pytest.mark.parametrize( + ("command", "expected_model"), + [ + ("/model acme/new-model", "acme/new-model"), + ("/models@blacki_bot acme/new-model", "acme/new-model"), + ("/model default", "acme/deployment-default"), + ], +) +async def test_rapid_prompt_waits_for_model_save( + command_harness, command: str, expected_model: str, prompt_count: int +) -> None: + bot, storage, records = command_harness + await send(bot, "Remember apricot") + await send(bot, "/model acme/previous") + bot.api.send_message.reset_mock() + + def update(seq: int, text: str) -> Update: + return Update( + update_id=seq, + message=Message( + message_id=seq, + date="2026-10-23T00:00:00Z", + chat=Chat(id=123, type="private"), + message_thread_id=7, + text=text, + ), + ) + + # Hold the real transaction lock so the command pauses before its write. + tasks = [] + try: + async with storage._lock: + command_task = asyncio.create_task( + bot._safe_handle_update(update(10, command)) + ) + tasks.append(command_task) + await asyncio.sleep(0) + for seq in range(11, 11 + prompt_count): + tasks.append( + asyncio.create_task( + bot._safe_handle_update(update(seq, "Recall apricot")) + ) + ) + await asyncio.sleep(0) + assert records[-1]["model"] == "acme/deployment-default" + + results = await asyncio.wait_for( + asyncio.gather(*tasks, return_exceptions=True), timeout=5 + ) + assert results[0] is None + assert results[-1] is None + assert records[-1]["model"] == expected_model + assert any( + message["role"] == "user" and "Remember apricot" in str(message["content"]) + for message in records[-1]["messages"] + ) + assert any( + "Model saved for the next turn" in call.kwargs["text"] + for call in bot.api.send_message.await_args_list + ) + finally: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True)