vexi-cli 0.5.5 → 0.9.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.
- package/README.md +221 -32
- package/dist/agent.js +330 -42
- package/dist/agent.test.js +66 -0
- package/dist/cli.js +147 -11
- package/dist/config.js +18 -4
- package/dist/endpoint.js +174 -0
- package/dist/endpoint.test.js +146 -0
- package/dist/explain/index.js +1 -7
- package/dist/git/index.js +154 -0
- package/dist/git/index.test.js +178 -0
- package/dist/graph/html.js +2 -8
- package/dist/i18n/index.js +44 -12
- package/dist/mcp/client.js +12 -2
- package/dist/mcp/server.js +4 -3
- package/dist/memory/compress.test.js +52 -0
- package/dist/memory/index.js +10 -5
- package/dist/onboarding.js +105 -0
- package/dist/onboarding.test.js +76 -0
- package/dist/providers/anthropic.js +174 -35
- package/dist/providers/detect.js +18 -3
- package/dist/providers/index.js +60 -7
- package/dist/providers/manifest.js +96 -0
- package/dist/providers/manifest.test.js +71 -0
- package/dist/providers/openai-compat.js +202 -31
- package/dist/providers/openai-compat.test.js +66 -0
- package/dist/providers/types.js +12 -3
- package/dist/replay/export.js +1 -7
- package/dist/skills/index.js +12 -4
- package/dist/snapshots/index.js +278 -0
- package/dist/snapshots/index.test.js +105 -0
- package/dist/tools/index.js +280 -0
- package/dist/tools/index.test.js +131 -0
- package/dist/update/index.js +190 -0
- package/dist/usage/index.js +95 -0
- package/dist/usage/index.test.js +51 -0
- package/dist/utils/html.js +8 -0
- package/dist/version.js +4 -0
- package/package.json +11 -2
package/dist/cli.js
CHANGED
|
@@ -17,8 +17,11 @@
|
|
|
17
17
|
* vexi learn [--apply] learn your coding style from past sessions
|
|
18
18
|
*/
|
|
19
19
|
import { Command } from 'commander';
|
|
20
|
+
import { confirm } from '@inquirer/prompts';
|
|
21
|
+
import { VERSION } from './version.js';
|
|
20
22
|
import ora from 'ora';
|
|
21
|
-
import {
|
|
23
|
+
import { checkForUpdate, runUpdate, runUninstall } from './update/index.js';
|
|
24
|
+
import { runAgent, runPrint } from './agent.js';
|
|
22
25
|
import { loadConfig, resetConfig, CONFIG_PATH } from './config.js';
|
|
23
26
|
import { loadSkills, addSkill, removeSkill } from './skills/index.js';
|
|
24
27
|
import { listSessions } from './replay/recorder.js';
|
|
@@ -28,11 +31,13 @@ import { buildGraph } from './graph/index.js';
|
|
|
28
31
|
import { exportGraphHtml } from './graph/html.js';
|
|
29
32
|
import { loadMcpConfig, saveMcpConfig, MCP_CONFIG_PATH } from './mcp/config.js';
|
|
30
33
|
import { learn, applyLearned, DEFAULT_MAX_SESSIONS } from './learn/index.js';
|
|
31
|
-
import {
|
|
34
|
+
import { createProviderFromConfig, PROVIDER_INFO } from './providers/index.js';
|
|
35
|
+
import { runOnboarding } from './onboarding.js';
|
|
36
|
+
import * as nodeRlPromises from 'node:readline/promises';
|
|
32
37
|
import { openInDefaultApp } from './utils/open.js';
|
|
38
|
+
import { SnapshotManager } from './snapshots/index.js';
|
|
33
39
|
import { detectSystemLang, getStrings, normalizeLang, t, SUPPORTED_LANGS } from './i18n/index.js';
|
|
34
40
|
import { accent, dim, err, ok } from './ui/index.js';
|
|
35
|
-
export const VERSION = '0.5.5';
|
|
36
41
|
/** Resolve the active language: --lang flag > saved config > system locale. */
|
|
37
42
|
async function resolveLang(flag) {
|
|
38
43
|
if (flag) {
|
|
@@ -53,16 +58,25 @@ export function buildCli() {
|
|
|
53
58
|
.description('Open-source AI coding agent for your terminal. BYOK, zero config, multilingual.')
|
|
54
59
|
.version(VERSION, '-v, --version')
|
|
55
60
|
.option('-l, --lang <lang>', `UI language (${SUPPORTED_LANGS.join('/')})`)
|
|
61
|
+
.option('-p, --print <prompt>', 'non-interactive: send one prompt, print the reply, exit (for scripts/CI)')
|
|
62
|
+
.option('-y, --yes', 'with --print, auto-run any shell commands the model proposes (no confirmation)')
|
|
56
63
|
.option('--mcp-server', 'run Vexi as an MCP server over stdio (for Claude Desktop, Cursor, etc.)')
|
|
64
|
+
.option('--no-update-check', 'skip the daily background update check')
|
|
57
65
|
.action(async (options) => {
|
|
58
66
|
if (options.mcpServer) {
|
|
59
|
-
// stdout becomes the JSON-RPC channel
|
|
67
|
+
// stdout becomes the JSON-RPC channel -- no banner, no prompts.
|
|
60
68
|
const { runMcpServer } = await import('./mcp/server.js');
|
|
61
69
|
await runMcpServer();
|
|
62
70
|
return;
|
|
63
71
|
}
|
|
64
72
|
const lang = await resolveLang(options.lang);
|
|
65
|
-
|
|
73
|
+
if (options.print) {
|
|
74
|
+
await runPrint({ prompt: options.print, lang, autoYes: !!options.yes });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const noUpdateCheck = !options.updateCheck || !!process.env['VEXI_NO_UPDATE_CHECK'];
|
|
78
|
+
const updateCheckPromise = noUpdateCheck ? Promise.resolve(null) : checkForUpdate();
|
|
79
|
+
await runAgent({ lang, version: VERSION, updateCheckPromise });
|
|
66
80
|
});
|
|
67
81
|
const config = program.command('config').description('Manage Vexi configuration');
|
|
68
82
|
config
|
|
@@ -72,12 +86,16 @@ export function buildCli() {
|
|
|
72
86
|
const cfg = await loadConfig();
|
|
73
87
|
console.log(dim('config: ') + accent(CONFIG_PATH));
|
|
74
88
|
if (cfg) {
|
|
75
|
-
|
|
76
|
-
console.log(dim('
|
|
89
|
+
const providerLabel = cfg.displayName ?? PROVIDER_INFO[cfg.provider]?.label ?? cfg.provider;
|
|
90
|
+
console.log(dim('provider: ') + accent(providerLabel));
|
|
91
|
+
const defaultModel = PROVIDER_INFO[cfg.provider]?.defaultModel ?? 'unknown';
|
|
92
|
+
console.log(dim('model: ') + accent(cfg.model ?? defaultModel));
|
|
93
|
+
if (cfg.baseUrl)
|
|
94
|
+
console.log(dim('baseUrl: ') + accent(cfg.baseUrl));
|
|
77
95
|
console.log(dim('lang: ') + accent(cfg.lang ?? 'auto'));
|
|
78
96
|
}
|
|
79
97
|
else {
|
|
80
|
-
console.log(dim('No configuration yet — run `vexi` to
|
|
98
|
+
console.log(dim('No configuration yet — run `vexi` or `vexi setup` to configure.'));
|
|
81
99
|
}
|
|
82
100
|
});
|
|
83
101
|
config
|
|
@@ -112,7 +130,15 @@ export function buildCli() {
|
|
|
112
130
|
.action(async (source) => {
|
|
113
131
|
const s = getStrings(await resolveLang());
|
|
114
132
|
try {
|
|
115
|
-
const name = await addSkill(process.cwd(), source
|
|
133
|
+
const name = await addSkill(process.cwd(), source, {
|
|
134
|
+
confirmRemoteContent: async (content, skillName, sourceUrl) => {
|
|
135
|
+
console.log(dim(`\nFetched from ${sourceUrl} — this will be added to every prompt as an instruction the AI must follow:\n`));
|
|
136
|
+
console.log(dim('─'.repeat(60)));
|
|
137
|
+
console.log(content.slice(0, 2000) + (content.length > 2000 ? dim('\n… (truncated)') : ''));
|
|
138
|
+
console.log(dim('─'.repeat(60)));
|
|
139
|
+
return confirm({ message: `Save as skill "${skillName}"?`, default: false });
|
|
140
|
+
},
|
|
141
|
+
});
|
|
116
142
|
console.log(ok(t(s.skillAdded, { name })));
|
|
117
143
|
}
|
|
118
144
|
catch (e) {
|
|
@@ -184,7 +210,7 @@ export function buildCli() {
|
|
|
184
210
|
process.exitCode = 1;
|
|
185
211
|
return;
|
|
186
212
|
}
|
|
187
|
-
const provider =
|
|
213
|
+
const provider = createProviderFromConfig(cfg);
|
|
188
214
|
const spinner = ora({ text: dim(s.explaining), spinner: 'dots' }).start();
|
|
189
215
|
let started = false;
|
|
190
216
|
try {
|
|
@@ -296,7 +322,7 @@ export function buildCli() {
|
|
|
296
322
|
process.exitCode = 1;
|
|
297
323
|
return;
|
|
298
324
|
}
|
|
299
|
-
const provider =
|
|
325
|
+
const provider = createProviderFromConfig(cfg);
|
|
300
326
|
const maxSessions = Math.max(1, parseInt(options.sessions ?? '', 10) || DEFAULT_MAX_SESSIONS);
|
|
301
327
|
const spinner = ora({ text: dim(s.learnAnalyzing), spinner: 'dots' }).start();
|
|
302
328
|
try {
|
|
@@ -329,5 +355,115 @@ export function buildCli() {
|
|
|
329
355
|
process.exitCode = 1;
|
|
330
356
|
}
|
|
331
357
|
});
|
|
358
|
+
// ── Undo / Redo / History / Clean (Feature 8) ────────────────────────
|
|
359
|
+
program
|
|
360
|
+
.command('undo')
|
|
361
|
+
.description('Revert the last AI file edit in the current session')
|
|
362
|
+
.action(async () => {
|
|
363
|
+
const s = getStrings(await resolveLang());
|
|
364
|
+
const mgr = await SnapshotManager.forCurrentSession(process.cwd());
|
|
365
|
+
if (!mgr) {
|
|
366
|
+
console.log(dim(s.snapshotNoSession));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const entry = await mgr.undo().catch(() => null);
|
|
370
|
+
if (!entry) {
|
|
371
|
+
console.log(dim(s.undoNone));
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
console.log(ok(t(s.undoDone, { files: entry.files.join(', ') })));
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
program
|
|
378
|
+
.command('redo')
|
|
379
|
+
.description('Re-apply the last undone AI file edit')
|
|
380
|
+
.action(async () => {
|
|
381
|
+
const s = getStrings(await resolveLang());
|
|
382
|
+
const mgr = await SnapshotManager.forCurrentSession(process.cwd());
|
|
383
|
+
if (!mgr) {
|
|
384
|
+
console.log(dim(s.snapshotNoSession));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const entry = await mgr.redo().catch(() => null);
|
|
388
|
+
if (!entry) {
|
|
389
|
+
console.log(dim(s.redoNone));
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
console.log(ok(t(s.redoDone, { files: entry.files.join(', ') })));
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
program
|
|
396
|
+
.command('history')
|
|
397
|
+
.description('List recent AI file edits in this session (with timestamps)')
|
|
398
|
+
.action(async () => {
|
|
399
|
+
const s = getStrings(await resolveLang());
|
|
400
|
+
const mgr = await SnapshotManager.forCurrentSession(process.cwd());
|
|
401
|
+
if (!mgr) {
|
|
402
|
+
console.log(dim(s.snapshotNoSession));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const entries = await mgr.list().catch(() => []);
|
|
406
|
+
if (entries.length === 0) {
|
|
407
|
+
console.log(dim(s.historyNone));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
console.log(dim(s.historyHeader));
|
|
411
|
+
for (const e of entries) {
|
|
412
|
+
const time = new Date(e.at).toLocaleTimeString();
|
|
413
|
+
console.log(accent(` ${time}`) + dim(` ${e.files.join(', ')}`) + dim(` — ${e.label.slice(0, 60)}`));
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
program
|
|
417
|
+
.command('clean')
|
|
418
|
+
.description('Clear old snapshot sessions to free disk space (.vexi/snapshots/)')
|
|
419
|
+
.action(async () => {
|
|
420
|
+
const s = getStrings(await resolveLang());
|
|
421
|
+
const mgr = await SnapshotManager.forCurrentSession(process.cwd());
|
|
422
|
+
const count = await SnapshotManager.cleanAll(process.cwd(), mgr?.sessionId);
|
|
423
|
+
console.log(ok(t(s.cleanDone, { count: String(count) })));
|
|
424
|
+
});
|
|
425
|
+
// ── URL-based provider setup (vexi setup) ────────────────────────────
|
|
426
|
+
program
|
|
427
|
+
.command('setup')
|
|
428
|
+
.description('Configure your API provider by pasting an endpoint URL')
|
|
429
|
+
.action(async () => {
|
|
430
|
+
const io = {
|
|
431
|
+
prompt: async (question) => {
|
|
432
|
+
const iface = nodeRlPromises.createInterface({
|
|
433
|
+
input: process.stdin,
|
|
434
|
+
output: process.stdout,
|
|
435
|
+
});
|
|
436
|
+
try {
|
|
437
|
+
return await iface.question(question);
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
iface.close();
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
write: (line) => console.log(line),
|
|
444
|
+
};
|
|
445
|
+
try {
|
|
446
|
+
await runOnboarding(io);
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
console.error(err(e instanceof Error ? e.message : String(e)));
|
|
450
|
+
process.exitCode = 1;
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
// ── Self-update (vexi update) ─────────────────────────────────────────
|
|
454
|
+
program
|
|
455
|
+
.command('update')
|
|
456
|
+
.description('Update Vexi to the latest version (npm install -g vexi-cli@latest)')
|
|
457
|
+
.action(async () => {
|
|
458
|
+
await runUpdate();
|
|
459
|
+
});
|
|
460
|
+
// ── Self-uninstall (vexi uninstall) ──────────────────────────────────
|
|
461
|
+
program
|
|
462
|
+
.command('uninstall')
|
|
463
|
+
.description('Uninstall Vexi. Keeps ~/.vexi unless --purge is passed.')
|
|
464
|
+
.option('--purge', 'also delete ~/.vexi (config, keys, memory, sessions)')
|
|
465
|
+
.action(async (options) => {
|
|
466
|
+
await runUninstall(!!options.purge);
|
|
467
|
+
});
|
|
332
468
|
return program;
|
|
333
469
|
}
|
package/dist/config.js
CHANGED
|
@@ -10,15 +10,29 @@
|
|
|
10
10
|
import { promises as fs } from 'node:fs';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
+
import { z } from 'zod';
|
|
13
14
|
import { readJson, writeJsonAtomic } from './utils/fs-atomic.js';
|
|
15
|
+
const VexiConfigSchema = z.object({
|
|
16
|
+
provider: z.string().min(1),
|
|
17
|
+
displayName: z.string().optional(),
|
|
18
|
+
apiKey: z.string().min(1),
|
|
19
|
+
baseUrl: z.string().optional(),
|
|
20
|
+
model: z.string().optional(),
|
|
21
|
+
lang: z.string().optional(),
|
|
22
|
+
});
|
|
14
23
|
export const VEXI_DIR = join(homedir(), '.vexi');
|
|
15
24
|
export const CONFIG_PATH = join(VEXI_DIR, 'config.json');
|
|
16
|
-
/** Load the config, or `null` if it doesn't exist / is unreadable. */
|
|
25
|
+
/** Load the config, or `null` if it doesn't exist / is unreadable / fails validation. */
|
|
17
26
|
export async function loadConfig() {
|
|
18
|
-
const
|
|
19
|
-
if (!
|
|
27
|
+
const raw = await readJson(CONFIG_PATH);
|
|
28
|
+
if (!raw)
|
|
20
29
|
return null;
|
|
21
|
-
|
|
30
|
+
const result = VexiConfigSchema.safeParse(raw);
|
|
31
|
+
if (!result.success) {
|
|
32
|
+
process.stderr.write(`[vexi] config validation failed: ${result.error.message}\n`);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return result.data;
|
|
22
36
|
}
|
|
23
37
|
/** Save the config atomically with owner-only permissions. */
|
|
24
38
|
export async function saveConfig(config) {
|
package/dist/endpoint.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-based provider identification and model discovery.
|
|
3
|
+
*
|
|
4
|
+
* The user pastes an endpoint URL (e.g. https://openrouter.ai/api/v1).
|
|
5
|
+
* identifyFromUrl() inspects the hostname and path to determine which
|
|
6
|
+
* provider it is and how to authenticate. discoverModels() then calls
|
|
7
|
+
* the /models endpoint to get the live model list.
|
|
8
|
+
*/
|
|
9
|
+
const OPERATION_SUFFIXES = [
|
|
10
|
+
'/chat/completions',
|
|
11
|
+
'/completions',
|
|
12
|
+
'/models',
|
|
13
|
+
'/messages',
|
|
14
|
+
];
|
|
15
|
+
/** Strip trailing slashes and known operation path suffixes so the user can
|
|
16
|
+
* paste a full URL (e.g. .../chat/completions) and still get the API root. */
|
|
17
|
+
function normalizeBase(rawUrl) {
|
|
18
|
+
let url = rawUrl.trim().replace(/\/+$/, '');
|
|
19
|
+
for (const suffix of OPERATION_SUFFIXES) {
|
|
20
|
+
if (url.endsWith(suffix)) {
|
|
21
|
+
url = url.slice(0, url.length - suffix.length);
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return url;
|
|
26
|
+
}
|
|
27
|
+
function isPrivateIp(host) {
|
|
28
|
+
return (host === 'localhost' ||
|
|
29
|
+
host === '127.0.0.1' ||
|
|
30
|
+
host.endsWith('.local') ||
|
|
31
|
+
/^192\.168\.\d{1,3}\.\d{1,3}$/.test(host) ||
|
|
32
|
+
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Identify the provider from a raw endpoint URL.
|
|
36
|
+
* Returns a UrlIdentity describing the provider, normalized base URL,
|
|
37
|
+
* API shape, and where to discover models.
|
|
38
|
+
*/
|
|
39
|
+
export function identifyFromUrl(rawUrl) {
|
|
40
|
+
const baseUrl = normalizeBase(rawUrl);
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = new URL(baseUrl);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error(`Invalid URL: ${rawUrl}`);
|
|
47
|
+
}
|
|
48
|
+
const host = parsed.hostname.toLowerCase();
|
|
49
|
+
const path = parsed.pathname;
|
|
50
|
+
// OpenRouter
|
|
51
|
+
if (host === 'openrouter.ai') {
|
|
52
|
+
return {
|
|
53
|
+
provider: 'openrouter',
|
|
54
|
+
displayName: 'OpenRouter',
|
|
55
|
+
baseUrl,
|
|
56
|
+
apiShape: 'openai',
|
|
57
|
+
modelsUrl: `${baseUrl}/models`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// Z.ai
|
|
61
|
+
if (host.endsWith('z.ai')) {
|
|
62
|
+
const displayName = path.toLowerCase().includes('/coding/') ? 'Z.ai (Coding Plan)' : 'Z.ai';
|
|
63
|
+
return {
|
|
64
|
+
provider: 'zai',
|
|
65
|
+
displayName,
|
|
66
|
+
baseUrl,
|
|
67
|
+
apiShape: 'openai',
|
|
68
|
+
modelsUrl: `${baseUrl}/models`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Groq
|
|
72
|
+
if (host.endsWith('groq.com')) {
|
|
73
|
+
return {
|
|
74
|
+
provider: 'groq',
|
|
75
|
+
displayName: 'Groq',
|
|
76
|
+
baseUrl,
|
|
77
|
+
apiShape: 'openai',
|
|
78
|
+
modelsUrl: `${baseUrl}/models`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// OpenAI
|
|
82
|
+
if (host === 'api.openai.com') {
|
|
83
|
+
return {
|
|
84
|
+
provider: 'openai',
|
|
85
|
+
displayName: 'OpenAI',
|
|
86
|
+
baseUrl,
|
|
87
|
+
apiShape: 'openai',
|
|
88
|
+
modelsUrl: `${baseUrl}/models`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Anthropic
|
|
92
|
+
if (host === 'api.anthropic.com') {
|
|
93
|
+
return {
|
|
94
|
+
provider: 'anthropic',
|
|
95
|
+
displayName: 'Anthropic',
|
|
96
|
+
baseUrl,
|
|
97
|
+
apiShape: 'anthropic',
|
|
98
|
+
modelsUrl: `${baseUrl}/models`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// Google Gemini
|
|
102
|
+
if (host.endsWith('generativelanguage.googleapis.com')) {
|
|
103
|
+
return {
|
|
104
|
+
provider: 'gemini',
|
|
105
|
+
displayName: 'Google Gemini',
|
|
106
|
+
baseUrl,
|
|
107
|
+
apiShape: 'openai',
|
|
108
|
+
modelsUrl: `${baseUrl}/models`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// Cloudflare Workers AI -- model is embedded in the path after /ai/run/
|
|
112
|
+
if (host.endsWith('api.cloudflare.com') && path.includes('/ai/run/')) {
|
|
113
|
+
const aiRunStart = path.indexOf('/ai/run/') + '/ai/run/'.length;
|
|
114
|
+
let modelFromPath = path.slice(aiRunStart);
|
|
115
|
+
if (modelFromPath.startsWith('@cf/')) {
|
|
116
|
+
modelFromPath = modelFromPath.slice('@cf/'.length);
|
|
117
|
+
}
|
|
118
|
+
// baseUrl is the account endpoint up to /ai/run (without the model)
|
|
119
|
+
const cfBase = `${parsed.protocol}//${parsed.host}${path.slice(0, aiRunStart - 1)}`;
|
|
120
|
+
return {
|
|
121
|
+
provider: 'cloudflare',
|
|
122
|
+
displayName: 'Cloudflare Workers AI',
|
|
123
|
+
baseUrl: cfBase,
|
|
124
|
+
apiShape: 'openai',
|
|
125
|
+
modelsUrl: null,
|
|
126
|
+
modelFromPath: modelFromPath || undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// Local / private LAN servers (Ollama, LM Studio, vLLM, etc.)
|
|
130
|
+
if (isPrivateIp(host)) {
|
|
131
|
+
return {
|
|
132
|
+
provider: 'local',
|
|
133
|
+
displayName: 'Local (OpenAI-compatible)',
|
|
134
|
+
baseUrl,
|
|
135
|
+
apiShape: 'openai',
|
|
136
|
+
modelsUrl: `${baseUrl}/models`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// Unknown / custom endpoint -- assume OpenAI-compatible
|
|
140
|
+
return {
|
|
141
|
+
provider: 'custom',
|
|
142
|
+
displayName: `Custom (${host})`,
|
|
143
|
+
baseUrl,
|
|
144
|
+
apiShape: 'openai',
|
|
145
|
+
modelsUrl: `${baseUrl}/models`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Discover the available models for a given endpoint.
|
|
150
|
+
*
|
|
151
|
+
* If the URL embeds the model (Cloudflare), returns that model directly.
|
|
152
|
+
* If modelsUrl is null, returns an empty array.
|
|
153
|
+
* Otherwise calls GET /models and maps the response to a list of id strings.
|
|
154
|
+
*
|
|
155
|
+
* Accepts an optional fetchFn for testability (defaults to globalThis.fetch).
|
|
156
|
+
*/
|
|
157
|
+
export async function discoverModels(identity, apiKey, fetchFn = globalThis.fetch) {
|
|
158
|
+
if (identity.modelFromPath) {
|
|
159
|
+
return [identity.modelFromPath];
|
|
160
|
+
}
|
|
161
|
+
if (!identity.modelsUrl) {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
const headers = identity.apiShape === 'anthropic'
|
|
165
|
+
? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }
|
|
166
|
+
: { Authorization: `Bearer ${apiKey}` };
|
|
167
|
+
const res = await fetchFn(identity.modelsUrl, { headers });
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
throw new Error(`Provider identified as "${identity.displayName}" but GET /models returned HTTP ${res.status}. ` +
|
|
170
|
+
`Enter the model ID manually.`);
|
|
171
|
+
}
|
|
172
|
+
const json = (await res.json());
|
|
173
|
+
return (json.data ?? []).map((m) => m.id).filter(Boolean);
|
|
174
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { identifyFromUrl, discoverModels } from './endpoint.js';
|
|
3
|
+
// ── identifyFromUrl ──────────────────────────────────────────────────────────
|
|
4
|
+
describe('identifyFromUrl', () => {
|
|
5
|
+
it('identifies OpenRouter', () => {
|
|
6
|
+
const id = identifyFromUrl('https://openrouter.ai/api/v1');
|
|
7
|
+
expect(id.provider).toBe('openrouter');
|
|
8
|
+
expect(id.displayName).toBe('OpenRouter');
|
|
9
|
+
expect(id.baseUrl).toBe('https://openrouter.ai/api/v1');
|
|
10
|
+
expect(id.apiShape).toBe('openai');
|
|
11
|
+
expect(id.modelsUrl).toBe('https://openrouter.ai/api/v1/models');
|
|
12
|
+
});
|
|
13
|
+
it('strips /chat/completions suffix', () => {
|
|
14
|
+
const id = identifyFromUrl('https://openrouter.ai/api/v1/chat/completions');
|
|
15
|
+
expect(id.baseUrl).toBe('https://openrouter.ai/api/v1');
|
|
16
|
+
});
|
|
17
|
+
it('strips trailing slash', () => {
|
|
18
|
+
const id = identifyFromUrl('https://openrouter.ai/api/v1/');
|
|
19
|
+
expect(id.baseUrl).toBe('https://openrouter.ai/api/v1');
|
|
20
|
+
});
|
|
21
|
+
it('identifies Z.ai coding plan', () => {
|
|
22
|
+
const id = identifyFromUrl('https://api.z.ai/coding/v1');
|
|
23
|
+
expect(id.provider).toBe('zai');
|
|
24
|
+
expect(id.displayName).toBe('Z.ai (Coding Plan)');
|
|
25
|
+
expect(id.apiShape).toBe('openai');
|
|
26
|
+
});
|
|
27
|
+
it('identifies Z.ai generic', () => {
|
|
28
|
+
const id = identifyFromUrl('https://api.z.ai/v1');
|
|
29
|
+
expect(id.provider).toBe('zai');
|
|
30
|
+
expect(id.displayName).toBe('Z.ai');
|
|
31
|
+
});
|
|
32
|
+
it('identifies Anthropic', () => {
|
|
33
|
+
const id = identifyFromUrl('https://api.anthropic.com/v1');
|
|
34
|
+
expect(id.provider).toBe('anthropic');
|
|
35
|
+
expect(id.apiShape).toBe('anthropic');
|
|
36
|
+
expect(id.modelsUrl).toBe('https://api.anthropic.com/v1/models');
|
|
37
|
+
});
|
|
38
|
+
it('identifies OpenAI', () => {
|
|
39
|
+
const id = identifyFromUrl('https://api.openai.com/v1');
|
|
40
|
+
expect(id.provider).toBe('openai');
|
|
41
|
+
expect(id.apiShape).toBe('openai');
|
|
42
|
+
});
|
|
43
|
+
it('identifies Groq', () => {
|
|
44
|
+
const id = identifyFromUrl('https://api.groq.com/openai/v1');
|
|
45
|
+
expect(id.provider).toBe('groq');
|
|
46
|
+
expect(id.displayName).toBe('Groq');
|
|
47
|
+
});
|
|
48
|
+
it('identifies localhost as local', () => {
|
|
49
|
+
const id = identifyFromUrl('http://localhost:11434/v1');
|
|
50
|
+
expect(id.provider).toBe('local');
|
|
51
|
+
expect(id.displayName).toBe('Local (OpenAI-compatible)');
|
|
52
|
+
expect(id.modelsUrl).toBe('http://localhost:11434/v1/models');
|
|
53
|
+
});
|
|
54
|
+
it('identifies 192.168.x.x as local', () => {
|
|
55
|
+
const id = identifyFromUrl('http://192.168.1.100:8080/v1');
|
|
56
|
+
expect(id.provider).toBe('local');
|
|
57
|
+
});
|
|
58
|
+
it('identifies Cloudflare and extracts model from path', () => {
|
|
59
|
+
const id = identifyFromUrl('https://api.cloudflare.com/client/v4/accounts/abc123/ai/run/@cf/meta/llama-3.1-8b');
|
|
60
|
+
expect(id.provider).toBe('cloudflare');
|
|
61
|
+
expect(id.displayName).toBe('Cloudflare Workers AI');
|
|
62
|
+
expect(id.modelsUrl).toBeNull();
|
|
63
|
+
expect(id.modelFromPath).toBe('meta/llama-3.1-8b');
|
|
64
|
+
});
|
|
65
|
+
it('classifies unknown public host as custom', () => {
|
|
66
|
+
const id = identifyFromUrl('https://my-proxy.example.com/v1');
|
|
67
|
+
expect(id.provider).toBe('custom');
|
|
68
|
+
expect(id.displayName).toContain('my-proxy.example.com');
|
|
69
|
+
expect(id.apiShape).toBe('openai');
|
|
70
|
+
});
|
|
71
|
+
it('throws on invalid URL', () => {
|
|
72
|
+
expect(() => identifyFromUrl('not-a-url')).toThrow('Invalid URL');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
// ── discoverModels ────────────────────────────────────────────────────────────
|
|
76
|
+
describe('discoverModels', () => {
|
|
77
|
+
it('returns modelFromPath directly without fetching', async () => {
|
|
78
|
+
const identity = {
|
|
79
|
+
provider: 'cloudflare',
|
|
80
|
+
displayName: 'Cloudflare Workers AI',
|
|
81
|
+
baseUrl: 'https://api.cloudflare.com/client/v4/accounts/abc/ai/run',
|
|
82
|
+
apiShape: 'openai',
|
|
83
|
+
modelsUrl: null,
|
|
84
|
+
modelFromPath: 'meta/llama-3.1-8b',
|
|
85
|
+
};
|
|
86
|
+
const fetchFn = vi.fn();
|
|
87
|
+
const models = await discoverModels(identity, 'key', fetchFn);
|
|
88
|
+
expect(models).toEqual(['meta/llama-3.1-8b']);
|
|
89
|
+
expect(fetchFn).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
it('returns empty array when modelsUrl is null and no modelFromPath', async () => {
|
|
92
|
+
const identity = {
|
|
93
|
+
provider: 'custom',
|
|
94
|
+
displayName: 'Custom',
|
|
95
|
+
baseUrl: 'https://example.com',
|
|
96
|
+
apiShape: 'openai',
|
|
97
|
+
modelsUrl: null,
|
|
98
|
+
};
|
|
99
|
+
const models = await discoverModels(identity, 'key', vi.fn());
|
|
100
|
+
expect(models).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
it('fetches /models and maps response data to ids', async () => {
|
|
103
|
+
const identity = {
|
|
104
|
+
provider: 'openrouter',
|
|
105
|
+
displayName: 'OpenRouter',
|
|
106
|
+
baseUrl: 'https://openrouter.ai/api/v1',
|
|
107
|
+
apiShape: 'openai',
|
|
108
|
+
modelsUrl: 'https://openrouter.ai/api/v1/models',
|
|
109
|
+
};
|
|
110
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
111
|
+
ok: true,
|
|
112
|
+
json: async () => ({ data: [{ id: 'openai/gpt-4o' }, { id: 'anthropic/claude-3.5-sonnet' }] }),
|
|
113
|
+
});
|
|
114
|
+
const models = await discoverModels(identity, 'sk-test', mockFetch);
|
|
115
|
+
expect(models).toEqual(['openai/gpt-4o', 'anthropic/claude-3.5-sonnet']);
|
|
116
|
+
expect(mockFetch).toHaveBeenCalledWith('https://openrouter.ai/api/v1/models', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer sk-test' }) }));
|
|
117
|
+
});
|
|
118
|
+
it('uses x-api-key header for Anthropic shape', async () => {
|
|
119
|
+
const identity = {
|
|
120
|
+
provider: 'anthropic',
|
|
121
|
+
displayName: 'Anthropic',
|
|
122
|
+
baseUrl: 'https://api.anthropic.com/v1',
|
|
123
|
+
apiShape: 'anthropic',
|
|
124
|
+
modelsUrl: 'https://api.anthropic.com/v1/models',
|
|
125
|
+
};
|
|
126
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
127
|
+
ok: true,
|
|
128
|
+
json: async () => ({ data: [{ id: 'claude-3-5-sonnet-20241022' }] }),
|
|
129
|
+
});
|
|
130
|
+
await discoverModels(identity, 'sk-ant-test', mockFetch);
|
|
131
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
132
|
+
headers: expect.objectContaining({ 'x-api-key': 'sk-ant-test' }),
|
|
133
|
+
}));
|
|
134
|
+
});
|
|
135
|
+
it('throws when /models returns non-OK status', async () => {
|
|
136
|
+
const identity = {
|
|
137
|
+
provider: 'groq',
|
|
138
|
+
displayName: 'Groq',
|
|
139
|
+
baseUrl: 'https://api.groq.com/openai/v1',
|
|
140
|
+
apiShape: 'openai',
|
|
141
|
+
modelsUrl: 'https://api.groq.com/openai/v1/models',
|
|
142
|
+
};
|
|
143
|
+
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 401 });
|
|
144
|
+
await expect(discoverModels(identity, 'bad-key', mockFetch)).rejects.toThrow('HTTP 401');
|
|
145
|
+
});
|
|
146
|
+
});
|
package/dist/explain/index.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { promises as fs } from 'node:fs';
|
|
14
14
|
import { basename, join, relative, resolve } from 'node:path';
|
|
15
|
+
import { escapeHtml } from '../utils/html.js';
|
|
15
16
|
/** Limits to keep prompts within context windows. */
|
|
16
17
|
const MAX_FILE_BYTES = 100 * 1024;
|
|
17
18
|
const MAX_TOTAL_BYTES = 120 * 1024;
|
|
@@ -229,10 +230,3 @@ function inline(text) {
|
|
|
229
230
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
230
231
|
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
231
232
|
}
|
|
232
|
-
function escapeHtml(text) {
|
|
233
|
-
return text
|
|
234
|
-
.replaceAll('&', '&')
|
|
235
|
-
.replaceAll('<', '<')
|
|
236
|
-
.replaceAll('>', '>')
|
|
237
|
-
.replaceAll('"', '"');
|
|
238
|
-
}
|