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
package/dist/memory/index.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* never forgets previous decisions — even across sessions.
|
|
15
15
|
*/
|
|
16
16
|
import { join } from 'node:path';
|
|
17
|
+
import { z } from 'zod';
|
|
17
18
|
import { readJson, writeJsonAtomic } from '../utils/fs-atomic.js';
|
|
18
19
|
/** Messages that always stay in the context verbatim. */
|
|
19
20
|
export const KEEP_RECENT = 10;
|
|
@@ -90,14 +91,12 @@ export async function compressIntoMemory(provider, memory, oldMessages) {
|
|
|
90
91
|
},
|
|
91
92
|
], () => { });
|
|
92
93
|
const parsed = extractJson(raw);
|
|
93
|
-
if (!parsed
|
|
94
|
+
if (!parsed)
|
|
94
95
|
return memory;
|
|
95
|
-
}
|
|
96
96
|
return {
|
|
97
97
|
version: 1,
|
|
98
98
|
summary: parsed.summary.trim(),
|
|
99
99
|
decisions: parsed.decisions
|
|
100
|
-
.filter((d) => typeof d === 'string')
|
|
101
100
|
.map((d) => d.trim())
|
|
102
101
|
.filter(Boolean)
|
|
103
102
|
.slice(0, 20),
|
|
@@ -109,14 +108,20 @@ export async function compressIntoMemory(provider, memory, oldMessages) {
|
|
|
109
108
|
return memory; // never let compression break the session
|
|
110
109
|
}
|
|
111
110
|
}
|
|
112
|
-
|
|
111
|
+
const MemoryJsonSchema = z.object({
|
|
112
|
+
summary: z.string(),
|
|
113
|
+
decisions: z.array(z.string()),
|
|
114
|
+
});
|
|
115
|
+
/** Extract and validate the first JSON object from a model reply (tolerates fences/prose). */
|
|
113
116
|
function extractJson(text) {
|
|
114
117
|
const start = text.indexOf('{');
|
|
115
118
|
const end = text.lastIndexOf('}');
|
|
116
119
|
if (start === -1 || end <= start)
|
|
117
120
|
return null;
|
|
118
121
|
try {
|
|
119
|
-
|
|
122
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
123
|
+
const result = MemoryJsonSchema.safeParse(parsed);
|
|
124
|
+
return result.success ? result.data : null;
|
|
120
125
|
}
|
|
121
126
|
catch {
|
|
122
127
|
return null;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive onboarding flow for `vexi setup`.
|
|
3
|
+
*
|
|
4
|
+
* The IO interface is injected so tests can drive the prompts without
|
|
5
|
+
* touching stdin/stdout. The optional discoverFn and saveFn are also
|
|
6
|
+
* injected for testability.
|
|
7
|
+
*/
|
|
8
|
+
import { saveConfig } from './config.js';
|
|
9
|
+
import { identifyFromUrl, discoverModels } from './endpoint.js';
|
|
10
|
+
export { loadConfig, saveConfig } from './config.js';
|
|
11
|
+
/**
|
|
12
|
+
* Run the interactive setup wizard.
|
|
13
|
+
*
|
|
14
|
+
* Steps:
|
|
15
|
+
* 1. Ask for the API endpoint URL
|
|
16
|
+
* 2. Identify the provider from the URL
|
|
17
|
+
* 3. Ask for the API key
|
|
18
|
+
* 4. Discover available models (or ask for a manual entry on failure)
|
|
19
|
+
* 5. Let the user choose a model (or enter one manually)
|
|
20
|
+
* 6. Save config and return it
|
|
21
|
+
*/
|
|
22
|
+
export async function runOnboarding(io, discoverFn = discoverModels, saveFn = saveConfig) {
|
|
23
|
+
io.write('');
|
|
24
|
+
io.write('Welcome to Vexi setup. Let\'s configure your AI provider.');
|
|
25
|
+
io.write('');
|
|
26
|
+
// Step 1: URL
|
|
27
|
+
const rawUrl = await io.prompt('Paste your API endpoint URL (e.g. https://openrouter.ai/api/v1): ');
|
|
28
|
+
if (!rawUrl.trim()) {
|
|
29
|
+
throw new Error('No URL provided. Run `vexi setup` again to configure.');
|
|
30
|
+
}
|
|
31
|
+
// Step 2: Identify
|
|
32
|
+
let identity;
|
|
33
|
+
try {
|
|
34
|
+
identity = identifyFromUrl(rawUrl.trim());
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error(`Could not parse URL "${rawUrl}". Please check the URL and try again.`);
|
|
38
|
+
}
|
|
39
|
+
io.write(` Detected provider: ${identity.displayName}`);
|
|
40
|
+
io.write(` Base URL: ${identity.baseUrl}`);
|
|
41
|
+
io.write('');
|
|
42
|
+
// Step 3: API key
|
|
43
|
+
const apiKey = await io.prompt(`Enter your ${identity.displayName} API key: `);
|
|
44
|
+
if (!apiKey.trim()) {
|
|
45
|
+
throw new Error('No API key provided. Run `vexi setup` again to configure.');
|
|
46
|
+
}
|
|
47
|
+
// Step 4: Discover models
|
|
48
|
+
let models = [];
|
|
49
|
+
try {
|
|
50
|
+
models = await discoverFn(identity, apiKey.trim());
|
|
51
|
+
if (models.length === 0) {
|
|
52
|
+
io.write(' No models returned by /models endpoint.');
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
io.write(` Found ${models.length} model(s).`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
io.write(` Warning: could not fetch models — ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
io.write('');
|
|
62
|
+
// Step 5: Choose or enter model
|
|
63
|
+
let model;
|
|
64
|
+
if (models.length > 0) {
|
|
65
|
+
io.write('Available models:');
|
|
66
|
+
models.slice(0, 20).forEach((m, i) => io.write(` ${String(i + 1).padStart(2)}. ${m}`));
|
|
67
|
+
if (models.length > 20) {
|
|
68
|
+
io.write(` ... and ${models.length - 20} more`);
|
|
69
|
+
}
|
|
70
|
+
io.write('');
|
|
71
|
+
const choice = await io.prompt(`Enter a number (1-${Math.min(20, models.length)}) or type a model ID directly: `);
|
|
72
|
+
const num = parseInt(choice.trim(), 10);
|
|
73
|
+
if (!isNaN(num) && num >= 1 && num <= models.length) {
|
|
74
|
+
model = models[num - 1];
|
|
75
|
+
}
|
|
76
|
+
else if (choice.trim()) {
|
|
77
|
+
model = choice.trim();
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
model = models[0];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const manualModel = await io.prompt('Enter the model ID to use (e.g. gpt-4o): ');
|
|
85
|
+
if (!manualModel.trim()) {
|
|
86
|
+
throw new Error('No model specified. Run `vexi setup` again to configure.');
|
|
87
|
+
}
|
|
88
|
+
model = manualModel.trim();
|
|
89
|
+
}
|
|
90
|
+
io.write('');
|
|
91
|
+
io.write(` Using model: ${model}`);
|
|
92
|
+
io.write('');
|
|
93
|
+
// Step 6: Save
|
|
94
|
+
const config = {
|
|
95
|
+
provider: identity.provider,
|
|
96
|
+
displayName: identity.displayName,
|
|
97
|
+
baseUrl: identity.baseUrl,
|
|
98
|
+
apiKey: apiKey.trim(),
|
|
99
|
+
model,
|
|
100
|
+
};
|
|
101
|
+
await saveFn(config);
|
|
102
|
+
io.write(`Configuration saved. You can now run: vexi`);
|
|
103
|
+
io.write('');
|
|
104
|
+
return config;
|
|
105
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { runOnboarding } from './onboarding.js';
|
|
3
|
+
function makeIO(answers) {
|
|
4
|
+
const lines = [];
|
|
5
|
+
let callIndex = 0;
|
|
6
|
+
const io = {
|
|
7
|
+
prompt: async (_q) => answers[callIndex++] ?? '',
|
|
8
|
+
write: (line) => lines.push(line),
|
|
9
|
+
};
|
|
10
|
+
return { io, lines };
|
|
11
|
+
}
|
|
12
|
+
describe('runOnboarding', () => {
|
|
13
|
+
it('happy path: discovers models and saves config', async () => {
|
|
14
|
+
const { io } = makeIO([
|
|
15
|
+
'https://openrouter.ai/api/v1', // URL
|
|
16
|
+
'sk-or-test', // API key
|
|
17
|
+
'1', // pick model #1
|
|
18
|
+
]);
|
|
19
|
+
const discoverFn = vi.fn().mockResolvedValue(['openai/gpt-4o', 'mistralai/mistral-7b']);
|
|
20
|
+
const saveFn = vi.fn().mockResolvedValue(undefined);
|
|
21
|
+
const config = await runOnboarding(io, discoverFn, saveFn);
|
|
22
|
+
expect(config.provider).toBe('openrouter');
|
|
23
|
+
expect(config.displayName).toBe('OpenRouter');
|
|
24
|
+
expect(config.baseUrl).toBe('https://openrouter.ai/api/v1');
|
|
25
|
+
expect(config.apiKey).toBe('sk-or-test');
|
|
26
|
+
expect(config.model).toBe('openai/gpt-4o');
|
|
27
|
+
expect(discoverFn).toHaveBeenCalledWith(expect.objectContaining({ provider: 'openrouter' }), 'sk-or-test');
|
|
28
|
+
expect(saveFn).toHaveBeenCalledWith(config);
|
|
29
|
+
});
|
|
30
|
+
it('falls back to manual model entry when /models fails', async () => {
|
|
31
|
+
const { io } = makeIO([
|
|
32
|
+
'https://api.groq.com/openai/v1', // URL
|
|
33
|
+
'gsk_test', // API key
|
|
34
|
+
'llama-3.3-70b-versatile', // manual model entry
|
|
35
|
+
]);
|
|
36
|
+
const discoverFn = vi.fn().mockRejectedValue(new Error('HTTP 401. Enter the model ID manually.'));
|
|
37
|
+
const saveFn = vi.fn().mockResolvedValue(undefined);
|
|
38
|
+
const config = await runOnboarding(io, discoverFn, saveFn);
|
|
39
|
+
expect(config.model).toBe('llama-3.3-70b-versatile');
|
|
40
|
+
expect(saveFn).toHaveBeenCalledWith(config);
|
|
41
|
+
});
|
|
42
|
+
it('throws when no URL is provided', async () => {
|
|
43
|
+
const { io } = makeIO(['']); // empty URL
|
|
44
|
+
await expect(runOnboarding(io, vi.fn(), vi.fn())).rejects.toThrow('No URL provided');
|
|
45
|
+
});
|
|
46
|
+
it('throws when no API key is provided', async () => {
|
|
47
|
+
const { io } = makeIO([
|
|
48
|
+
'https://openrouter.ai/api/v1', // valid URL
|
|
49
|
+
'', // empty API key
|
|
50
|
+
]);
|
|
51
|
+
const discoverFn = vi.fn().mockResolvedValue([]);
|
|
52
|
+
await expect(runOnboarding(io, discoverFn, vi.fn())).rejects.toThrow('No API key provided');
|
|
53
|
+
});
|
|
54
|
+
it('accepts a typed model id directly instead of a number', async () => {
|
|
55
|
+
const { io } = makeIO([
|
|
56
|
+
'https://api.openai.com/v1',
|
|
57
|
+
'sk-test',
|
|
58
|
+
'o3-mini', // typed directly, not a list number
|
|
59
|
+
]);
|
|
60
|
+
const discoverFn = vi.fn().mockResolvedValue(['gpt-4o', 'gpt-4o-mini']);
|
|
61
|
+
const saveFn = vi.fn().mockResolvedValue(undefined);
|
|
62
|
+
const config = await runOnboarding(io, discoverFn, saveFn);
|
|
63
|
+
expect(config.model).toBe('o3-mini');
|
|
64
|
+
});
|
|
65
|
+
it('defaults to first model when empty choice given', async () => {
|
|
66
|
+
const { io } = makeIO([
|
|
67
|
+
'https://api.openai.com/v1',
|
|
68
|
+
'sk-test',
|
|
69
|
+
'', // empty → default to first
|
|
70
|
+
]);
|
|
71
|
+
const discoverFn = vi.fn().mockResolvedValue(['gpt-4o', 'gpt-4o-mini']);
|
|
72
|
+
const saveFn = vi.fn().mockResolvedValue(undefined);
|
|
73
|
+
const config = await runOnboarding(io, discoverFn, saveFn);
|
|
74
|
+
expect(config.model).toBe('gpt-4o');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -4,56 +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';
|
|
8
|
-
const
|
|
7
|
+
import { sseEvents, truncate, createStreamTimeoutGuard } from './openai-compat.js';
|
|
8
|
+
const ANTHROPIC_BASE = 'https://api.anthropic.com/v1';
|
|
9
9
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
10
|
-
|
|
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
|
+
}
|
|
45
|
+
export function createAnthropicProvider(apiKey, model, baseUrl) {
|
|
46
|
+
const messagesEndpoint = `${baseUrl ?? ANTHROPIC_BASE}/messages`;
|
|
11
47
|
return {
|
|
12
48
|
id: 'anthropic',
|
|
13
49
|
model,
|
|
14
|
-
|
|
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) {
|
|
15
137
|
// Anthropic takes the system prompt as a top-level field
|
|
16
138
|
const system = messages
|
|
17
139
|
.filter((m) => m.role === 'system')
|
|
18
140
|
.map((m) => m.content)
|
|
19
141
|
.join('\n\n');
|
|
20
142
|
const chat = messages.filter((m) => m.role !== 'system');
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
'
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 = '';
|
|
44
169
|
try {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
+
}
|
|
50
184
|
}
|
|
51
185
|
}
|
|
52
|
-
catch {
|
|
53
|
-
|
|
186
|
+
catch (e) {
|
|
187
|
+
throw guard.wrapStreamError(e);
|
|
54
188
|
}
|
|
189
|
+
if (onUsage)
|
|
190
|
+
onUsage(usage);
|
|
191
|
+
return full;
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
guard.dispose();
|
|
55
195
|
}
|
|
56
|
-
return full;
|
|
57
196
|
},
|
|
58
197
|
};
|
|
59
198
|
}
|
package/dist/providers/detect.js
CHANGED
|
@@ -14,13 +14,28 @@
|
|
|
14
14
|
* interactive fallback.
|
|
15
15
|
*/
|
|
16
16
|
export const PROVIDER_PATTERNS = [
|
|
17
|
+
// ── Distinctive prefixes (safe auto-detect) ───────────────────────────
|
|
17
18
|
{ provider: 'anthropic', pattern: /^sk-ant-/ },
|
|
18
19
|
{ provider: 'openrouter', pattern: /^sk-or-/ },
|
|
19
20
|
{ provider: 'groq', pattern: /^gsk_/ },
|
|
20
21
|
{ provider: 'gemini', pattern: /^AIza/ },
|
|
21
|
-
|
|
22
|
-
//
|
|
23
|
-
|
|
22
|
+
{ provider: 'cerebras', pattern: /^csk-/ },
|
|
23
|
+
// Zhipu AI (GLM): <32-char-lowercase-hex>.<alphanumeric>
|
|
24
|
+
// e.g. b277fbf3dc1045c79229ff3c65a6f89b.fZ3rWjaTnH6UW6rm
|
|
25
|
+
{ provider: 'glm', pattern: /^[0-9a-f]{32}\.[A-Za-z0-9]+$/ },
|
|
26
|
+
// ── OpenAI project keys (`sk-proj-...`) are distinctive ───────────────
|
|
27
|
+
{ provider: 'openai', pattern: /^sk-proj-/ },
|
|
28
|
+
// ── Ambiguous `sk-` prefix ────────────────────────────────────────────
|
|
29
|
+
// DeepSeek and Moonshot (Kimi) also start with `sk-`, just like classic
|
|
30
|
+
// OpenAI keys. Matching both patterns makes detectProvider() return null
|
|
31
|
+
// (ambiguous) so the user gets the interactive provider-selection prompt
|
|
32
|
+
// instead of silently mis-routing to the wrong API.
|
|
33
|
+
{ provider: 'deepseek', pattern: /^sk-[a-z0-9]{32}$/ }, // sk- + 32 lowercase alphanum
|
|
34
|
+
{ provider: 'moonshot', pattern: /^sk-[A-Za-z0-9]{43,}$/ }, // sk- + 43+ mixed alphanum
|
|
35
|
+
// Classic OpenAI keys (`sk-` without `proj-`, not matching deepseek/moonshot shapes):
|
|
36
|
+
{ provider: 'openai', pattern: /^sk-(?!proj-|ant-|or-)/ },
|
|
37
|
+
// Qwen (DashScope), Mistral, MiniMax have no distinctive key prefix →
|
|
38
|
+
// they always fall back to the interactive provider-selection list.
|
|
24
39
|
];
|
|
25
40
|
/**
|
|
26
41
|
* Sanitize a pasted API key: trim whitespace/newlines and strip
|
package/dist/providers/index.js
CHANGED
|
@@ -5,27 +5,50 @@
|
|
|
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. */
|
|
11
13
|
const BASE_URLS = {
|
|
14
|
+
// ── International ────────────────────────────────────────────────────
|
|
12
15
|
openai: 'https://api.openai.com/v1',
|
|
13
16
|
openrouter: 'https://openrouter.ai/api/v1',
|
|
14
17
|
groq: 'https://api.groq.com/openai/v1',
|
|
15
|
-
// Gemini's OpenAI-compatibility endpoint
|
|
16
18
|
gemini: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
19
|
+
mistral: 'https://api.mistral.ai/v1',
|
|
20
|
+
cerebras: 'https://api.cerebras.ai/v1',
|
|
21
|
+
// ── Chinese AI ───────────────────────────────────────────────────────
|
|
22
|
+
glm: 'https://open.bigmodel.cn/api/paas/v4', // Zhipu AI — GLM-4-Flash free
|
|
23
|
+
deepseek: 'https://api.deepseek.com/v1', // DeepSeek V3 — very cheap, free credits
|
|
24
|
+
qwen: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', // Alibaba Qwen — international
|
|
25
|
+
moonshot: 'https://api.moonshot.cn/v1', // Kimi — free quota on signup
|
|
26
|
+
minimax: 'https://api.minimax.chat/v1', // MiniMax-Text-01 — free tier
|
|
17
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
|
+
}
|
|
18
40
|
export function createProvider(id, apiKey, model) {
|
|
19
|
-
const
|
|
41
|
+
const info = PROVIDER_INFO[id];
|
|
42
|
+
if (!info) {
|
|
43
|
+
throw new Error(`Unknown provider "${id}". Run \`vexi config reset\` to reconfigure.`);
|
|
44
|
+
}
|
|
45
|
+
// Prefer an explicit model, then a remote-manifest default, then the compiled-in default.
|
|
46
|
+
const resolvedModel = model ?? defaultModelFor(id, info.defaultModel);
|
|
20
47
|
if (id === 'anthropic') {
|
|
21
48
|
return createAnthropicProvider(apiKey, resolvedModel);
|
|
22
49
|
}
|
|
23
50
|
const extraHeaders = id === 'openrouter'
|
|
24
|
-
? {
|
|
25
|
-
// OpenRouter attribution headers (optional but recommended)
|
|
26
|
-
'HTTP-Referer': 'https://github.com/Elomami1976/vexi',
|
|
27
|
-
'X-Title': 'Vexi',
|
|
28
|
-
}
|
|
51
|
+
? { 'HTTP-Referer': 'https://vexi.pro', 'X-Title': 'Vexi' }
|
|
29
52
|
: undefined;
|
|
30
53
|
return createOpenAICompatProvider({
|
|
31
54
|
id,
|
|
@@ -33,5 +56,35 @@ export function createProvider(id, apiKey, model) {
|
|
|
33
56
|
apiKey,
|
|
34
57
|
model: resolvedModel,
|
|
35
58
|
extraHeaders,
|
|
59
|
+
supportsTools: providerSupportsNativeTools(id),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Create a provider from a VexiConfig produced by `vexi setup`.
|
|
64
|
+
* When config.baseUrl is set, uses it directly (URL-based routing).
|
|
65
|
+
* Falls back to the ProviderId-based createProvider for old configs.
|
|
66
|
+
*/
|
|
67
|
+
export function createProviderFromConfig(config) {
|
|
68
|
+
if (!config.baseUrl) {
|
|
69
|
+
// Old-style config: route by ProviderId
|
|
70
|
+
return createProvider(config.provider, config.apiKey, config.model);
|
|
71
|
+
}
|
|
72
|
+
const model = config.model ?? defaultModelFor(config.provider, 'gpt-4o');
|
|
73
|
+
if (config.provider === 'anthropic') {
|
|
74
|
+
return createAnthropicProvider(config.apiKey, model, config.baseUrl);
|
|
75
|
+
}
|
|
76
|
+
const extraHeaders = config.provider === 'openrouter'
|
|
77
|
+
? { 'HTTP-Referer': 'https://vexi.pro', 'X-Title': 'Vexi' }
|
|
78
|
+
: undefined;
|
|
79
|
+
// Z.ai GLM-5 models support reasoning_effort for extended thinking
|
|
80
|
+
const extraBody = model.startsWith('z-ai/glm-5') ? { reasoning_effort: 'high' } : undefined;
|
|
81
|
+
return createOpenAICompatProvider({
|
|
82
|
+
id: config.provider,
|
|
83
|
+
baseUrl: config.baseUrl,
|
|
84
|
+
apiKey: config.apiKey,
|
|
85
|
+
model,
|
|
86
|
+
extraHeaders,
|
|
87
|
+
extraBody,
|
|
88
|
+
supportsTools: providerSupportsNativeTools(config.provider),
|
|
36
89
|
});
|
|
37
90
|
}
|