From 77ce4a73340a04b06294c50b9a78b4ca8d50ba10 Mon Sep 17 00:00:00 2001 From: William Turner Date: Sun, 23 Aug 2026 14:40:46 +0000 Subject: [PATCH] Add !ai Matrix command for querying other models via OpenRouter --- .env.example | 4 ++++ README.md | 4 ++++ agent/src/matrixBot.js | 44 ++++++++++++++++++++++++++++++++++++----- agent/src/openrouter.js | 33 +++++++++++++++++++++++++++++++ docker-compose.yml | 2 ++ 5 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 agent/src/openrouter.js diff --git a/.env.example b/.env.example index 0a30d13..acd4996 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,10 @@ MATRIX_HOMESERVER_URL=https://matrix.apps.williamturner.eu MATRIX_BOT_TOKEN= MATRIX_CONTROL_ROOM_ID= +# --- openrouter (the "!ai" chat command — other models, not the coding agent) --- +OPENROUTER_API_KEY= +OPENROUTER_DEFAULT_MODEL=openai/gpt-4o-mini + # --- portainer (GitOps redeploy) --- PORTAINER_STACK_WEBHOOK_URL= diff --git a/README.md b/README.md index 8d39d34..86fe219 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Three things this gives you: (Gitea Actions) and redeploys the stack (Portainer webhook). - **Chat-driven coding agent**: `!claude owner/repo ` in the Matrix control room clones the repo, runs Claude Code, and opens a PR with the result. +- **Ask other models**: `!ai ` (default model) or `!ai provider/model ` + (e.g. `!ai google/gemini-2.0-flash-001 explain this error`) queries any model on + OpenRouter and replies in the room. No repo/file access — just a chat reply, unlike + `!claude` which is the only command that can edit files and open PRs. Nothing here auto-merges. Every path stops at a comment or an open PR — a human clicks merge. diff --git a/agent/src/matrixBot.js b/agent/src/matrixBot.js index 21c2cfa..980bc8c 100644 --- a/agent/src/matrixBot.js +++ b/agent/src/matrixBot.js @@ -1,19 +1,42 @@ import { MatrixClient, SimpleFsStorageProvider, AutojoinRoomsMixin } from "matrix-bot-sdk"; import { runChatTask } from "./runner.js"; +import { askOpenRouter, DEFAULT_MODEL } from "./openrouter.js"; const HOMESERVER_URL = process.env.MATRIX_HOMESERVER_URL; const ACCESS_TOKEN = process.env.MATRIX_BOT_TOKEN; const CONTROL_ROOM_ID = process.env.MATRIX_CONTROL_ROOM_ID; const GITEA_URL = process.env.GITEA_URL; +const MAX_REPLY_LENGTH = 4000; + // "!claude owner/repo do the thing" — everything after the repo slug is the instruction. -function parseCommand(text) { +function parseClaudeCommand(text) { const match = text.match(/^!claude\s+([^\s/]+\/[^\s/]+)\s+(.+)$/s); if (!match) return null; const [, repoFullName, instruction] = match; return { repoFullName, instruction: instruction.trim() }; } +// "!ai " uses the default model. "!ai provider/model " (first token +// contains a "/") picks a specific OpenRouter model, e.g. "!ai google/gemini-2.0-flash-001 +// summarize this repo's README". +function parseAiCommand(text) { + const match = text.match(/^!ai\s+(.+)$/s); + if (!match) return null; + const rest = match[1].trim(); + const firstSpace = rest.search(/\s/); + const firstToken = firstSpace === -1 ? rest : rest.slice(0, firstSpace); + if (firstToken.includes("/") && firstSpace !== -1) { + return { model: firstToken, prompt: rest.slice(firstSpace + 1).trim() }; + } + return { model: DEFAULT_MODEL, prompt: rest }; +} + +function truncate(text) { + if (text.length <= MAX_REPLY_LENGTH) return text; + return `${text.slice(0, MAX_REPLY_LENGTH)}\n\n[truncated]`; +} + export async function startMatrixBot() { if (!HOMESERVER_URL || !ACCESS_TOKEN || !CONTROL_ROOM_ID) { console.warn("Matrix env vars not set — skipping bot startup"); @@ -27,14 +50,25 @@ export async function startMatrixBot() { client.on("room.message", async (roomId, event) => { // Invite-only control room enforces who can reach the bot at all; this just scopes // command handling to that one room. No need to fetch/compare the bot's own user ID - // to filter out its own messages — its replies ("Working on it...", "Opened PR:...", - // "Failed:...") never match the !claude command pattern below, so they're ignored - // by parseCommand() returning null, same as any other non-command message. + // to filter out its own messages — its replies never match either command pattern + // below, so they're ignored the same as any other non-command message. if (roomId !== CONTROL_ROOM_ID) return; const body = event.content?.body; if (!body) return; - const cmd = parseCommand(body); + const aiCmd = parseAiCommand(body); + if (aiCmd) { + try { + const reply = await askOpenRouter(aiCmd.model, aiCmd.prompt); + await client.sendText(roomId, `[${aiCmd.model}] ${truncate(reply)}`); + } catch (err) { + console.error("openrouter query failed", err); + await client.sendText(roomId, `Failed: ${err.message}`); + } + return; + } + + const cmd = parseClaudeCommand(body); if (!cmd) return; const [owner, repo] = cmd.repoFullName.split("/"); diff --git a/agent/src/openrouter.js b/agent/src/openrouter.js new file mode 100644 index 0000000..56ae82c --- /dev/null +++ b/agent/src/openrouter.js @@ -0,0 +1,33 @@ +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; +const DEFAULT_MODEL = process.env.OPENROUTER_DEFAULT_MODEL || "openai/gpt-4o-mini"; + +export async function askOpenRouter(model, prompt) { + if (!OPENROUTER_API_KEY) { + throw new Error("OPENROUTER_API_KEY is not set"); + } + + const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: model || DEFAULT_MODEL, + messages: [{ role: "user", content: prompt }], + }), + }); + + if (!res.ok) { + throw new Error(`OpenRouter request failed: ${res.status} ${await res.text()}`); + } + + const data = await res.json(); + const content = data.choices?.[0]?.message?.content; + if (!content) { + throw new Error(`OpenRouter returned no content: ${JSON.stringify(data)}`); + } + return content; +} + +export { DEFAULT_MODEL }; diff --git a/docker-compose.yml b/docker-compose.yml index 03405b1..4a70070 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,8 @@ services: MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL} MATRIX_BOT_TOKEN: ${MATRIX_BOT_TOKEN} MATRIX_CONTROL_ROOM_ID: ${MATRIX_CONTROL_ROOM_ID} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} + OPENROUTER_DEFAULT_MODEL: ${OPENROUTER_DEFAULT_MODEL:-openai/gpt-4o-mini} volumes: - agent_workspace:/workspace networks: