Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

# =============================================================================
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion public/api-docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ <h4>Request Body Parameters</h4>
<td><code>provider</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td><code>claude</code>, <code>cursor</code>, or <code>codex</code> (default: <code>claude</code>)</td>
<td><code>claude</code>, <code>cursor</code>, <code>codex</code>, <code>gemini</code>, <code>openrouter</code>, or <code>local</code> (default: <code>claude</code>)</td>
</tr>
<tr>
<td><code>stream</code></td>
Expand All @@ -539,6 +539,12 @@ <h4>Request Body Parameters</h4>
Model identifier for the AI provider (loading from constants...)
</td>
</tr>
<tr>
<td><code>baseUrl</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td>Custom OpenRouter-compatible base URL. Useful for relay / proxy services when <code>provider</code> is <code>openrouter</code>.</td>
</tr>
<tr>
<td><code>cleanup</code></td>
<td>boolean</td>
Expand Down
35 changes: 35 additions & 0 deletions server/__tests__/cli.test.mjs
Original file line number Diff line number Diff line change
@@ -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',
},
});
});
});
36 changes: 36 additions & 0 deletions server/__tests__/openrouter-config.test.mjs
Original file line number Diff line number Diff line change
@@ -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({});
});
});
9 changes: 8 additions & 1 deletion server/__tests__/session-delete.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand All @@ -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;
Expand Down
15 changes: 8 additions & 7 deletions server/cli-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(() => '');
Expand Down
59 changes: 18 additions & 41 deletions server/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -168,13 +169,15 @@ Options:
--database-path <path> Set custom database location
--model <model> OpenRouter model slug (chat command)
--key <key> OpenRouter API key (chat command)
--base-url <url> OpenRouter or relay base URL (chat command)
-h, --help Show this help information
-v, --version Show version information

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
Expand All @@ -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'}
Expand Down Expand Up @@ -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) {
Expand All @@ -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':
Expand All @@ -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);
});
}
9 changes: 9 additions & 0 deletions server/database/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1838,6 +1846,7 @@ const referencesDb = {
export {
db,
initializeDatabase,
closeDatabase,
userDb,
autoResearchDb,
appSettingsDb,
Expand Down
Loading