diff --git a/docs/configuration.md b/docs/configuration.md index 89d4c3a4..1d0f8278 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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: diff --git a/package-lock.json b/package-lock.json index cd5f4006..bdaf1733 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "(GPL-3.0-only AND AGPL-3.0-only)", "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", @@ -125,22 +125,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.170.tgz", - "integrity": "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA==", + "version": "0.3.226", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.226.tgz", + "integrity": "sha512-RvaZCZSKGjNIN/bDrQbyq/XkjVaUAPThxFrwFz2jdl6DvGnUtsGlt7hmPsaGC6BDudbA8yvkZFSqaJveK3WhfQ==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.226", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.226", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.226", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.226", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.226", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.226", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.226", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.226" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -149,9 +149,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.170.tgz", - "integrity": "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg==", + "version": "0.3.226", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.226.tgz", + "integrity": "sha512-ycyuSgN2XaSYdze1eM2wDwNmXS5wPqIh1RxiDs99ywPr9lpe3Y/Xcv0nz9JN5ahNoPIgWHIfI9Ac1EWCOdIF1Q==", "cpu": [ "arm64" ], @@ -162,9 +162,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.170.tgz", - "integrity": "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ==", + "version": "0.3.226", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.226.tgz", + "integrity": "sha512-sOOCkhtMDGVKs6k3fpTAkCML974qOnt8Bm9zlC6rV0HkM0aP4bdDY1RAlKLF4fHmOP2s5fPTY3myZiHGDFnuUg==", "cpu": [ "x64" ], @@ -175,9 +175,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.170.tgz", - "integrity": "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ==", + "version": "0.3.226", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.226.tgz", + "integrity": "sha512-YNwwC37m2vcY47mWZGqRmDh2ZSrO0Z01iTlIDsPmvKv03+7pwyaXVuq01Evtyp7see+KGeIYkMN37HhEt/h+8Q==", "cpu": [ "arm64" ], @@ -188,9 +188,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.170.tgz", - "integrity": "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg==", + "version": "0.3.226", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.226.tgz", + "integrity": "sha512-w/hsZ2SqJTyPxLgQiK6X0c2yQJ1W3jAJW5UV0gXLq6wnzUeOnHVtG+TnJu2LuHseHovRqDQ9t8EsDgnZE0vdlA==", "cpu": [ "arm64" ], @@ -201,9 +201,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.170.tgz", - "integrity": "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg==", + "version": "0.3.226", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.226.tgz", + "integrity": "sha512-gPoHNeko9E+bmKVPRiAcCAOyBBrVcIH/WdjmyaGVoTP2bKibTs978A42rMNtAnuPBcAGAiImQimUU7w1TXESFw==", "cpu": [ "x64" ], @@ -214,9 +214,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.170.tgz", - "integrity": "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ==", + "version": "0.3.226", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.226.tgz", + "integrity": "sha512-sMRt4ocfctoYLxPKpbOUd8hHhoMz2eQX8d3DN78Gl8r4uTpsDz5NCFLdkk7ikuRzXvoMPzUrFy+wFVIF0B7TLA==", "cpu": [ "x64" ], @@ -227,9 +227,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.170.tgz", - "integrity": "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg==", + "version": "0.3.226", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.226.tgz", + "integrity": "sha512-qkzWTR3Ns8PimC5rx4+cwfuyHlCRocGIAcdWDUgpnI70qH5GlqX9R0VfM7wGOCs/C+fJ04Hg0GfAkMv4xriZwA==", "cpu": [ "arm64" ], @@ -240,9 +240,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.170.tgz", - "integrity": "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA==", + "version": "0.3.226", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.226.tgz", + "integrity": "sha512-uxVbLwGSX6lvO5Tazv0gZu8WSg1o14DQsqGSY+5pDNUk28KmNbFIQAjky9KeDzk9lnf63/aQPPsaq6UAikWjqA==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index b20b08fb..11cb984f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/__tests__/models-route.test.mjs b/server/__tests__/models-route.test.mjs new file mode 100644 index 00000000..89ad87c1 --- /dev/null +++ b/server/__tests__/models-route.test.mjs @@ -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']); + }); +}); diff --git a/server/claude-sdk.js b/server/claude-sdk.js index 4e4a026a..2ea802de 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -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, diff --git a/server/index.js b/server/index.js index 3b83e75c..55f51870 100755 --- a/server/index.js +++ b/server/index.js @@ -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'; @@ -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); @@ -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, diff --git a/server/routes/commands.js b/server/routes/commands.js index ad7d10c6..54921872 100644 --- a/server/routes/commands.js +++ b/server/routes/commands.js @@ -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); @@ -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'; diff --git a/server/routes/models.js b/server/routes/models.js new file mode 100644 index 00000000..ced05220 --- /dev/null +++ b/server/routes/models.js @@ -0,0 +1,63 @@ +import express from 'express'; + +import { + clearModelDiscoveryCache, + getDiscoverableProviders, + getModelsForProvider, +} from '../utils/harnessModelDiscovery.js'; + +const router = express.Router(); + +/** + * GET /api/models/providers + * Which providers this build can probe for a live model list. + */ +router.get('/providers', async (req, res) => { + try { + res.json({ providers: getDiscoverableProviders() }); + } catch (error) { + console.error('[ERROR] Failed to list discoverable providers:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +/** + * GET /api/models/:provider + * + * Resolves to the harness's own model list when it can be probed, otherwise to + * the built-in list. `source` tells the client which it got, so the picker can + * show that it is running on a fallback. + * + * Pass ?refresh=1 to bypass the discovery cache. + */ +router.get('/:provider', async (req, res) => { + try { + const { provider } = req.params; + const force = req.query.refresh === '1' || req.query.refresh === 'true'; + + const payload = await getModelsForProvider(provider, { force }); + res.json(payload); + } catch (error) { + console.error('[ERROR] Failed to resolve models:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /api/models/:provider/refresh + * Drop the cached list and re-probe. Useful right after a CLI upgrade or login. + */ +router.post('/:provider/refresh', async (req, res) => { + try { + const { provider } = req.params; + clearModelDiscoveryCache(provider); + + const payload = await getModelsForProvider(provider, { force: true }); + res.json(payload); + } catch (error) { + console.error('[ERROR] Failed to refresh models:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +export default router; diff --git a/server/utils/__tests__/harnessModelDiscovery.test.js b/server/utils/__tests__/harnessModelDiscovery.test.js new file mode 100644 index 00000000..9b1d3c2c --- /dev/null +++ b/server/utils/__tests__/harnessModelDiscovery.test.js @@ -0,0 +1,464 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; + +/** + * These tests drive the real discovery code against a fake harness: a small + * Node script that speaks the same line-delimited JSON-RPC over stdio that + * `codex app-server` does. That keeps the transport, timeout, and parsing logic + * under test without requiring the Codex CLI to be installed. + */ + +let tmpDir; +let mod; + +async function writeFakeCodex(name, body) { + const file = path.join(tmpDir, name); + await fs.writeFile(file, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 }); + return file; +} + +const RESPOND_WITH_MODELS = ` +const models = [ + { id: 'gpt-9-alpha', model: 'gpt-9-alpha', displayName: 'GPT-9 Alpha', hidden: false, isDefault: true }, + { id: 'gpt-9-beta', model: 'gpt-9-beta', displayName: 'GPT-9 Beta', hidden: false, isDefault: false }, + { id: 'gpt-9-secret', model: 'gpt-9-secret', displayName: 'Hidden', hidden: true, isDefault: false }, +]; +let buf = ''; +process.stdin.on('data', (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\\n')) !== -1) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.method === 'initialize') { + process.stdout.write(JSON.stringify({ id: msg.id, result: { userAgent: 'fake' } }) + '\\n'); + process.stdout.write(JSON.stringify({ method: 'some/notification', params: {} }) + '\\n'); + } else if (msg.method === 'model/list') { + process.stdout.write(JSON.stringify({ id: msg.id, result: { data: models, nextCursor: null } }) + '\\n'); + } + } +}); +`; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'drclaw-models-')); + vi.resetModules(); + mod = await import('../harnessModelDiscovery.js'); + mod.clearModelDiscoveryCache(); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe('mergeModelOptions', () => { + it('puts discovered models first and keeps unlisted built-ins as deprecated', () => { + const merged = mod.mergeModelOptions( + [{ value: 'new-1', label: 'New One' }, { value: 'shared', label: 'Shared (live)' }], + [{ value: 'shared', label: 'Shared (built-in)' }, { value: 'retired', label: 'Retired' }], + ); + + expect(merged.map((o) => o.value)).toEqual(['new-1', 'shared', 'retired']); + // The harness's own label wins for a model both lists know about. + expect(merged[1].label).toBe('Shared (live)'); + expect(merged[1].deprecated).toBeUndefined(); + // A model the harness no longer serves is kept but marked, so a user whose + // saved preference points at it is not stranded. + expect(merged[2].deprecated).toBe(true); + }); + + it('drops duplicates and entries without a value', () => { + const merged = mod.mergeModelOptions( + [{ value: 'a' }, { value: 'a', label: 'dupe' }, { label: 'no value' }], + [{ value: 'a', label: 'built-in' }], + ); + expect(merged).toEqual([{ value: 'a', label: 'a' }]); + }); +}); + +describe('codex model discovery', () => { + it('reads the live list over JSON-RPC and hides hidden models', async () => { + const fake = await writeFakeCodex('fake-codex.mjs', RESPOND_WITH_MODELS); + + const payload = await mod.getModelsForProvider('codex', { + env: { ...process.env, CODEX_CLI_PATH: fake }, + }); + + expect(payload.source).toBe('discovered'); + const discovered = payload.options.filter((o) => !o.deprecated).map((o) => o.value); + expect(discovered).toEqual(['gpt-9-alpha', 'gpt-9-beta']); + expect(payload.options.map((o) => o.value)).not.toContain('gpt-9-secret'); + }); + + it('adopts the harness default when the configured one is no longer served', async () => { + const fake = await writeFakeCodex('fake-codex.mjs', RESPOND_WITH_MODELS); + + const payload = await mod.getModelsForProvider('codex', { + env: { ...process.env, CODEX_CLI_PATH: fake }, + }); + + // The built-in CODEX_MODELS.DEFAULT is not in the fake harness's catalogue, + // so defaulting to it would send every new session into an error. + expect(payload.default).toBe('gpt-9-alpha'); + }); + + it('keeps built-in models available alongside discovered ones', async () => { + const fake = await writeFakeCodex('fake-codex.mjs', RESPOND_WITH_MODELS); + const { CODEX_MODELS } = await import('../../../shared/modelConstants.js'); + + const payload = await mod.getModelsForProvider('codex', { + env: { ...process.env, CODEX_CLI_PATH: fake }, + }); + + for (const builtIn of CODEX_MODELS.OPTIONS) { + expect(payload.options.some((o) => o.value === builtIn.value)).toBe(true); + } + }); + + it('falls back to the built-in list when the harness is missing', async () => { + const { CODEX_MODELS } = await import('../../../shared/modelConstants.js'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const payload = await mod.getModelsForProvider('codex', { + env: { ...process.env, CODEX_CLI_PATH: path.join(tmpDir, 'does-not-exist') }, + }); + + expect(payload.source).toBe('static'); + expect(payload.options).toEqual(CODEX_MODELS.OPTIONS); + expect(payload.default).toBe(CODEX_MODELS.DEFAULT); + expect(payload.error).toBeTruthy(); + }); + + it('falls back rather than hanging when the harness never answers', async () => { + // A harness that accepts input and goes silent must not wedge the picker. + const fake = await writeFakeCodex('hang.mjs', 'process.stdin.resume();'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const started = Date.now(); + const payload = await mod.getModelsForProvider('codex', { + timeoutMs: 300, + env: { ...process.env, CODEX_CLI_PATH: fake }, + }); + + expect(payload.source).toBe('static'); + expect(Date.now() - started).toBeLessThan(5000); + }); + + it('falls back when the harness replies with a JSON-RPC error', async () => { + const fake = await writeFakeCodex('err.mjs', ` +let buf=''; +process.stdin.on('data',(d)=>{buf+=d.toString();let i; +while((i=buf.indexOf('\\n'))!==-1){const line=buf.slice(0,i);buf=buf.slice(i+1); +if(!line.trim())continue;const m=JSON.parse(line); +process.stdout.write(JSON.stringify({id:m.id,error:{message:'not logged in'}})+'\\n');}}); +`); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const payload = await mod.getModelsForProvider('codex', { + env: { ...process.env, CODEX_CLI_PATH: fake }, + }); + + expect(payload.source).toBe('static'); + expect(payload.error).toContain('not logged in'); + }); + + it('ignores non-JSON banner output before the JSON-RPC stream', async () => { + const fake = await writeFakeCodex('banner.mjs', ` +process.stdout.write('Welcome to Fake Codex!\\n'); +${RESPOND_WITH_MODELS} +`); + + const payload = await mod.getModelsForProvider('codex', { + env: { ...process.env, CODEX_CLI_PATH: fake }, + }); + + expect(payload.source).toBe('discovered'); + }); +}); + +describe('protocol robustness', () => { + it('follows nextCursor pagination across pages', async () => { + const fake = await writeFakeCodex('paged.mjs', ` +const pages = { + null: { data: [{ model: 'page-1-a', displayName: 'A', hidden: false, isDefault: true }], nextCursor: 'c1' }, + c1: { data: [{ model: 'page-2-a', displayName: 'B', hidden: false, isDefault: false }], nextCursor: 'c2' }, + c2: { data: [{ model: 'page-3-a', displayName: 'C', hidden: false, isDefault: false }], nextCursor: null }, +}; +let buf = ''; +process.stdin.on('data', (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\\n')) !== -1) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.method === 'initialize') { + process.stdout.write(JSON.stringify({ id: msg.id, result: {} }) + '\\n'); + } else if (msg.method === 'model/list') { + const key = msg.params && msg.params.cursor ? msg.params.cursor : 'null'; + process.stdout.write(JSON.stringify({ id: msg.id, result: pages[key] }) + '\\n'); + } + } +}); +`); + + const payload = await mod.getModelsForProvider('codex', { + env: { ...process.env, CODEX_CLI_PATH: fake }, + }); + + const live = payload.options.filter((o) => !o.deprecated).map((o) => o.value); + // Two-page-only pagination would have stopped at page-2-a. + expect(live).toEqual(['page-1-a', 'page-2-a', 'page-3-a']); + }); + + it('stops paginating rather than looping on an endless cursor', async () => { + const fake = await writeFakeCodex('endless.mjs', ` +let n = 0; +let buf = ''; +process.stdin.on('data', (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\\n')) !== -1) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.method === 'initialize') { + process.stdout.write(JSON.stringify({ id: msg.id, result: {} }) + '\\n'); + } else if (msg.method === 'model/list') { + n += 1; + process.stdout.write(JSON.stringify({ + id: msg.id, + result: { data: [{ model: 'm' + n, hidden: false }], nextCursor: 'always-more' }, + }) + '\\n'); + } + } +}); +`); + + const started = Date.now(); + const payload = await mod.getModelsForProvider('codex', { + timeoutMs: 10_000, + env: { ...process.env, CODEX_CLI_PATH: fake }, + }); + + expect(payload.source).toBe('discovered'); + expect(payload.options.filter((o) => !o.deprecated).length).toBeLessThanOrEqual(10); + expect(Date.now() - started).toBeLessThan(10_000); + }); + + it('sends the initialized notification after initialize', async () => { + // Some app-server versions reject requests made before this notification. + const marker = path.join(tmpDir, 'saw-initialized'); + const fake = await writeFakeCodex('strict.mjs', ` +import { writeFileSync } from 'fs'; +let initialized = false; +let buf = ''; +process.stdin.on('data', (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\\n')) !== -1) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.method === 'initialize') { + process.stdout.write(JSON.stringify({ id: msg.id, result: {} }) + '\\n'); + } else if (msg.method === 'initialized') { + initialized = true; + writeFileSync(${JSON.stringify(marker)}, 'yes'); + } else if (msg.method === 'model/list') { + if (!initialized) { + process.stdout.write(JSON.stringify({ id: msg.id, error: { message: 'Not initialized' } }) + '\\n'); + return; + } + process.stdout.write(JSON.stringify({ + id: msg.id, + result: { data: [{ model: 'strict-ok', hidden: false, isDefault: true }], nextCursor: null }, + }) + '\\n'); + } + } +}); +`); + + const payload = await mod.getModelsForProvider('codex', { + env: { ...process.env, CODEX_CLI_PATH: fake }, + }); + + expect(await fs.readFile(marker, 'utf8')).toBe('yes'); + expect(payload.source).toBe('discovered'); + expect(payload.options[0].value).toBe('strict-ok'); + }); + + it('keeps multi-byte labels intact when they straddle a stdout chunk', async () => { + // Emits the JSON one byte at a time, guaranteeing that multi-byte UTF-8 + // characters are split across 'data' events. + const fake = await writeFakeCodex('bytewise.mjs', ` +const payload = JSON.stringify({ + id: 101, + result: { data: [{ model: 'zh-model', displayName: '请问大家有变卡的情况吗 🦞', hidden: false, isDefault: true }], nextCursor: null }, +}) + '\\n'; +let buf = ''; +process.stdin.on('data', (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\\n')) !== -1) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.method === 'initialize') { + process.stdout.write(JSON.stringify({ id: msg.id, result: {} }) + '\\n'); + } else if (msg.method === 'model/list') { + const bytes = Buffer.from(payload, 'utf8'); + for (const byte of bytes) process.stdout.write(Buffer.from([byte])); + } + } +}); +`); + + const payload = await mod.getModelsForProvider('codex', { + env: { ...process.env, CODEX_CLI_PATH: fake }, + }); + + expect(payload.source).toBe('discovered'); + expect(payload.options[0].label).toBe('请问大家有变卡的情况吗 🦞'); + expect(payload.options[0].label).not.toContain('�'); + }); +}); + +describe('caching', () => { + it('serves repeat lookups from cache instead of re-spawning the harness', async () => { + const marker = path.join(tmpDir, 'spawn-count'); + const fake = await writeFakeCodex('counting.mjs', ` +import { appendFileSync } from 'fs'; +appendFileSync(${JSON.stringify(marker)}, 'x'); +${RESPOND_WITH_MODELS} +`); + const env = { ...process.env, CODEX_CLI_PATH: fake }; + + await mod.getModelsForProvider('codex', { env }); + await mod.getModelsForProvider('codex', { env }); + await mod.getModelsForProvider('codex', { env }); + + expect((await fs.readFile(marker, 'utf8')).length).toBe(1); + }); + + it('collapses concurrent cold lookups onto one probe', async () => { + const marker = path.join(tmpDir, 'spawn-count'); + const fake = await writeFakeCodex('counting.mjs', ` +import { appendFileSync } from 'fs'; +appendFileSync(${JSON.stringify(marker)}, 'x'); +${RESPOND_WITH_MODELS} +`); + const env = { ...process.env, CODEX_CLI_PATH: fake }; + + const [a, b, c] = await Promise.all([ + mod.getModelsForProvider('codex', { env }), + mod.getModelsForProvider('codex', { env }), + mod.getModelsForProvider('codex', { env }), + ]); + + expect((await fs.readFile(marker, 'utf8')).length).toBe(1); + expect(a).toBe(b); + expect(b).toBe(c); + }); + + it('re-probes a failed provider sooner than a successful one', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const missing = { ...process.env, CODEX_CLI_PATH: path.join(tmpDir, 'nope') }; + + const failed = await mod.getModelsForProvider('codex', { env: missing, ttlMs: 10 * 60 * 1000 }); + expect(failed.source).toBe('static'); + + // The failure TTL is capped well below the success TTL so the picker + // recovers on its own once the CLI is installed or the user logs in. + const fake = await writeFakeCodex('fake-codex.mjs', RESPOND_WITH_MODELS); + await new Promise((resolve) => setTimeout(resolve, 50)); + const forced = await mod.getModelsForProvider('codex', { + force: true, + env: { ...process.env, CODEX_CLI_PATH: fake }, + }); + expect(forced.source).toBe('discovered'); + }); + + it('a forced refresh does not join an already-running stale probe', async () => { + // A probe that answers slowly, and then a refresh issued while it is still + // in flight. Joining the pending probe would hand back exactly the stale + // answer the refresh was meant to discard. + const slow = await writeFakeCodex('slow.mjs', ` +let buf = ''; +process.stdin.on('data', (d) => { + buf += d.toString(); + let i; + while ((i = buf.indexOf('\\n')) !== -1) { + const line = buf.slice(0, i); buf = buf.slice(i + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.method === 'initialize') { + process.stdout.write(JSON.stringify({ id: msg.id, result: {} }) + '\\n'); + } else if (msg.method === 'model/list') { + setTimeout(() => { + process.stdout.write(JSON.stringify({ + id: msg.id, + result: { data: [{ model: 'stale-model', hidden: false, isDefault: true }], nextCursor: null }, + }) + '\\n'); + }, 400); + } + } +}); +`); + const fresh = await writeFakeCodex('fresh.mjs', RESPOND_WITH_MODELS); + + const pending = mod.getModelsForProvider('codex', { + env: { ...process.env, CODEX_CLI_PATH: slow }, + }); + + const refreshed = await mod.getModelsForProvider('codex', { + force: true, + env: { ...process.env, CODEX_CLI_PATH: fresh }, + }); + + expect(refreshed.options[0].value).toBe('gpt-9-alpha'); + expect(refreshed.options.map((o) => o.value)).not.toContain('stale-model'); + + await pending; + // The slow probe finishing last must not overwrite the refreshed result. + const afterSettling = await mod.getModelsForProvider('codex', { + env: { ...process.env, CODEX_CLI_PATH: fresh }, + }); + expect(afterSettling.options.map((o) => o.value)).not.toContain('stale-model'); + }); + + it('clears one provider without clearing the rest', async () => { + const fake = await writeFakeCodex('fake-codex.mjs', RESPOND_WITH_MODELS); + const env = { ...process.env, CODEX_CLI_PATH: fake }; + + await mod.getModelsForProvider('codex', { env }); + expect(() => mod.clearModelDiscoveryCache('codex')).not.toThrow(); + expect(() => mod.clearModelDiscoveryCache()).not.toThrow(); + }); +}); + +describe('providers without a discoverer', () => { + it('returns the built-in list unchanged', async () => { + const { CLAUDE_MODELS } = await import('../../../shared/modelConstants.js'); + const payload = await mod.getModelsForProvider('claude'); + + expect(payload.source).toBe('static'); + expect(payload.options).toEqual(CLAUDE_MODELS.OPTIONS); + expect(payload.error).toBeNull(); + }); + + it('reports an unknown provider without throwing', async () => { + const payload = await mod.getModelsForProvider('not-a-provider'); + + expect(payload.options).toEqual([]); + expect(payload.error).toContain('Unknown provider'); + }); + + it('only advertises providers it can actually probe', () => { + expect(mod.getDiscoverableProviders()).toEqual(['codex', 'openrouter']); + }); +}); diff --git a/server/utils/harnessModelDiscovery.js b/server/utils/harnessModelDiscovery.js new file mode 100644 index 00000000..aa9612fd --- /dev/null +++ b/server/utils/harnessModelDiscovery.js @@ -0,0 +1,455 @@ +/** + * Ask each harness which models it actually supports. + * + * The model lists in shared/modelConstants.js are hand-maintained, so every time + * a CLI ships a new model somebody has to notice and send a patch — and until + * they do, the picker offers models that no longer exist and hides the ones that + * do. (Concrete example: the pinned Codex list still offered `gpt-5.6`, `o3` and + * `o4-mini`, while codex-cli 0.145.0 actually serves `gpt-5.6-sol`, + * `gpt-5.4-mini` and `gpt-5.3-codex-spark`.) + * + * This module asks the tool instead. Discovery is strictly additive: every + * provider keeps its hand-maintained list, and a failed, slow or unsupported + * probe simply falls back to it. A harness must never be able to break the + * picker just by being absent, old, or unresponsive — hence the hard timeouts + * and the fact that nothing here is ever awaited on a critical path. + */ + +import { spawn } from 'child_process'; +import { StringDecoder } from 'string_decoder'; +import { + CLAUDE_MODELS, + CODEX_MODELS, + CURSOR_MODELS, + GEMINI_MODELS, + LOCAL_MODELS, + NANO_CLAUDE_CODE_MODELS, + OPENROUTER_MODELS, +} from '../../shared/modelConstants.js'; +import { buildCodexCliEnv, getCodexCliCommand } from './codexCli.js'; + +const STATIC_MODELS = { + claude: CLAUDE_MODELS, + codex: CODEX_MODELS, + cursor: CURSOR_MODELS, + gemini: GEMINI_MODELS, + local: LOCAL_MODELS, + nano: NANO_CLAUDE_CODE_MODELS, + openrouter: OPENROUTER_MODELS, +}; + +export const DISCOVERY_TTL_MS = 10 * 60 * 1000; +export const DISCOVERY_TIMEOUT_MS = 15 * 1000; + +const cache = new Map(); // provider -> { expiresAt, payload } +const inFlight = new Map(); // provider -> Promise +const generation = new Map(); // provider -> number; only the newest probe may write +const lastLoggedFailure = new Map(); // provider -> message + +/** + * Report a discovery failure once per distinct cause. + * + * Falling back is an expected, benign state — the CLI may simply not be + * installed — and failures are re-tried on a short TTL, so logging every one + * would spam the console for a non-problem. + */ +function reportDiscoveryFailure(provider, error) { + const message = String(error?.message || error); + if (lastLoggedFailure.get(provider) === message) { + return; + } + lastLoggedFailure.set(provider, message); + console.warn(`[models] ${provider} model discovery unavailable, using built-in list: ${message}`); +} + +/** + * Merge discovered options over the static list. + * + * Discovered order wins (the harness knows which model it wants to surface + * first), but static entries that the probe did not return are kept at the end + * rather than dropped. A harness can legitimately report a narrower list than + * dr-claw supports — for example only the models the current account is + * entitled to — and silently deleting a model a user already has selected would + * strand their saved preference. + */ +export function mergeModelOptions(discovered, staticOptions) { + const seen = new Set(); + const merged = []; + + for (const option of discovered) { + if (!option?.value || seen.has(option.value)) continue; + seen.add(option.value); + merged.push({ value: option.value, label: option.label || option.value }); + } + + for (const option of staticOptions || []) { + if (!option?.value || seen.has(option.value)) continue; + seen.add(option.value); + merged.push({ ...option, deprecated: true }); + } + + return merged; +} + +/** + * Drive one JSON-RPC request/response exchange over a child process's stdio. + * + * Kept generic because more than one harness speaks line-delimited JSON-RPC over + * stdio; `onMessage` returns a value to finish, or undefined to keep reading. + */ +function jsonRpcOverStdio({ command, args, env, cwd, timeoutMs, requests, onMessage }) { + return new Promise((resolve, reject) => { + let child; + try { + child = spawn(command, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env, + cwd, + shell: false, + }); + } catch (error) { + reject(error); + return; + } + + let settled = false; + // A multi-byte UTF-8 character can straddle two stdout chunks; decoding each + // chunk independently would corrupt model labels and descriptions. + const decoder = new StringDecoder('utf8'); + let buffer = ''; + let stderr = ''; + + const finish = (fn, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + child.kill(); + // SIGTERM is a request. Escalate so a CLI that traps or ignores it + // cannot outlive the probe holding its pipes open. Unref'd so this + // timer can never keep the server alive on its own. + const killTimer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch (_) { /* already gone */ } + }, 2000); + killTimer.unref?.(); + } catch (_) { /* already gone */ } + fn(value); + }; + + const timer = setTimeout(() => { + finish(reject, new Error(`${command} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + child.on('error', (error) => finish(reject, error)); + child.stderr?.on('data', (chunk) => { + // Bounded: a chatty harness must not be able to grow this without limit. + if (stderr.length < 8192) stderr += chunk.toString(); + }); + + child.on('close', (code) => { + finish(reject, new Error(`${command} exited with code ${code}${stderr ? `: ${stderr.trim().slice(0, 300)}` : ''}`)); + }); + + child.stdout?.on('data', (chunk) => { + buffer += decoder.write(chunk); + let newlineIndex; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if (!line) continue; + + let message; + try { + message = JSON.parse(line); + } catch (_) { + continue; // Non-JSON banner lines are normal on some CLIs. + } + + let outcome; + try { + outcome = onMessage(message, (request) => { + child.stdin?.write(`${JSON.stringify(request)}\n`); + }); + } catch (error) { + finish(reject, error); + return; + } + + if (outcome !== undefined) { + finish(resolve, outcome); + return; + } + } + }); + + for (const request of requests) { + child.stdin?.write(`${JSON.stringify(request)}\n`); + } + }); +} + +/** + * Codex exposes its live catalogue through the app-server's `model/list` + * JSON-RPC method, which is the same source the Codex UI's own picker reads. + */ +const CODEX_INITIALIZE_ID = 1; +const CODEX_MODEL_LIST_BASE_ID = 100; +// Bounded so a harness that keeps handing back a cursor cannot spin forever. +// The real catalogue is a single page of well under 100 entries. +const CODEX_MAX_MODEL_PAGES = 10; + +async function discoverCodexModels({ timeoutMs, env = process.env } = {}) { + const command = getCodexCliCommand(env); + const collected = []; + let page = 0; + + const requestPage = (send, cursor) => { + page += 1; + send({ + jsonrpc: '2.0', + id: CODEX_MODEL_LIST_BASE_ID + page, + method: 'model/list', + params: { includeHidden: false, ...(cursor ? { cursor } : {}) }, + }); + }; + + const models = await jsonRpcOverStdio({ + command, + args: ['app-server'], + env: buildCodexCliEnv(env), + timeoutMs, + requests: [{ + jsonrpc: '2.0', + id: CODEX_INITIALIZE_ID, + method: 'initialize', + params: { clientInfo: { name: 'dr-claw', title: 'Dr. Claw', version: '1.0.0' } }, + }], + onMessage: (message, send) => { + if (message.id === CODEX_INITIALIZE_ID) { + if (message.error) { + throw new Error(`initialize failed: ${message.error.message || 'unknown error'}`); + } + // codex-cli 0.145 answers model/list without this, but the documented + // lifecycle expects it and other versions may enforce it. It is a + // notification, so sending it costs nothing where it is not required. + send({ jsonrpc: '2.0', method: 'initialized', params: {} }); + requestPage(send, null); + return undefined; + } + + if (typeof message.id === 'number' && message.id > CODEX_MODEL_LIST_BASE_ID) { + if (message.error) { + throw new Error(`model/list failed: ${message.error.message || 'unknown error'}`); + } + collected.push(...(message.result?.data || [])); + + const nextCursor = message.result?.nextCursor; + if (nextCursor && page < CODEX_MAX_MODEL_PAGES) { + requestPage(send, nextCursor); + return undefined; + } + + return collected; + } + + return undefined; // Notifications and unrelated ids. + }, + }); + + return models + .filter((model) => model && !model.hidden && (model.model || model.id)) + .map((model) => ({ + value: model.model || model.id, + label: model.displayName || model.model || model.id, + description: model.description || undefined, + isDefault: Boolean(model.isDefault), + })); +} + +/** + * OpenRouter publishes its full catalogue over HTTP, so no CLI is involved. + * The list is thousands of entries long; the picker already allows free-form + * entry, so we surface it whole and let the combo box filter. + */ +async function discoverOpenRouterModels({ timeoutMs } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch('https://openrouter.ai/api/v1/models', { + signal: controller.signal, + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`OpenRouter responded ${response.status}`); + } + + const body = await response.json(); + return (body?.data || []) + .filter((model) => model?.id) + .map((model) => ({ + value: model.id, + label: model.name || model.id, + description: model.description ? String(model.description).slice(0, 200) : undefined, + })); + } finally { + clearTimeout(timer); + } +} + +const DISCOVERERS = { + codex: discoverCodexModels, + openrouter: discoverOpenRouterModels, +}; + +/** Providers this build knows how to probe. */ +export function getDiscoverableProviders() { + return Object.keys(DISCOVERERS); +} + +function buildFallbackPayload(provider, error = null) { + const config = STATIC_MODELS[provider]; + return { + provider, + options: config?.OPTIONS || [], + default: config?.DEFAULT ?? '', + allowsCustom: Boolean(config?.ALLOWS_CUSTOM), + source: 'static', + discoveredAt: null, + error: error ? String(error.message || error) : null, + }; +} + +async function runDiscovery(provider, { timeoutMs, env }) { + const config = STATIC_MODELS[provider]; + const discoverer = DISCOVERERS[provider]; + + if (!discoverer) { + return buildFallbackPayload(provider); + } + + try { + const discovered = await discoverer({ timeoutMs, env }); + + if (!Array.isArray(discovered) || discovered.length === 0) { + throw new Error('harness returned no models'); + } + + const options = mergeModelOptions(discovered, config?.OPTIONS); + const discoveredDefault = discovered.find((model) => model.isDefault)?.value; + + // Keep the configured default only if the *harness* still serves it — not + // merely if it survived into the merged list, which also carries retired + // entries. Defaulting to a model the harness dropped sends every new session + // straight into an error. + const configuredDefault = config?.DEFAULT; + const harnessStillServesConfigured = discovered.some((model) => model.value === configuredDefault); + + return { + provider, + options, + default: harnessStillServesConfigured + ? configuredDefault + : (discoveredDefault || options[0]?.value || configuredDefault || ''), + allowsCustom: Boolean(config?.ALLOWS_CUSTOM), + source: 'discovered', + discoveredAt: new Date().toISOString(), + error: null, + }; + } catch (error) { + reportDiscoveryFailure(provider, error); + return buildFallbackPayload(provider, error); + } +} + +/** + * Resolve the model list for a provider, discovering it when possible. + * + * Always resolves — never rejects — so a caller can render a picker + * unconditionally. + */ +export async function getModelsForProvider(provider, options = {}) { + const { + force = false, + timeoutMs = DISCOVERY_TIMEOUT_MS, + ttlMs = DISCOVERY_TTL_MS, + env = process.env, + } = options; + + if (!STATIC_MODELS[provider]) { + return { ...buildFallbackPayload(provider), error: `Unknown provider: ${provider}` }; + } + + const cached = cache.get(provider); + if (!force && cached && cached.expiresAt > Date.now()) { + return cached.payload; + } + + // Collapse concurrent callers: several picker mounts can race on a cold cache, + // and each miss spawns a child process. An explicit refresh must not join an + // already-running probe, though — that probe was started before whatever the + // user just changed (a CLI upgrade, a login), so its result is exactly the + // stale answer they asked us to discard. + if (!force && inFlight.has(provider)) { + return inFlight.get(provider); + } + + // Probes are not cancellable, so a superseded one still runs to completion and + // resolves whenever it likes — often after the probe that replaced it. Stamp + // each with a generation and let only the newest write to the cache, or a slow + // stale probe lands last and undoes the refresh that replaced it. + const myGeneration = (generation.get(provider) || 0) + 1; + generation.set(provider, myGeneration); + + const promise = runDiscovery(provider, { timeoutMs, env }) + .then((payload) => { + if (generation.get(provider) !== myGeneration) { + return payload; + } + // Only a successful probe earns the full TTL. A fallback is re-tried + // sooner so the picker recovers once the CLI is installed or logged in. + const ttl = payload.source === 'discovered' ? ttlMs : Math.min(ttlMs, 60_000); + cache.set(provider, { expiresAt: Date.now() + ttl, payload }); + return payload; + }) + .finally(() => { + if (inFlight.get(provider) === promise) { + inFlight.delete(provider); + } + }); + + inFlight.set(provider, promise); + return promise; +} + +/** + * Forget cached results. Also drops the in-flight entry so a probe already + * running against the old state cannot repopulate the cache after the clear. + */ +export function clearModelDiscoveryCache(provider = null) { + // Bumping the generation invalidates any probe already running against the + // state we were just told to forget, so it cannot repopulate the cache. + const invalidate = (key) => { + cache.delete(key); + inFlight.delete(key); + lastLoggedFailure.delete(key); + generation.set(key, (generation.get(key) || 0) + 1); + }; + + if (provider) { + invalidate(provider); + return; + } + + for (const key of new Set([...cache.keys(), ...inFlight.keys(), ...generation.keys()])) { + invalidate(key); + } +} + +export const __testing = { + discoverCodexModels, + discoverOpenRouterModels, + jsonRpcOverStdio, + STATIC_MODELS, +}; diff --git a/shared/modelConstants.js b/shared/modelConstants.js index 70e32706..24445aa1 100644 --- a/shared/modelConstants.js +++ b/shared/modelConstants.js @@ -13,6 +13,7 @@ export const CLAUDE_MODELS = { // Models in SDK format (what the actual SDK accepts) OPTIONS: [ + { value: 'claude-opus-5', label: 'Opus 5' }, { value: 'claude-fable-5', label: 'Fable 5' }, { value: 'claude-fable-5[1m]', label: 'Fable 5 [1M]' }, { value: 'sonnet', label: 'Sonnet' }, diff --git a/src/components/chat/hooks/useHarnessModels.ts b/src/components/chat/hooks/useHarnessModels.ts new file mode 100644 index 00000000..ca474d27 --- /dev/null +++ b/src/components/chat/hooks/useHarnessModels.ts @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { authenticatedFetch } from '../../../utils/api'; +import type { SessionProvider } from '../../../types/app'; + +export interface HarnessModelOption { + value: string; + label: string; + description?: string; + /** Present in the built-in list but not reported by the harness itself. */ + deprecated?: boolean; +} + +export interface HarnessModels { + options: HarnessModelOption[] | null; + /** 'discovered' when the harness answered; 'static' when we fell back. */ + source: 'discovered' | 'static' | null; + defaultModel: string | null; + isLoading: boolean; + refresh: () => void; +} + +/** + * Ask the server for the model list the selected harness actually supports. + * + * Returns `options: null` until (and unless) discovery produces something, so + * callers keep rendering their built-in list rather than flashing an empty + * picker. The server never fails this call — it falls back to the built-in list + * — so the only states here are "not answered yet" and "answered". + */ +export function useHarnessModels(provider: SessionProvider | string | null | undefined): HarnessModels { + const [options, setOptions] = useState(null); + const [source, setSource] = useState<'discovered' | 'static' | null>(null); + const [defaultModel, setDefaultModel] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [refreshToken, setRefreshToken] = useState(0); + + // Guards against a slow response for a provider the user has already switched + // away from overwriting the current one. + const requestedProviderRef = useRef(null); + + const refresh = useCallback(() => setRefreshToken((n) => n + 1), []); + + useEffect(() => { + if (!provider) { + setOptions(null); + setSource(null); + setDefaultModel(null); + return; + } + + let cancelled = false; + requestedProviderRef.current = provider; + setIsLoading(true); + // Clear immediately rather than on response: otherwise the picker keeps + // rendering the previous provider's models for the duration of the request. + setOptions(null); + setSource(null); + setDefaultModel(null); + + const query = refreshToken > 0 ? '?refresh=1' : ''; + authenticatedFetch(`/api/models/${encodeURIComponent(provider)}${query}`) + .then(async (res) => (res.ok ? res.json() : null)) + .then((data) => { + if (cancelled || requestedProviderRef.current !== provider) return; + if (!data || !Array.isArray(data.options)) { + setOptions(null); + setSource(null); + return; + } + // A static answer carries no information the caller does not already + // have compiled in, so leave it on its own list. + setSource(data.source ?? null); + setOptions(data.source === 'discovered' ? data.options : null); + setDefaultModel(typeof data.default === 'string' ? data.default : null); + }) + .catch(() => { + if (cancelled) return; + setOptions(null); + setSource(null); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [provider, refreshToken]); + + return { options, source, defaultModel, isLoading, refresh }; +} + +export default useHarnessModels; diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index f4c087b8..eec34c72 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -9,7 +9,7 @@ import ReferencePicker from '../../../references/view/ReferencePicker'; import PromptBadgeDropdown from './PromptBadgeDropdown'; import { Plus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import type { ChangeEvent, ClipboardEvent, @@ -30,6 +30,7 @@ import type { SessionMode, SessionProvider } from '../../../../types/app'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS, LOCAL_MODELS, NANO_CLAUDE_CODE_MODELS, OPENROUTER_MODELS } from '../../../../../shared/modelConstants'; import { authenticatedFetch } from '../../../../utils/api'; import { isAutoResearchScenario } from '../../utils/autoResearch'; +import { useHarnessModels } from '../../hooks/useHarnessModels'; // New subcomponents import SkillDropdown from './SkillDropdown'; @@ -336,10 +337,17 @@ export default function ChatComposer({ }; }, [sessionProvider, localModelProp, setLocalModel, t]); + // Prefer the list the harness reports over the compiled-in one, so a CLI that + // ships new models is picked up without a dr-claw release. Falls back to the + // built-in list whenever discovery is unavailable. + const { options: discoveredModels, defaultModel: discoveredDefault } = useHarnessModels(sessionProvider); + const rawModelConfig = getModelConfig(sessionProvider); const modelConfig = sessionProvider === 'local' && ollamaModels.length > 0 ? { ...rawModelConfig, OPTIONS: ollamaModels } - : rawModelConfig; + : discoveredModels && discoveredModels.length > 0 + ? { ...rawModelConfig, OPTIONS: discoveredModels } + : rawModelConfig; const selectProvider = (next: SessionProvider) => { if (providerAvailability?.[next]?.cliAvailable === false) return; @@ -358,6 +366,31 @@ export default function ChatComposer({ else { setCursorModel?.(value); localStorage.setItem('cursor-model', value); } }; + // Rescue a stored preference the harness has retired. Discovery keeps such a + // model visible in the picker so nothing silently disappears, but leaving it + // *selected* means every new session is submitted against a model the harness + // will reject. Only fires when the harness answered and positively excludes + // the current value, and at most once per (provider, model) so it can never + // fight a user who deliberately re-selects a deprecated entry. + const rescuedModelRef = useRef(null); + useEffect(() => { + if (!discoveredModels || !discoveredDefault || !currentModel) return; + if (discoveredDefault === currentModel) return; + + const servedByHarness = discoveredModels.some( + (option) => option.value === currentModel && !option.deprecated, + ); + if (servedByHarness) return; + + const rescueKey = `${sessionProvider}:${currentModel}`; + if (rescuedModelRef.current === rescueKey) return; + rescuedModelRef.current = rescueKey; + + handleModelChange(discoveredDefault); + // handleModelChange is redefined every render but only closes over setters. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [discoveredModels, discoveredDefault, currentModel, sessionProvider]); + const sessionModeChoices: Array<{ id: SessionMode; titleKey: string }> = [ { id: 'research', titleKey: 'session.mode.researchTitle' }, { id: 'workspace_qa', titleKey: 'session.mode.workspaceQaTitle' },