Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <author/model-id>`
- 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
Expand Down
26 changes: 23 additions & 3 deletions docs/telegram-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <author/model-id>` | 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 |
Expand All @@ -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
Expand Down
35 changes: 29 additions & 6 deletions src/blacki/telegram/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1415,17 +1428,23 @@ 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)
elif command == "/help":
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 model_argument is not None:
if model_argument:
await self._settings_menu.set_model(
Comment thread
QueryPlanner marked this conversation as resolved.
Resolved
chat_id, message.message_thread_id, model_argument
)
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
Expand Down Expand Up @@ -1897,7 +1916,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 <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"
f"{gmail_commands}"
f"{health_commands}"
Expand Down Expand Up @@ -1932,7 +1953,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"
Expand Down
110 changes: 65 additions & 45 deletions src/blacki/telegram/settings_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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 <author/model-id> or /model default."

_SETTINGS_MODEL_PREFIX = "s:m:"
_SETTINGS_REASONING_PREFIX = "s:r:"
Expand Down Expand Up @@ -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
Expand All @@ -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(
[
Expand All @@ -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)

Expand Down Expand Up @@ -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 <author/model-id>."
)
return
if action == "reasoning" and value not in {
_INHERIT_REASONING,
Expand All @@ -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(
Expand All @@ -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),
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading