feat(bridge): LLM routing lib + model fallback chain to free model

- 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
This commit is contained in:
Manohar 2026-05-02 20:08:43 +00:00
parent 76620da6b2
commit fbeba9e300
3 changed files with 730 additions and 3 deletions

250
bridge/src/lib/llm.ts Normal file
View file

@ -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<string> {
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(() => "<no body>");
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(() => "<no body>");
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: <details>" }
* 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: <id>
reason: <one short sentence, max 15 words>`;
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<string | null> {
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<string | null> {
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;
}
}

319
bridge/src/lib/telegram.ts Normal file
View file

@ -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 <agent>…" Telegram reply, store reply message_id
* 6. docker exec openclaw agent --session-id tg_<chat_id> -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<string, any> {
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<string, string | number> = {}
): Promise<any> {
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<string, any>): Promise<any> {
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<number | null> {
try {
const body: Record<string, any> = {
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<void> {
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<void> {
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<number, Promise<void>>();
function enqueueForChat(chatId: number, fn: () => Promise<void>): 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<void> {
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);
}
}
}
}

View file

@ -1,12 +1,44 @@
/** /**
* GET /tiger/config/models list all models Tiger knows about * routes/models.ts Available models + per-agent model overrides
* Response: { ok: true, models: [{ id, name, provider, reasoning, contextWindow }] } *
* 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 { 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(); 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) => { router.get("/", async (_req: Request, res: Response) => {
try { try {
const models = await readModels(); 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<string, any> = {};
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; export default router;