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,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
|
+
});
|
|
@@ -7,47 +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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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();
|
|
33
129
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 = '';
|
|
38
157
|
try {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
}
|
|
44
174
|
}
|
|
45
175
|
}
|
|
46
|
-
catch {
|
|
47
|
-
|
|
176
|
+
catch (e) {
|
|
177
|
+
throw guard.wrapStreamError(e);
|
|
48
178
|
}
|
|
179
|
+
return full;
|
|
49
180
|
}
|
|
50
|
-
|
|
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);
|
|
51
222
|
},
|
|
52
223
|
};
|
|
53
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
|
+
});
|
package/dist/providers/types.js
CHANGED
|
@@ -16,9 +16,18 @@ export class ProviderError extends Error {
|
|
|
16
16
|
}
|
|
17
17
|
/** Human-friendly provider metadata. */
|
|
18
18
|
export const PROVIDER_INFO = {
|
|
19
|
-
|
|
19
|
+
// ── International providers ───────────────────────────────────────────
|
|
20
|
+
anthropic: { label: 'Anthropic (Claude)', defaultModel: 'claude-sonnet-5' },
|
|
20
21
|
openai: { label: 'OpenAI (GPT)', defaultModel: 'gpt-4o-mini' },
|
|
21
22
|
openrouter: { label: 'OpenRouter', defaultModel: 'openrouter/auto' },
|
|
22
|
-
groq: { label: 'Groq', defaultModel: 'llama-3.3-70b-versatile' },
|
|
23
|
-
gemini: { label: 'Google Gemini', defaultModel: 'gemini-2.5-flash' },
|
|
23
|
+
groq: { label: 'Groq (free tier)', defaultModel: 'llama-3.3-70b-versatile', free: true },
|
|
24
|
+
gemini: { label: 'Google Gemini (free tier)', defaultModel: 'gemini-2.5-flash', free: true },
|
|
25
|
+
mistral: { label: 'Mistral AI', defaultModel: 'mistral-small-latest' },
|
|
26
|
+
cerebras: { label: 'Cerebras (free tier)', defaultModel: 'llama-3.3-70b', free: true },
|
|
27
|
+
// ── Chinese AI providers ──────────────────────────────────────────────
|
|
28
|
+
glm: { label: 'Zhipu AI — GLM (free tier)', defaultModel: 'glm-4-flash', free: true },
|
|
29
|
+
deepseek: { label: 'DeepSeek (free tier)', defaultModel: 'deepseek-chat', free: true },
|
|
30
|
+
qwen: { label: 'Alibaba Qwen (free tier)', defaultModel: 'qwen-turbo', free: true },
|
|
31
|
+
moonshot: { label: 'Kimi — Moonshot AI (free tier)', defaultModel: 'moonshot-v1-8k', free: true },
|
|
32
|
+
minimax: { label: 'MiniMax (free tier)', defaultModel: 'MiniMax-Text-01', free: true },
|
|
24
33
|
};
|
package/dist/replay/export.js
CHANGED
|
@@ -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('&', '&')
|
|
312
|
-
.replaceAll('<', '<')
|
|
313
|
-
.replaceAll('>', '>')
|
|
314
|
-
.replaceAll('"', '"');
|
|
315
|
-
}
|
package/dist/skills/index.js
CHANGED
|
@@ -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 });
|