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)}
/>
+
+
+ Relay / Base URL
+
+
setOpenrouterBaseUrl(e.target.value)}
+ />
+
+ Fill the full API base path. Most relays use `/v1`; official OpenRouter uses `/api/v1`.
+
+
- {isVerifyingOpenRouter ? 'Verifying...' : 'Verify & Save Key'}
+ {isVerifyingOpenRouter ? 'Verifying...' : 'Verify & Save Settings'}
{openrouterVerifyResult && (