vexi-cli 0.8.0 → 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.
@@ -7,48 +7,218 @@
7
7
  * Vexi dependency-free (plain `fetch`, no SDKs).
8
8
  */
9
9
  import { ProviderError } from './types.js';
10
+ /** Report OpenAI-style `usage` ({prompt_tokens, completion_tokens}) if present. */
11
+ function reportUsage(json, onUsage) {
12
+ if (!onUsage)
13
+ return;
14
+ const u = json.usage;
15
+ if (u && (u.prompt_tokens || u.completion_tokens)) {
16
+ onUsage({ inputTokens: u.prompt_tokens ?? 0, outputTokens: u.completion_tokens ?? 0 });
17
+ }
18
+ }
19
+ /** Translate Vexi's neutral ChatMessage[] into OpenAI chat-completions format. */
20
+ function toOpenAIMessages(messages) {
21
+ return messages.map((m) => {
22
+ if (m.role === 'assistant' && m.toolCalls?.length) {
23
+ return {
24
+ role: 'assistant',
25
+ content: m.content || null,
26
+ tool_calls: m.toolCalls.map((tc) => ({
27
+ id: tc.id,
28
+ type: 'function',
29
+ function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
30
+ })),
31
+ };
32
+ }
33
+ if (m.role === 'tool') {
34
+ return { role: 'tool', tool_call_id: m.toolCallId ?? '', content: m.content };
35
+ }
36
+ return { role: m.role, content: m.content };
37
+ });
38
+ }
10
39
  export function createOpenAICompatProvider(opts) {
11
40
  return {
12
41
  id: opts.id,
13
42
  model: opts.model,
14
- async stream(messages, onText) {
15
- const res = await fetch(`${opts.baseUrl}/chat/completions`, {
16
- method: 'POST',
17
- headers: {
18
- 'Content-Type': 'application/json',
19
- Authorization: `Bearer ${opts.apiKey}`,
20
- ...opts.extraHeaders,
21
- },
22
- body: JSON.stringify({
23
- model: opts.model,
24
- messages,
25
- stream: true,
26
- ...opts.extraBody,
27
- }),
28
- }).catch((err) => {
29
- throw new ProviderError(`Network error: ${err.message}`);
30
- });
31
- if (!res.ok || !res.body) {
32
- const body = await res.text().catch(() => '');
33
- throw new ProviderError(`${opts.id} API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
43
+ supportsTools: opts.supportsTools ?? false,
44
+ async streamTools(messages, tools, onText, onUsage) {
45
+ const guard = createStreamTimeoutGuard(opts.id);
46
+ try {
47
+ const res = await fetch(`${opts.baseUrl}/chat/completions`, {
48
+ method: 'POST',
49
+ headers: {
50
+ 'Content-Type': 'application/json',
51
+ Authorization: `Bearer ${opts.apiKey}`,
52
+ ...opts.extraHeaders,
53
+ },
54
+ body: JSON.stringify({
55
+ model: opts.model,
56
+ messages: toOpenAIMessages(messages),
57
+ tools: tools.map((t) => ({
58
+ type: 'function',
59
+ function: { name: t.name, description: t.description, parameters: t.inputSchema },
60
+ })),
61
+ stream: true,
62
+ stream_options: { include_usage: true },
63
+ ...opts.extraBody,
64
+ }),
65
+ signal: guard.signal,
66
+ }).catch((err) => {
67
+ throw guard.wrapNetworkError(err);
68
+ });
69
+ if (!res.ok || !res.body) {
70
+ const body = await res.text().catch(() => '');
71
+ throw new ProviderError(`${opts.id} API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
72
+ }
73
+ let full = '';
74
+ // Streamed tool calls arrive as fragments keyed by their array index;
75
+ // accumulate id/name/arguments across deltas, then parse at the end.
76
+ const acc = new Map();
77
+ try {
78
+ for await (const data of sseEvents(res.body)) {
79
+ guard.resetIdleTimer();
80
+ if (data === '[DONE]')
81
+ break;
82
+ try {
83
+ const json = JSON.parse(data);
84
+ const delta = json.choices?.[0]?.delta;
85
+ if (delta?.content) {
86
+ full += delta.content;
87
+ onText(delta.content);
88
+ }
89
+ if (Array.isArray(delta?.tool_calls)) {
90
+ for (const tc of delta.tool_calls) {
91
+ const idx = tc.index ?? 0;
92
+ const cur = acc.get(idx) ?? { id: '', name: '', args: '' };
93
+ if (tc.id)
94
+ cur.id = tc.id;
95
+ if (tc.function?.name)
96
+ cur.name = tc.function.name;
97
+ if (tc.function?.arguments)
98
+ cur.args += tc.function.arguments;
99
+ acc.set(idx, cur);
100
+ }
101
+ }
102
+ reportUsage(json, onUsage);
103
+ }
104
+ catch {
105
+ // Ignore malformed/keep-alive chunks
106
+ }
107
+ }
108
+ }
109
+ catch (e) {
110
+ throw guard.wrapStreamError(e);
111
+ }
112
+ const toolCalls = [...acc.entries()]
113
+ .sort((a, b) => a[0] - b[0])
114
+ .map(([idx, c]) => {
115
+ let args = {};
116
+ try {
117
+ args = c.args ? JSON.parse(c.args) : {};
118
+ }
119
+ catch {
120
+ args = {};
121
+ }
122
+ return { id: c.id || `call_${idx}`, name: c.name, arguments: args };
123
+ })
124
+ .filter((c) => c.name);
125
+ return { text: full, toolCalls };
126
+ }
127
+ finally {
128
+ guard.dispose();
34
129
  }
35
- let full = '';
36
- for await (const data of sseEvents(res.body)) {
37
- if (data === '[DONE]')
38
- break;
130
+ },
131
+ async stream(messages, onText, onUsage) {
132
+ const guard = createStreamTimeoutGuard(opts.id);
133
+ try {
134
+ const res = await fetch(`${opts.baseUrl}/chat/completions`, {
135
+ method: 'POST',
136
+ headers: {
137
+ 'Content-Type': 'application/json',
138
+ Authorization: `Bearer ${opts.apiKey}`,
139
+ ...opts.extraHeaders,
140
+ },
141
+ body: JSON.stringify({
142
+ model: opts.model,
143
+ messages: toOpenAIMessages(messages),
144
+ stream: true,
145
+ stream_options: { include_usage: true },
146
+ ...opts.extraBody,
147
+ }),
148
+ signal: guard.signal,
149
+ }).catch((err) => {
150
+ throw guard.wrapNetworkError(err);
151
+ });
152
+ if (!res.ok || !res.body) {
153
+ const body = await res.text().catch(() => '');
154
+ throw new ProviderError(`${opts.id} API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
155
+ }
156
+ let full = '';
39
157
  try {
40
- const json = JSON.parse(data);
41
- const text = json.choices?.[0]?.delta?.content;
42
- if (text) {
43
- full += text;
44
- onText(text);
158
+ for await (const data of sseEvents(res.body)) {
159
+ guard.resetIdleTimer();
160
+ if (data === '[DONE]')
161
+ break;
162
+ try {
163
+ const json = JSON.parse(data);
164
+ const text = json.choices?.[0]?.delta?.content;
165
+ if (text) {
166
+ full += text;
167
+ onText(text);
168
+ }
169
+ reportUsage(json, onUsage);
170
+ }
171
+ catch {
172
+ // Ignore malformed/keep-alive chunks
173
+ }
45
174
  }
46
175
  }
47
- catch {
48
- // Ignore malformed/keep-alive chunks
176
+ catch (e) {
177
+ throw guard.wrapStreamError(e);
49
178
  }
179
+ return full;
50
180
  }
51
- return full;
181
+ finally {
182
+ guard.dispose();
183
+ }
184
+ },
185
+ };
186
+ }
187
+ /** Abort the connection if the server goes silent for this long between chunks. */
188
+ const IDLE_TIMEOUT_MS = 60_000;
189
+ /** Hard ceiling on total stream duration, regardless of activity. */
190
+ const MAX_STREAM_MS = 10 * 60 * 1000;
191
+ /**
192
+ * Shared guard against hung provider connections: aborts the fetch if no
193
+ * bytes arrive for IDLE_TIMEOUT_MS, or if the whole stream runs past
194
+ * MAX_STREAM_MS, so a stalled provider can never freeze the chat loop.
195
+ */
196
+ export function createStreamTimeoutGuard(providerId) {
197
+ const controller = new AbortController();
198
+ let idleTimer;
199
+ const hardTimer = setTimeout(() => controller.abort(), MAX_STREAM_MS);
200
+ const resetIdleTimer = () => {
201
+ clearTimeout(idleTimer);
202
+ idleTimer = setTimeout(() => controller.abort(), IDLE_TIMEOUT_MS);
203
+ };
204
+ resetIdleTimer();
205
+ return {
206
+ signal: controller.signal,
207
+ resetIdleTimer,
208
+ wrapNetworkError(err) {
209
+ if (controller.signal.aborted)
210
+ return new ProviderError(`${providerId} request timed out (no response).`);
211
+ return new ProviderError(`Network error: ${err.message}`);
212
+ },
213
+ wrapStreamError(e) {
214
+ if (controller.signal.aborted) {
215
+ return new ProviderError(`${providerId} stream stalled or exceeded the time limit and was aborted.`);
216
+ }
217
+ return e;
218
+ },
219
+ dispose() {
220
+ clearTimeout(idleTimer);
221
+ clearTimeout(hardTimer);
52
222
  },
53
223
  };
54
224
  }
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { createOpenAICompatProvider } from './openai-compat.js';
3
+ /** Build a fake fetch Response whose body streams the given SSE lines. */
4
+ function sseResponse(lines) {
5
+ const body = new ReadableStream({
6
+ start(controller) {
7
+ const enc = new TextEncoder();
8
+ for (const l of lines)
9
+ controller.enqueue(enc.encode(`data: ${l}\n`));
10
+ controller.close();
11
+ },
12
+ });
13
+ return { ok: true, status: 200, body };
14
+ }
15
+ afterEach(() => vi.restoreAllMocks());
16
+ describe('openai-compat streamTools', () => {
17
+ it('reassembles a tool call split across streamed deltas', async () => {
18
+ const chunks = [
19
+ JSON.stringify({ choices: [{ delta: { content: 'let me edit' } }] }),
20
+ JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'edit_file' } }] } }] }),
21
+ JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"path":"a.ts",' } }] } }] }),
22
+ JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '"old":"x","new":"y"}' } }] } }] }),
23
+ '[DONE]',
24
+ ];
25
+ vi.stubGlobal('fetch', vi.fn(async () => sseResponse(chunks)));
26
+ const provider = createOpenAICompatProvider({
27
+ id: 'openai', baseUrl: 'https://x/v1', apiKey: 'k', model: 'gpt', supportsTools: true,
28
+ });
29
+ let streamed = '';
30
+ const { text, toolCalls } = await provider.streamTools([{ role: 'user', content: 'go' }], [{ name: 'edit_file', description: 'd', inputSchema: { type: 'object' } }], (c) => { streamed += c; });
31
+ expect(streamed).toBe('let me edit');
32
+ expect(text).toBe('let me edit');
33
+ expect(toolCalls).toEqual([
34
+ { id: 'call_1', name: 'edit_file', arguments: { path: 'a.ts', old: 'x', new: 'y' } },
35
+ ]);
36
+ });
37
+ it('returns no tool calls for a plain text answer', async () => {
38
+ vi.stubGlobal('fetch', vi.fn(async () => sseResponse([
39
+ JSON.stringify({ choices: [{ delta: { content: 'hi' } }] }),
40
+ '[DONE]',
41
+ ])));
42
+ const provider = createOpenAICompatProvider({
43
+ id: 'openai', baseUrl: 'https://x/v1', apiKey: 'k', model: 'gpt', supportsTools: true,
44
+ });
45
+ const { text, toolCalls } = await provider.streamTools([{ role: 'user', content: 'hi' }], [], () => { });
46
+ expect(text).toBe('hi');
47
+ expect(toolCalls).toEqual([]);
48
+ });
49
+ it('reports token usage from the final include_usage chunk', async () => {
50
+ vi.stubGlobal('fetch', vi.fn(async () => sseResponse([
51
+ JSON.stringify({ choices: [{ delta: { content: 'hi' } }] }),
52
+ JSON.stringify({ choices: [], usage: { prompt_tokens: 42, completion_tokens: 7 } }),
53
+ '[DONE]',
54
+ ])));
55
+ const provider = createOpenAICompatProvider({ id: 'openai', baseUrl: 'x', apiKey: 'k', model: 'gpt' });
56
+ let usage = null;
57
+ await provider.stream([{ role: 'user', content: 'hi' }], () => { }, (u) => { usage = u; });
58
+ expect(usage).toEqual({ inputTokens: 42, outputTokens: 7 });
59
+ });
60
+ it('advertises native tool support via the flag', () => {
61
+ const p = createOpenAICompatProvider({ id: 'openai', baseUrl: 'x', apiKey: 'k', model: 'gpt', supportsTools: true });
62
+ expect(p.supportsTools).toBe(true);
63
+ const noTools = createOpenAICompatProvider({ id: 'minimax', baseUrl: 'x', apiKey: 'k', model: 'm' });
64
+ expect(noTools.supportsTools).toBe(false);
65
+ });
66
+ });
@@ -17,7 +17,7 @@ export class ProviderError extends Error {
17
17
  /** Human-friendly provider metadata. */
18
18
  export const PROVIDER_INFO = {
19
19
  // ── International providers ───────────────────────────────────────────
20
- anthropic: { label: 'Anthropic (Claude)', defaultModel: 'claude-sonnet-4-5' },
20
+ anthropic: { label: 'Anthropic (Claude)', defaultModel: 'claude-sonnet-5' },
21
21
  openai: { label: 'OpenAI (GPT)', defaultModel: 'gpt-4o-mini' },
22
22
  openrouter: { label: 'OpenRouter', defaultModel: 'openrouter/auto' },
23
23
  groq: { label: 'Groq (free tier)', defaultModel: 'llama-3.3-70b-versatile', free: true },
@@ -16,6 +16,7 @@
16
16
  import { promises as fs } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
  import { loadSession } from './recorder.js';
19
+ import { escapeHtml } from '../utils/html.js';
19
20
  /** UI labels for the generated page, per export language. */
20
21
  const LABELS = {
21
22
  en: {
@@ -306,10 +307,3 @@ document.getElementById('record').onclick = async () => {
306
307
  </html>
307
308
  `;
308
309
  }
309
- function escapeHtml(text) {
310
- return text
311
- .replaceAll('&', '&amp;')
312
- .replaceAll('<', '&lt;')
313
- .replaceAll('>', '&gt;')
314
- .replaceAll('"', '&quot;');
315
- }
@@ -50,7 +50,7 @@ export function skillsBlock(skills) {
50
50
  * Add a skill from a local file path or a URL (GitHub URLs are converted
51
51
  * to raw content automatically). Returns the saved skill name.
52
52
  */
53
- export async function addSkill(root, source) {
53
+ export async function addSkill(root, source, opts = {}) {
54
54
  let content;
55
55
  let name;
56
56
  if (/^https?:\/\//i.test(source)) {
@@ -61,14 +61,22 @@ export async function addSkill(root, source) {
61
61
  }
62
62
  content = await res.text();
63
63
  name = skillNameFromUrl(source);
64
+ content = content.slice(0, MAX_SKILL_SIZE).trim();
65
+ if (!content)
66
+ throw new Error('Skill source is empty.');
67
+ if (opts.confirmRemoteContent) {
68
+ const proceed = await opts.confirmRemoteContent(content, sanitizeName(name), url);
69
+ if (!proceed)
70
+ throw new Error('Skill add cancelled.');
71
+ }
64
72
  }
65
73
  else {
66
74
  content = await fs.readFile(source, 'utf8');
67
75
  name = basename(source, extname(source));
76
+ content = content.slice(0, MAX_SKILL_SIZE).trim();
77
+ if (!content)
78
+ throw new Error('Skill source is empty.');
68
79
  }
69
- content = content.slice(0, MAX_SKILL_SIZE).trim();
70
- if (!content)
71
- throw new Error('Skill source is empty.');
72
80
  name = sanitizeName(name);
73
81
  const dir = skillsDir(root);
74
82
  await fs.mkdir(dir, { recursive: true });
@@ -20,8 +20,21 @@
20
20
  */
21
21
  import { promises as fs } from 'node:fs';
22
22
  import { existsSync } from 'node:fs';
23
- import { join, isAbsolute, relative, dirname } from 'node:path';
23
+ import { join, isAbsolute, relative, dirname, resolve, sep } from 'node:path';
24
24
  import { writeJsonAtomic, readJson } from '../utils/fs-atomic.js';
25
+ /**
26
+ * Resolve `rel` against `root` and verify the result stays inside `root`.
27
+ * Blocks path traversal (e.g. a relative path containing `..` segments)
28
+ * from letting snapshot save/restore read or overwrite files outside the
29
+ * project — snapshots are only ever meant to cover project files.
30
+ */
31
+ function resolveWithinRoot(root, rel) {
32
+ const absRoot = resolve(root);
33
+ const abs = isAbsolute(rel) ? resolve(rel) : resolve(absRoot, rel);
34
+ if (abs !== absRoot && !abs.startsWith(absRoot + sep))
35
+ return null;
36
+ return abs;
37
+ }
25
38
  const MAX_SNAPSHOTS = 50;
26
39
  function makeId() {
27
40
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
@@ -79,8 +92,8 @@ export class SnapshotManager {
79
92
  await fs.mkdir(dir, { recursive: true });
80
93
  const saved = [];
81
94
  for (const rel of relPaths) {
82
- const abs = isAbsolute(rel) ? rel : join(this.root, rel);
83
- if (!existsSync(abs))
95
+ const abs = resolveWithinRoot(this.root, rel);
96
+ if (!abs || !existsSync(abs))
84
97
  continue;
85
98
  await fs.copyFile(abs, join(dir, encodeRelPath(rel)));
86
99
  saved.push(rel);
@@ -90,7 +103,9 @@ export class SnapshotManager {
90
103
  async restoreFiles(id, relPaths) {
91
104
  const dir = join(this.sessionDir, id, 'files');
92
105
  for (const rel of relPaths) {
93
- const dest = isAbsolute(rel) ? rel : join(this.root, rel);
106
+ const dest = resolveWithinRoot(this.root, rel);
107
+ if (!dest)
108
+ continue;
94
109
  try {
95
110
  await fs.copyFile(join(dir, encodeRelPath(rel)), dest);
96
111
  }
@@ -215,6 +230,8 @@ export class SnapshotManager {
215
230
  if (!p || p.startsWith('-') || p.startsWith('http') || p === '/dev/null')
216
231
  return;
217
232
  const abs = isAbsolute(p) ? p : join(cwd, p);
233
+ if (!resolveWithinRoot(cwd, abs))
234
+ return; // never track files outside the project root
218
235
  if (existsSync(abs)) {
219
236
  found.add(relative(cwd, abs).replace(/\\/g, '/'));
220
237
  }