From 6a0c49e5c77a7333acba5cc831c69b9b9081d5b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 07:17:32 +0000 Subject: [PATCH] feat: add OpenRouter support + Railway auto-wire template - Add OpenRouter as third AI provider option (100+ models) - Create railway-template.json with auto-wired PostgreSQL + Redis - Template auto-references DATABASE_URL and REDIS_URL from services - Default model for OpenRouter: anthropic/claude-3.5-sonnet - Update README with OpenRouter configuration https://claude.ai/code/session_015VqJ7gN4vaxtYfYc92UjLs --- railway-template.json | 70 ++++++++++++++++++++++++++++ secure/README.md | 9 +++- secure/agent.ts | 105 +++++++++++++++++++++++++++++++++++++++++- secure/config.ts | 10 ++-- 4 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 railway-template.json diff --git a/railway-template.json b/railway-template.json new file mode 100644 index 000000000..875839848 --- /dev/null +++ b/railway-template.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "name": "AssureBot", + "description": "Lean, secure, self-hosted AI assistant with Telegram, document analysis, and scheduled tasks", + "icon": "https://raw.githubusercontent.com/TNovs1/moltbot/main/secure/icon.png", + "services": [ + { + "name": "assurebot", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "secure/Dockerfile" + }, + "deploy": { + "startCommand": "node dist/index.js", + "healthcheckPath": "/health", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + }, + "variables": { + "DATABASE_URL": { + "reference": "postgres.DATABASE_URL" + }, + "REDIS_URL": { + "reference": "redis.REDIS_URL" + }, + "TELEGRAM_BOT_TOKEN": { + "description": "Telegram bot token from @BotFather", + "required": true + }, + "ALLOWED_USERS": { + "description": "Comma-separated Telegram user IDs (e.g., 123456789,987654321)", + "required": true + }, + "ANTHROPIC_API_KEY": { + "description": "Anthropic API key (or use OPENAI_API_KEY or OPENROUTER_API_KEY)", + "required": false + }, + "OPENAI_API_KEY": { + "description": "OpenAI API key (or use ANTHROPIC_API_KEY or OPENROUTER_API_KEY)", + "required": false + }, + "OPENROUTER_API_KEY": { + "description": "OpenRouter API key (or use ANTHROPIC_API_KEY or OPENAI_API_KEY)", + "required": false + }, + "AI_MODEL": { + "description": "Model to use (e.g., claude-3-5-sonnet-20241022, gpt-4o, anthropic/claude-3.5-sonnet)", + "required": false + }, + "WEBHOOK_SECRET": { + "description": "Secret for authenticating webhooks (auto-generated if empty)", + "required": false + }, + "SANDBOX_ENABLED": { + "description": "Enable Docker sandbox for code execution", + "default": "false" + } + } + }, + { + "name": "postgres", + "plugin": "postgresql" + }, + { + "name": "redis", + "plugin": "redis" + } + ] +} diff --git a/secure/README.md b/secure/README.md index b1020ce73..e3ad514e2 100644 --- a/secure/README.md +++ b/secure/README.md @@ -70,7 +70,14 @@ Your AI agent that runs on your infrastructure, answers only to you, and you can ```bash TELEGRAM_BOT_TOKEN=123456:ABC-DEF... # From @BotFather ALLOWED_USERS=123456789,987654321 # Telegram user IDs -ANTHROPIC_API_KEY=sk-ant-... # Or OPENAI_API_KEY + +# AI Provider (one required) +ANTHROPIC_API_KEY=sk-ant-... # Claude direct +# or +OPENAI_API_KEY=sk-... # OpenAI direct +# or +OPENROUTER_API_KEY=sk-or-... # OpenRouter (100+ models) +AI_MODEL=anthropic/claude-3.5-sonnet # Optional: override default model ``` ### Optional diff --git a/secure/agent.ts b/secure/agent.ts index 5381ccaa9..f9d5ac5aa 100644 --- a/secure/agent.ts +++ b/secure/agent.ts @@ -39,11 +39,12 @@ export type AgentResponse = { export type AgentCore = { chat: (messages: Message[], systemPrompt?: string) => Promise; analyzeImage: (imageData: string, mediaType: ImageContent["mediaType"], prompt?: string) => Promise; - provider: "anthropic" | "openai"; + provider: "anthropic" | "openai" | "openrouter"; }; const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514"; const DEFAULT_OPENAI_MODEL = "gpt-4o"; +const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-3.5-sonnet"; const DEFAULT_SYSTEM_PROMPT = `You are a helpful AI assistant running as a secure, self-hosted bot. @@ -234,10 +235,112 @@ function createOpenAIAgent(config: SecureConfig, audit: AuditLogger): AgentCore }; } +function createOpenRouterAgent(config: SecureConfig, audit: AuditLogger): AgentCore { + // OpenRouter uses OpenAI-compatible API + const client = new OpenAI({ + apiKey: config.ai.apiKey, + baseURL: "https://openrouter.ai/api/v1", + defaultHeaders: { + "HTTP-Referer": "https://github.com/TNovs1/moltbot", + "X-Title": "AssureBot", + }, + }); + + const model = config.ai.model || DEFAULT_OPENROUTER_MODEL; + + type OpenAIContent = OpenAI.ChatCompletionContentPart[]; + + function convertContent(content: MessageContent): string | OpenAIContent { + if (typeof content === "string") { + return content; + } + return content.map((part) => { + if (part.type === "text") { + return { type: "text" as const, text: part.text }; + } + return { + type: "image_url" as const, + image_url: { + url: `data:${part.mediaType};base64,${part.data}`, + }, + }; + }); + } + + return { + provider: "openrouter", + + async chat(messages: Message[], systemPrompt?: string): Promise { + try { + const openaiMessages: OpenAI.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt || DEFAULT_SYSTEM_PROMPT }, + ]; + + for (const m of messages) { + if (m.role === "user") { + openaiMessages.push({ + role: "user", + content: convertContent(m.content), + }); + } else { + openaiMessages.push({ + role: "assistant", + content: typeof m.content === "string" ? m.content : "", + }); + } + } + + const response = await client.chat.completions.create({ + model, + max_tokens: 4096, + messages: openaiMessages, + }); + + const text = response.choices[0]?.message?.content || ""; + + return { + text, + usage: response.usage + ? { + inputTokens: response.usage.prompt_tokens, + outputTokens: response.usage.completion_tokens, + } + : undefined, + }; + } catch (err) { + audit.error({ + error: `OpenRouter API error: ${err instanceof Error ? err.message : String(err)}`, + }); + throw err; + } + }, + + async analyzeImage( + imageData: string, + mediaType: ImageContent["mediaType"], + prompt = "What's in this image? Describe it in detail." + ): Promise { + const messages: Message[] = [ + { + role: "user", + content: [ + { type: "image", data: imageData, mediaType }, + { type: "text", text: prompt }, + ], + }, + ]; + return this.chat(messages); + }, + }; +} + export function createAgent(config: SecureConfig, audit: AuditLogger): AgentCore { if (config.ai.provider === "anthropic") { return createAnthropicAgent(config, audit); } + if (config.ai.provider === "openrouter") { + return createOpenRouterAgent(config, audit); + } return createOpenAIAgent(config, audit); } diff --git a/secure/config.ts b/secure/config.ts index 026fe0052..7c5feb0ee 100644 --- a/secure/config.ts +++ b/secure/config.ts @@ -14,7 +14,7 @@ export type SecureConfig = { // AI Provider ai: { - provider: "anthropic" | "openai"; + provider: "anthropic" | "openai" | "openrouter"; apiKey: string; model?: string; }; @@ -95,9 +95,10 @@ function parseAllowedUsers(value: string): number[] { .filter((n) => Number.isFinite(n) && n > 0); } -function detectAiProvider(): { provider: "anthropic" | "openai"; apiKey: string } { +function detectAiProvider(): { provider: "anthropic" | "openai" | "openrouter"; apiKey: string } { const anthropicKey = process.env.ANTHROPIC_API_KEY; const openaiKey = process.env.OPENAI_API_KEY; + const openrouterKey = process.env.OPENROUTER_API_KEY; if (anthropicKey) { return { provider: "anthropic", apiKey: anthropicKey }; @@ -105,8 +106,11 @@ function detectAiProvider(): { provider: "anthropic" | "openai"; apiKey: string if (openaiKey) { return { provider: "openai", apiKey: openaiKey }; } + if (openrouterKey) { + return { provider: "openrouter", apiKey: openrouterKey }; + } - throw new Error("Missing AI provider key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY"); + throw new Error("Missing AI provider key. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or OPENROUTER_API_KEY"); } function generateSecureToken(): string {