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
7 changes: 6 additions & 1 deletion src/gateway/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { isCommand as _isCmd, isCommandWithArgs as _isCmdArgs, resolveAgentThrea
import { saveAttachments as _saveAttachments, type AttachmentResult } from "./attachments";
import { handleStreaming as _handleStream } from "./streaming";
import { handleNew, handleRestart, handleUpdate, handleCompact, handleStatus, handleStop, handleVerbose, handleDoctor, handleCrons, type CommandContext } from "./commands";
import { handleModel } from "./model-command";
import { handleModel, handleModelAction, MODEL_ACTION_ID } from "./model-command";
import { handleLater } from "./later-command";
import { TelegramAdapter } from "../transports";
import type { TransportAdapter } from "../transports";
Expand Down Expand Up @@ -327,6 +327,11 @@ export class Gateway {
await handleOrAbort(thread, message);
});

// ── Handle inline keyboard callbacks ───
this.chat.onAction(MODEL_ACTION_ID, async (event: any) => {
await handleModelAction({ value: event.value, thread: event.thread });
});

await this.chat.initialize();

const platforms = Object.keys(this.config.chat.adapters).join(", ");
Expand Down
119 changes: 101 additions & 18 deletions src/gateway/model-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,34 @@
*
* Allows switching the default AI model from Telegram.
* Reads/writes ~/.pi/agent/settings.json (defaultProvider + defaultModel).
*
* When called without arguments, shows an inline keyboard with model buttons.
* When a button is clicked, the onAction handler applies the selection.
*/

import { homedir } from "node:os";
import { join } from "node:path";
import { readFileSync, writeFileSync } from "node:fs";

/** Known model aliases → Bedrock model IDs */
const MODEL_ALIASES: Record<string, { provider: string; model: string; label: string }> = {
export const MODEL_ALIASES: Record<string, { provider: string; model: string; label: string }> = {
"opus": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-6", label: "Claude Opus 4.6" },
"opus-4.6": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-6", label: "Claude Opus 4.6" },
"opus-4.7": { provider: "amazon-bedrock", model: "us.anthropic.claude-opus-4-7", label: "Claude Opus 4.7" },
"sonnet": { provider: "amazon-bedrock", model: "us.anthropic.claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
"sonnet-4.6": { provider: "amazon-bedrock", model: "us.anthropic.claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
"haiku": { provider: "amazon-bedrock", model: "us.anthropic.claude-haiku-4-5", label: "Claude Haiku 4.5" },
Comment on lines +16 to 20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore versioned model aliases to avoid invalid model writes

Dropping versioned aliases (for example sonnet-4.6, haiku-4.5, opus-4.6) regresses existing /model <alias> inputs: these now miss MODEL_ALIASES and fall into the raw-ID branch in applyModelSelection, which writes values like defaultModel = "sonnet-4.6" even though Bedrock expects full IDs (for example us.anthropic...). Users who previously used these aliases will silently persist invalid model IDs.

Useful? React with 👍 / 👎.

"haiku-4.5": { provider: "amazon-bedrock", model: "us.anthropic.claude-haiku-4-5", label: "Claude Haiku 4.5" },
};

/** Models shown in the inline keyboard (short aliases only) */
const KEYBOARD_MODELS = ["opus-4.7", "opus", "sonnet", "haiku"] as const;

/** Action ID for model selection callbacks */
export const MODEL_ACTION_ID = "model_select";

const SETTINGS_PATH = join(homedir(), ".pi", "agent", "settings.json");

/** Callback data prefix used by @chat-adapter/telegram */
const CALLBACK_PREFIX = "chat:";

export interface ModelCommandContext {
thread: any;
text: string;
Expand All @@ -43,11 +52,25 @@ function writeSettings(settings: Record<string, any>): void {
function getCurrentModel(settings: Record<string, any>): string {
const provider = settings.defaultProvider ?? "unknown";
const model = settings.defaultModel ?? "unknown";
// Try to find a friendly label
for (const [alias, info] of Object.entries(MODEL_ALIASES)) {
if (info.provider === provider && info.model === model) return `${info.label} (${alias})`;
if (info.provider === provider && info.model === model) return `${info.label}`;
}
return `${provider}/${model}`;
return `${model}`;
}

function encodeCallbackData(actionId: string, value: string): string {
return `${CALLBACK_PREFIX}${JSON.stringify({ a: actionId, v: value })}`;
}

function buildInlineKeyboard(): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
const rows = KEYBOARD_MODELS.map(alias => {
const info = MODEL_ALIASES[alias];
return [{
text: info.label,
callback_data: encodeCallbackData(MODEL_ACTION_ID, alias),
}];
});
return { inline_keyboard: rows };
}

export async function handleModel(ctx: ModelCommandContext): Promise<void> {
Expand All @@ -57,31 +80,61 @@ export async function handleModel(ctx: ModelCommandContext): Promise<void> {

const settings = readSettings();

// No argument: show current model + available options
// No argument: show inline keyboard
if (!target) {
const current = getCurrentModel(settings);
const aliases = Object.entries(MODEL_ALIASES)
.filter(([alias]) => !alias.includes(".")) // Show short aliases only
.map(([alias, info]) => ` \`${alias}\` → ${info.label}`)
.join("\n");
const msgText = `🤖 Current model: <b>${current}</b>\n\nSelect a model:`;

// Try to send with inline keyboard via telegramFetch
const adapter = thread?.adapter;
if (adapter?.telegramFetch) {
const chatId = thread?.platformThreadId?.split(":")?.[0] ?? thread?.id?.split(":")?.[0];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse Telegram thread ID from the numeric segment

The inline-keyboard path derives chat_id with split(":")[0], but Telegram threads are encoded as telegram:<chatId> (see src/transports/telegram/telegram-adapter.ts:88-90, which reads index 1). In Telegram chats this makes chat_id become "telegram", so sendMessage fails and the new button UI always falls back to plain text instead of rendering clickable model buttons.

Useful? React with 👍 / 👎.

if (chatId) {
try {
await adapter.telegramFetch("sendMessage", {
chat_id: chatId,
text: msgText,
parse_mode: "HTML",
reply_markup: buildInlineKeyboard(),
});
return;
} catch (err) {
console.warn("[roundhouse] /model inline keyboard failed, falling back:", (err as Error).message);
}
}
}

// Fallback: plain text
const aliases = KEYBOARD_MODELS.map(a => ` \`${a}\` → ${MODEL_ALIASES[a].label}`).join("\n");
await postWithFallback(thread, `🤖 *Current model:* ${current}\n\n*Available:*\n${aliases}\n\n_Usage:_ \`/model sonnet\``);
return;
}

// Resolve alias or use as raw model ID
// Resolve alias
await applyModelSelection(target, settings, thread, postWithFallback);
}

/**
* Apply a model selection (used by both /model <arg> and inline keyboard callback).
*/
export async function applyModelSelection(
target: string,
settings: Record<string, any> | null,
thread: any,
postWithFallback: (thread: any, text: string) => Promise<void>,
): Promise<void> {
if (!settings) settings = readSettings();

const resolved = MODEL_ALIASES[target];
if (!resolved) {
// Check if it looks like a full model ID (contains a dot or slash)
if (target.includes(".") || target.includes("/")) {
// Use as-is with current provider
const provider = settings.defaultProvider ?? "amazon-bedrock";
settings.defaultModel = target;
settings.defaultProvider = provider;
writeSettings(settings);
await postWithFallback(thread, `✅ Model set to: \`${provider}/${target}\`\n\n⚠️ Restart needed: \`/restart\``);
await postWithFallback(thread, `✅ Model set to: \`${provider}/${target}\``);
} else {
const aliases = Object.keys(MODEL_ALIASES).filter(a => !a.includes(".")).join(", ");
const aliases = Object.keys(MODEL_ALIASES).join(", ");
await postWithFallback(thread, `❌ Unknown model: \`${target}\`\n\nAvailable: ${aliases}`);
}
return;
Expand All @@ -91,6 +144,36 @@ export async function handleModel(ctx: ModelCommandContext): Promise<void> {
settings.defaultModel = resolved.model;
writeSettings(settings);

await postWithFallback(thread, `✅ Model switched to: *${resolved.label}*\n\n⚠️ Takes effect on next agent turn (new sessions use new model).`);
await postWithFallback(thread, `✅ Switched to *${resolved.label}*`);
console.log(`[roundhouse] /model: switched to ${resolved.provider}/${resolved.model}`);
}

/**
* Handle inline keyboard callback for model selection.
* Call this from chat.onAction(MODEL_ACTION_ID, ...).
*/
export async function handleModelAction(event: {
value?: string;
thread: any;
}): Promise<void> {
const alias = event.value;
if (!alias) return;

const settings = readSettings();
const resolved = MODEL_ALIASES[alias];
if (!resolved) return;

settings.defaultProvider = resolved.provider;
settings.defaultModel = resolved.model;
writeSettings(settings);

// Post confirmation to the thread
if (event.thread) {
try {
await event.thread.post({ markdown: `✅ Switched to *${resolved.label}*` });
} catch {
try { await event.thread.post(`✅ Switched to ${resolved.label}`); } catch {}
}
}
console.log(`[roundhouse] /model (button): switched to ${resolved.provider}/${resolved.model}`);
}
Loading