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
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user