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
This commit is contained in:
Claude 2026-01-30 07:17:32 +00:00
parent b5d78db832
commit 6a0c49e5c7
No known key found for this signature in database
4 changed files with 189 additions and 5 deletions

70
railway-template.json Normal file
View File

@ -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"
}
]
}

View File

@ -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

View File

@ -39,11 +39,12 @@ export type AgentResponse = {
export type AgentCore = {
chat: (messages: Message[], systemPrompt?: string) => Promise<AgentResponse>;
analyzeImage: (imageData: string, mediaType: ImageContent["mediaType"], prompt?: string) => Promise<AgentResponse>;
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<AgentResponse> {
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<AgentResponse> {
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);
}

View File

@ -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 {