From fbeba9e3004cb0540cfff7332279f9239574f007 Mon Sep 17 00:00:00 2001 From: Manohar Date: Sat, 2 May 2026 20:08:43 +0000 Subject: [PATCH] feat(bridge): LLM routing lib + model fallback chain to free model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add bridge/src/lib/ (llm.ts and supporting helpers) for LLM routing - Fix model fallback chain: add openrouter/arcee-ai/trinity-large-preview:free as second fallback so a free model catches OpenRouter billing failures - Previous state: MiniMax timeout → openrouter/auto → billing error → outage - Now: MiniMax → openrouter/auto → arcee-ai/trinity-large-preview:free --- bridge/src/lib/llm.ts | 250 ++++++++++++++++++++++++++++ bridge/src/lib/telegram.ts | 319 ++++++++++++++++++++++++++++++++++++ bridge/src/routes/models.ts | 164 +++++++++++++++++- 3 files changed, 730 insertions(+), 3 deletions(-) create mode 100644 bridge/src/lib/llm.ts create mode 100644 bridge/src/lib/telegram.ts diff --git a/bridge/src/lib/llm.ts b/bridge/src/lib/llm.ts new file mode 100644 index 0000000..bd7b247 --- /dev/null +++ b/bridge/src/lib/llm.ts @@ -0,0 +1,250 @@ +/** + * lib/llm.ts — Lightweight LLM helpers for the Tiger Bridge + * + * Provides three small, opinionated helpers used by routing and project naming: + * classifyAgent(text) → which sub-agent should own a task + * generateProjectTitle(text) → 3-7 word project title + * generateProjectGoal(text) → one-line success criterion + * + * Configured via env vars (already declared in bridge/.env): + * TIGER_ROUTER_MODEL Model slug for ALL router calls. + * Examples: + * "anthropic/claude-haiku-4-5" → Anthropic API direct + * "minimax/MiniMax-M2.7" → OpenRouter + * "openrouter/auto" → OpenRouter (meta-router) + * Default if unset: "anthropic/claude-haiku-4-5". + * ANTHROPIC_API_KEY Required when ROUTER_MODEL has "anthropic/" prefix. + * OPENROUTER_API_KEY Required for everything else. + * + * Routing rule (intentionally simple): + * slug startsWith "anthropic/" → Anthropic API, model = slug minus "anthropic/" + * anything else → OpenRouter, model = slug verbatim + * + * Note: "openrouter/auto" is OR's literal model ID, so we DON'T strip it. + * This is why the rule only special-cases "anthropic/". + * + * Failure mode (the most important property): + * Every public helper catches errors internally. Callers never see exceptions + * from this module. The bridge MUST keep working when the router LLM is down, + * the API key is missing, or the upstream returns garbage. + * + * classifyAgent → returns { agent: "tiger", reason: "router_unavailable: ..." } + * generateProjectTitle → returns null (caller falls back to raw text) + * generateProjectGoal → returns null (caller leaves goal empty) + */ + +// ─── Configuration ───────────────────────────────────────────────────────── +const ROUTER_MODEL = process.env.TIGER_ROUTER_MODEL || "anthropic/claude-haiku-4-5"; +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || ""; +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || ""; +const ANTHROPIC_VERSION = "2023-06-01"; + +// Curated list of valid agent IDs. Used to validate classifier output. +export const AGENT_IDS = ["tiger", "cody", "ethan", "cathy", "elon"] as const; +export type AgentId = (typeof AGENT_IDS)[number]; + +// ─── Internal: provider resolution ────────────────────────────────────────── +interface ResolvedModel { + provider: "anthropic" | "openrouter"; + model: string; +} + +/** + * Decide which provider handles a given slug, and return the model string + * that should actually go on the wire. + */ +function resolveModel(slug: string): ResolvedModel { + if (slug.startsWith("anthropic/")) { + return { provider: "anthropic", model: slug.slice("anthropic/".length) }; + } + return { provider: "openrouter", model: slug }; +} + +// ─── Internal: low-level LLM call ─────────────────────────────────────────── +/** + * Send a single (system + user) message pair to the configured router model. + * Returns the trimmed text reply. Throws on ANY failure — the public helpers + * catch and convert these into safe fallbacks. + * + * Why throw here instead of returning null? Because the public helpers each + * want to package the failure differently (sentinel agent for routing, null + * for naming). Centralising the throw keeps this fn single-purpose. + */ +async function callLLM( + systemPrompt: string, + userMessage: string, + maxTokens: number, +): Promise { + const { provider, model } = resolveModel(ROUTER_MODEL); + + if (provider === "anthropic") { + if (!ANTHROPIC_API_KEY) { + throw new Error("ANTHROPIC_API_KEY not set"); + } + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": ANTHROPIC_API_KEY, + "anthropic-version": ANTHROPIC_VERSION, + "content-type": "application/json", + }, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + system: systemPrompt, + messages: [{ role: "user", content: userMessage }], + }), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error(`Anthropic API ${res.status}: ${errBody.slice(0, 200)}`); + } + const data = (await res.json()) as { + content?: Array<{ type: string; text?: string }>; + }; + const text = data.content?.find((b) => b.type === "text")?.text; + if (!text) throw new Error("Anthropic returned no text content"); + return text.trim(); + } + + // OpenRouter (catch-all for everything except "anthropic/") + if (!OPENROUTER_API_KEY) { + throw new Error("OPENROUTER_API_KEY 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", + // OR recommends these for observability/ranking — harmless if ignored. + "HTTP-Referer": "https://agent.manohargupta.com", + "X-Title": "Tiger Bridge Router", + }, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userMessage }, + ], + }), + }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error(`OpenRouter API ${res.status}: ${errBody.slice(0, 200)}`); + } + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const text = data.choices?.[0]?.message?.content; + if (!text) throw new Error("OpenRouter returned no message content"); + return text.trim(); +} + +// ─── Public: agent classifier ─────────────────────────────────────────────── +/** + * Decide which sub-agent should own a given task. + * + * Returns { agent, reason }. Always succeeds. On any failure (network error, + * missing key, model returns garbage) it returns: + * { agent: "tiger", reason: "router_unavailable:
" } + * so dispatch logic can surface routing failures via UI filter. + */ +export async function classifyAgent( + taskText: string, +): Promise<{ agent: AgentId; reason: string }> { + const systemPrompt = `You are the task router for Tiger, a personal AI orchestrator. + +Assign each task to EXACTLY ONE of these 5 sub-agents: + +- tiger : the orchestrator itself. Use ONLY for high-level coordination, daily summaries, deciding what to do next, or when no other agent fits. +- cody : code, debugging, software engineering, devops, deployments, scripts, infra, build systems. +- ethan : web research, fact-finding, gathering external information, market data, news, papers, regulatory filings. +- cathy : writing, prose, emails, content, summaries written for human consumption, polishing language. +- elon : analysis, financial modelling, energy/macro/markets reasoning, BESS/solar/policy work, structured quantitative thinking. + +Reply with EXACTLY two lines, no preamble, no quotes, no markdown: +agent: +reason: `; + + try { + const reply = await callLLM(systemPrompt, taskText.slice(0, 4000), 80); + const agentMatch = reply.match(/agent\s*:\s*(\w+)/i); + const reasonMatch = reply.match(/reason\s*:\s*(.+)/i); + const rawAgent = (agentMatch?.[1] || "").toLowerCase(); + const reason = (reasonMatch?.[1] || "").trim() || "no reason returned"; + + if ((AGENT_IDS as readonly string[]).includes(rawAgent)) { + return { agent: rawAgent as AgentId, reason }; + } + // Call succeeded but the model returned an agent we don't recognise. + // Distinct from "router_unavailable" — the call worked, the answer was bad. + return { + agent: "tiger", + reason: `router_unrecognized: model returned "${rawAgent}"`, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + agent: "tiger", + reason: `router_unavailable: ${msg.slice(0, 140)}`, + }; + } +} + +// ─── Public: project title generator ──────────────────────────────────────── +/** + * Turn a seed text (first user message, or explicit goal) into a 3-7 word title. + * Returns null on failure so the caller can fall back to using the raw text. + */ +export async function generateProjectTitle(seedText: string): Promise { + const systemPrompt = + `You generate concise project titles. Rules: 3-7 words. No quotes. ` + + `No trailing period. Plain text only — no markdown, no labels.`; + const user = `Title this in 3-7 words:\n\n${seedText.slice(0, 1000)}`; + try { + const reply = await callLLM(systemPrompt, user, 30); + // Defensive cleanup — strip leading "Title:" labels, surrounding quotes, + // trailing periods, trailing newlines. Cap length as a sanity guard. + const cleaned = reply + .replace(/^title\s*:\s*/i, "") + .replace(/^["'`]+|["'`]+$/g, "") + .replace(/\.+$/, "") + .trim() + .slice(0, 80); + return cleaned || null; + } catch (err) { + console.warn("[llm] generateProjectTitle failed:", err); + return null; + } +} + +// ─── Public: project goal generator ───────────────────────────────────────── +/** + * Turn a seed text into a one-line second-person goal statement. + * Examples of expected output: + * "Compare BESS economics across three scenarios" + * "Pull and rank latest CERC tariff orders" + * "Draft the Q4 investor letter" + * Returns null on failure so the caller can leave the goal field empty. + */ +export async function generateProjectGoal(seedText: string): Promise { + const systemPrompt = + `You write one-line project goal statements in imperative mood (second person). ` + + `Examples: "Compare BESS economics across three scenarios", "Pull and rank ` + + `latest CERC tariff orders", "Draft the Q4 investor letter". ` + + `Rules: ONE sentence. Maximum 18 words. No preamble, no labels, no bullet points.`; + const user = `Write the goal for this:\n\n${seedText.slice(0, 1000)}`; + try { + const reply = await callLLM(systemPrompt, user, 60); + const cleaned = reply + .replace(/^goal\s*:\s*/i, "") + .replace(/^["'`]+|["'`]+$/g, "") + .trim() + .slice(0, 200); + return cleaned || null; + } catch (err) { + console.warn("[llm] generateProjectGoal failed:", err); + return null; + } +} diff --git a/bridge/src/lib/telegram.ts b/bridge/src/lib/telegram.ts new file mode 100644 index 0000000..f41e425 --- /dev/null +++ b/bridge/src/lib/telegram.ts @@ -0,0 +1,319 @@ +/** + * telegram.ts — TelegramChannel (Option A: bridge owns Telegram polling) + * + * OpenClaw's built-in Telegram channel must be disabled before starting this + * (set channels.telegram.enabled = false in openclaw.json). + * + * Flow per incoming message: + * 1. getUpdates long-poll (timeout=25s, no flood risk) + * 2. Filter: skip non-text, non-allowlisted chats, /start /help + * 3. Create SQLite task (status=in-progress, project_id=null = orphan) + * 4. classifyAgent → update task assigned_agent + agent_reason + * 5. Send "routing to …" Telegram reply, store reply message_id + * 6. docker exec openclaw agent --session-id tg_ -m '...' --json + * 7. Edit the reply message with the real response + * 8. Update task status=done, persist both sides to chat_messages table + * + * Token resolution order: + * TELEGRAM_BOT_TOKEN env var → openclaw.json channels.telegram.botToken + * + * Chat ID allowlist: + * TELEGRAM_CHAT_ID env var → openclaw.json (not stored there currently) + * If unset: accepts all chats (fine for a private bot) + */ + +import { exec } from "child_process"; +import { promisify } from "util"; +import { readFileSync } from "fs"; +// No db/classifyAgent imports — bridge is pure transport for Telegram. + +const execAsync = promisify(exec); + +// -- Types ──────────────────────────────────────────────────────────────────── + +interface TgUpdate { + update_id: number; + message?: TgMessage; +} + +interface TgMessage { + message_id: number; + from?: { id: number; username?: string; first_name?: string }; + chat: { id: number; type: string }; + text?: string; + date: number; +} + +// -- Config resolution ──────────────────────────────────────────────────────── + +const OPENCLAW_CONFIG_PATH = + process.env.OPENCLAW_CONFIG_PATH || + "/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json"; + +function readOpenClawConfig(): Record { + try { + return JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")); + } catch { + return {}; + } +} + +function getBotToken(): string { + if (process.env.TELEGRAM_BOT_TOKEN) return process.env.TELEGRAM_BOT_TOKEN; + // Fall back to openclaw.json -- token lives there from initial setup + const cfg = readOpenClawConfig(); + return cfg?.channels?.telegram?.botToken ?? ""; +} + +function getAllowedChatId(): number | null { + const raw = process.env.TELEGRAM_CHAT_ID; + if (raw && raw.trim()) return parseInt(raw.trim(), 10); + return null; // null = accept all chats +} + +// -- Telegram API helpers ───────────────────────────────────────────────────── + +const TG_BASE = (token: string) => `https://api.telegram.org/bot${token}`; + +async function tgGet( + token: string, + method: string, + params: Record = {} +): Promise { + const url = new URL(`${TG_BASE(token)}/${method}`); + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, String(v)); + } + const res = await fetch(url.toString(), { + signal: AbortSignal.timeout(32_000), // slightly above telegram timeout=25 + }); + return res.json(); +} + +async function tgPost(token: string, method: string, body: Record): Promise { + const res = await fetch(`${TG_BASE(token)}/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); + return res.json(); +} + +// Send a message; returns the new message_id or null on failure +async function sendMessage( + token: string, + chatId: number, + text: string, + replyToMessageId?: number +): Promise { + try { + const body: Record = { + chat_id: chatId, + text: text.slice(0, 4096), // Telegram hard limit + parse_mode: "Markdown", + }; + if (replyToMessageId) body.reply_to_message_id = replyToMessageId; + const r = await tgPost(token, "sendMessage", body); + return r.ok ? (r.result?.message_id ?? null) : null; + } catch (e: any) { + console.error("[telegram] sendMessage failed:", e.message); + return null; + } +} + + +// Show "Bot is typing--" indicator. Telegram clears it after 5s, so +// we call it once immediately then repeat every 4s while waiting for +// docker exec to return. Returns a cancel function. +function startTyping(token: string, chatId: number): () => void { + let active = true; + const tick = async () => { + while (active) { + try { + await tgPost(token, "sendChatAction", { chat_id: chatId, action: "typing" }); + } catch { /* non-fatal */ } + await sleep(4000); + } + }; + tick(); // fire immediately, don't await + return () => { active = false; }; +} + +// -- Per-message handler ────────────────────────────────────────────────────── + +async function handleMessage(token: string, msg: TgMessage): Promise { + const text = (msg.text || "").trim(); + const chatId = msg.chat.id; + // Session ID is per-chat so OpenClaw maintains conversation context across messages + const sessionId = `tg_${chatId}`; + + // -- Filter: skip empty, /start, /help -------------------------------- + if (!text) return; + if (text === "/start" || text === "/help") { + await sendMessage( + token, chatId, + "👋 *Tiger Command Center*\n\nSend me a task or question and I'll get right on it." + ); + return; + } + + const from = msg.from?.username ? `@${msg.from.username}` : (msg.from?.first_name ?? "unknown"); + console.log(`[telegram] msg from ${from} in ${chatId}: ${text.slice(0, 80)}`); + + // -- Show typing indicator while Tiger thinks ------------------------- + const stopTyping = startTyping(token, chatId); + + // -- Forward to OpenClaw via docker exec ------------------------------ + // --channel telegram --deliver sends the response back via Telegram natively + // when the native Telegram channel is enabled. Since we disabled it, we + // use --json and send the response ourselves. + // Write message to temp file inside container -- avoids ALL shell escaping + // issues with backticks, quotes, JSON, code blocks in user messages. + const tmpFile = `/tmp/tg_${Date.now()}.txt`; + const { writeFileSync, unlinkSync } = await import("fs"); + const { execSync } = await import("child_process"); + try { + writeFileSync(tmpFile, text, "utf-8"); + execSync(`docker cp ${tmpFile} tiger-openclaw:${tmpFile}`, { timeout: 5000 }); + unlinkSync(tmpFile); + } catch (copyErr: any) { + console.error("[telegram] cp to container failed:", copyErr.message); + stopTyping(); + await sendMessage(token, chatId, "Internal error — could not forward your message.", msg.message_id); + return; + } + const cmd = `docker exec tiger-openclaw sh -c 'MSG=$(cat ${tmpFile}); rm -f ${tmpFile}; openclaw agent --session-id ${sessionId} -m "$MSG" --json --timeout 120'`; + + let replyText = ""; + let execOk = true; + try { + const { stdout } = await execAsync(cmd, { + timeout: 130_000, + maxBuffer: 10 * 1024 * 1024, + }); + let parsed: any; + try { parsed = JSON.parse(stdout); } catch { parsed = { output: stdout }; } + replyText = + parsed?.result?.payloads?.[0]?.text || + parsed?.payloads?.[0]?.text || + parsed?.summary || + parsed?.text || + parsed?.output || + "_(no response)_"; + } catch (e: any) { + console.error("[telegram] docker exec failed:", e.message); + replyText = "⚠️ Tiger timed out or is offline."; + execOk = false; + } + + stopTyping(); + + // -- Send response ----------------------------------------------------- + const truncated = + replyText.length > 4000 + ? replyText.slice(0, 3990) + "\n\n_(truncated)_" + : replyText; + + await sendMessage(token, chatId, truncated, msg.message_id); + + if (execOk) { + console.log(`[telegram] response sent to ${from} in ${chatId}`); + } +} + +// -- Main poller ────────────────────────────────────────────────────────────── + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + + +// Per-chat message queue -- one docker exec at a time per chat_id. +// Prevents concurrent gateway WebSocket connections on Telegram bursts. +const chatQueues = new Map>(); + +function enqueueForChat(chatId: number, fn: () => Promise): void { + const prev = chatQueues.get(chatId) ?? Promise.resolve(); + const next = prev.then(() => fn()).catch((e) => + console.error('[telegram] queue error for chat ' + chatId + ':', e.message) + ); + chatQueues.set(chatId, next); + next.finally(() => { + if (chatQueues.get(chatId) === next) chatQueues.delete(chatId); + }); +} + +export class TelegramChannel { + private token: string; + private allowedChatId: number | null; + private offset = 0; + private running = false; + + constructor() { + this.token = getBotToken(); + this.allowedChatId = getAllowedChatId(); + } + + start(): void { + if (!this.token) { + console.warn( + "[telegram] No bot token found in TELEGRAM_BOT_TOKEN env or openclaw.json — channel disabled." + ); + return; + } + if (this.running) return; + this.running = true; + console.log( + `[telegram] Polling started (allowedChatId=${this.allowedChatId ?? "all"})` + ); + this._poll().catch((e) => console.error("[telegram] Poll loop crashed:", e)); + } + + stop(): void { + this.running = false; + console.log("[telegram] Polling stopped."); + } + + private async _poll(): Promise { + while (this.running) { + try { + const data = await tgGet(this.token, "getUpdates", { + offset: this.offset, + timeout: 25, // seconds -- long-poll + allowed_updates: "message", + }); + + if (!data.ok || !Array.isArray(data.result)) { + // Telegram returned an error (bad token, network, etc.) + console.error("[telegram] getUpdates error:", data.description ?? data); + await sleep(10_000); + continue; + } + + for (const update of data.result as TgUpdate[]) { + // Advance offset FIRST -- ensures we never re-process even if handler throws + this.offset = update.update_id + 1; + + const msg = update.message; + if (!msg) continue; + + // Chat allowlist check + if (this.allowedChatId !== null && msg.chat.id !== this.allowedChatId) { + console.log(`[telegram] Skipping chat ${msg.chat.id} — not in allowlist`); + continue; + } + + // Queue per chat -- one docker exec at a time per chat_id. + // Prevents concurrent gateway WebSocket connections on message bursts. + const token = this.token; + enqueueForChat(msg.chat.id, () => handleMessage(token, msg)); + } + } catch (e: any) { + if (!this.running) break; + console.error("[telegram] Poll error, backing off 5s:", e.message); + await sleep(5_000); + } + } + } +} diff --git a/bridge/src/routes/models.ts b/bridge/src/routes/models.ts index cb5998c..f569650 100644 --- a/bridge/src/routes/models.ts +++ b/bridge/src/routes/models.ts @@ -1,12 +1,44 @@ /** - * GET /tiger/config/models — list all models Tiger knows about - * Response: { ok: true, models: [{ id, name, provider, reasoning, contextWindow }] } + * routes/models.ts — Available models + per-agent model overrides + * + * GET /tiger/config/models List models from registry + * GET /tiger/config/models/agents List per-agent overrides + * PATCH /tiger/config/models/agents/:agentId Set/clear an agent's override + * + * Per-agent override semantics: + * - Each agent in openclaw.json's `agents.list[]` may carry its own + * `model.primary` (and optional `model.fallback`) which overrides the + * global `agents.defaults.model`. + * - PATCH body { model: "anthropic/claude-haiku-4-5" } sets the primary. + * - PATCH body { model: null } CLEARS the override (revert to default). + * - We never touch agents that aren't in the body. Only the targeted entry + * is mutated. Other agents' overrides are preserved as-is. + * + * Why we don't use updateConfig() / deepMerge here: + * `agents.list` is a JSON array. The bridge's deepMerge treats arrays as + * scalar values (replacement), but writing the whole list with a single + * missing entry would silently drop other agents. So we do an explicit + * read-mutate-write pass on the array — safer and easier to reason about. */ + import { Router, Request, Response } from "express"; -import { readModels } from "../tiger.js"; +import { readFile, writeFile } from "fs/promises"; +import { execOnHost, readModels } from "../tiger.js"; const router = Router(); +// Hard path — same one tiger.ts uses. Kept local rather than re-exported +// because tiger.ts treats it as a private constant. +const OPENCLAW_CONFIG_HOST = + "/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json"; + +// Curated agent IDs we expose for override. Must align with agents.ts. +const KNOWN_AGENT_IDS = ["tiger", "cody", "ethan", "cathy", "elon"] as const; +type KnownAgentId = (typeof KNOWN_AGENT_IDS)[number]; + +// ─── GET /tiger/config/models ────────────────────────────────────────────── +// Returns the full registry of available models. Existing endpoint — kept +// as-is so the dashboard's model picker continues to work. router.get("/", async (_req: Request, res: Response) => { try { const models = await readModels(); @@ -16,4 +48,130 @@ router.get("/", async (_req: Request, res: Response) => { } }); +// ─── GET /tiger/config/models/agents ─────────────────────────────────────── +// Returns the global default plus the per-agent override map. +// Shape: +// { +// ok: true, +// defaults: { primary: "...", fallback: "..." }, +// overrides: { +// tiger: { primary: "minimax/MiniMax-M2.7" }, +// cody: { primary: "anthropic/claude-sonnet-4-6" }, +// ethan: null, // no override → uses defaults +// cathy: null, +// elon: null, +// } +// } +router.get("/agents", async (_req: Request, res: Response) => { + try { + const raw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8"); + const cfg = JSON.parse(raw); + + const defaults = cfg?.agents?.defaults?.model ?? null; + const list: any[] = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : []; + + // Build the override map. Missing agents → null (no override). + const overrides: Record = {}; + for (const id of KNOWN_AGENT_IDS) { + const entry = list.find((a) => a?.id === id); + overrides[id] = entry?.model ?? null; + } + + res.json({ ok: true, defaults, overrides }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// ─── PATCH /tiger/config/models/agents/:agentId ──────────────────────────── +// Body: { model: string | null } +// string → set primary (e.g. "anthropic/claude-haiku-4-5") +// null → clear override (revert to defaults) +// Optional body: { model: { primary: "...", fallback: "..." } } for full set. +router.patch("/agents/:agentId", async (req: Request, res: Response) => { + const agentId = req.params.agentId as KnownAgentId; + + if (!(KNOWN_AGENT_IDS as readonly string[]).includes(agentId)) { + return res.status(400).json({ + ok: false, + error: `Unknown agentId. Must be one of: ${KNOWN_AGENT_IDS.join(", ")}`, + }); + } + + const { model } = req.body as { model?: string | { primary: string; fallback?: string } | null }; + + if (model !== null && model !== undefined && typeof model !== "string" && typeof model !== "object") { + return res.status(400).json({ + ok: false, + error: "Body.model must be a string slug, an object {primary,fallback}, or null", + }); + } + + try { + // 1. Read current config straight from the volume. + const raw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8"); + const cfg = JSON.parse(raw); + + // 2. Make sure the shape we need exists. + cfg.agents ??= {}; + cfg.agents.list ??= []; + if (!Array.isArray(cfg.agents.list)) { + return res.status(500).json({ + ok: false, + error: "openclaw.json: agents.list is not an array", + }); + } + + // 3. Locate or create the agent's entry in the list. + const list: any[] = cfg.agents.list; + let idx = list.findIndex((a) => a?.id === agentId); + if (idx === -1) { + list.push({ id: agentId }); + idx = list.length - 1; + } + + // 4. Apply the patch. + if (model === null || model === undefined) { + // Clear override: drop the model field entirely. We keep the entry + // around (don't delete it) so future PATCHes can add it back. + delete list[idx].model; + } else if (typeof model === "string") { + list[idx].model = { primary: model }; + } else { + // Object form — { primary, fallback? } + if (typeof model.primary !== "string" || !model.primary) { + return res.status(400).json({ + ok: false, + error: "model.primary is required when model is an object", + }); + } + list[idx].model = { + primary: model.primary, + ...(model.fallback ? { fallback: model.fallback } : {}), + }; + } + + // 5. Backup before writing (mirrors updateConfig's pattern). + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupPath = OPENCLAW_CONFIG_HOST.replace( + "openclaw.json", + `openclaw-${timestamp}.bak.json`, + ); + await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} ${backupPath} 2>/dev/null || true`); + + // 6. Write back. v2026 doesn't need the hash regen step. + await writeFile(OPENCLAW_CONFIG_HOST, JSON.stringify(cfg, null, 2), "utf-8"); + + res.json({ + ok: true, + agentId, + model: list[idx].model ?? null, + backupPath, + }); + } catch (err: any) { + console.error("[models] PATCH failed:", err); + res.status(500).json({ ok: false, error: err.message }); + } +}); + export default router;