diff --git a/bot/bot.ts b/bot/bot.ts index ce32023..e0ddb96 100644 --- a/bot/bot.ts +++ b/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 { + const headers: Record = { 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 = + `๐Ÿง  LLM model picker\n\n` + + `Active: ${escape(current)}\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 = + `๐Ÿง  LLM model picker\n\n` + + `Active: ${escape(modelId)}\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("DELETE FROM messages WHERE chat_id = ?").run(chatId); await sendText(ctx, `๐Ÿงน cleared ${before} 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("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 } }; 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( + "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