feat(redstone): /model picker (OpenRouter) + fix /clear leaving its own reply

Two changes:

1) /model — admin-only Telegram command that fetches GET
   https://openrouter.ai/api/v1/models, filters to models whose
   supported_parameters includes "tools", pins openrouter/free and
   openrouter/auto to the top, then sorts the rest free-first /
   alpha-by-name. Renders a vertically-stacked InlineKeyboard
   (one model per row, capped at 20) with badges:

      <current>   🟢 free / 💰 paid   🛠 tools   🧠 thinking

   Tap-to-select callback writes the model id into the new
   settings(key,value) table. callOpenRouterOnce resolves the
   model at every call (currentOpenRouterModel() → kvGet ??
   OPENROUTER_MODEL env ?? "openrouter/free"), so the new pick
   takes effect on the very next message — no restart, no
   container rebuild.

2) /clear — was leaving its own confirmation message ("🧹 cleared
   N messages from this chat.") in the DB, so the next /clear
   would count it. Now wipes once, replies, wipes again — the
   chat's messages row count truly stays at 0 across repeats.

Schema migration: CREATE TABLE IF NOT EXISTS settings(key, value,
updated_at) — idempotent, no Bun migration needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 00:00:08 +02:00
parent 7217e3553b
commit aae9ddd429

View File

@@ -103,6 +103,11 @@ db.exec(`
); );
CREATE INDEX IF NOT EXISTS idx_sticker_library_last_seen CREATE INDEX IF NOT EXISTS idx_sticker_library_last_seen
ON sticker_library(last_seen DESC); ON sticker_library(last_seen DESC);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`); `);
// Idempotent migrations. // Idempotent migrations.
@@ -592,6 +597,121 @@ async function renderConfirm(ctx: Context, action: string, prompt: string) {
return editPanel(ctx, "main", text, kb); return editPanel(ctx, "main", text, kb);
} }
// ---- OpenRouter model picker ----
type ORModelListItem = {
id: string;
name?: string;
description?: string;
pricing?: { prompt?: string; completion?: string };
supported_parameters?: string[];
context_length?: number;
};
function isFreeModel(m: ORModelListItem): boolean {
if (m.id.endsWith(":free") || m.id === "openrouter/free") return true;
const p = Number.parseFloat(m.pricing?.prompt ?? "");
const c = Number.parseFloat(m.pricing?.completion ?? "");
return Number.isFinite(p) && Number.isFinite(c) && p === 0 && c === 0;
}
async function fetchOpenRouterModels(): Promise<ORModelListItem[]> {
const headers: Record<string, string> = { Accept: "application/json" };
if (OPENROUTER_API_KEY) headers.Authorization = `Bearer ${OPENROUTER_API_KEY}`;
const res = await fetch("https://openrouter.ai/api/v1/models", {
headers,
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`openrouter /models -> ${res.status}`);
const data = (await res.json()) as { data?: ORModelListItem[] };
return data.data ?? [];
}
function modelButtonLabel(m: ORModelListItem, isCurrent: boolean): string {
const free = isFreeModel(m);
const thinking = m.supported_parameters?.includes("reasoning") ?? false;
const flag = isCurrent ? "✅ " : "";
// 🟢 free / 💰 paid · 🛠 tools (guaranteed by filter) · 🧠 thinking (or
// a half-width spacer when absent so columns stay aligned in the keyboard).
const badges = `${free ? "🟢" : "💰"}🛠${thinking ? "🧠" : "·"}`;
const name = (m.name || m.id).replace(/\s*\(free\)\s*$/i, "");
return `${flag}${badges} ${name}`.slice(0, 64);
}
async function renderModelPicker(ctx: Context) {
if (!OPENROUTER_API_KEY) {
await sendText(ctx, "OpenRouter is not configured — set OPENROUTER_API_KEY to enable model switching.");
return;
}
const current = currentOpenRouterModel();
let models: ORModelListItem[];
try {
models = await fetchOpenRouterModels();
} catch (e) {
await sendText(ctx, `Couldn't load model list: ${(e as Error).message}`);
return;
}
// Must support tool calling — Redstone won't work otherwise.
const usable = models.filter((m) => m.supported_parameters?.includes("tools"));
// Router models (auto-pickers) pinned to the top, then alpha by display name.
const ROUTER_RE = /^openrouter\/(free|auto)\b/;
const routers = usable.filter((m) => ROUTER_RE.test(m.id));
const rest = usable.filter((m) => !ROUTER_RE.test(m.id))
.sort((a, b) => {
const af = isFreeModel(a), bf = isFreeModel(b);
if (af !== bf) return af ? -1 : 1;
return (a.name || a.id).localeCompare(b.name || b.id);
});
const list = [...routers, ...rest].slice(0, 20); // Telegram keyboard practicality
if (list.length === 0) {
await sendText(ctx, "No tool-capable models returned by OpenRouter.");
return;
}
const kb = new InlineKeyboard();
for (const m of list) {
kb.text(modelButtonLabel(m, m.id === current), `model:${m.id}`).row();
}
const header =
`<b>🧠 LLM model picker</b>\n\n` +
`Active: <code>${escape(current)}</code>\n\n` +
`🟢 free · 💰 paid · 🛠 tools · 🧠 thinking`;
await sendText(ctx, header, kb);
}
async function selectModel(ctx: Context, modelId: string) {
const c = ctx as Context & { user?: User };
if (c.user?.role !== "admin") {
await ctx.answerCallbackQuery({ text: "admin only", show_alert: true });
return;
}
kvSet("llm_model", modelId);
await ctx.answerCallbackQuery({ text: `model: ${modelId}` });
// Re-render the picker so the ✅ moves to the new pick.
try {
const models = await fetchOpenRouterModels();
const usable = models.filter((m) => m.supported_parameters?.includes("tools"));
const ROUTER_RE = /^openrouter\/(free|auto)\b/;
const routers = usable.filter((m) => ROUTER_RE.test(m.id));
const rest = usable.filter((m) => !ROUTER_RE.test(m.id))
.sort((a, b) => {
const af = isFreeModel(a), bf = isFreeModel(b);
if (af !== bf) return af ? -1 : 1;
return (a.name || a.id).localeCompare(b.name || b.id);
});
const list = [...routers, ...rest].slice(0, 20);
const kb = new InlineKeyboard();
for (const m of list) {
kb.text(modelButtonLabel(m, m.id === modelId), `model:${m.id}`).row();
}
const header =
`<b>🧠 LLM model picker</b>\n\n` +
`Active: <code>${escape(modelId)}</code>\n\n` +
`🟢 free · 💰 paid · 🛠 tools · 🧠 thinking`;
await ctx.editMessageText(header, { parse_mode: "HTML", reply_markup: kb });
} catch {
/* leave keyboard as-is if refresh fails */
}
}
// ---- Action helpers ---- // ---- Action helpers ----
async function answerToast(ctx: Context, text: string) { async function answerToast(ctx: Context, text: string) {
if (ctx.callbackQuery) await ctx.answerCallbackQuery({ text }).catch(() => {}); if (ctx.callbackQuery) await ctx.answerCallbackQuery({ text }).catch(() => {});
@@ -659,6 +779,8 @@ bot.on("callback_query:data", async (ctx) => {
if (data === "act:logs:srv") return void await renderLogs(ctx, "srv"); if (data === "act:logs:srv") return void await renderLogs(ctx, "srv");
if (data === "act:logs:pf") return void await renderLogs(ctx, "pf"); if (data === "act:logs:pf") return void await renderLogs(ctx, "pf");
if (data.startsWith("model:")) return void await selectModel(ctx, data.slice("model:".length));
if (data === "info:adduser") { if (data === "info:adduser") {
await ctx.answerCallbackQuery({ text: "use /adduser @username [name] (or numeric id, or @me)", show_alert: true }); await ctx.answerCallbackQuery({ text: "use /adduser @username [name] (or numeric id, or @me)", show_alert: true });
return; return;
@@ -859,6 +981,10 @@ bot.command("clear", async (ctx) => {
).get(chatId)?.n ?? 0; ).get(chatId)?.n ?? 0;
db.query<unknown, [number]>("DELETE FROM messages WHERE chat_id = ?").run(chatId); db.query<unknown, [number]>("DELETE FROM messages WHERE chat_id = ?").run(chatId);
await sendText(ctx, `🧹 cleared <b>${before}</b> message${before === 1 ? "" : "s"} from this chat.`); await sendText(ctx, `🧹 cleared <b>${before}</b> message${before === 1 ? "" : "s"} from this chat.`);
// sendText auto-logs the confirmation back into messages → the next /clear
// would otherwise count it as a leftover. Wipe again so the chat is truly
// empty afterwards and subsequent /clear keeps returning 0.
db.query<unknown, [number]>("DELETE FROM messages WHERE chat_id = ?").run(chatId);
}); });
// User management // User management
@@ -988,6 +1114,11 @@ bot.command("teststicker", admin(async (ctx) => {
await maybeSendSticker(ctx, event); await maybeSendSticker(ctx, event);
})); }));
// LLM model picker — admin can switch the OpenRouter model the bot uses
// for Redstone replies. Persists in settings(llm_model); active selection
// is resolved at every callOpenRouterOnce so changes apply immediately.
bot.command("model", admin(async (ctx) => { await renderModelPicker(ctx); }));
// History — last N messages in this chat // History — last N messages in this chat
bot.command("history", admin(async (ctx) => { bot.command("history", admin(async (ctx) => {
const n = Math.min(50, Math.max(5, Number.parseInt((ctx.match as string).trim(), 10) || 20)); const n = Math.min(50, Math.max(5, Number.parseInt((ctx.match as string).trim(), 10) || 20));
@@ -1101,6 +1232,20 @@ type GeminiPart =
| { functionResponse: { name: string; response: Record<string, unknown> } }; | { functionResponse: { name: string; response: Record<string, unknown> } };
type GeminiContent = { role: "user" | "model"; parts: GeminiPart[] }; type GeminiContent = { role: "user" | "model"; parts: GeminiPart[] };
// Generic kv-style settings store. The /model command writes the active
// OpenRouter model id here, and callOpenRouterOnce reads it at call time
// so changes take effect on the very next message — no restart needed.
const Q_kvGet = db.query<{ value: string }, [string]>("SELECT value FROM settings WHERE key = ?");
const Q_kvSet = db.query<unknown, [string, string]>(
"INSERT INTO settings (key, value) VALUES (?, ?) " +
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')",
);
function kvGet(key: string): string | null { return Q_kvGet.get(key)?.value ?? null; }
function kvSet(key: string, value: string): void { Q_kvSet.run(key, value); }
function currentOpenRouterModel(): string {
return kvGet("llm_model") || OPENROUTER_MODEL;
}
// Pull the last n text messages from this chat for continuity. // Pull the last n text messages from this chat for continuity.
const Q_chatHistory = db.query< const Q_chatHistory = db.query<
{ direction: "in" | "out"; body: string | null }, { direction: "in" | "out"; body: string | null },
@@ -1298,7 +1443,7 @@ async function callOpenRouterOnce(messages: ORMessage[]): Promise<ORResponse | n
"X-Title": OPENROUTER_TITLE, "X-Title": OPENROUTER_TITLE,
}, },
body: JSON.stringify({ body: JSON.stringify({
model: OPENROUTER_MODEL, model: currentOpenRouterModel(),
messages, messages,
tools: openRouterTools, tools: openRouterTools,
temperature: 0.6, temperature: 0.6,