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.
@@ -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 || typeof parsed.summary !== 'string' || !Array.isArray(parsed.decisions)) {
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
- /** Extract the first JSON object from a model reply (tolerates fences/prose). */
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
- return JSON.parse(text.slice(start, end + 1));
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 ANTHROPIC_API = 'https://api.anthropic.com/v1/messages';
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
- export function createAnthropicProvider(apiKey, model) {
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
- 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) {
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 res = await fetch(ANTHROPIC_API, {
22
- method: 'POST',
23
- headers: {
24
- 'Content-Type': 'application/json',
25
- 'x-api-key': apiKey,
26
- 'anthropic-version': ANTHROPIC_VERSION,
27
- },
28
- body: JSON.stringify({
29
- model,
30
- max_tokens: 8192,
31
- ...(system ? { system } : {}),
32
- messages: chat,
33
- stream: true,
34
- }),
35
- }).catch((err) => {
36
- throw new ProviderError(`Network error: ${err.message}`);
37
- });
38
- if (!res.ok || !res.body) {
39
- const body = await res.text().catch(() => '');
40
- throw new ProviderError(`anthropic API error (HTTP ${res.status}): ${truncate(body, 300)}`, res.status);
41
- }
42
- let full = '';
43
- 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 = '';
44
169
  try {
45
- const json = JSON.parse(data);
46
- if (json.type === 'content_block_delta' && json.delta?.type === 'text_delta') {
47
- const text = json.delta.text;
48
- full += text;
49
- 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
+ }
50
184
  }
51
185
  }
52
- catch {
53
- // Ignore malformed/keep-alive chunks
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
  }
@@ -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
- // OpenAI: classic `sk-...` and new project keys `sk-proj-...`,
22
- // excluding the Anthropic / OpenRouter prefixes above.
23
- { provider: 'openai', pattern: /^sk-(?!ant-|or-)/ },
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
@@ -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 resolvedModel = model ?? PROVIDER_INFO[id].defaultModel;
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
  }