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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { parseBuiltinToolCall, executeBuiltinTool, buildNativeTools, dispatchNativeTool } from './index.js';
|
|
6
|
+
import { SnapshotManager } from '../snapshots/index.js';
|
|
7
|
+
// A snapshot manager that writes into a throwaway session dir under root.
|
|
8
|
+
function makeSnapshots(root) {
|
|
9
|
+
return new SnapshotManager(root, 'test-session');
|
|
10
|
+
}
|
|
11
|
+
let root;
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
root = await fs.mkdtemp(join(tmpdir(), 'vexi-tools-'));
|
|
14
|
+
});
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
// ── parseBuiltinToolCall ──────────────────────────────────────────────────────
|
|
19
|
+
describe('parseBuiltinToolCall', () => {
|
|
20
|
+
it('parses a read_file call', () => {
|
|
21
|
+
const call = parseBuiltinToolCall('```vexi-tool\n{"tool": "read_file", "path": "a.ts"}\n```');
|
|
22
|
+
expect(call).toEqual({ tool: 'read_file', args: { path: 'a.ts' } });
|
|
23
|
+
});
|
|
24
|
+
it('ignores MCP calls (those have a server field)', () => {
|
|
25
|
+
const call = parseBuiltinToolCall('```vexi-tool\n{"server": "github", "tool": "read_file", "arguments": {}}\n```');
|
|
26
|
+
expect(call).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
it('ignores unknown tool names', () => {
|
|
29
|
+
expect(parseBuiltinToolCall('```vexi-tool\n{"tool": "rm_rf", "path": "/"}\n```')).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it('returns null when no block present', () => {
|
|
32
|
+
expect(parseBuiltinToolCall('just some prose')).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
// ── executeBuiltinTool ────────────────────────────────────────────────────────
|
|
36
|
+
describe('executeBuiltinTool: write_file', () => {
|
|
37
|
+
it('creates a new file', async () => {
|
|
38
|
+
const r = await executeBuiltinTool({ tool: 'write_file', args: { path: 'x.txt', content: 'hello\nworld' } }, root, makeSnapshots(root));
|
|
39
|
+
expect(r.result).toContain('created x.txt');
|
|
40
|
+
expect(await fs.readFile(join(root, 'x.txt'), 'utf-8')).toBe('hello\nworld');
|
|
41
|
+
});
|
|
42
|
+
it('rejects paths outside the project root', async () => {
|
|
43
|
+
const r = await executeBuiltinTool({ tool: 'write_file', args: { path: '../escape.txt', content: 'x' } }, root, makeSnapshots(root));
|
|
44
|
+
expect(r.result).toContain('outside the project root');
|
|
45
|
+
expect(r.files).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('executeBuiltinTool: edit_file', () => {
|
|
49
|
+
it('replaces a unique substring', async () => {
|
|
50
|
+
await fs.writeFile(join(root, 'a.ts'), 'const x = 1;\nconst y = 2;\n');
|
|
51
|
+
const r = await executeBuiltinTool({ tool: 'edit_file', args: { path: 'a.ts', old: 'const y = 2;', new: 'const y = 3;' } }, root, makeSnapshots(root));
|
|
52
|
+
expect(r.result).toContain('edited a.ts');
|
|
53
|
+
expect(await fs.readFile(join(root, 'a.ts'), 'utf-8')).toBe('const x = 1;\nconst y = 3;\n');
|
|
54
|
+
});
|
|
55
|
+
it('errors when old string is not found', async () => {
|
|
56
|
+
await fs.writeFile(join(root, 'a.ts'), 'const x = 1;\n');
|
|
57
|
+
const r = await executeBuiltinTool({ tool: 'edit_file', args: { path: 'a.ts', old: 'nope', new: 'x' } }, root, makeSnapshots(root));
|
|
58
|
+
expect(r.result).toContain('not found');
|
|
59
|
+
expect(await fs.readFile(join(root, 'a.ts'), 'utf-8')).toBe('const x = 1;\n');
|
|
60
|
+
});
|
|
61
|
+
it('errors on a non-unique match unless all=true', async () => {
|
|
62
|
+
await fs.writeFile(join(root, 'a.ts'), 'a\na\na\n');
|
|
63
|
+
const ambiguous = await executeBuiltinTool({ tool: 'edit_file', args: { path: 'a.ts', old: 'a', new: 'b' } }, root, makeSnapshots(root));
|
|
64
|
+
expect(ambiguous.result).toContain('matches 3 places');
|
|
65
|
+
const all = await executeBuiltinTool({ tool: 'edit_file', args: { path: 'a.ts', old: 'a', new: 'b', all: true } }, root, makeSnapshots(root));
|
|
66
|
+
expect(all.result).toContain('3 replacements');
|
|
67
|
+
expect(await fs.readFile(join(root, 'a.ts'), 'utf-8')).toBe('b\nb\nb\n');
|
|
68
|
+
});
|
|
69
|
+
it('snapshots the pre-edit file so undo restores it exactly', async () => {
|
|
70
|
+
await fs.writeFile(join(root, 'a.ts'), 'original\n');
|
|
71
|
+
const snaps = makeSnapshots(root);
|
|
72
|
+
await executeBuiltinTool({ tool: 'edit_file', args: { path: 'a.ts', old: 'original', new: 'changed' } }, root, snaps);
|
|
73
|
+
expect(await fs.readFile(join(root, 'a.ts'), 'utf-8')).toBe('changed\n');
|
|
74
|
+
const entry = await snaps.undo();
|
|
75
|
+
expect(entry).not.toBeNull();
|
|
76
|
+
expect(await fs.readFile(join(root, 'a.ts'), 'utf-8')).toBe('original\n');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
// ── native tool routing (Feature #2) ──────────────────────────────────────────
|
|
80
|
+
describe('buildNativeTools', () => {
|
|
81
|
+
it('exposes the three built-in tools plus namespaced MCP tools', () => {
|
|
82
|
+
const mcp = { tools: [{ server: 'github', name: 'search', description: 'Search repos', inputSchema: { type: 'object' } }] };
|
|
83
|
+
const { defs, route } = buildNativeTools(mcp);
|
|
84
|
+
const names = defs.map((d) => d.name);
|
|
85
|
+
expect(names).toContain('read_file');
|
|
86
|
+
expect(names).toContain('write_file');
|
|
87
|
+
expect(names).toContain('edit_file');
|
|
88
|
+
expect(names).toContain('github__search');
|
|
89
|
+
expect(route.get('read_file')).toEqual({ kind: 'builtin' });
|
|
90
|
+
expect(route.get('github__search')).toEqual({ kind: 'mcp', server: 'github', tool: 'search' });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('dispatchNativeTool', () => {
|
|
94
|
+
it('routes built-in names to the file engine', async () => {
|
|
95
|
+
await fs.writeFile(join(root, 'a.ts'), 'x');
|
|
96
|
+
const mcp = { tools: [] };
|
|
97
|
+
const { route } = buildNativeTools(mcp);
|
|
98
|
+
const r = await dispatchNativeTool('read_file', { path: 'a.ts' }, route, root, makeSnapshots(root), mcp);
|
|
99
|
+
expect(r.result).toContain('FILE a.ts');
|
|
100
|
+
});
|
|
101
|
+
it('routes namespaced names to the MCP server', async () => {
|
|
102
|
+
let called = null;
|
|
103
|
+
const mcp = {
|
|
104
|
+
tools: [{ server: 'github', name: 'search', description: 'd', inputSchema: {} }],
|
|
105
|
+
callTool: async (server, tool) => { called = { server, tool }; return 'mcp-result'; },
|
|
106
|
+
};
|
|
107
|
+
const { route } = buildNativeTools(mcp);
|
|
108
|
+
const r = await dispatchNativeTool('github__search', { q: 'x' }, route, root, makeSnapshots(root), mcp);
|
|
109
|
+
expect(r.result).toBe('mcp-result');
|
|
110
|
+
expect(called).toEqual({ server: 'github', tool: 'search' });
|
|
111
|
+
});
|
|
112
|
+
it('errors on an unknown tool name', async () => {
|
|
113
|
+
const mcp = { tools: [] };
|
|
114
|
+
const { route } = buildNativeTools(mcp);
|
|
115
|
+
const r = await dispatchNativeTool('bogus', {}, route, root, makeSnapshots(root), mcp);
|
|
116
|
+
expect(r.result).toContain('unknown tool');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('executeBuiltinTool: read_file', () => {
|
|
120
|
+
it('returns numbered lines within a range', async () => {
|
|
121
|
+
await fs.writeFile(join(root, 'a.ts'), 'l1\nl2\nl3\nl4\n');
|
|
122
|
+
const r = await executeBuiltinTool({ tool: 'read_file', args: { path: 'a.ts', start: 2, end: 3 } }, root, makeSnapshots(root));
|
|
123
|
+
expect(r.result).toContain('2\tl2');
|
|
124
|
+
expect(r.result).toContain('3\tl3');
|
|
125
|
+
expect(r.result).not.toContain('l4');
|
|
126
|
+
});
|
|
127
|
+
it('errors on a missing file', async () => {
|
|
128
|
+
const r = await executeBuiltinTool({ tool: 'read_file', args: { path: 'nope.ts' } }, root, makeSnapshots(root));
|
|
129
|
+
expect(r.result).toContain('file not found');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update management: check, install, and uninstall.
|
|
3
|
+
*
|
|
4
|
+
* checkForUpdate - non-blocking; returns cached result fast, fires network
|
|
5
|
+
* check in the background and caches it for the next run.
|
|
6
|
+
* runUpdate - npm install -g vexi-cli@latest with streamed output.
|
|
7
|
+
* runUninstall - npm uninstall -g vexi-cli with optional ~/.vexi purge.
|
|
8
|
+
*/
|
|
9
|
+
import { promises as fs } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { confirm } from '@inquirer/prompts';
|
|
13
|
+
import { VEXI_DIR } from '../config.js';
|
|
14
|
+
import { VERSION } from '../version.js';
|
|
15
|
+
import { dim, err, ok, warn, accent } from '../ui/index.js';
|
|
16
|
+
const UPDATE_CACHE_PATH = join(VEXI_DIR, 'update-check.json');
|
|
17
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
18
|
+
const FETCH_TIMEOUT_MS = 1500;
|
|
19
|
+
const NPM_REGISTRY = 'https://registry.npmjs.org/vexi-cli/latest';
|
|
20
|
+
/** Returns true if version string `a` is strictly greater than `b`. */
|
|
21
|
+
function semverGt(a, b) {
|
|
22
|
+
const parts = (v) => v.replace(/[^0-9.]/g, '').split('.').map((n) => parseInt(n, 10) || 0);
|
|
23
|
+
const [aMaj = 0, aMin = 0, aPat = 0] = parts(a);
|
|
24
|
+
const [bMaj = 0, bMin = 0, bPat = 0] = parts(b);
|
|
25
|
+
if (aMaj !== bMaj)
|
|
26
|
+
return aMaj > bMaj;
|
|
27
|
+
if (aMin !== bMin)
|
|
28
|
+
return aMin > bMin;
|
|
29
|
+
return aPat > bPat;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Check whether a newer version of vexi-cli is published on npm.
|
|
33
|
+
*
|
|
34
|
+
* - Returns the newer version string, or null (no update / check failed).
|
|
35
|
+
* - Reads a local cache first; only hits the network when the cache is
|
|
36
|
+
* older than 24 hours.
|
|
37
|
+
* - When the cache is stale, fires a background fetch (max 1500 ms) and
|
|
38
|
+
* updates the cache when done. Returns null for this run so startup is
|
|
39
|
+
* never blocked by the network.
|
|
40
|
+
* - All errors are swallowed silently.
|
|
41
|
+
*/
|
|
42
|
+
export async function checkForUpdate() {
|
|
43
|
+
try {
|
|
44
|
+
const cacheRaw = await fs.readFile(UPDATE_CACHE_PATH, 'utf8').catch(() => null);
|
|
45
|
+
if (cacheRaw) {
|
|
46
|
+
const cache = JSON.parse(cacheRaw);
|
|
47
|
+
const age = Date.now() - new Date(cache.lastCheck).getTime();
|
|
48
|
+
if (age < CHECK_INTERVAL_MS) {
|
|
49
|
+
// Cache is fresh: return immediately
|
|
50
|
+
return semverGt(cache.latest, VERSION) ? cache.latest : null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Cache is stale or missing: fire a background fetch and return null now
|
|
54
|
+
void (async () => {
|
|
55
|
+
try {
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
58
|
+
const res = await fetch(NPM_REGISTRY, { signal: controller.signal });
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
if (!res.ok)
|
|
61
|
+
return;
|
|
62
|
+
const json = (await res.json());
|
|
63
|
+
const latest = json.version;
|
|
64
|
+
if (!latest)
|
|
65
|
+
return;
|
|
66
|
+
await fs.mkdir(VEXI_DIR, { recursive: true }).catch(() => { });
|
|
67
|
+
await fs.writeFile(UPDATE_CACHE_PATH, JSON.stringify({ lastCheck: new Date().toISOString(), latest }), 'utf8').catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// network error or timeout -- silently ignored
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Spawn npm with inherited stdio and return whether it exited successfully. */
|
|
80
|
+
async function runNpm(args) {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
83
|
+
const child = spawn(cmd, args, { stdio: 'inherit' });
|
|
84
|
+
child.on('error', (e) => {
|
|
85
|
+
if (e.code === 'ENOENT') {
|
|
86
|
+
console.error(err('\nnpm not found. Install Node.js (which includes npm) from https://nodejs.org'));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
console.error(err(`\nFailed to run npm: ${e.message}`));
|
|
90
|
+
}
|
|
91
|
+
resolve(false);
|
|
92
|
+
});
|
|
93
|
+
child.on('close', (code) => resolve(code === 0));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Run `npm install -g vexi-cli@latest`, streaming npm output to the terminal.
|
|
98
|
+
* Prints the manual command and exits non-zero on any failure.
|
|
99
|
+
*/
|
|
100
|
+
export async function runUpdate() {
|
|
101
|
+
console.log(dim('Running: npm install -g vexi-cli@latest\n'));
|
|
102
|
+
const ok_ = await runNpm(['install', '-g', 'vexi-cli@latest']);
|
|
103
|
+
if (ok_) {
|
|
104
|
+
console.log('\n' + ok('Vexi updated successfully.'));
|
|
105
|
+
console.log(dim('Restart your terminal or run `vexi --version` to confirm.'));
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
console.log('\n' + warn('If the error above is a permissions problem, try one of:'));
|
|
109
|
+
console.log(dim(' sudo npm install -g vexi-cli@latest'));
|
|
110
|
+
console.log(dim(' # Windows: run your terminal as Administrator'));
|
|
111
|
+
console.log(dim(' # or use a Node version manager (nvm, fnm, volta)'));
|
|
112
|
+
console.log(dim('\nManual command: npm install -g vexi-cli@latest'));
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Uninstall vexi-cli.
|
|
118
|
+
*
|
|
119
|
+
* 1. Describes what will happen and asks for confirmation.
|
|
120
|
+
* 2. Runs `npm uninstall -g vexi-cli`, streaming output.
|
|
121
|
+
* 3. If --purge: asks for a SECOND confirmation, then deletes ~/.vexi.
|
|
122
|
+
*
|
|
123
|
+
* Always prints the manual command, because on some platforms (Windows,
|
|
124
|
+
* certain npm prefixes) a running binary cannot delete itself in-process.
|
|
125
|
+
*/
|
|
126
|
+
export async function runUninstall(purge) {
|
|
127
|
+
// Step 1: Describe what will happen
|
|
128
|
+
console.log();
|
|
129
|
+
console.log(accent('What will happen:'));
|
|
130
|
+
console.log(dim(' - Remove: vexi-cli global npm package'));
|
|
131
|
+
if (purge) {
|
|
132
|
+
console.log(warn(` - Delete: ${VEXI_DIR}`));
|
|
133
|
+
console.log(warn(' (config, API keys, memory, snapshots -- permanent!)'));
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
console.log(dim(` - Keep: ${VEXI_DIR} (your config, keys, and memory are preserved)`));
|
|
137
|
+
console.log(dim(' Use --purge to also delete this directory.'));
|
|
138
|
+
}
|
|
139
|
+
console.log();
|
|
140
|
+
// Step 2: First confirmation
|
|
141
|
+
const proceed = await confirm({ message: 'Remove vexi-cli?', default: false }).catch(() => false);
|
|
142
|
+
if (!proceed) {
|
|
143
|
+
console.log(dim('\nCancelled. Nothing was changed.'));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
console.log(dim('\nRunning: npm uninstall -g vexi-cli\n'));
|
|
147
|
+
const uninstallOk = await runNpm(['uninstall', '-g', 'vexi-cli']);
|
|
148
|
+
if (!uninstallOk) {
|
|
149
|
+
console.log('\n' + warn('Uninstall may have failed. Run this manually if needed:'));
|
|
150
|
+
console.log(dim(' npm uninstall -g vexi-cli'));
|
|
151
|
+
if (purge) {
|
|
152
|
+
console.log(dim(' rm -rf ~/.vexi # macOS / Linux'));
|
|
153
|
+
console.log(dim(' rmdir /s /q %USERPROFILE%\\.vexi # Windows'));
|
|
154
|
+
}
|
|
155
|
+
process.exitCode = 1;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
console.log('\n' + ok('vexi-cli uninstalled.'));
|
|
159
|
+
console.log(dim('Manual command for reference: npm uninstall -g vexi-cli'));
|
|
160
|
+
console.log(dim('To reinstall: npm install -g vexi-cli'));
|
|
161
|
+
// Step 3: Purge ~/.vexi if requested
|
|
162
|
+
if (purge) {
|
|
163
|
+
console.log();
|
|
164
|
+
console.log(warn(`About to permanently delete ${VEXI_DIR}`));
|
|
165
|
+
console.log(warn('This removes config, API keys, memory, and sessions. This cannot be undone.'));
|
|
166
|
+
console.log();
|
|
167
|
+
const confirmPurge = await confirm({
|
|
168
|
+
message: `Delete ${VEXI_DIR} permanently?`,
|
|
169
|
+
default: false,
|
|
170
|
+
}).catch(() => false);
|
|
171
|
+
if (!confirmPurge) {
|
|
172
|
+
console.log(dim(`\n${VEXI_DIR} was not deleted.`));
|
|
173
|
+
console.log(dim(`To remove it manually:`));
|
|
174
|
+
console.log(dim(` rm -rf ~/.vexi # macOS / Linux`));
|
|
175
|
+
console.log(dim(` rmdir /s /q %USERPROFILE%\\.vexi # Windows`));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
await fs.rm(VEXI_DIR, { recursive: true, force: true });
|
|
180
|
+
console.log(ok(`Deleted ${VEXI_DIR}`));
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
console.error(err(`Could not delete ${VEXI_DIR}: ${e instanceof Error ? e.message : String(e)}`));
|
|
184
|
+
console.log(dim('Remove it manually:'));
|
|
185
|
+
console.log(dim(` rm -rf ~/.vexi # macOS / Linux`));
|
|
186
|
+
console.log(dim(` rmdir /s /q %USERPROFILE%\\.vexi # Windows`));
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token & cost tracking — Feature #3.
|
|
3
|
+
*
|
|
4
|
+
* Vexi is bring-your-own-key, so the user pays the provider directly. This
|
|
5
|
+
* module accumulates the token usage reported by the provider APIs over a
|
|
6
|
+
* session and estimates the dollar cost from a small built-in price table.
|
|
7
|
+
*
|
|
8
|
+
* Cost is best-effort: when a model isn't in the table (or a provider doesn't
|
|
9
|
+
* report usage) we still show token counts and simply omit the dollar figure.
|
|
10
|
+
*/
|
|
11
|
+
const PRICES = [
|
|
12
|
+
// ── Anthropic ──
|
|
13
|
+
{ match: 'claude-opus', price: { in: 15, out: 75 } },
|
|
14
|
+
{ match: 'claude-3-opus', price: { in: 15, out: 75 } },
|
|
15
|
+
{ match: 'claude-sonnet', price: { in: 3, out: 15 } },
|
|
16
|
+
{ match: 'claude-3-5-sonnet', price: { in: 3, out: 15 } },
|
|
17
|
+
{ match: 'claude-3-7-sonnet', price: { in: 3, out: 15 } },
|
|
18
|
+
{ match: 'claude-haiku', price: { in: 0.8, out: 4 } },
|
|
19
|
+
{ match: 'claude-3-5-haiku', price: { in: 0.8, out: 4 } },
|
|
20
|
+
{ match: 'claude-3-haiku', price: { in: 0.25, out: 1.25 } },
|
|
21
|
+
// ── OpenAI ──
|
|
22
|
+
{ match: 'gpt-4o-mini', price: { in: 0.15, out: 0.6 } },
|
|
23
|
+
{ match: 'gpt-4o', price: { in: 2.5, out: 10 } },
|
|
24
|
+
{ match: 'gpt-4.1-mini', price: { in: 0.4, out: 1.6 } },
|
|
25
|
+
{ match: 'gpt-4.1-nano', price: { in: 0.1, out: 0.4 } },
|
|
26
|
+
{ match: 'gpt-4.1', price: { in: 2, out: 8 } },
|
|
27
|
+
{ match: 'o4-mini', price: { in: 1.1, out: 4.4 } },
|
|
28
|
+
{ match: 'o3-mini', price: { in: 1.1, out: 4.4 } },
|
|
29
|
+
// ── DeepSeek ──
|
|
30
|
+
{ match: 'deepseek-chat', price: { in: 0.27, out: 1.1 } },
|
|
31
|
+
{ match: 'deepseek-reasoner', price: { in: 0.55, out: 2.19 } },
|
|
32
|
+
// ── Mistral ──
|
|
33
|
+
{ match: 'mistral-small', price: { in: 0.2, out: 0.6 } },
|
|
34
|
+
{ match: 'mistral-large', price: { in: 2, out: 6 } },
|
|
35
|
+
// ── Known free tiers (default models) — explicitly zero-cost ──
|
|
36
|
+
{ match: 'llama-3.3-70b', price: { in: 0, out: 0 } },
|
|
37
|
+
{ match: 'gemini-2.5-flash', price: { in: 0, out: 0 } },
|
|
38
|
+
{ match: 'gemini-1.5-flash', price: { in: 0, out: 0 } },
|
|
39
|
+
{ match: 'glm-4-flash', price: { in: 0, out: 0 } },
|
|
40
|
+
{ match: 'qwen-turbo', price: { in: 0, out: 0 } },
|
|
41
|
+
{ match: 'moonshot-v1', price: { in: 0, out: 0 } },
|
|
42
|
+
{ match: 'minimax', price: { in: 0, out: 0 } },
|
|
43
|
+
];
|
|
44
|
+
/** Look up the price for a model id, or null when unknown. */
|
|
45
|
+
export function priceForModel(model) {
|
|
46
|
+
const id = model.toLowerCase();
|
|
47
|
+
let best = null;
|
|
48
|
+
for (const entry of PRICES) {
|
|
49
|
+
if (id.includes(entry.match) && (!best || entry.match.length > best.match.length)) {
|
|
50
|
+
best = entry;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return best?.price ?? null;
|
|
54
|
+
}
|
|
55
|
+
/** Estimate the USD cost of a usage total for a model, or null when unpriced. */
|
|
56
|
+
export function estimateCost(model, usage) {
|
|
57
|
+
const price = priceForModel(model);
|
|
58
|
+
if (!price)
|
|
59
|
+
return null;
|
|
60
|
+
return (usage.inputTokens * price.in + usage.outputTokens * price.out) / 1_000_000;
|
|
61
|
+
}
|
|
62
|
+
/** Accumulates token usage across a session. */
|
|
63
|
+
export class UsageTracker {
|
|
64
|
+
input = 0;
|
|
65
|
+
output = 0;
|
|
66
|
+
turns = 0;
|
|
67
|
+
add(usage) {
|
|
68
|
+
if (usage.inputTokens > 0 || usage.outputTokens > 0) {
|
|
69
|
+
this.input += usage.inputTokens;
|
|
70
|
+
this.output += usage.outputTokens;
|
|
71
|
+
this.turns++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
get totals() {
|
|
75
|
+
return { inputTokens: this.input, outputTokens: this.output };
|
|
76
|
+
}
|
|
77
|
+
get hasData() {
|
|
78
|
+
return this.turns > 0;
|
|
79
|
+
}
|
|
80
|
+
/** A one-line human summary, e.g. "12,340 tokens (8.1k in / 4.2k out) · ~$0.0123". */
|
|
81
|
+
summary(model) {
|
|
82
|
+
if (!this.hasData)
|
|
83
|
+
return 'No usage reported yet.';
|
|
84
|
+
const total = this.input + this.output;
|
|
85
|
+
const cost = estimateCost(model, this.totals);
|
|
86
|
+
const costPart = cost === null ? '' : ` · ~$${cost.toFixed(4)}`;
|
|
87
|
+
return `${fmt(total)} tokens (${fmt(this.input)} in / ${fmt(this.output)} out)${costPart}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Compact number formatting: 1234 -> "1,234", 12340 -> "12.3k". */
|
|
91
|
+
function fmt(n) {
|
|
92
|
+
if (n < 10_000)
|
|
93
|
+
return n.toLocaleString('en-US');
|
|
94
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { UsageTracker, priceForModel, estimateCost } from './index.js';
|
|
3
|
+
describe('priceForModel', () => {
|
|
4
|
+
it('matches the most specific prefix (mini before base)', () => {
|
|
5
|
+
expect(priceForModel('gpt-4o-mini')).toEqual({ in: 0.15, out: 0.6 });
|
|
6
|
+
expect(priceForModel('gpt-4o-2024-08-06')).toEqual({ in: 2.5, out: 10 });
|
|
7
|
+
});
|
|
8
|
+
it('prices known free-tier models at zero', () => {
|
|
9
|
+
expect(priceForModel('gemini-2.5-flash')).toEqual({ in: 0, out: 0 });
|
|
10
|
+
expect(priceForModel('glm-4-flash')).toEqual({ in: 0, out: 0 });
|
|
11
|
+
});
|
|
12
|
+
it('returns null for unknown models', () => {
|
|
13
|
+
expect(priceForModel('some-random-model')).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('estimateCost', () => {
|
|
17
|
+
it('computes cost from per-million pricing', () => {
|
|
18
|
+
// 1M input @ $3, 1M output @ $15 => $18
|
|
19
|
+
expect(estimateCost('claude-sonnet-5', { inputTokens: 1_000_000, outputTokens: 1_000_000 })).toBeCloseTo(18);
|
|
20
|
+
});
|
|
21
|
+
it('returns null when the model is unpriced', () => {
|
|
22
|
+
expect(estimateCost('mystery', { inputTokens: 100, outputTokens: 100 })).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('UsageTracker', () => {
|
|
26
|
+
it('accumulates across turns and ignores empty usage', () => {
|
|
27
|
+
const t = new UsageTracker();
|
|
28
|
+
t.add({ inputTokens: 100, outputTokens: 50 });
|
|
29
|
+
t.add({ inputTokens: 0, outputTokens: 0 }); // ignored
|
|
30
|
+
t.add({ inputTokens: 200, outputTokens: 80 });
|
|
31
|
+
expect(t.totals).toEqual({ inputTokens: 300, outputTokens: 130 });
|
|
32
|
+
expect(t.hasData).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
it('summarizes with a cost estimate for priced models', () => {
|
|
35
|
+
const t = new UsageTracker();
|
|
36
|
+
t.add({ inputTokens: 1000, outputTokens: 500 });
|
|
37
|
+
const summary = t.summary('gpt-4o-mini');
|
|
38
|
+
expect(summary).toContain('1,500 tokens');
|
|
39
|
+
expect(summary).toContain('~$');
|
|
40
|
+
});
|
|
41
|
+
it('omits cost for unpriced models but still shows tokens', () => {
|
|
42
|
+
const t = new UsageTracker();
|
|
43
|
+
t.add({ inputTokens: 1000, outputTokens: 500 });
|
|
44
|
+
const summary = t.summary('mystery-model');
|
|
45
|
+
expect(summary).toContain('1,500 tokens');
|
|
46
|
+
expect(summary).not.toContain('$');
|
|
47
|
+
});
|
|
48
|
+
it('reports no data before any usage', () => {
|
|
49
|
+
expect(new UsageTracker().hasData).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vexi-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Open-source AI coding agent for your terminal. Bring your own key, zero config, multilingual.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"build": "tsc",
|
|
20
20
|
"dev": "tsc --watch",
|
|
21
21
|
"start": "node dist/index.js",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"lint": "eslint src",
|
|
22
24
|
"prepublishOnly": "npm run build"
|
|
23
25
|
},
|
|
24
26
|
"keywords": [
|
|
@@ -33,6 +35,9 @@
|
|
|
33
35
|
"gemini",
|
|
34
36
|
"groq",
|
|
35
37
|
"openrouter",
|
|
38
|
+
"glm",
|
|
39
|
+
"mistral",
|
|
40
|
+
"cerebras",
|
|
36
41
|
"multilingual"
|
|
37
42
|
],
|
|
38
43
|
"author": "Elomami1976",
|
|
@@ -55,6 +60,10 @@
|
|
|
55
60
|
},
|
|
56
61
|
"devDependencies": {
|
|
57
62
|
"@types/node": "^22.10.0",
|
|
58
|
-
"typescript": "^
|
|
63
|
+
"@typescript-eslint/eslint-plugin": "^8.61.1",
|
|
64
|
+
"@typescript-eslint/parser": "^8.61.1",
|
|
65
|
+
"eslint": "^9.39.4",
|
|
66
|
+
"typescript": "^5.7.0",
|
|
67
|
+
"vitest": "^4.1.9"
|
|
59
68
|
}
|
|
60
69
|
}
|