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.
- package/README.md +105 -22
- package/dist/agent.js +252 -40
- package/dist/agent.test.js +66 -0
- package/dist/cli.js +17 -2
- package/dist/explain/index.js +1 -7
- package/dist/git/index.js +13 -1
- package/dist/graph/html.js +2 -8
- package/dist/i18n/index.js +4 -4
- package/dist/mcp/client.js +5 -1
- package/dist/providers/anthropic.js +171 -33
- package/dist/providers/index.js +19 -2
- package/dist/providers/manifest.js +96 -0
- package/dist/providers/manifest.test.js +71 -0
- package/dist/providers/openai-compat.js +202 -32
- package/dist/providers/openai-compat.test.js +66 -0
- package/dist/providers/types.js +1 -1
- package/dist/replay/export.js +1 -7
- package/dist/skills/index.js +12 -4
- package/dist/snapshots/index.js +21 -4
- package/dist/tools/index.js +280 -0
- package/dist/tools/index.test.js +131 -0
- package/dist/usage/index.js +95 -0
- package/dist/usage/index.test.js +51 -0
- package/dist/utils/html.js +8 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
'
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/providers/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|