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:
147
bot/bot.ts
147
bot/bot.ts
@@ -103,6 +103,11 @@ db.exec(`
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sticker_library_last_seen
|
||||
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.
|
||||
@@ -592,6 +597,121 @@ async function renderConfirm(ctx: Context, action: string, prompt: string) {
|
||||
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 ----
|
||||
async function answerToast(ctx: Context, text: string) {
|
||||
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:pf") return void await renderLogs(ctx, "pf");
|
||||
|
||||
if (data.startsWith("model:")) return void await selectModel(ctx, data.slice("model:".length));
|
||||
|
||||
if (data === "info:adduser") {
|
||||
await ctx.answerCallbackQuery({ text: "use /adduser @username [name] (or numeric id, or @me)", show_alert: true });
|
||||
return;
|
||||
@@ -859,6 +981,10 @@ bot.command("clear", async (ctx) => {
|
||||
).get(chatId)?.n ?? 0;
|
||||
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.`);
|
||||
// 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
|
||||
@@ -988,6 +1114,11 @@ bot.command("teststicker", admin(async (ctx) => {
|
||||
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
|
||||
bot.command("history", admin(async (ctx) => {
|
||||
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> } };
|
||||
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.
|
||||
const Q_chatHistory = db.query<
|
||||
{ direction: "in" | "out"; body: string | null },
|
||||
@@ -1298,7 +1443,7 @@ async function callOpenRouterOnce(messages: ORMessage[]): Promise<ORResponse | n
|
||||
"X-Title": OPENROUTER_TITLE,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: OPENROUTER_MODEL,
|
||||
model: currentOpenRouterModel(),
|
||||
messages,
|
||||
tools: openRouterTools,
|
||||
temperature: 0.6,
|
||||
|
||||
Reference in New Issue
Block a user