@sanqianx/claude-ai-workbench 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +129 -0
  3. package/apps/agent-terminal/app.css +567 -0
  4. package/apps/agent-terminal/app.js +2392 -0
  5. package/apps/agent-terminal/index.html +158 -0
  6. package/apps/ai-profile-settings-host/index.html +25 -0
  7. package/apps/ai-profile-settings-host/page.css +1 -0
  8. package/apps/ai-profile-settings-host/page.js +113 -0
  9. package/apps/minimal-test-host/README.md +15 -0
  10. package/apps/minimal-test-host/host.css +2 -0
  11. package/apps/minimal-test-host/host.js +35 -0
  12. package/apps/minimal-test-host/index.html +58 -0
  13. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -0
  14. package/docs/sdk/FILE_LAYOUT.md +31 -0
  15. package/docs/sdk/INTEGRATION.md +126 -0
  16. package/docs/sdk/README.md +21 -0
  17. package/docs/sdk/openapi.yaml +760 -0
  18. package/package.json +51 -0
  19. package/packages/client/README.md +11 -0
  20. package/packages/client/index.d.ts +81 -0
  21. package/packages/client/index.js +198 -0
  22. package/packages/client/package.json +29 -0
  23. package/packages/contracts/README.md +9 -0
  24. package/packages/contracts/index.d.ts +252 -0
  25. package/packages/contracts/index.js +295 -0
  26. package/packages/contracts/package.json +24 -0
  27. package/packages/core/README.md +8 -0
  28. package/packages/core/index.d.ts +76 -0
  29. package/packages/core/index.js +214 -0
  30. package/packages/core/package.json +29 -0
  31. package/packages/server/README.md +17 -0
  32. package/packages/server/bin/agent-terminal-server.js +36 -0
  33. package/packages/server/bin/claude-workbench-server.js +46 -0
  34. package/packages/server/bin/claude-workbench.js +225 -0
  35. package/packages/server/index.d.ts +145 -0
  36. package/packages/server/index.js +183 -0
  37. package/packages/server/lib/agent-discovery.js +112 -0
  38. package/packages/server/lib/agent-registry.js +98 -0
  39. package/packages/server/lib/attachment-store.js +78 -0
  40. package/packages/server/lib/bridges/app-server-bridge.js +372 -0
  41. package/packages/server/lib/bridges/opencode-server-bridge.js +490 -0
  42. package/packages/server/lib/capture-settings-store.js +34 -0
  43. package/packages/server/lib/context-resolver.js +40 -0
  44. package/packages/server/lib/drivers/cli-json-runner.js +960 -0
  45. package/packages/server/lib/drivers/codex-driver.js +1413 -0
  46. package/packages/server/lib/drivers/opencode-driver.js +1527 -0
  47. package/packages/server/lib/drivers/zcode-driver.js +1167 -0
  48. package/packages/server/lib/event-stream.js +35 -0
  49. package/packages/server/lib/folder-picker.js +623 -0
  50. package/packages/server/lib/http-handler.js +254 -0
  51. package/packages/server/lib/json-file.js +19 -0
  52. package/packages/server/lib/model-detection.js +295 -0
  53. package/packages/server/lib/profile-store.js +149 -0
  54. package/packages/server/lib/project-store.js +102 -0
  55. package/packages/server/lib/session-archive-store.js +53 -0
  56. package/packages/server/lib/system-doctor.js +119 -0
  57. package/packages/server/lib/ui-preferences-store.js +48 -0
  58. package/packages/server/lib/workbench-service.js +620 -0
  59. package/packages/server/package.json +34 -0
  60. package/packages/ui/README.md +30 -0
  61. package/packages/ui/ai-profile-settings.d.ts +45 -0
  62. package/packages/ui/ai-profile-settings.js +283 -0
  63. package/packages/ui/claude-terminal.d.ts +31 -0
  64. package/packages/ui/claude-terminal.js +343 -0
  65. package/packages/ui/index.d.ts +18 -0
  66. package/packages/ui/index.js +51 -0
  67. package/packages/ui/package.json +42 -0
  68. package/packages/ui/theme.css +13 -0
  69. package/src/assets/claude-prompts.json +21 -0
  70. package/src/backend/http/ai-profile-routes.js +61 -0
  71. package/src/backend/http/claude-routes.js +170 -0
  72. package/src/backend/http/node-helpers.js +45 -0
  73. package/src/backend/index.js +21 -0
  74. package/src/backend/lib/ai-adapter.js +144 -0
  75. package/src/backend/lib/ai-profile-store.js +259 -0
  76. package/src/backend/lib/ai-vendor-presets.js +83 -0
  77. package/src/backend/lib/ai-workspace.js +140 -0
  78. package/src/backend/lib/automation-config.js +253 -0
  79. package/src/backend/lib/claude-cli-runner.js +1749 -0
  80. package/src/backend/lib/data-dir.js +169 -0
  81. package/src/backend/lib/llm-client.js +176 -0
  82. package/src/backend/lib/model-context-windows.js +82 -0
  83. package/src/backend/lib/prompt-registry.js +166 -0
  84. package/src/backend/lib/runtime-config.js +121 -0
  85. package/src/index.js +7 -0
  86. package/src/sdk-wave-a.ts +1 -0
  87. package/src/sdk-wave-b.ts +1 -0
  88. package/src/sdk-wave-c.ts +1 -0
  89. package/src/sdk-wave-d.ts +1 -0
  90. package/src/sdk-wave-e.ts +1 -0
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+
3
+ const { DEFAULT_API_PREFIX, cleanId } = require('../../contracts');
4
+ const { pickFolder: defaultPickFolder } = require('./folder-picker');
5
+
6
+ function createHttpHandler(service, options = {}) {
7
+ const prefix = String(options.apiPrefix || DEFAULT_API_PREFIX).replace(/\/$/, '');
8
+ // Stateless host capability — the picker module is required directly.
9
+ const pickFolder = options.pickFolder || defaultPickFolder;
10
+ return async function handle(req, res) {
11
+ const url = new URL(req.url, 'http://127.0.0.1');
12
+ if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return false;
13
+ const path = url.pathname.slice(prefix.length) || '/';
14
+ // System endpoints trigger host-native side effects (a modal folder
15
+ // dialog) and answer with a local filesystem path. The API-wide CORS `*`
16
+ // would let any website stack dialogs and read the choice, so they are
17
+ // restricted to loopback origins — same-origin GETs and non-browser
18
+ // clients send no Origin header at all and always pass.
19
+ if (path.startsWith('/system/')) {
20
+ const origin = req.headers.origin;
21
+ if (origin && !isLoopbackOrigin(origin) && origin !== options.corsOrigin) {
22
+ return json(res, 403, { ok: false, error: 'cross-origin access to system endpoints is not allowed' });
23
+ }
24
+ if (origin) {
25
+ res.setHeader('Access-Control-Allow-Origin', origin);
26
+ res.setHeader('Vary', 'Origin');
27
+ }
28
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return true; }
29
+ } else {
30
+ setCors(req, res, options.corsOrigin);
31
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return true; }
32
+ }
33
+ try {
34
+ if (req.method === 'GET' && path === '/health') return json(res, 200, service.health());
35
+ // {path} on success, {path:null} when the user cancelled, 501 when the
36
+ // host has no folder picker, 500 on real failures. `win=x,y,w,h` (the
37
+ // calling browser window's screen rect) anchors the dialog so it opens
38
+ // over the UI instead of the screen's top-left corner.
39
+ if (req.method === 'GET' && path === '/system/pick-folder') {
40
+ const parts = (url.searchParams.get('win') || '').split(',').map(Number);
41
+ const windowRect = parts.length === 4 && parts.every(Number.isFinite)
42
+ ? { x: parts[0], y: parts[1], width: parts[2], height: parts[3] }
43
+ : undefined;
44
+ return json(res, 200, await pickFolder({ windowRect, title: '选择项目文件夹' }));
45
+ }
46
+ // Environment diagnostics: resolved executables, live version probes,
47
+ // login-state files (no secrets). Answers "discovery showed the agent
48
+ // but turns failed" on fresh machines.
49
+ if (req.method === 'GET' && path === '/system/doctor') {
50
+ return json(res, 200, (options.doctor && options.doctor()) || {
51
+ schema: 'agent-terminal-doctor/v1',
52
+ error: 'doctor not wired',
53
+ });
54
+ }
55
+ if (req.method === 'GET' && path === '/runtime') return json(res, 200, service.runtime());
56
+ if (req.method === 'GET' && path === '/agents') return json(res, 200, service.listAgents());
57
+ if (req.method === 'GET' && path === '/profiles') return json(res, 200, service.listProfiles());
58
+ if (req.method === 'POST' && path === '/profiles') return json(res, 201, service.saveProfile(await body(req)));
59
+ if (req.method === 'POST' && path === '/profiles/test') return json(res, 200, await service.testProfileDraft(await body(req)));
60
+ if (req.method === 'POST' && path === '/profiles/detect-models') return json(res, 200, await service.detectProfileModels(await body(req)));
61
+ if (req.method === 'GET' && path === '/capture-settings') return json(res, 200, service.getCaptureSettings());
62
+ if (req.method === 'PUT' && path === '/capture-settings') return json(res, 200, service.saveCaptureSettings(await body(req)));
63
+ if (req.method === 'GET' && path === '/ui-preferences') return json(res, 200, service.getUiPreferences());
64
+ if (req.method === 'PUT' && path === '/ui-preferences') return json(res, 200, service.saveUiPreferences(await body(req)));
65
+
66
+ if (path === '/projects' && req.method === 'GET') return json(res, 200, service.listProjects());
67
+ if (path === '/projects' && req.method === 'POST') return json(res, 201, service.createProject(await body(req)));
68
+ const projectMatch = path.match(/^\/projects\/([^/]+)$/);
69
+ if (projectMatch) {
70
+ const id = decode(projectMatch[1]);
71
+ if (req.method === 'GET') return json(res, 200, service.getProject(id));
72
+ if (req.method === 'PATCH') return json(res, 200, service.renameProject(id, await body(req)));
73
+ if (req.method === 'DELETE') {
74
+ service.removeProject(id, { keepSessions: url.searchParams.get('keepSessions') === '1' });
75
+ res.writeHead(204); res.end(); return true;
76
+ }
77
+ }
78
+
79
+ let match = path.match(/^\/profiles\/([^/]+)$/);
80
+ if (match) {
81
+ const id = decode(match[1]);
82
+ if (req.method === 'GET') {
83
+ const profile = service.getProfile(id);
84
+ if (!profile) throw Object.assign(new Error('profile not found'), { status: 404 });
85
+ return json(res, 200, profile);
86
+ }
87
+ if (req.method === 'PUT') return json(res, 200, service.saveProfile(await body(req), id));
88
+ if (req.method === 'DELETE') {
89
+ if (!service.deleteProfile(id)) throw Object.assign(new Error('profile not found'), { status: 404 });
90
+ res.writeHead(204); res.end(); return true;
91
+ }
92
+ }
93
+
94
+ match = path.match(/^\/profiles\/([^/]+)\/credential$/);
95
+ if (match && req.method === 'PUT') {
96
+ service.setCredential(decode(match[1]), await body(req));
97
+ res.writeHead(204); res.end(); return true;
98
+ }
99
+ match = path.match(/^\/profiles\/([^/]+)\/test$/);
100
+ if (match && req.method === 'POST') return json(res, 200, await service.testProfile(decode(match[1])));
101
+
102
+ if (path === '/sessions' && req.method === 'GET') {
103
+ const sessions = service.listSessions(url.searchParams.get('contextId'), {
104
+ includeArchived: ['1', 'true'].includes(url.searchParams.get('archived')),
105
+ });
106
+ return json(res, 200, { sessions });
107
+ }
108
+ if (path === '/sessions' && req.method === 'POST') {
109
+ const input = await body(req);
110
+ const session = service.startSession(input.context, input.aiProfileId, input);
111
+ return json(res, 201, { session });
112
+ }
113
+
114
+ match = path.match(/^\/sessions\/([^/]+)\/archive$/);
115
+ if (match && req.method === 'PUT') return json(res, 200, service.archiveSession(decode(match[1])));
116
+ if (match && req.method === 'DELETE') {
117
+ service.restoreSession(decode(match[1]));
118
+ res.writeHead(204); res.end(); return true;
119
+ }
120
+
121
+ match = path.match(/^\/sessions\/([^/]+)\/context-usage$/);
122
+ if (match && req.method === 'GET') return json(res, 200, service.contextUsage(decode(match[1])));
123
+
124
+ match = path.match(/^\/sessions\/([^/]+)$/);
125
+ if (match && req.method === 'GET') return json(res, 200, service.loadSession(decode(match[1])));
126
+
127
+ match = path.match(/^\/sessions\/([^/]+)\/events$/);
128
+ if (match && req.method === 'GET') {
129
+ const sessionId = decode(match[1]);
130
+ const after = sequenceFromRequest(req, url);
131
+ service.loadSession(sessionId);
132
+ res.writeHead(200, {
133
+ 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store, no-transform',
134
+ Connection: 'keep-alive', 'X-Accel-Buffering': 'no',
135
+ 'Access-Control-Allow-Origin': options.corsOrigin || '*',
136
+ });
137
+ res.write(': claude-workbench stream\n\n');
138
+ const unsubscribe = service.subscribe(sessionId, after, event => writeSse(res, event));
139
+ const heartbeat = setInterval(() => { try { res.write(`: keepalive ${Date.now()}\n\n`); } catch {} }, 15000);
140
+ heartbeat.unref && heartbeat.unref();
141
+ let closed = false;
142
+ const close = () => { if (closed) return; closed = true; clearInterval(heartbeat); try { unsubscribe(); } catch {} };
143
+ req.on('close', close); req.on('error', close); res.on('close', close);
144
+ return true;
145
+ }
146
+
147
+ match = path.match(/^\/sessions\/([^/]+)\/input$/);
148
+ if (match && req.method === 'POST') {
149
+ const input = await body(req, 32 * 1024 * 1024);
150
+ return json(res, 202, await service.send(decode(match[1]), input.text, input.options, input.attachments));
151
+ }
152
+ match = path.match(/^\/sessions\/([^/]+)\/commands$/);
153
+ if (match && req.method === 'GET') return json(res, 200, await service.listCommands(decode(match[1])));
154
+
155
+ match = path.match(/^\/sessions\/([^/]+)\/attachments\/([^/]+)$/);
156
+ if (match && req.method === 'GET') {
157
+ const attachment = service.readAttachment(decode(match[1]), decode(match[2]));
158
+ const headers = {
159
+ 'Content-Type': attachment.metadata.mediaType,
160
+ 'Content-Length': attachment.data.length,
161
+ 'Cache-Control': 'private, max-age=3600',
162
+ 'X-Content-Type-Options': 'nosniff',
163
+ };
164
+ // Images stay inline for the chat UI; anything textual is forced to
165
+ // download so a crafted .html/.svg attachment can never render in the
166
+ // page origin.
167
+ if (!String(attachment.metadata.mediaType || '').startsWith('image/')) {
168
+ const safeName = String(attachment.metadata.name || 'attachment').replace(/[^\w.\-]+/g, '_').slice(0, 100) || 'attachment';
169
+ headers['Content-Disposition'] = `attachment; filename="${safeName}"`;
170
+ }
171
+ res.writeHead(200, headers);
172
+ res.end(attachment.data);
173
+ return true;
174
+ }
175
+ match = path.match(/^\/sessions\/([^/]+)\/permissions\/([^/]+)\/resolve$/);
176
+ if (match && req.method === 'POST') {
177
+ return json(res, 200, service.resolvePermission(decode(match[1]), decode(match[2]), await body(req)));
178
+ }
179
+ match = path.match(/^\/sessions\/([^/]+)\/abort$/);
180
+ if (match && req.method === 'POST') return json(res, 202, service.abort(decode(match[1])));
181
+
182
+ match = path.match(/^\/sessions\/([^/]+)\/permission-mode$/);
183
+ if (match && req.method === 'PUT') {
184
+ const input = await body(req);
185
+ return json(res, 200, service.setPermissionMode(decode(match[1]), input.mode));
186
+ }
187
+ match = path.match(/^\/sessions\/([^/]+)\/selection$/);
188
+ if (match && req.method === 'PUT') {
189
+ const input = await body(req);
190
+ return json(res, 200, service.updateSessionSelection(decode(match[1]), input.aiProfileId, input.model));
191
+ }
192
+ throw Object.assign(new Error('route not found'), { status: 404 });
193
+ } catch (error) {
194
+ if (res.headersSent) {
195
+ if (!res.writableEnded) try { res.end(); } catch {}
196
+ return true;
197
+ }
198
+ const status = Number(error.status) || (error.message === 'request body too large' ? 413 : 400);
199
+ return json(res, status, { ok: false, error: safeError(error) });
200
+ }
201
+ };
202
+ }
203
+
204
+ function body(req, limit = 1024 * 1024) {
205
+ return new Promise((resolve, reject) => {
206
+ const chunks = []; let size = 0; let settled = false;
207
+ req.on('data', chunk => {
208
+ if (settled) return;
209
+ size += chunk.length;
210
+ if (size > limit) { settled = true; reject(new Error('request body too large')); return; }
211
+ chunks.push(chunk);
212
+ });
213
+ req.on('end', () => {
214
+ if (settled) return;
215
+ try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); }
216
+ catch { reject(Object.assign(new Error('invalid JSON body'), { status: 400 })); }
217
+ });
218
+ req.on('error', reject);
219
+ });
220
+ }
221
+
222
+ function json(res, status, value) {
223
+ const payload = status === 204 ? undefined : JSON.stringify(value);
224
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
225
+ res.end(payload);
226
+ return true;
227
+ }
228
+ function writeSse(res, event) {
229
+ res.write(`id: ${event.sequence}\n`);
230
+ res.write(`event: ${event.type}\n`);
231
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
232
+ }
233
+ function sequenceFromRequest(req, url) {
234
+ const value = req.headers['last-event-id'] || url.searchParams.get('after') || 0;
235
+ const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
236
+ }
237
+ function setCors(req, res, origin) {
238
+ res.setHeader('Access-Control-Allow-Origin', origin || '*');
239
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Last-Event-ID');
240
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
241
+ }
242
+ // `Origin` values a system endpoint may serve: the local machine itself
243
+ // (the app is same-origin already; a dev server on another loopback port is
244
+ // fine). `Origin: null` (sandboxed/data frames) and foreign hosts are not.
245
+ function isLoopbackOrigin(origin) {
246
+ let parsed;
247
+ try { parsed = new URL(origin); } catch { return false; }
248
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
249
+ return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(parsed.hostname);
250
+ }
251
+ function decode(value) { try { return cleanId(decodeURIComponent(value)); } catch (error) { error.status = 400; throw error; } }
252
+ function safeError(error) { return String(error && error.message || 'request failed').replace(/[\r\n]+/g, ' ').slice(0, 500); }
253
+
254
+ module.exports = { createHttpHandler, body, writeSse, sequenceFromRequest };
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function readJson(file, fallback) {
7
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
8
+ catch { return fallback; }
9
+ }
10
+
11
+ function writeJson(file, value, mode = 0o600) {
12
+ fs.mkdirSync(path.dirname(file), { recursive: true });
13
+ const temp = `${file}.${process.pid}.tmp`;
14
+ fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode });
15
+ fs.renameSync(temp, file);
16
+ try { fs.chmodSync(file, mode); } catch {}
17
+ }
18
+
19
+ module.exports = { readJson, writeJson };
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ // Auto-detection of a provider's usable models, modeled after all-api-hub:
4
+ // 1. The provider's own models endpoint yields the ids the key can use —
5
+ // Anthropic-style (x-api-key, /v1/models with cursor pagination and a
6
+ // Bearer retry on 401) and OpenAI-style (Bearer, /v1/models with a
7
+ // /models fallback only for missing-route 404/405).
8
+ // 2. Capability metadata (context window, input modalities, reasoning,
9
+ // tool calls) comes from the models.dev catalog, cached in memory for
10
+ // 24h and degrading to "unavailable" when it cannot be fetched.
11
+ // The two sides are joined with a forgiving id match chain so relay-station
12
+ // ids like claude-3-5-sonnet-20241022-latest still find their metadata.
13
+
14
+ const MODELS_DEV_URL = 'https://models.dev/models.json';
15
+ const METADATA_TTL_MS = 24 * 60 * 60 * 1000;
16
+ const ANTHROPIC_VERSION = '2023-06-01';
17
+ const PAGE_LIMIT = 1000;
18
+ const MAX_PAGES = 5;
19
+ const MAX_MODELS = 2000;
20
+ const REQUEST_TIMEOUT_MS = 15000;
21
+ const DATE_SUFFIX = /-(?:latest|preview|stable|\d{4}-?\d{2}-?\d{2}|\d{8,10})(?:-\d+)?$/i;
22
+
23
+ function createModelDetection(options = {}) {
24
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
25
+ if (typeof fetchImpl !== 'function') throw new Error('fetch is required for model detection');
26
+ const contextLookup = typeof options.contextLookup === 'function' ? options.contextLookup : () => null;
27
+ const modelsDevUrl = options.modelsDevUrl || MODELS_DEV_URL;
28
+ let cache = null; // { entries, exact, tokenKeys, fetchedAt }
29
+ let inFlight = null;
30
+
31
+ async function fetchJson(url, headers) {
32
+ const controller = new AbortController();
33
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
34
+ try {
35
+ const response = await fetchImpl(url, { headers, signal: controller.signal });
36
+ const text = await response.text();
37
+ let data = null;
38
+ try { data = text ? JSON.parse(text) : null; } catch { /* non-JSON error bodies stay raw */ }
39
+ return { response, data, text };
40
+ } finally {
41
+ clearTimeout(timer);
42
+ }
43
+ }
44
+
45
+ function endpointError(label, url, response, text) {
46
+ const snippet = String(text || '').replace(/\s+/g, ' ').slice(0, 200);
47
+ return Object.assign(
48
+ new Error(`${label} request failed: HTTP ${response.status}${snippet ? ` — ${snippet}` : ''}`),
49
+ { status: 400, upstreamStatus: response.status, url },
50
+ );
51
+ }
52
+
53
+ // "https://x.test/v1" and "https://x.test/v1/" both normalize to
54
+ // "https://x.test" so /v1/models is never doubled.
55
+ function stripVersionSuffix(baseUrl) {
56
+ return String(baseUrl || '').trim().replace(/\/+$/, '').replace(/\/v1(?:beta)?$/i, '');
57
+ }
58
+
59
+ function collectIds(data) {
60
+ const rows = data && Array.isArray(data.data) ? data.data : Array.isArray(data) ? data : null;
61
+ if (!rows) return null;
62
+ const ids = [];
63
+ for (const row of rows) {
64
+ const id = row && typeof row === 'object' ? String(row.id || '').trim() : '';
65
+ if (id) ids.push(id);
66
+ }
67
+ return ids;
68
+ }
69
+
70
+ async function fetchAnthropicModels(baseUrl, apiKey) {
71
+ const root = stripVersionSuffix(baseUrl);
72
+ const seen = new Set();
73
+ const ids = [];
74
+ let afterId = '';
75
+ for (let page = 0; page < MAX_PAGES && ids.length < MAX_MODELS; page++) {
76
+ const query = `limit=${PAGE_LIMIT}${afterId ? `&after_id=${encodeURIComponent(afterId)}` : ''}`;
77
+ const url = `${root}/v1/models?${query}`;
78
+ let result = await fetchJson(url, { 'x-api-key': apiKey, 'anthropic-version': ANTHROPIC_VERSION });
79
+ if (result.response.status === 401) {
80
+ // Some relays only accept Bearer auth; retry once before failing.
81
+ result = await fetchJson(url, { Authorization: `Bearer ${apiKey}`, 'anthropic-version': ANTHROPIC_VERSION });
82
+ }
83
+ if (!result.response.ok) throw endpointError('Anthropic models', url, result.response, result.text);
84
+ const pageIds = collectIds(result.data);
85
+ if (!pageIds) throw Object.assign(new Error(`Anthropic models response has no model list: ${url}`), { status: 400 });
86
+ for (const id of pageIds) { if (!seen.has(id)) { seen.add(id); ids.push(id); } }
87
+ const last = pageIds.length ? pageIds[pageIds.length - 1] : '';
88
+ if (result.data.has_more !== true || !last || last === afterId) break;
89
+ afterId = last;
90
+ }
91
+ return ids.slice(0, MAX_MODELS);
92
+ }
93
+
94
+ async function fetchOpenAiModels(baseUrl, apiKey) {
95
+ const root = stripVersionSuffix(baseUrl);
96
+ const auth = { Authorization: `Bearer ${apiKey}` };
97
+ let result = await fetchJson(`${root}/v1/models`, auth);
98
+ // Volcengine-style hosts expose /models on an already-prefixed base URL;
99
+ // only a missing route falls through — auth and server errors fail fast.
100
+ if (result.response.status === 404 || result.response.status === 405) {
101
+ result = await fetchJson(`${String(baseUrl).trim().replace(/\/+$/, '')}/models`, auth);
102
+ }
103
+ if (!result.response.ok) throw endpointError('OpenAI models', root, result.response, result.text);
104
+ const ids = collectIds(result.data);
105
+ if (!ids) throw Object.assign(new Error(`OpenAI models response has no model list: ${root}/v1/models`), { status: 400 });
106
+ return ids.slice(0, MAX_MODELS);
107
+ }
108
+
109
+ // ---- models.dev capability catalog ----
110
+
111
+ function normalizeCatalog(payload) {
112
+ const entries = [];
113
+ const source = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
114
+ for (const [key, raw] of Object.entries(source)) {
115
+ if (!raw || typeof raw !== 'object') continue;
116
+ // The map key "provider/model-id" is authoritative; entry.id may be bare.
117
+ const bareId = String(key.includes('/') ? key.slice(key.lastIndexOf('/') + 1) : raw.id || key).trim();
118
+ if (!bareId) continue;
119
+ const modalities = raw.modalities && typeof raw.modalities === 'object' ? raw.modalities : {};
120
+ const limit = raw.limit && typeof raw.limit === 'object' ? raw.limit : {};
121
+ const context = Number(limit.context);
122
+ const maxOutput = Number(limit.output);
123
+ entries.push({
124
+ id: bareId,
125
+ name: String(raw.name || bareId).trim(),
126
+ provider: String(key.includes('/') ? key.slice(0, key.indexOf('/')) : raw.provider_id || '').trim(),
127
+ reasoning: raw.reasoning === true,
128
+ toolCall: raw.tool_call === true,
129
+ attachment: raw.attachment === true,
130
+ input: Array.isArray(modalities.input) ? modalities.input.map(String) : [],
131
+ output: Array.isArray(modalities.output) ? modalities.output.map(String) : [],
132
+ context: Number.isFinite(context) && context > 0 ? Math.floor(context) : null,
133
+ maxOutput: Number.isFinite(maxOutput) && maxOutput > 0 ? Math.floor(maxOutput) : null,
134
+ });
135
+ }
136
+ const exact = new Map();
137
+ for (const entry of entries) {
138
+ const key = entry.id.toLowerCase();
139
+ if (exact.has(key)) exact.set(key, null); // ambiguous: never match silently
140
+ else exact.set(key, entry);
141
+ }
142
+ const tokenKeys = new Map();
143
+ for (const entry of entries) {
144
+ const key = tokenKey(entry.id);
145
+ if (tokenKeys.has(key)) tokenKeys.set(key, null);
146
+ else tokenKeys.set(key, entry);
147
+ }
148
+ return { entries, exact, tokenKeys, fetchedAt: Date.now() };
149
+ }
150
+
151
+ function tokenKey(id) {
152
+ const base = String(id || '').toLowerCase().replace(/[._]/g, '-');
153
+ const tokens = base.split('-').filter(token => token && !DATE_SUFFIX.test(`-${token}`) && token !== 'latest' && token !== 'preview');
154
+ if (!tokens.length) return base;
155
+ const [first, ...rest] = tokens;
156
+ return `${first}:${[...rest].sort().join('-')}`;
157
+ }
158
+
159
+ function matchModel(catalog, modelId) {
160
+ if (!catalog || !modelId) return null;
161
+ const bare = String(modelId).toLowerCase();
162
+ const direct = catalog.exact.get(bare);
163
+ if (direct) return direct;
164
+ // Fetched ids sometimes carry a provider prefix (openai/gpt-4o).
165
+ const suffix = bare.includes('/') ? bare.slice(bare.lastIndexOf('/') + 1) : bare;
166
+ if (suffix !== bare) {
167
+ const prefixed = catalog.exact.get(suffix);
168
+ if (prefixed) return prefixed;
169
+ }
170
+ let stripped = suffix;
171
+ while (true) {
172
+ const next = stripped.replace(DATE_SUFFIX, '');
173
+ if (next === stripped) break;
174
+ stripped = next;
175
+ const hit = catalog.exact.get(stripped);
176
+ if (hit) return hit;
177
+ }
178
+ const fuzzy = catalog.tokenKeys.get(tokenKey(suffix));
179
+ return fuzzy || null;
180
+ }
181
+
182
+ async function loadCatalog() {
183
+ if (cache && Date.now() - cache.fetchedAt < METADATA_TTL_MS) return cache;
184
+ if (!inFlight) {
185
+ inFlight = (async () => {
186
+ try {
187
+ const result = await fetchJson(modelsDevUrl, {});
188
+ if (!result.response.ok) return null;
189
+ const next = normalizeCatalog(result.data);
190
+ cache = next;
191
+ return next;
192
+ } catch {
193
+ return null; // offline/blocked: detection continues without capabilities
194
+ } finally {
195
+ inFlight = null;
196
+ }
197
+ })();
198
+ }
199
+ return inFlight;
200
+ }
201
+
202
+ function capabilityTags(entry) {
203
+ if (!entry) return [];
204
+ const tags = [];
205
+ if (entry.reasoning) tags.push('思考');
206
+ if (entry.input.includes('image')) tags.push('图片');
207
+ if (entry.input.includes('audio')) tags.push('音频');
208
+ if (entry.input.includes('pdf')) tags.push('PDF');
209
+ if (entry.toolCall) tags.push('工具');
210
+ return tags.slice(0, 8);
211
+ }
212
+
213
+ // Dual-protocol relays usually expose both routes on one origin: the OpenAI
214
+ // api at /v1 and the Anthropic api at /anthropic.
215
+ function sameOriginPath(baseUrl, segment) {
216
+ try { return `${new URL(baseUrl).origin}${segment}`; } catch { return ''; }
217
+ }
218
+
219
+ async function detectModels({ profile } = {}) {
220
+ if (!profile || typeof profile !== 'object') {
221
+ throw Object.assign(new Error('profile is required'), { status: 400 });
222
+ }
223
+ const apiKey = String(profile.apiKey || '');
224
+ // Explicit slots first; an empty slot falls back to probing the same
225
+ // origin of the filled one, and a verified hit is reported so the caller
226
+ // can pin it.
227
+ const anthropicBase = String(profile.baseUrl || '').trim()
228
+ || sameOriginPath(String(profile.openaiBaseUrl || '').trim(), '/anthropic');
229
+ const openaiBase = String(profile.openaiBaseUrl || '').trim()
230
+ || sameOriginPath(String(profile.baseUrl || '').trim(), '/v1');
231
+ if (!anthropicBase && !openaiBase) {
232
+ throw Object.assign(new Error('a base URL is required to detect models'), { status: 400 });
233
+ }
234
+ if (!apiKey) {
235
+ throw Object.assign(new Error('an API key is required to detect models'), { status: 400 });
236
+ }
237
+
238
+ const attempts = [];
239
+ if (anthropicBase) {
240
+ attempts.push({ label: 'anthropic', base: anthropicBase, run: () => fetchAnthropicModels(anthropicBase, apiKey) });
241
+ }
242
+ if (openaiBase) {
243
+ attempts.push({ label: 'openai', base: openaiBase, run: () => fetchOpenAiModels(openaiBase, apiKey) });
244
+ }
245
+
246
+ const sources = {};
247
+ const failures = [];
248
+ const ordered = []; // canonical first-seen order: anthropic side wins
249
+ const seen = new Set();
250
+ for (const attempt of attempts) {
251
+ try {
252
+ const ids = await attempt.run();
253
+ sources[attempt.label] = { ok: true, baseUrl: attempt.base, count: ids.length };
254
+ for (const id of ids) {
255
+ const bare = id.includes('/') ? id.slice(id.lastIndexOf('/') + 1) : id;
256
+ if (!seen.has(bare)) { seen.add(bare); ordered.push(bare); }
257
+ }
258
+ } catch (error) {
259
+ sources[attempt.label] = { ok: false, baseUrl: attempt.base, error: error.message };
260
+ failures.push(error.message);
261
+ }
262
+ }
263
+ if (!ordered.length) {
264
+ throw Object.assign(new Error(failures.join(' ; ') || 'no models detected'), { status: 400 });
265
+ }
266
+
267
+ const catalog = await loadCatalog();
268
+ const models = ordered.slice(0, MAX_MODELS).map(id => {
269
+ const meta = matchModel(catalog, id);
270
+ const contextWindow = meta && meta.context || contextLookup(id) || null;
271
+ return {
272
+ id,
273
+ openaiId: id.replace(/\[1m\]$/i, ''),
274
+ label: meta && meta.name || id,
275
+ contextWindow,
276
+ tags: capabilityTags(meta),
277
+ reasoning: meta ? meta.reasoning : null,
278
+ toolCall: meta ? meta.toolCall : null,
279
+ input: meta ? meta.input : null,
280
+ output: meta ? meta.output : null,
281
+ maxOutput: meta ? meta.maxOutput : null,
282
+ };
283
+ });
284
+ return {
285
+ ok: true,
286
+ models,
287
+ sources,
288
+ metadataSource: catalog ? 'models.dev' : 'unavailable',
289
+ };
290
+ }
291
+
292
+ return { detectModels, matchModel, normalizeCatalog, stripVersionSuffix, tokenKey };
293
+ }
294
+
295
+ module.exports = { createModelDetection, MODELS_DEV_URL, METADATA_TTL_MS };