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:
parent
76620da6b2
commit
fbeba9e300
3 changed files with 730 additions and 3 deletions
250
bridge/src/lib/llm.ts
Normal file
250
bridge/src/lib/llm.ts
Normal 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
319
bridge/src/lib/telegram.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue