vexi-cli 0.8.0 → 0.9.1

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.
@@ -4,57 +4,195 @@
4
4
  * so it gets its own small client instead of the OpenAI-compatible one.
5
5
  */
6
6
  import { ProviderError } from './types.js';
7
- import { sseEvents, truncate } from './openai-compat.js';
7
+ import { sseEvents, truncate, createStreamTimeoutGuard } from './openai-compat.js';
8
8
  const ANTHROPIC_BASE = 'https://api.anthropic.com/v1';
9
9
  const ANTHROPIC_VERSION = '2023-06-01';
10
+ /**
11
+ * Accumulate token usage from an Anthropic SSE event. Input tokens arrive in
12
+ * `message_start`; output tokens accumulate in `message_delta`. Returns the
13
+ * running total (mutated in place).
14
+ */
15
+ function accumulateUsage(json, usage) {
16
+ if (json.type === 'message_start' && json.message?.usage) {
17
+ usage.inputTokens = json.message.usage.input_tokens ?? usage.inputTokens;
18
+ }
19
+ else if (json.type === 'message_delta' && json.usage) {
20
+ usage.outputTokens = json.usage.output_tokens ?? usage.outputTokens;
21
+ }
22
+ }
23
+ /** Translate Vexi's neutral ChatMessage[] into Anthropic's content-block format. */
24
+ function toAnthropicMessages(chat) {
25
+ return chat.map((m) => {
26
+ if (m.role === 'assistant' && m.toolCalls?.length) {
27
+ const blocks = [];
28
+ if (m.content)
29
+ blocks.push({ type: 'text', text: m.content });
30
+ for (const tc of m.toolCalls) {
31
+ blocks.push({ type: 'tool_use', id: tc.id, name: tc.name, input: tc.arguments });
32
+ }
33
+ return { role: 'assistant', content: blocks };
34
+ }
35
+ if (m.role === 'tool') {
36
+ // Tool results are carried on a user-role turn in Anthropic's API.
37
+ return {
38
+ role: 'user',
39
+ content: [{ type: 'tool_result', tool_use_id: m.toolCallId ?? '', content: m.content }],
40
+ };
41
+ }
42
+ return { role: m.role, content: m.content };
43
+ });
44
+ }
10
45
  export function createAnthropicProvider(apiKey, model, baseUrl) {
11
46
  const messagesEndpoint = `${baseUrl ?? ANTHROPIC_BASE}/messages`;
12
47
  return {
13
48
  id: 'anthropic',
14
49
  model,
15
- async stream(messages, onText) {
50
+ supportsTools: true,
51
+ async streamTools(messages, tools, onText, onUsage) {
52
+ const system = messages.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n');
53
+ const chat = messages.filter((m) => m.role !== 'system');
54
+ const usage = { inputTokens: 0, outputTokens: 0 };
55
+ const guard = createStreamTimeoutGuard('anthropic');
56
+ try {
57
+ const res = await fetch(messagesEndpoint, {
58
+ method: 'POST',
59
+ headers: {
60
+ 'Content-Type': 'application/json',
61
+ 'x-api-key': apiKey,
62
+ 'anthropic-version': ANTHROPIC_VERSION,
63
+ },
64
+ body: JSON.stringify({
65
+ model,
66
+ max_tokens: 8192,
67
+ ...(system ? { system } : {}),
68
+ messages: toAnthropicMessages(chat),
69
+ tools: tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema })),
70
+ stream: true,
71
+ }),
72
+ signal: guard.signal,
73
+ }).catch((err) => {
74
+ throw guard.wrapNetworkError(err);
75
+ });
76
+ if (!res.ok || !res.body) {
77
+ const body = await res.text().catch(() => '');
78
+ throw new ProviderError(`anthropic API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
79
+ }
80
+ let full = '';
81
+ const toolCalls = [];
82
+ // tool_use blocks stream their arguments as partial_json fragments,
83
+ // keyed by content-block index; accumulate then parse at block stop.
84
+ const partials = new Map();
85
+ try {
86
+ for await (const data of sseEvents(res.body)) {
87
+ guard.resetIdleTimer();
88
+ try {
89
+ const json = JSON.parse(data);
90
+ if (json.type === 'content_block_start' && json.content_block?.type === 'tool_use') {
91
+ partials.set(json.index, { id: json.content_block.id, name: json.content_block.name, json: '' });
92
+ }
93
+ else if (json.type === 'content_block_delta') {
94
+ if (json.delta?.type === 'text_delta') {
95
+ full += json.delta.text;
96
+ onText(json.delta.text);
97
+ }
98
+ else if (json.delta?.type === 'input_json_delta') {
99
+ const p = partials.get(json.index);
100
+ if (p)
101
+ p.json += json.delta.partial_json ?? '';
102
+ }
103
+ }
104
+ else if (json.type === 'content_block_stop') {
105
+ const p = partials.get(json.index);
106
+ if (p) {
107
+ let args = {};
108
+ try {
109
+ args = p.json ? JSON.parse(p.json) : {};
110
+ }
111
+ catch {
112
+ args = {};
113
+ }
114
+ toolCalls.push({ id: p.id, name: p.name, arguments: args });
115
+ partials.delete(json.index);
116
+ }
117
+ }
118
+ accumulateUsage(json, usage);
119
+ }
120
+ catch {
121
+ // Ignore malformed/keep-alive chunks
122
+ }
123
+ }
124
+ }
125
+ catch (e) {
126
+ throw guard.wrapStreamError(e);
127
+ }
128
+ if (onUsage)
129
+ onUsage(usage);
130
+ return { text: full, toolCalls };
131
+ }
132
+ finally {
133
+ guard.dispose();
134
+ }
135
+ },
136
+ async stream(messages, onText, onUsage) {
16
137
  // Anthropic takes the system prompt as a top-level field
17
138
  const system = messages
18
139
  .filter((m) => m.role === 'system')
19
140
  .map((m) => m.content)
20
141
  .join('\n\n');
21
142
  const chat = messages.filter((m) => m.role !== 'system');
22
- const res = await fetch(messagesEndpoint, {
23
- method: 'POST',
24
- headers: {
25
- 'Content-Type': 'application/json',
26
- 'x-api-key': apiKey,
27
- 'anthropic-version': ANTHROPIC_VERSION,
28
- },
29
- body: JSON.stringify({
30
- model,
31
- max_tokens: 8192,
32
- ...(system ? { system } : {}),
33
- messages: chat,
34
- stream: true,
35
- }),
36
- }).catch((err) => {
37
- throw new ProviderError(`Network error: ${err.message}`);
38
- });
39
- if (!res.ok || !res.body) {
40
- const body = await res.text().catch(() => '');
41
- throw new ProviderError(`anthropic API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
42
- }
43
- let full = '';
44
- for await (const data of sseEvents(res.body)) {
143
+ const usage = { inputTokens: 0, outputTokens: 0 };
144
+ const guard = createStreamTimeoutGuard('anthropic');
145
+ try {
146
+ const res = await fetch(messagesEndpoint, {
147
+ method: 'POST',
148
+ headers: {
149
+ 'Content-Type': 'application/json',
150
+ 'x-api-key': apiKey,
151
+ 'anthropic-version': ANTHROPIC_VERSION,
152
+ },
153
+ body: JSON.stringify({
154
+ model,
155
+ max_tokens: 8192,
156
+ ...(system ? { system } : {}),
157
+ messages: chat,
158
+ stream: true,
159
+ }),
160
+ signal: guard.signal,
161
+ }).catch((err) => {
162
+ throw guard.wrapNetworkError(err);
163
+ });
164
+ if (!res.ok || !res.body) {
165
+ const body = await res.text().catch(() => '');
166
+ throw new ProviderError(`anthropic API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
167
+ }
168
+ let full = '';
45
169
  try {
46
- const json = JSON.parse(data);
47
- if (json.type === 'content_block_delta' && json.delta?.type === 'text_delta') {
48
- const text = json.delta.text;
49
- full += text;
50
- onText(text);
170
+ for await (const data of sseEvents(res.body)) {
171
+ guard.resetIdleTimer();
172
+ try {
173
+ const json = JSON.parse(data);
174
+ if (json.type === 'content_block_delta' && json.delta?.type === 'text_delta') {
175
+ const text = json.delta.text;
176
+ full += text;
177
+ onText(text);
178
+ }
179
+ accumulateUsage(json, usage);
180
+ }
181
+ catch {
182
+ // Ignore malformed/keep-alive chunks
183
+ }
51
184
  }
52
185
  }
53
- catch {
54
- // Ignore malformed/keep-alive chunks
186
+ catch (e) {
187
+ throw guard.wrapStreamError(e);
55
188
  }
189
+ if (onUsage)
190
+ onUsage(usage);
191
+ return full;
192
+ }
193
+ finally {
194
+ guard.dispose();
56
195
  }
57
- return full;
58
196
  },
59
197
  };
60
198
  }
@@ -38,11 +38,19 @@ export const PROVIDER_PATTERNS = [
38
38
  // they always fall back to the interactive provider-selection list.
39
39
  ];
40
40
  /**
41
- * Sanitize a pasted API key: trim whitespace/newlines and strip
42
- * surrounding quotes (users often paste keys with extra characters).
41
+ * Sanitize a pasted API key: strip characters that can't be sent in an HTTP
42
+ * header, trim whitespace/newlines, and strip surrounding quotes.
43
+ *
44
+ * API keys are always printable ASCII, but pasting one — especially on an
45
+ * RTL/multilingual system — can silently pull in invisible bidi marks
46
+ * (U+200E/U+200F), a non-breaking space, smart quotes, or a stray newline.
47
+ * `fetch()` then rejects the Authorization header with a cryptic
48
+ * "character … has a value greater than 255" error on *every* request. We
49
+ * drop anything outside printable ASCII up front so that never happens.
43
50
  */
44
51
  export function sanitizeKey(raw) {
45
- let key = raw.trim();
52
+ // Keep only space + printable ASCII (0x20–0x7E); trim() handles edge spaces.
53
+ let key = raw.replace(/[^\x20-\x7E]/g, '').trim();
46
54
  // Strip matching surrounding quotes ("key", 'key', `key`)
47
55
  while (key.length >= 2 && `"'\``.includes(key[0]) && key[0] === key[key.length - 1]) {
48
56
  key = key.slice(1, -1).trim();
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sanitizeKey, detectProvider } from './detect.js';
3
+ describe('sanitizeKey', () => {
4
+ it('trims surrounding whitespace and quotes', () => {
5
+ expect(sanitizeKey(' gsk_abc123 ')).toBe('gsk_abc123');
6
+ expect(sanitizeKey('"gsk_abc123"')).toBe('gsk_abc123');
7
+ expect(sanitizeKey("'gsk_abc123'")).toBe('gsk_abc123');
8
+ });
9
+ it('strips invisible bidi marks pasted on RTL systems', () => {
10
+ // U+200F RIGHT-TO-LEFT MARK embedded around the key — the exact class of
11
+ // char that makes fetch() throw "value greater than 255" on the header.
12
+ expect(sanitizeKey('‏gsk_abc123‏')).toBe('gsk_abc123');
13
+ expect(sanitizeKey('gsk_‎abc123')).toBe('gsk_abc123');
14
+ });
15
+ it('strips non-breaking spaces, newlines, and smart quotes', () => {
16
+ expect(sanitizeKey('gsk_abc 123')).toBe('gsk_abc123');
17
+ expect(sanitizeKey('gsk_abc123\n')).toBe('gsk_abc123');
18
+ expect(sanitizeKey('“gsk_abc123”')).toBe('gsk_abc123');
19
+ });
20
+ it('produces a header-safe (all printable ASCII) result', () => {
21
+ const cleaned = sanitizeKey('‏gsk_ab c123');
22
+ expect([...cleaned].every((ch) => ch.charCodeAt(0) >= 0x21 && ch.charCodeAt(0) <= 0x7e)).toBe(true);
23
+ });
24
+ it('leaves a clean key untouched and still auto-detects the provider', () => {
25
+ const key = sanitizeKey('gsk_1234567890');
26
+ expect(key).toBe('gsk_1234567890');
27
+ expect(detectProvider(key)).toBe('groq');
28
+ });
29
+ });
@@ -5,6 +5,8 @@
5
5
  import { PROVIDER_INFO } from './types.js';
6
6
  import { createAnthropicProvider } from './anthropic.js';
7
7
  import { createOpenAICompatProvider } from './openai-compat.js';
8
+ import { defaultModelFor } from './manifest.js';
9
+ export { refreshManifest } from './manifest.js';
8
10
  export { detectProvider, sanitizeKey, PROVIDER_PATTERNS } from './detect.js';
9
11
  export { PROVIDER_INFO, ProviderError } from './types.js';
10
12
  /** Base URLs for the OpenAI-compatible providers. */
@@ -23,12 +25,25 @@ const BASE_URLS = {
23
25
  moonshot: 'https://api.moonshot.cn/v1', // Kimi — free quota on signup
24
26
  minimax: 'https://api.minimax.chat/v1', // MiniMax-Text-01 — free tier
25
27
  };
28
+ /**
29
+ * Provider ids whose APIs reliably support native function-calling. Others
30
+ * (and unknown URL-based endpoints) fall back to the text `vexi-tool` protocol.
31
+ */
32
+ const NATIVE_TOOL_PROVIDERS = new Set([
33
+ 'anthropic', 'openai', 'openrouter', 'groq', 'gemini', 'mistral',
34
+ 'cerebras', 'deepseek', 'qwen', 'moonshot', 'glm',
35
+ ]);
36
+ /** Whether native function-calling should be used for a given provider id. */
37
+ export function providerSupportsNativeTools(id) {
38
+ return NATIVE_TOOL_PROVIDERS.has(id);
39
+ }
26
40
  export function createProvider(id, apiKey, model) {
27
41
  const info = PROVIDER_INFO[id];
28
42
  if (!info) {
29
43
  throw new Error(`Unknown provider "${id}". Run \`vexi config reset\` to reconfigure.`);
30
44
  }
31
- const resolvedModel = model ?? info.defaultModel;
45
+ // Prefer an explicit model, then a remote-manifest default, then the compiled-in default.
46
+ const resolvedModel = model ?? defaultModelFor(id, info.defaultModel);
32
47
  if (id === 'anthropic') {
33
48
  return createAnthropicProvider(apiKey, resolvedModel);
34
49
  }
@@ -41,6 +56,7 @@ export function createProvider(id, apiKey, model) {
41
56
  apiKey,
42
57
  model: resolvedModel,
43
58
  extraHeaders,
59
+ supportsTools: providerSupportsNativeTools(id),
44
60
  });
45
61
  }
46
62
  /**
@@ -53,7 +69,7 @@ export function createProviderFromConfig(config) {
53
69
  // Old-style config: route by ProviderId
54
70
  return createProvider(config.provider, config.apiKey, config.model);
55
71
  }
56
- const model = config.model ?? 'gpt-4o';
72
+ const model = config.model ?? defaultModelFor(config.provider, 'gpt-4o');
57
73
  if (config.provider === 'anthropic') {
58
74
  return createAnthropicProvider(config.apiKey, model, config.baseUrl);
59
75
  }
@@ -69,5 +85,6 @@ export function createProviderFromConfig(config) {
69
85
  model,
70
86
  extraHeaders,
71
87
  extraBody,
88
+ supportsTools: providerSupportsNativeTools(config.provider),
72
89
  });
73
90
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Remote model manifest — Feature #4.
3
+ *
4
+ * Default models drift as providers ship new versions. Hardcoding them in
5
+ * PROVIDER_INFO means a stale default requires a full Vexi release to fix.
6
+ * This module lets Vexi pick up refreshed defaults from a small, version-pinned
7
+ * JSON manifest hosted at vexi.pro, cached locally, with the compiled-in
8
+ * PROVIDER_INFO defaults as the always-safe fallback.
9
+ *
10
+ * Design mirrors the npm update check (src/update): a synchronous read of the
11
+ * local cache on the hot path, plus a best-effort background refresh that never
12
+ * blocks startup and swallows all errors.
13
+ *
14
+ * manifest shape: { "version": 1, "defaults": { "openai": "gpt-4o-mini", … } }
15
+ */
16
+ import { promises as fs } from 'node:fs';
17
+ import { readFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { VEXI_DIR } from '../config.js';
20
+ const MANIFEST_URL = 'https://vexi.pro/models.json';
21
+ const MANIFEST_CACHE_PATH = join(VEXI_DIR, 'models.json');
22
+ const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
23
+ const FETCH_TIMEOUT_MS = 1500;
24
+ /** Manifest schema version this Vexi understands. Newer manifests are ignored. */
25
+ const SUPPORTED_MANIFEST_VERSION = 1;
26
+ /** Validate an unknown value as a usable manifest, or return null. */
27
+ function parseManifest(raw) {
28
+ if (!raw || typeof raw !== 'object')
29
+ return null;
30
+ const obj = raw;
31
+ if (obj.version !== SUPPORTED_MANIFEST_VERSION)
32
+ return null;
33
+ if (!obj.defaults || typeof obj.defaults !== 'object')
34
+ return null;
35
+ const defaults = {};
36
+ for (const [k, v] of Object.entries(obj.defaults)) {
37
+ if (typeof v === 'string' && v.trim())
38
+ defaults[k] = v.trim();
39
+ }
40
+ return { version: SUPPORTED_MANIFEST_VERSION, defaults };
41
+ }
42
+ /**
43
+ * Read the locally cached manifest synchronously (hot path). Returns null when
44
+ * absent, unreadable, malformed, or a version this build doesn't support.
45
+ */
46
+ function loadCachedManifest() {
47
+ try {
48
+ const raw = readFileSync(MANIFEST_CACHE_PATH, 'utf8');
49
+ return parseManifest(JSON.parse(raw));
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /**
56
+ * Resolve the default model for a provider id: a cached remote override if one
57
+ * exists, otherwise the compiled-in fallback. Never throws.
58
+ */
59
+ export function defaultModelFor(id, fallback) {
60
+ const manifest = loadCachedManifest();
61
+ return manifest?.defaults[id] ?? fallback;
62
+ }
63
+ /**
64
+ * Best-effort background refresh of the model manifest. Fire-and-forget from
65
+ * startup — returns immediately if the cache is fresh, otherwise fetches (max
66
+ * 1500 ms) and rewrites the cache. All errors are swallowed.
67
+ */
68
+ export async function refreshManifest() {
69
+ try {
70
+ const cacheRaw = await fs.readFile(MANIFEST_CACHE_PATH, 'utf8').catch(() => null);
71
+ if (cacheRaw) {
72
+ try {
73
+ const cache = JSON.parse(cacheRaw);
74
+ const age = Date.now() - new Date(cache.lastCheck).getTime();
75
+ if (Number.isFinite(age) && age < REFRESH_INTERVAL_MS)
76
+ return; // still fresh
77
+ }
78
+ catch {
79
+ // malformed cache — fall through and refetch
80
+ }
81
+ }
82
+ const controller = new AbortController();
83
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
84
+ const res = await fetch(MANIFEST_URL, { signal: controller.signal }).finally(() => clearTimeout(timer));
85
+ if (!res.ok)
86
+ return;
87
+ const manifest = parseManifest(await res.json());
88
+ if (!manifest)
89
+ return;
90
+ await fs.mkdir(VEXI_DIR, { recursive: true }).catch(() => { });
91
+ await fs.writeFile(MANIFEST_CACHE_PATH, JSON.stringify({ ...manifest, lastCheck: new Date().toISOString() }), 'utf8').catch(() => { });
92
+ }
93
+ catch {
94
+ // network error, timeout, or bad JSON — silently ignored
95
+ }
96
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ // config.ts derives VEXI_DIR from os.homedir() at import time, and manifest.ts
6
+ // imports VEXI_DIR from it. Mock homedir to a throwaway dir and re-import so the
7
+ // cache path is deterministic on every platform (no reliance on $HOME/$USERPROFILE).
8
+ let home;
9
+ let defaultModelFor;
10
+ let refreshManifest;
11
+ beforeEach(async () => {
12
+ home = await fs.mkdtemp(join(tmpdir(), 'vexi-manifest-'));
13
+ vi.resetModules();
14
+ vi.doMock('node:os', async (importOriginal) => {
15
+ const actual = await importOriginal();
16
+ return { ...actual, homedir: () => home };
17
+ });
18
+ const mod = await import('./manifest.js');
19
+ defaultModelFor = mod.defaultModelFor;
20
+ refreshManifest = mod.refreshManifest;
21
+ });
22
+ afterEach(async () => {
23
+ vi.doUnmock('node:os');
24
+ vi.restoreAllMocks();
25
+ await fs.rm(home, { recursive: true, force: true });
26
+ });
27
+ async function writeCache(obj) {
28
+ await fs.mkdir(join(home, '.vexi'), { recursive: true });
29
+ await fs.writeFile(join(home, '.vexi', 'models.json'), JSON.stringify(obj), 'utf8');
30
+ }
31
+ describe('defaultModelFor', () => {
32
+ it('returns the compiled-in fallback when no cache exists', () => {
33
+ expect(defaultModelFor('openai', 'gpt-4o-mini')).toBe('gpt-4o-mini');
34
+ });
35
+ it('prefers a cached remote override', async () => {
36
+ await writeCache({ version: 1, defaults: { openai: 'gpt-5-mini' }, lastCheck: new Date().toISOString() });
37
+ expect(defaultModelFor('openai', 'gpt-4o-mini')).toBe('gpt-5-mini');
38
+ expect(defaultModelFor('anthropic', 'claude-sonnet-5')).toBe('claude-sonnet-5'); // not overridden → fallback
39
+ });
40
+ it('ignores a manifest with an unsupported version', async () => {
41
+ await writeCache({ version: 99, defaults: { openai: 'from-the-future' }, lastCheck: new Date().toISOString() });
42
+ expect(defaultModelFor('openai', 'gpt-4o-mini')).toBe('gpt-4o-mini');
43
+ });
44
+ it('ignores malformed cache files', async () => {
45
+ await fs.mkdir(join(home, '.vexi'), { recursive: true });
46
+ await fs.writeFile(join(home, '.vexi', 'models.json'), 'not json', 'utf8');
47
+ expect(defaultModelFor('groq', 'llama-3.3-70b-versatile')).toBe('llama-3.3-70b-versatile');
48
+ });
49
+ });
50
+ describe('refreshManifest', () => {
51
+ it('fetches and caches a valid manifest, which then feeds defaultModelFor', async () => {
52
+ vi.stubGlobal('fetch', vi.fn(async () => ({
53
+ ok: true,
54
+ json: async () => ({ version: 1, defaults: { deepseek: 'deepseek-v4' } }),
55
+ })));
56
+ await refreshManifest();
57
+ expect(defaultModelFor('deepseek', 'deepseek-chat')).toBe('deepseek-v4');
58
+ });
59
+ it('skips the network when the cache is still fresh', async () => {
60
+ await writeCache({ version: 1, defaults: { openai: 'cached' }, lastCheck: new Date().toISOString() });
61
+ const fetchSpy = vi.fn();
62
+ vi.stubGlobal('fetch', fetchSpy);
63
+ await refreshManifest();
64
+ expect(fetchSpy).not.toHaveBeenCalled();
65
+ });
66
+ it('swallows network errors and leaves the fallback intact', async () => {
67
+ vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
68
+ await expect(refreshManifest()).resolves.toBeUndefined();
69
+ expect(defaultModelFor('openai', 'gpt-4o-mini')).toBe('gpt-4o-mini');
70
+ });
71
+ });