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
33 changes: 33 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,39 @@ Platform mode is an advanced deployment option. Most users should leave these co

---

## Model Discovery

Dr. Claw asks each harness which models it actually supports rather than relying
only on the list compiled into the app, so a CLI that ships a new model shows up
without waiting for a Dr. Claw release.

| Provider | Source | Notes |
|----------|--------|-------|
| Codex | `codex app-server` → `model/list` JSON-RPC | Same catalogue the Codex CLI's own picker reads. Honours `CODEX_CLI_PATH`. |
| OpenRouter | `GET https://openrouter.ai/api/v1/models` | Public endpoint, no key needed. |
| Claude, Cursor, Gemini, Nano | Built-in list | These CLIs expose no model-listing command today. |
| Local GPU | Ollama `/api/tags` | Existing behaviour, unchanged. |

Discovery is strictly additive and never blocks the UI:

- Results are cached for 10 minutes; a failed probe is re-tried after 1 minute
so the picker recovers on its own once a CLI is installed or logged in.
- Every probe has a 15-second hard timeout. If the harness is missing, old,
logged out, or unresponsive, the built-in list is used instead.
- Models present in the built-in list but no longer served by the harness are
kept at the end of the picker and marked deprecated, so an existing saved
model preference is never stranded.

### API

| Endpoint | Description |
|----------|-------------|
| `GET /api/models/:provider` | Model list for a provider. `source` is `discovered` or `static`. Add `?refresh=1` to bypass the cache. |
| `POST /api/models/:provider/refresh` | Drop the cache and re-probe — useful right after upgrading or logging into a CLI. |
| `GET /api/models/providers` | Providers this build can probe. |

---

## OSS Mode vs Platform Mode

Dr. Claw supports two authentication paths:
Expand Down
72 changes: 36 additions & 36 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"node": "20.x || 22.x || 24.x"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.170",
"@anthropic-ai/claude-agent-sdk": "0.3.226",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.2.4",
Expand Down
89 changes: 89 additions & 0 deletions server/__tests__/models-route.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import http from 'http';

/**
* Exercises the /api/models routes over real HTTP, mounted without the auth
* middleware. Registration in this environment needs config the test harness
* does not have, and the auth layer is not what these routes add.
*/

let server;
let baseUrl;

beforeAll(async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});

const { default: modelsRoutes } = await import('../routes/models.js');
const app = express();
app.use(express.json());
app.use('/api/models', modelsRoutes);

server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
baseUrl = `http://127.0.0.1:${server.address().port}`;
});

afterAll(async () => {
vi.restoreAllMocks();
if (server) {
await new Promise((resolve) => server.close(resolve));
}
});

async function get(path) {
const res = await fetch(`${baseUrl}${path}`);
return { status: res.status, body: await res.json() };
}

describe('GET /api/models/:provider', () => {
it('always returns a usable list, even for a provider it cannot probe', async () => {
const { CLAUDE_MODELS } = await import('../../shared/modelConstants.js');
const { status, body } = await get('/api/models/claude');

expect(status).toBe(200);
expect(body.provider).toBe('claude');
expect(body.source).toBe('static');
expect(body.options).toEqual(CLAUDE_MODELS.OPTIONS);
expect(body.default).toBe(CLAUDE_MODELS.DEFAULT);
});

it('reports allowsCustom so the client can render a free-text picker', async () => {
const { body } = await get('/api/models/openrouter');
expect(body.allowsCustom).toBe(true);
});

it('answers 200 with an error field for an unknown provider rather than throwing', async () => {
const { status, body } = await get('/api/models/definitely-not-real');

expect(status).toBe(200);
expect(body.options).toEqual([]);
expect(body.error).toContain('Unknown provider');
});

it('never rejects when the harness is absent — the picker must still render', async () => {
const previous = process.env.CODEX_CLI_PATH;
process.env.CODEX_CLI_PATH = '/nonexistent/codex-binary';
try {
const { clearModelDiscoveryCache } = await import('../utils/harnessModelDiscovery.js');
clearModelDiscoveryCache('codex');

const { status, body } = await get('/api/models/codex');
expect(status).toBe(200);
expect(body.source).toBe('static');
expect(body.options.length).toBeGreaterThan(0);
} finally {
if (previous === undefined) delete process.env.CODEX_CLI_PATH;
else process.env.CODEX_CLI_PATH = previous;
}
});
});

describe('GET /api/models/providers', () => {
it('lists only the providers that can be probed', async () => {
const { status, body } = await get('/api/models/providers');

expect(status).toBe(200);
expect(body.providers).toEqual(['codex', 'openrouter']);
});
});
1 change: 1 addition & 0 deletions server/claude-sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ function getContextWindowForModel(modelName) {
// API format names
'claude-fable-5[1m]': 1000000,
'claude-fable-5': 200000,
'claude-opus-5': 1000000,
'claude-opus-4-8': 200000,
'claude-opus-4-7': 200000,
'claude-opus-4-6': 200000,
Expand Down
5 changes: 5 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import skillsRoutes from './routes/skills.js';
import modelsRoutes from './routes/models.js';
import telemetryRoutes from './routes/telemetry.js';
import computeRoutes from './routes/compute.js';
import newsRoutes from './routes/news.js';
Expand Down Expand Up @@ -518,6 +519,9 @@ app.use('/api/codex', authenticateToken, codexRoutes);
// Skills API Routes (protected)
app.use('/api/skills', authenticateToken, skillsRoutes);

// Harness model discovery Routes (protected)
app.use('/api/models', authenticateToken, modelsRoutes);

// Telemetry API Routes (protected)
app.use('/api/telemetry', authenticateToken, telemetryRoutes);

Expand Down Expand Up @@ -2895,6 +2899,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authentica
const MODEL_CONTEXT_WINDOWS = {
'claude-fable-5[1m]': 1000000,
'claude-fable-5': 200000,
'claude-opus-5': 1000000,
'claude-opus-4-8': 200000,
'claude-opus-4-7': 200000,
'claude-opus-4-6': 200000,
Expand Down
17 changes: 13 additions & 4 deletions server/routes/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { fileURLToPath } from 'url';
import os from 'os';
import matter from 'gray-matter';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
import { getModelsForProvider } from '../utils/harnessModelDiscovery.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand Down Expand Up @@ -189,11 +190,19 @@ Custom commands can be created in:
},

'/model': async (args, context) => {
// Read available models from centralized constants
// Ask each harness for its live list, falling back to the built-in
// constants. Without this, /model would keep offering models a CLI has
// retired while hiding ones it just shipped.
const [claude, cursor, codex] = await Promise.all([
getModelsForProvider('claude'),
getModelsForProvider('cursor'),
getModelsForProvider('codex'),
]);

const availableModels = {
claude: CLAUDE_MODELS.OPTIONS.map(o => o.value),
cursor: CURSOR_MODELS.OPTIONS.map(o => o.value),
codex: CODEX_MODELS.OPTIONS.map(o => o.value)
claude: claude.options.map(o => o.value),
cursor: cursor.options.map(o => o.value),
codex: codex.options.map(o => o.value)
};

const currentProvider = context?.provider || 'claude';
Expand Down
Loading
Loading