diff --git a/.env.example b/.env.example index 4d843562..a155737e 100755 --- a/.env.example +++ b/.env.example @@ -47,6 +47,7 @@ VITE_CONTEXT_WINDOW=160000 # ============================================================================= # OPENAI_API_KEY=sk-... # OPENROUTER_API_KEY=sk-or-... +# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 # OPENROUTER_MODEL=anthropic/claude-sonnet-4 # ============================================================================= diff --git a/README.md b/README.md index a316ff60..a93c2f93 100644 --- a/README.md +++ b/README.md @@ -521,6 +521,9 @@ Auto Research email notifications are configured inside the app at **Settings - **Environment variable:** `export OPENROUTER_API_KEY=sk-or-...` - **`.env` file:** add `OPENROUTER_API_KEY=sk-or-...` to your project `.env` - **UI:** go to **Settings → OpenRouter** and paste your key +3. If you use a relay / proxy endpoint, also set `OPENROUTER_BASE_URL` to the full compatible base path: + - **Official OpenRouter:** `OPENROUTER_BASE_URL=https://openrouter.ai/api/v1` + - **Relay example:** `OPENROUTER_BASE_URL=https://your-relay.example.com/v1` ### Using OpenRouter in the UI @@ -541,6 +544,9 @@ node server/cli.js chat --model moonshotai/kimi-k2.5 # With an explicit API key node server/cli.js chat --model deepseek/deepseek-r1 --key sk-or-your-key + +# With a relay / proxy endpoint +node server/cli.js chat --model deepseek/deepseek-r1 --base-url https://your-relay.example.com/v1 ``` The CLI supports the same tools as the UI (file I/O, shell, grep, glob, web search, web fetch, todo). Type your message and the agent will execute multi-step research tasks autonomously. diff --git a/public/api-docs.html b/public/api-docs.html index 350595e3..48be5bed 100644 --- a/public/api-docs.html +++ b/public/api-docs.html @@ -523,7 +523,7 @@

Request Body Parameters

provider string Optional - claude, cursor, or codex (default: claude) + claude, cursor, codex, gemini, openrouter, or local (default: claude) stream @@ -539,6 +539,12 @@

Request Body Parameters

Model identifier for the AI provider (loading from constants...) + + baseUrl + string + Optional + Custom OpenRouter-compatible base URL. Useful for relay / proxy services when provider is openrouter. + cleanup boolean diff --git a/server/__tests__/cli.test.mjs b/server/__tests__/cli.test.mjs new file mode 100644 index 00000000..af3923b6 --- /dev/null +++ b/server/__tests__/cli.test.mjs @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { parseCliArgs } from '../utils/cliArgs.js'; + +describe('cli parseArgs', () => { + it('parses OpenRouter relay base URL for chat sessions', () => { + expect(parseCliArgs([ + 'chat', + '--model', + 'deepseek/deepseek-r1', + '--key', + 'sk-or-test', + '--base-url', + 'https://relay.example.com/v1', + ])).toEqual({ + command: 'chat', + options: { + model: 'deepseek/deepseek-r1', + key: 'sk-or-test', + baseUrl: 'https://relay.example.com/v1', + }, + }); + }); + + it('parses inline base-url arguments', () => { + expect(parseCliArgs([ + 'chat', + '--base-url=https://relay.example.com/v1', + ])).toEqual({ + command: 'chat', + options: { + baseUrl: 'https://relay.example.com/v1', + }, + }); + }); +}); diff --git a/server/__tests__/openrouter-config.test.mjs b/server/__tests__/openrouter-config.test.mjs new file mode 100644 index 00000000..37c51d24 --- /dev/null +++ b/server/__tests__/openrouter-config.test.mjs @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_OPENROUTER_BASE_URL, + getOpenRouterBaseUrl, + getOpenRouterProviderHeaders, + isOfficialOpenRouterBaseUrl, + normalizeOpenRouterBaseUrl, +} from '../utils/openrouterConfig.js'; + +describe('openrouterConfig', () => { + it('uses the official OpenRouter endpoint by default', () => { + expect(normalizeOpenRouterBaseUrl()).toBe(DEFAULT_OPENROUTER_BASE_URL); + expect(getOpenRouterBaseUrl({})).toBe(DEFAULT_OPENROUTER_BASE_URL); + }); + + it('normalizes custom relay URLs by trimming trailing slashes', () => { + expect(normalizeOpenRouterBaseUrl('https://relay.example.com/v1/')).toBe('https://relay.example.com/v1'); + }); + + it('falls back to the default URL when the configured env value is invalid', () => { + expect(getOpenRouterBaseUrl({ OPENROUTER_BASE_URL: 'not-a-url' })).toBe(DEFAULT_OPENROUTER_BASE_URL); + }); + + it('detects official OpenRouter hosts', () => { + expect(isOfficialOpenRouterBaseUrl('https://openrouter.ai/api/v1')).toBe(true); + expect(isOfficialOpenRouterBaseUrl('https://relay.example.com/v1')).toBe(false); + }); + + it('only attaches OpenRouter provider headers for the official endpoint', () => { + expect(getOpenRouterProviderHeaders('https://openrouter.ai/api/v1', 'Dr. Claw')).toEqual({ + 'HTTP-Referer': 'https://github.com/OpenLAIR/dr-claw', + 'X-Title': 'Dr. Claw', + }); + expect(getOpenRouterProviderHeaders('https://relay.example.com/v1', 'Dr. Claw')).toEqual({}); + }); +}); diff --git a/server/__tests__/session-delete.test.mjs b/server/__tests__/session-delete.test.mjs index 81b44289..c5943ebd 100644 --- a/server/__tests__/session-delete.test.mjs +++ b/server/__tests__/session-delete.test.mjs @@ -8,12 +8,14 @@ const originalUserProfile = process.env.USERPROFILE; const originalDatabasePath = process.env.DATABASE_PATH; let tempRoot = null; +let loadedDatabase = null; async function loadTestModules() { vi.resetModules(); const projects = await import('../projects.js'); const database = await import('../database/db.js'); await database.initializeDatabase(); + loadedDatabase = database; return { projects, database }; } @@ -26,7 +28,12 @@ describe('session deletion fallbacks', () => { }); afterEach(async () => { - vi.resetModules(); + try { + loadedDatabase?.closeDatabase?.(); + } finally { + loadedDatabase = null; + vi.resetModules(); + } if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; diff --git a/server/cli-chat.js b/server/cli-chat.js index bf7ae8d2..8e65a135 100644 --- a/server/cli-chat.js +++ b/server/cli-chat.js @@ -14,10 +14,9 @@ import path from 'path'; import os from 'os'; import { exec } from 'child_process'; import { promisify } from 'util'; +import { getOpenRouterBaseUrl, getOpenRouterProviderHeaders } from './utils/openrouterConfig.js'; const execAsync = promisify(exec); - -const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; const MAX_AGENT_TURNS = 25; const BASH_TIMEOUT_MS = 120_000; const MAX_OUTPUT_CHARS = 80_000; @@ -267,19 +266,18 @@ async function executeTool(name, args, workingDir) { // ── Streaming API call ──────────────────────────────────────────────────────── -async function streamApiCall(apiKey, model, messages, tools) { +async function streamApiCall(baseUrl, apiKey, model, messages, tools) { const body = { model, messages, stream: true, stream_options: { include_usage: true } }; if (tools?.length) { body.tools = tools; body.tool_choice = 'auto'; } - return fetch(`${OPENROUTER_BASE_URL}/chat/completions`, { + return fetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', - 'HTTP-Referer': 'https://github.com/OpenLAIR/dr-claw', - 'X-Title': 'Dr. Claw CLI', + ...getOpenRouterProviderHeaders(baseUrl, 'Dr. Claw CLI'), }, body: JSON.stringify(body), }); @@ -341,6 +339,9 @@ async function consumeStream(response, onText) { export async function startChat(options = {}) { const apiKey = options.key || process.env.OPENROUTER_API_KEY; const model = options.model || process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4'; + const baseUrl = getOpenRouterBaseUrl({ + OPENROUTER_BASE_URL: options.baseUrl || process.env.OPENROUTER_BASE_URL, + }); const workingDir = process.cwd(); if (!apiKey) { @@ -411,7 +412,7 @@ export async function startChat(options = {}) { async function agentLoop(apiKey, model, messages, workingDir) { for (let turn = 0; turn < MAX_AGENT_TURNS; turn++) { - const response = await streamApiCall(apiKey, model, messages, TOOL_SCHEMAS); + const response = await streamApiCall(baseUrl, apiKey, model, messages, TOOL_SCHEMAS); if (!response.ok) { const errText = await response.text().catch(() => ''); diff --git a/server/cli.js b/server/cli.js index eb433be8..15250091 100755 --- a/server/cli.js +++ b/server/cli.js @@ -19,8 +19,9 @@ import fs from 'fs'; import path from 'path'; import os from 'os'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; import { dirname } from 'path'; +import { parseCliArgs } from './utils/cliArgs.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -168,6 +169,7 @@ Options: --database-path Set custom database location --model OpenRouter model slug (chat command) --key OpenRouter API key (chat command) + --base-url OpenRouter or relay base URL (chat command) -h, --help Show this help information -v, --version Show version information @@ -175,6 +177,7 @@ Examples: $ dr-claw # Start with defaults $ dr-claw chat # Terminal chat with OpenRouter $ dr-claw chat --model deepseek/deepseek-r1 + $ dr-claw chat --base-url https://your-relay.example.com/v1 $ dr-claw --port 8080 # Start on port 8080 $ dr-claw -p 3000 # Short form for port $ dr-claw start --port 4000 # Explicit start command @@ -186,6 +189,7 @@ Environment Variables: DATABASE_PATH Set custom database location CLAUDE_CLI_PATH Set custom Claude CLI path CONTEXT_WINDOW Set context window size (default: 160000) + OPENROUTER_BASE_URL Set OpenRouter or relay base URL for chat Documentation: ${packageJson.homepage || 'https://github.com/OpenLAIR/dr-claw'} @@ -266,44 +270,12 @@ async function startServer() { } // Parse CLI arguments -function parseArgs(args) { - const parsed = { command: 'start', options: {} }; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (arg === '--port' || arg === '-p') { - parsed.options.port = args[++i]; - } else if (arg.startsWith('--port=')) { - parsed.options.port = arg.split('=')[1]; - } else if (arg === '--database-path') { - parsed.options.databasePath = args[++i]; - } else if (arg.startsWith('--database-path=')) { - parsed.options.databasePath = arg.split('=')[1]; - } else if (arg === '--model' || arg === '-m') { - parsed.options.model = args[++i]; - } else if (arg.startsWith('--model=')) { - parsed.options.model = arg.split('=')[1]; - } else if (arg === '--key') { - parsed.options.key = args[++i]; - } else if (arg.startsWith('--key=')) { - parsed.options.key = arg.split('=')[1]; - } else if (arg === '--help' || arg === '-h') { - parsed.command = 'help'; - } else if (arg === '--version' || arg === '-v') { - parsed.command = 'version'; - } else if (!arg.startsWith('-')) { - parsed.command = arg; - } - } - - return parsed; -} +export const parseArgs = parseCliArgs; // Main CLI handler async function main() { const args = process.argv.slice(2); - const { command, options } = parseArgs(args); + const { command, options } = parseCliArgs(args); // Apply CLI options to environment variables if (options.port) { @@ -320,7 +292,7 @@ async function main() { case 'chat': { loadEnvFile(); const { startChat } = await import('./cli-chat.js'); - await startChat({ model: options.model, key: options.key }); + await startChat({ model: options.model, key: options.key, baseUrl: options.baseUrl }); break; } case 'status': @@ -347,8 +319,13 @@ async function main() { } } -// Run the CLI -main().catch(error => { - console.error('\n❌ Error:', error.message); - process.exit(1); -}); +const isDirectExecution = process.argv[1] + ? import.meta.url === pathToFileURL(process.argv[1]).href + : false; + +if (isDirectExecution) { + main().catch(error => { + console.error('\n❌ Error:', error.message); + process.exit(1); + }); +} diff --git a/server/database/db.js b/server/database/db.js index 29e7783d..1406478a 100644 --- a/server/database/db.js +++ b/server/database/db.js @@ -182,6 +182,14 @@ const initializeDatabase = async () => { } }; +const closeDatabase = () => { + if (!db.open) { + return; + } + + db.close(); +}; + // User database operations const userDb = { // Check if any users exist @@ -1838,6 +1846,7 @@ const referencesDb = { export { db, initializeDatabase, + closeDatabase, userDb, autoResearchDb, appSettingsDb, diff --git a/server/openrouter.js b/server/openrouter.js index 407166bf..046a632b 100644 --- a/server/openrouter.js +++ b/server/openrouter.js @@ -20,10 +20,10 @@ import { writeProjectTemplates } from './templates/index.js'; import { classifyError } from '../shared/errorClassifier.js'; import { applyStageTagsToSession, recordIndexedSession } from './utils/sessionIndex.js'; import { createRequestId, waitForToolApproval, matchesToolPermission } from './utils/permissions.js'; +import { getOpenRouterBaseUrl, getOpenRouterProviderHeaders } from './utils/openrouterConfig.js'; const execAsync = promisify(exec); -const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; const MAX_AGENT_TURNS = 30; const BASH_TIMEOUT_MS = 120_000; const MAX_OUTPUT_CHARS = 100_000; @@ -482,19 +482,18 @@ async function loadHistory(sessionId) { // Streaming API call + response parser // --------------------------------------------------------------------------- -async function streamApiCall(apiKey, model, messages, tools, signal) { +async function streamApiCall(baseUrl, apiKey, model, messages, tools, signal) { const body = { model, messages, stream: true, stream_options: { include_usage: true } }; if (tools?.length) { body.tools = tools; body.tool_choice = 'auto'; } - return fetch(`${OPENROUTER_BASE_URL}/chat/completions`, { + return fetch(`${baseUrl}/chat/completions`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', - 'HTTP-Referer': 'https://github.com/OpenLAIR/dr-claw', - 'X-Title': 'Dr. Claw', + ...getOpenRouterProviderHeaders(baseUrl, 'Dr. Claw'), }, body: JSON.stringify(body), signal, @@ -568,6 +567,7 @@ export async function queryOpenRouter(command, options = {}, ws) { cwd, projectPath, model = 'anthropic/claude-sonnet-4', + baseUrl: requestedBaseUrl, env, sessionMode, stageTagKeys, @@ -579,6 +579,9 @@ export async function queryOpenRouter(command, options = {}, ws) { const workingDirectory = cwd || projectPath || process.cwd(); const apiKey = env?.OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY; + const baseUrl = getOpenRouterBaseUrl({ + OPENROUTER_BASE_URL: requestedBaseUrl ?? env?.OPENROUTER_BASE_URL ?? process.env.OPENROUTER_BASE_URL, + }); if (!apiKey) { sendMessage(ws, { @@ -678,7 +681,7 @@ export async function queryOpenRouter(command, options = {}, ws) { console.log(`[OpenRouter] Turn ${turn}/${MAX_AGENT_TURNS} · model=${model} · msgs=${messages.length}`); const response = await streamApiCall( - apiKey, model, messages, + baseUrl, apiKey, model, messages, noToolFallback ? [] : tools, abortController.signal, ); diff --git a/server/routes/agent.js b/server/routes/agent.js index 22671d3f..c2aaa7cf 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -840,7 +840,7 @@ class ResponseCollector { * } */ router.post('/', validateExternalApiKey, async (req, res) => { - const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName } = req.body; + const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName, baseUrl } = req.body; // Parse stream and cleanup as booleans (handle string "true"/"false" from curl) const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true'); @@ -999,6 +999,7 @@ router.post('/', validateExternalApiKey, async (req, res) => { cwd: finalProjectPath, sessionId: null, model: model || OPENROUTER_MODELS.DEFAULT, + baseUrl, env: sessionEnv, }, writer); } else if (provider === 'local') { diff --git a/server/routes/cli-auth.js b/server/routes/cli-auth.js index 4f821eea..f098dfa2 100644 --- a/server/routes/cli-auth.js +++ b/server/routes/cli-auth.js @@ -6,6 +6,11 @@ import os from 'os'; import fetch from 'node-fetch'; import { resolveCursorCliCommand } from '../utils/cursorCommand.js'; import { resolveAvailableCliCommand } from '../utils/cliResolution.js'; +import { + getOpenRouterBaseUrl, + getOpenRouterProviderHeaders, + normalizeOpenRouterBaseUrl, +} from '../utils/openrouterConfig.js'; const router = express.Router(); @@ -46,6 +51,44 @@ function buildStatusPayload(result, agent) { }; } +async function upsertEnvValues(envPath, entries) { + let envContent = ''; + try { + envContent = await fs.readFile(envPath, 'utf8'); + } catch {} + + const nextValues = Object.fromEntries( + Object.entries(entries).map(([key, value]) => [key, String(value)]) + ); + const seenKeys = new Set(); + const newLines = envContent + .split('\n') + .map((line) => { + const trimmed = line.trim(); + const [rawKey] = line.split('='); + const key = rawKey?.trim(); + if (!key || !(key in nextValues)) { + return line; + } + + seenKeys.add(key); + return `${key}=${nextValues[key]}`; + }); + + Object.entries(nextValues).forEach(([key, value]) => { + if (!seenKeys.has(key)) { + newLines.push(`${key}=${value}`); + } + }); + + const finalContent = newLines + .filter((line, index, allLines) => line.trim() !== '' || index < allLines.length - 1) + .join('\n') + .replace(/\n*$/, '\n'); + + await fs.writeFile(envPath, finalContent); +} + router.get('/claude/status', async (req, res) => { try { const credentialsResult = await checkClaudeCredentials(); @@ -629,23 +672,30 @@ async function checkCodexCredentials() { router.get('/openrouter/status', async (req, res) => { try { const apiKey = process.env.OPENROUTER_API_KEY; + const baseUrl = getOpenRouterBaseUrl(process.env); if (apiKey) { - return res.json(buildStatusPayload({ + return res.json({ + ...buildStatusPayload({ authenticated: true, email: 'API Key Connected', cliAvailable: true, cliCommand: 'openrouter' - }, 'openrouter')); + }, 'openrouter'), + baseUrl, + }); } - return res.json(buildStatusPayload({ + return res.json({ + ...buildStatusPayload({ authenticated: false, email: null, error: 'OPENROUTER_API_KEY not set', cliAvailable: true, cliCommand: 'openrouter', installHint: 'Set OPENROUTER_API_KEY in your .env file or environment. Get a key at https://openrouter.ai/keys' - }, 'openrouter')); + }, 'openrouter'), + baseUrl, + }); } catch (error) { console.error('Error checking OpenRouter auth status:', error); res.status(500).json({ @@ -658,35 +708,50 @@ router.get('/openrouter/status', async (req, res) => { router.post('/openrouter/verify-api-key', async (req, res) => { try { - const { apiKey } = req.body; + const providedApiKey = typeof req.body?.apiKey === 'string' ? req.body.apiKey.trim() : ''; + const apiKey = providedApiKey || process.env.OPENROUTER_API_KEY; if (!apiKey) return res.status(400).json({ error: 'API key is required' }); - const response = await fetch('https://openrouter.ai/api/v1/models', { - headers: { 'Authorization': `Bearer ${apiKey}` } + let baseUrl; + try { + baseUrl = normalizeOpenRouterBaseUrl(req.body?.baseUrl); + } catch (error) { + return res.status(400).json({ error: error.message }); + } + + const response = await fetch(`${baseUrl}/models`, { + headers: { + Authorization: `Bearer ${apiKey}`, + ...getOpenRouterProviderHeaders(baseUrl, 'Dr. Claw'), + } }); if (response.ok) { const envPath = path.join(process.cwd(), '.env'); - let envContent = ''; - try { envContent = await fs.readFile(envPath, 'utf8'); } catch {} - - const lines = envContent.split('\n'); - let found = false; - const newLines = lines.map(line => { - if (line.trim().startsWith('OPENROUTER_API_KEY=')) { - found = true; - return `OPENROUTER_API_KEY=${apiKey}`; - } - return line; - }).filter(l => l.trim() !== '' || found); - - if (!found) newLines.push(`OPENROUTER_API_KEY=${apiKey}`); - await fs.writeFile(envPath, newLines.join('\n') + '\n'); + await upsertEnvValues(envPath, { + OPENROUTER_API_KEY: apiKey, + OPENROUTER_BASE_URL: baseUrl, + }); process.env.OPENROUTER_API_KEY = apiKey; + process.env.OPENROUTER_BASE_URL = baseUrl; - return res.json({ success: true, message: 'OpenRouter API key verified and saved.' }); + return res.json({ + success: true, + message: 'OpenRouter settings verified and saved.', + baseUrl, + }); } else { - return res.status(401).json({ error: 'Invalid API key' }); + const details = await response.text().catch(() => ''); + let error = `OpenRouter endpoint verification failed (${response.status}).`; + if (response.status === 401 || response.status === 403) { + error = 'The configured endpoint rejected this API key.'; + } else if (details) { + error = `${error} ${details.slice(0, 160)}`; + } else { + error = `${error} Make sure the relay exposes a compatible /models endpoint.`; + } + + return res.status(response.status === 401 || response.status === 403 ? 401 : 400).json({ error }); } } catch (error) { res.status(500).json({ error: error.message }); diff --git a/server/routes/settings.js b/server/routes/settings.js index 2e3d65af..0994ce7c 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -1,5 +1,10 @@ import express from 'express'; import { apiKeysDb, appSettingsDb, credentialsDb } from '../database/db.js'; +import { + getOpenRouterBaseUrl, + getOpenRouterProviderHeaders, + isOfficialOpenRouterBaseUrl, +} from '../utils/openrouterConfig.js'; const router = express.Router(); const AUTO_RESEARCH_SENDER_EMAIL_KEY = 'auto_research_sender_email'; @@ -244,34 +249,47 @@ router.put('/auto-research-resend-key', async (req, res) => { // OpenRouter Models (cached proxy) // =============================== -let openrouterModelsCache = { data: null, fetchedAt: 0 }; +let openrouterModelsCache = { data: null, fetchedAt: 0, baseUrl: null }; const OPENROUTER_CACHE_TTL = 1000 * 60 * 30; // 30 minutes router.get('/openrouter-models', async (_req, res) => { try { + const baseUrl = getOpenRouterBaseUrl(process.env); const now = Date.now(); - if (openrouterModelsCache.data && now - openrouterModelsCache.fetchedAt < OPENROUTER_CACHE_TTL) { + if ( + openrouterModelsCache.data && + openrouterModelsCache.baseUrl === baseUrl && + now - openrouterModelsCache.fetchedAt < OPENROUTER_CACHE_TTL + ) { return res.json(openrouterModelsCache.data); } - const response = await fetch( - 'https://openrouter.ai/api/v1/models?output_modalities=text&supported_parameters=tools', - { headers: { 'HTTP-Referer': 'https://github.com/OpenLAIR/dr-claw', 'X-Title': 'Dr. Claw' } } - ); + const apiKey = process.env.OPENROUTER_API_KEY; + const isOfficial = isOfficialOpenRouterBaseUrl(baseUrl); + const requestUrl = isOfficial + ? `${baseUrl}/models?output_modalities=text&supported_parameters=tools` + : `${baseUrl}/models`; + const headers = { + ...getOpenRouterProviderHeaders(baseUrl, 'Dr. Claw'), + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }; + + const response = await fetch(requestUrl, { headers }); if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`); const json = await response.json(); - const models = (json.data || []) - .filter((m) => m.id && m.name) + const rawModels = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : []; + const models = rawModels + .filter((m) => m?.id) .map((m) => ({ value: m.id, - label: m.name, + label: m.name || m.id, contextLength: m.context_length || null, pricing: m.pricing || null, })) .sort((a, b) => a.label.localeCompare(b.label)); - openrouterModelsCache = { data: { models }, fetchedAt: now }; + openrouterModelsCache = { data: { models }, fetchedAt: now, baseUrl }; res.json({ models }); } catch (error) { console.error('Error fetching OpenRouter models:', error); diff --git a/server/utils/cliArgs.js b/server/utils/cliArgs.js new file mode 100644 index 00000000..a45520d4 --- /dev/null +++ b/server/utils/cliArgs.js @@ -0,0 +1,37 @@ +export function parseCliArgs(args) { + const parsed = { command: 'start', options: {} }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--port' || arg === '-p') { + parsed.options.port = args[++i]; + } else if (arg.startsWith('--port=')) { + parsed.options.port = arg.split('=')[1]; + } else if (arg === '--database-path') { + parsed.options.databasePath = args[++i]; + } else if (arg.startsWith('--database-path=')) { + parsed.options.databasePath = arg.split('=')[1]; + } else if (arg === '--model' || arg === '-m') { + parsed.options.model = args[++i]; + } else if (arg.startsWith('--model=')) { + parsed.options.model = arg.split('=')[1]; + } else if (arg === '--key') { + parsed.options.key = args[++i]; + } else if (arg.startsWith('--key=')) { + parsed.options.key = arg.split('=')[1]; + } else if (arg === '--base-url') { + parsed.options.baseUrl = args[++i]; + } else if (arg.startsWith('--base-url=')) { + parsed.options.baseUrl = arg.split('=')[1]; + } else if (arg === '--help' || arg === '-h') { + parsed.command = 'help'; + } else if (arg === '--version' || arg === '-v') { + parsed.command = 'version'; + } else if (!arg.startsWith('-')) { + parsed.command = arg; + } + } + + return parsed; +} diff --git a/server/utils/openrouterConfig.js b/server/utils/openrouterConfig.js new file mode 100644 index 00000000..f9cc1fef --- /dev/null +++ b/server/utils/openrouterConfig.js @@ -0,0 +1,49 @@ +const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; +const OPENROUTER_REFERER = 'https://github.com/OpenLAIR/dr-claw'; + +export { DEFAULT_OPENROUTER_BASE_URL }; + +export function normalizeOpenRouterBaseUrl(baseUrl = DEFAULT_OPENROUTER_BASE_URL) { + const candidate = String(baseUrl || '').trim() || DEFAULT_OPENROUTER_BASE_URL; + + let parsed; + try { + parsed = new URL(candidate); + } catch { + throw new Error('OpenRouter base URL must be a valid http:// or https:// URL.'); + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('OpenRouter base URL must start with http:// or https://'); + } + + return parsed.toString().replace(/\/$/, ''); +} + +export function getOpenRouterBaseUrl(env = process.env) { + try { + return normalizeOpenRouterBaseUrl(env?.OPENROUTER_BASE_URL); + } catch { + return DEFAULT_OPENROUTER_BASE_URL; + } +} + +export function isOfficialOpenRouterBaseUrl(baseUrl) { + try { + const { hostname } = new URL(normalizeOpenRouterBaseUrl(baseUrl)); + return hostname === 'openrouter.ai' || hostname.endsWith('.openrouter.ai'); + } catch { + return false; + } +} + +export function getOpenRouterProviderHeaders(baseUrl, title = 'Dr. Claw') { + if (!isOfficialOpenRouterBaseUrl(baseUrl)) { + return {}; + } + + return { + 'HTTP-Referer': OPENROUTER_REFERER, + 'X-Title': title, + }; +} diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index 44c5db49..903771ea 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -165,6 +165,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) { cliAvailable: true, cliCommand: 'openrouter', installHint: null, + baseUrl: null, loading: true, error: null }); @@ -184,6 +185,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) { cliAvailable: true, cliCommand: null, installHint: null, + baseUrl: null, loading: false, error: null, ...overrides @@ -841,6 +843,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) { cliAvailable: data.cliAvailable !== false, cliCommand: data.cliCommand || 'openrouter', installHint: data.installHint || null, + baseUrl: data.baseUrl || null, loading: false, error: data.error || null }); @@ -1756,11 +1759,11 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) { selectedAgent === 'local' ? localAuthStatus : codexAuthStatus } - onLogin={ + onLogin={ selectedAgent === 'claude' ? handleClaudeLogin : selectedAgent === 'cursor' ? handleCursorLogin : selectedAgent === 'gemini' ? handleGeminiLogin : - selectedAgent === 'openrouter' ? (() => {}) : + selectedAgent === 'openrouter' ? checkOpenRouterAuthStatus : selectedAgent === 'local' ? checkLocalAuthStatus : handleCodexLogin } diff --git a/src/components/chat/hooks/useChatComposerState.ts b/src/components/chat/hooks/useChatComposerState.ts index c991c17b..f337514b 100644 --- a/src/components/chat/hooks/useChatComposerState.ts +++ b/src/components/chat/hooks/useChatComposerState.ts @@ -1123,6 +1123,7 @@ export function useChatComposerState({ sessionId: effectiveSessionId, resume: Boolean(effectiveSessionId), model: openrouterModel, + baseUrl: localStorage.getItem('openrouter-base-url') || undefined, permissionMode, toolsSettings, telemetryEnabled, diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index 2cf43907..3d16187a 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -415,8 +415,6 @@ export default function ProviderSelectionEmptyState({ type ModelOption = { value: string; label: string; contextLength?: number | null; isCustom?: boolean }; -let _modelsCache: ModelOption[] | null = null; - function OpenRouterModelInput({ value, options: fallbackOptions, @@ -428,7 +426,7 @@ function OpenRouterModelInput({ }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); - const [models, setModels] = useState(_modelsCache || fallbackOptions); + const [models, setModels] = useState(fallbackOptions); const [loading, setLoading] = useState(false); const [customDraft, setCustomDraft] = useState(''); const [showCustomInput, setShowCustomInput] = useState(false); @@ -438,14 +436,12 @@ function OpenRouterModelInput({ const customModels: ModelOption[] = JSON.parse(localStorage.getItem('openrouter-custom-models') || '[]'); const fetchModels = useCallback(async () => { - if (_modelsCache) { setModels(_modelsCache); return; } setLoading(true); try { const res = await authenticatedFetch('/api/settings/openrouter-models'); if (res.ok) { const data = await res.json(); if (data.models?.length) { - _modelsCache = data.models; setModels(data.models); } } diff --git a/src/components/settings/AccountContent.jsx b/src/components/settings/AccountContent.jsx index 00cc1b37..d6fa7f9c 100644 --- a/src/components/settings/AccountContent.jsx +++ b/src/components/settings/AccountContent.jsx @@ -82,6 +82,7 @@ export default function AccountContent({ agent, authStatus, onLogin }) { const [verifyResult, setVerifyResult] = useState(null); const [openrouterApiKey, setOpenrouterApiKey] = useState(''); + const [openrouterBaseUrl, setOpenrouterBaseUrl] = useState(() => localStorage.getItem('openrouter-base-url') || ''); const [isVerifyingOpenRouter, setIsVerifyingOpenRouter] = useState(false); const [openrouterVerifyResult, setOpenrouterVerifyResult] = useState(null); @@ -162,6 +163,18 @@ export default function AccountContent({ agent, authStatus, onLogin }) { } }, [agent, handleDetectGpus, handleLoadOllamaModels]); + useEffect(() => { + if (agent === 'openrouter') { + const nextBaseUrl = authStatus?.baseUrl || ''; + setOpenrouterBaseUrl(nextBaseUrl); + if (nextBaseUrl) { + localStorage.setItem('openrouter-base-url', nextBaseUrl); + } else { + localStorage.removeItem('openrouter-base-url'); + } + } + }, [agent, authStatus?.baseUrl]); + const handleSaveLocalConfig = async () => { localStorage.setItem('local-gpu-server-url', localServerUrl); localStorage.setItem('local-gpu-selected', selectedGpu); @@ -257,12 +270,23 @@ export default function AccountContent({ agent, authStatus, onLogin }) { try { const res = await authenticatedFetch('/api/cli/openrouter/verify-api-key', { method: 'POST', - body: JSON.stringify({ apiKey: openrouterApiKey.trim() }), + body: JSON.stringify({ + apiKey: openrouterApiKey.trim() || undefined, + baseUrl: openrouterBaseUrl.trim() || undefined, + }), }); const data = await res.json(); if (res.ok) { - setOpenrouterVerifyResult({ success: true, message: data.message || 'API key verified and saved.' }); + const nextBaseUrl = data.baseUrl || openrouterBaseUrl.trim(); + setOpenrouterVerifyResult({ success: true, message: data.message || 'OpenRouter settings verified and saved.' }); setOpenrouterApiKey(''); + setOpenrouterBaseUrl(nextBaseUrl); + if (nextBaseUrl) { + localStorage.setItem('openrouter-base-url', nextBaseUrl); + } else { + localStorage.removeItem('openrouter-base-url'); + } + if (typeof onLogin === 'function') onLogin(); } else { setOpenrouterVerifyResult({ success: false, message: data.error || 'Invalid API key' }); } @@ -553,8 +577,8 @@ export default function AccountContent({ agent, authStatus, onLogin }) {

{authStatus?.authenticated - ? 'Your API key is configured. Enter a new key below to replace it.' - : 'Enter your OpenRouter API key to connect. Get one at openrouter.ai/keys.'} + ? 'Your API key is configured. You can replace it below or point OpenRouter to a relay base URL.' + : 'Enter your OpenRouter API key to connect. If you use a relay, fill its base URL below.'}

@@ -568,13 +592,27 @@ export default function AccountContent({ agent, authStatus, onLogin }) { onChange={e => setOpenrouterApiKey(e.target.value)} />
+
+ + setOpenrouterBaseUrl(e.target.value)} + /> +
+ Fill the full API base path. Most relays use `/v1`; official OpenRouter uses `/api/v1`. +
+
{openrouterVerifyResult && (