vexi-cli 0.5.4 → 0.8.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.
@@ -12,13 +12,13 @@
12
12
  export const SUPPORTED_LANGS = ['en', 'ar', 'es', 'pt', 'fr'];
13
13
  const en = {
14
14
  welcome: 'Welcome to Vexi — your AI coding agent in the terminal.',
15
- firstRunIntro: 'First run: Vexi needs an API key (Anthropic, OpenAI, OpenRouter, Groq or Gemini).\nIt is stored locally in ~/.vexi/config.json — no login, no server, no telemetry.',
15
+ firstRunIntro: 'First run: Vexi needs an API key.\nSupported: Anthropic · OpenAI · OpenRouter · Groq · Gemini · GLM · Mistral · Cerebras · DeepSeek · Qwen · Kimi · MiniMax\nIf auto-detection picks the wrong provider, just re-enter your key and select from the list.\nStored locally in ~/.vexi/config.json — no login, no server, no telemetry.',
16
16
  enterApiKey: 'Paste your API key',
17
17
  detectedProvider: 'Provider detected: {provider}',
18
18
  detectFailed: 'Could not auto-detect the provider for this key.',
19
19
  selectProvider: 'Select your provider',
20
20
  configSaved: 'Saved to {path} (readable only by your OS user).',
21
- chatHint: 'Type your message. Commands: /help /model /clear /exit',
21
+ chatHint: 'Type your message. Commands: /help /model /clear /undo /redo /history /push /exit',
22
22
  thinking: 'Thinking…',
23
23
  goodbye: 'Goodbye! 👋',
24
24
  invalidKey: 'The API key was rejected by the provider (unauthorized).',
@@ -26,7 +26,7 @@ const en = {
26
26
  apiError: 'API error: {message}',
27
27
  emptyKey: 'No key entered.',
28
28
  historyCleared: 'Conversation history cleared.',
29
- helpText: '/help show this help\n/model switch model (e.g. /model gpt-4o)\n/memory show compressed project memory\n/clear clear conversation history\n/exit quit Vexi',
29
+ helpText: '/help show this help\n/model switch model (e.g. /model gpt-4o)\n/memory show compressed project memory\n/clear clear conversation history\n/undo revert last AI file edit\n/redo re-apply last undone edit\n/history list recent AI file edits\n/push stage, commit and push to git (/push --only to skip commit)\n/exit quit Vexi',
30
30
  modelSwitched: 'Model switched to {model}',
31
31
  configReset: 'Configuration deleted. Run `vexi` to set up again.',
32
32
  configResetNone: 'No configuration found.',
@@ -59,16 +59,24 @@ const en = {
59
59
  learnPreview: 'Learned from {sessions} sessions ({signals} corrections found):',
60
60
  learnApplyHint: 'Looks right? Save it with: vexi learn --apply',
61
61
  learnApplied: 'Learned style saved: {path} — now injected into every session.',
62
+ undoNone: 'Nothing to undo — no file snapshots in this session.',
63
+ undoDone: 'Reverted: {files}',
64
+ redoNone: 'Nothing to redo.',
65
+ redoDone: 'Re-applied: {files}',
66
+ historyNone: 'No file snapshots yet — make some AI edits first.',
67
+ historyHeader: 'Recent AI file edits (newest first):',
68
+ cleanDone: 'Cleaned {count} old snapshot session(s).',
69
+ snapshotNoSession: 'No active Vexi session found. Run `vexi` first to start a session.',
62
70
  };
63
71
  const es = {
64
72
  welcome: 'Bienvenido a Vexi — tu agente de programación con IA en la terminal.',
65
- firstRunIntro: 'Primer uso: Vexi necesita una clave API (Anthropic, OpenAI, OpenRouter, Groq o Gemini).\nSe guarda localmente en ~/.vexi/config.json — sin registro, sin servidor, sin telemetría.',
73
+ firstRunIntro: 'Primer uso: Vexi necesita una clave API.\nCompatibles (varios son gratuitos): Anthropic · OpenAI · OpenRouter · Groq · Gemini · GLM · Mistral · Cerebras\nGuardada en ~/.vexi/config.json — sin registro, sin servidor, sin telemetría.',
66
74
  enterApiKey: 'Pega tu clave API',
67
75
  detectedProvider: 'Proveedor detectado: {provider}',
68
76
  detectFailed: 'No se pudo detectar automáticamente el proveedor de esta clave.',
69
77
  selectProvider: 'Selecciona tu proveedor',
70
78
  configSaved: 'Guardado en {path} (solo legible por tu usuario del sistema).',
71
- chatHint: 'Escribe tu mensaje. Comandos: /help /model /clear /exit',
79
+ chatHint: 'Escribe tu mensaje. Comandos: /help /model /clear /push /exit',
72
80
  thinking: 'Pensando…',
73
81
  goodbye: '¡Hasta luego! 👋',
74
82
  invalidKey: 'El proveedor rechazó la clave API (no autorizada).',
@@ -76,7 +84,7 @@ const es = {
76
84
  apiError: 'Error de API: {message}',
77
85
  emptyKey: 'No se introdujo ninguna clave.',
78
86
  historyCleared: 'Historial de conversación borrado.',
79
- helpText: '/help mostrar esta ayuda\n/model cambiar modelo (p. ej. /model gpt-4o)\n/memory ver la memoria comprimida del proyecto\n/clear borrar historial de conversación\n/exit salir de Vexi',
87
+ helpText: '/help mostrar esta ayuda\n/model cambiar modelo (p. ej. /model gpt-4o)\n/memory ver la memoria comprimida del proyecto\n/clear borrar historial de conversación\n/push confirmar y enviar cambios a git\n/exit salir de Vexi',
80
88
  modelSwitched: 'Modelo cambiado a {model}',
81
89
  configReset: 'Configuración eliminada. Ejecuta `vexi` para configurar de nuevo.',
82
90
  configResetNone: 'No se encontró configuración.',
@@ -109,16 +117,24 @@ const es = {
109
117
  learnPreview: 'Aprendido de {sessions} sesiones ({signals} correcciones encontradas):',
110
118
  learnApplyHint: '¿Se ve bien? Guárdalo con: vexi learn --apply',
111
119
  learnApplied: 'Estilo aprendido guardado: {path} — ahora se inyecta en cada sesión.',
120
+ undoNone: 'Nada que deshacer — no hay instantáneas de archivos en esta sesión.',
121
+ undoDone: 'Revertido: {files}',
122
+ redoNone: 'Nada que rehacer.',
123
+ redoDone: 'Reaplicado: {files}',
124
+ historyNone: 'Aún no hay instantáneas de archivos — haz ediciones con IA primero.',
125
+ historyHeader: 'Ediciones de archivos recientes de la IA (más recientes primero):',
126
+ cleanDone: 'Se eliminaron {count} sesión(es) de instantáneas antiguas.',
127
+ snapshotNoSession: 'No se encontró ninguna sesión activa de Vexi. Ejecuta `vexi` primero.',
112
128
  };
113
129
  const pt = {
114
130
  welcome: 'Bem-vindo ao Vexi — seu agente de programação com IA no terminal.',
115
- firstRunIntro: 'Primeira execução: o Vexi precisa de uma chave de API (Anthropic, OpenAI, OpenRouter, Groq ou Gemini).\nEla é guardada localmente em ~/.vexi/config.json — sem login, sem servidor, sem telemetria.',
131
+ firstRunIntro: 'Primeira execução: o Vexi precisa de uma chave de API.\nCompatíveis (vários gratuitos): Anthropic · OpenAI · OpenRouter · Groq · Gemini · GLM · Mistral · Cerebras\nGuardada em ~/.vexi/config.json — sem login, sem servidor, sem telemetria.',
116
132
  enterApiKey: 'Cole sua chave de API',
117
133
  detectedProvider: 'Provedor detectado: {provider}',
118
134
  detectFailed: 'Não foi possível detectar automaticamente o provedor desta chave.',
119
135
  selectProvider: 'Selecione seu provedor',
120
136
  configSaved: 'Salvo em {path} (legível apenas pelo seu usuário do sistema).',
121
- chatHint: 'Digite sua mensagem. Comandos: /help /model /clear /exit',
137
+ chatHint: 'Digite sua mensagem. Comandos: /help /model /clear /push /exit',
122
138
  thinking: 'Pensando…',
123
139
  goodbye: 'Até logo! 👋',
124
140
  invalidKey: 'O provedor rejeitou a chave de API (não autorizada).',
@@ -126,7 +142,7 @@ const pt = {
126
142
  apiError: 'Erro de API: {message}',
127
143
  emptyKey: 'Nenhuma chave inserida.',
128
144
  historyCleared: 'Histórico de conversa apagado.',
129
- helpText: '/help mostrar esta ajuda\n/model trocar modelo (ex.: /model gpt-4o)\n/memory ver a memória comprimida do projeto\n/clear apagar histórico de conversa\n/exit sair do Vexi',
145
+ helpText: '/help mostrar esta ajuda\n/model trocar modelo (ex.: /model gpt-4o)\n/memory ver a memória comprimida do projeto\n/clear apagar histórico de conversa\n/push confirmar e enviar mudanças ao git\n/exit sair do Vexi',
130
146
  modelSwitched: 'Modelo alterado para {model}',
131
147
  configReset: 'Configuração excluída. Execute `vexi` para configurar novamente.',
132
148
  configResetNone: 'Nenhuma configuração encontrada.',
@@ -159,16 +175,24 @@ const pt = {
159
175
  learnPreview: 'Aprendido de {sessions} sessões ({signals} correções encontradas):',
160
176
  learnApplyHint: 'Parece certo? Salve com: vexi learn --apply',
161
177
  learnApplied: 'Estilo aprendido salvo: {path} — agora é injetado em cada sessão.',
178
+ undoNone: 'Nada a desfazer — sem instantâneos de arquivos nesta sessão.',
179
+ undoDone: 'Revertido: {files}',
180
+ redoNone: 'Nada a refazer.',
181
+ redoDone: 'Reaplicado: {files}',
182
+ historyNone: 'Ainda não há instantâneos de arquivos — faça edições com IA primeiro.',
183
+ historyHeader: 'Edições de arquivos recentes da IA (mais recentes primeiro):',
184
+ cleanDone: 'Foram removidas {count} sessão(ões) de instantâneos antigas.',
185
+ snapshotNoSession: 'Nenhuma sessão ativa do Vexi encontrada. Execute `vexi` primeiro.',
162
186
  };
163
187
  const fr = {
164
188
  welcome: 'Bienvenue dans Vexi — votre agent de codage IA dans le terminal.',
165
- firstRunIntro: 'Première utilisation : Vexi a besoin d\'une clé API (Anthropic, OpenAI, OpenRouter, Groq ou Gemini).\nElle est stockée localement dans ~/.vexi/config.json — sans compte, sans serveur, sans télémétrie.',
189
+ firstRunIntro: 'Première utilisation : Vexi a besoin d\'une clé API.\nCompatibles (plusieurs gratuits) : Anthropic · OpenAI · OpenRouter · Groq · Gemini · GLM · Mistral · Cerebras\nStockée dans ~/.vexi/config.json — sans compte, sans serveur, sans télémétrie.',
166
190
  enterApiKey: 'Collez votre clé API',
167
191
  detectedProvider: 'Fournisseur détecté : {provider}',
168
192
  detectFailed: 'Impossible de détecter automatiquement le fournisseur de cette clé.',
169
193
  selectProvider: 'Sélectionnez votre fournisseur',
170
194
  configSaved: 'Enregistré dans {path} (lisible uniquement par votre utilisateur système).',
171
- chatHint: 'Tapez votre message. Commandes : /help /model /clear /exit',
195
+ chatHint: 'Tapez votre message. Commandes : /help /model /clear /push /exit',
172
196
  thinking: 'Réflexion…',
173
197
  goodbye: 'À bientôt ! 👋',
174
198
  invalidKey: 'La clé API a été rejetée par le fournisseur (non autorisée).',
@@ -176,7 +200,7 @@ const fr = {
176
200
  apiError: 'Erreur API : {message}',
177
201
  emptyKey: 'Aucune clé saisie.',
178
202
  historyCleared: 'Historique de conversation effacé.',
179
- helpText: '/help afficher cette aide\n/model changer de modèle (ex. /model gpt-4o)\n/memory voir la mémoire compressée du projet\n/clear effacer l\'historique de conversation\n/exit quitter Vexi',
203
+ helpText: '/help afficher cette aide\n/model changer de modèle (ex. /model gpt-4o)\n/memory voir la mémoire compressée du projet\n/clear effacer l\'historique de conversation\n/push valider et pousser les modifications git\n/exit quitter Vexi',
180
204
  modelSwitched: 'Modèle changé pour {model}',
181
205
  configReset: 'Configuration supprimée. Lancez `vexi` pour reconfigurer.',
182
206
  configResetNone: 'Aucune configuration trouvée.',
@@ -209,6 +233,14 @@ const fr = {
209
233
  learnPreview: 'Appris de {sessions} sessions ({signals} corrections trouvées) :',
210
234
  learnApplyHint: 'Ça vous convient ? Enregistrez avec : vexi learn --apply',
211
235
  learnApplied: 'Style appris enregistré : {path} — désormais injecté dans chaque session.',
236
+ undoNone: 'Rien à annuler — aucun instantané de fichier dans cette session.',
237
+ undoDone: 'Rétabli : {files}',
238
+ redoNone: 'Rien à rétablir.',
239
+ redoDone: 'Réappliqué : {files}',
240
+ historyNone: 'Pas encore d\'instantanés de fichiers — effectuez d\'abord des modifications avec l\'IA.',
241
+ historyHeader: 'Modifications de fichiers récentes de l\'IA (plus récentes en premier) :',
242
+ cleanDone: '{count} session(s) d\'instantanés ancienne(s) supprimée(s).',
243
+ snapshotNoSession: 'Aucune session Vexi active trouvée. Exécutez `vexi` d\'abord.',
212
244
  };
213
245
  const STRINGS = {
214
246
  en,
@@ -9,6 +9,8 @@
9
9
  */
10
10
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
11
11
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
12
+ import { z } from 'zod';
13
+ import { VERSION } from '../version.js';
12
14
  import { loadMcpConfig } from './config.js';
13
15
  export class McpManager {
14
16
  connections = [];
@@ -26,7 +28,7 @@ export class McpManager {
26
28
  env: { ...process.env, ...server.env },
27
29
  stderr: 'ignore',
28
30
  });
29
- const client = new Client({ name: 'vexi', version: '0.5.0' });
31
+ const client = new Client({ name: 'vexi', version: VERSION });
30
32
  await client.connect(transport);
31
33
  const { tools } = await client.listTools();
32
34
  for (const tool of tools) {
@@ -46,12 +48,16 @@ export class McpManager {
46
48
  }
47
49
  return { connected, failed };
48
50
  }
51
+ static ArgsSchema = z.record(z.string(), z.unknown());
49
52
  /** Call a tool on a connected server; returns the text result. */
50
53
  async callTool(server, tool, args) {
51
54
  const connection = this.connections.find((c) => c.name === server);
52
55
  if (!connection)
53
56
  throw new Error(`MCP server "${server}" is not connected.`);
54
- const result = await connection.client.callTool({ name: tool, arguments: args });
57
+ const parsed = McpManager.ArgsSchema.safeParse(args);
58
+ if (!parsed.success)
59
+ throw new Error(`Invalid tool arguments: ${parsed.error.message}`);
60
+ const result = await connection.client.callTool({ name: tool, arguments: parsed.data });
55
61
  const content = Array.isArray(result.content) ? result.content : [];
56
62
  const text = content
57
63
  .map((c) => (c.type === 'text' ? (c.text ?? '') : `[${c.type}]`))
@@ -23,8 +23,9 @@
23
23
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
24
24
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
25
25
  import { z } from 'zod';
26
+ import { VERSION } from '../version.js';
26
27
  import { loadConfig } from '../config.js';
27
- import { createProvider } from '../providers/index.js';
28
+ import { createProviderFromConfig } from '../providers/index.js';
28
29
  import { scanProject, projectSummary } from '../scanner/index.js';
29
30
  import { loadMemory, memoryBlock } from '../memory/index.js';
30
31
  import { listSessions } from '../replay/recorder.js';
@@ -32,7 +33,7 @@ import { gatherSource, buildExplainMessages } from '../explain/index.js';
32
33
  import { SUPPORTED_LANGS } from '../i18n/index.js';
33
34
  export async function runMcpServer() {
34
35
  const root = process.cwd();
35
- const server = new McpServer({ name: 'vexi', version: '0.5.0' });
36
+ const server = new McpServer({ name: 'vexi', version: VERSION });
36
37
  // ── Resources ──────────────────────────────────────────────────────────
37
38
  server.registerResource('project-map', 'vexi://project', {
38
39
  title: 'Project map',
@@ -92,7 +93,7 @@ export async function runMcpServer() {
92
93
  };
93
94
  }
94
95
  try {
95
- const provider = createProvider(config.provider, config.apiKey, config.model);
96
+ const provider = createProviderFromConfig(config);
96
97
  const source = await gatherSource(path);
97
98
  const markdown = await provider.stream(buildExplainMessages(source, language), () => { });
98
99
  return { content: [{ type: 'text', text: markdown }] };
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { compressIntoMemory, KEEP_RECENT, COMPRESS_INTERVAL } from './index.js';
3
+ function makeMessages(n) {
4
+ return Array.from({ length: n }, (_, i) => ({
5
+ role: (i % 2 === 0 ? 'user' : 'assistant'),
6
+ content: `message ${i}`,
7
+ }));
8
+ }
9
+ const failingProvider = {
10
+ id: 'openai',
11
+ model: 'gpt-4o',
12
+ stream: async () => { throw new Error('provider failure'); },
13
+ };
14
+ const emptyMemory = {
15
+ version: 1,
16
+ summary: '',
17
+ decisions: [],
18
+ updatedAt: '',
19
+ compressedCount: 0,
20
+ };
21
+ describe('Fix 1: maybeCompress data-loss guard', () => {
22
+ it('returns original memory unchanged when provider.stream throws', async () => {
23
+ const messages = makeMessages(KEEP_RECENT + COMPRESS_INTERVAL + 2);
24
+ const original = { ...emptyMemory, summary: 'prior summary' };
25
+ const result = await compressIntoMemory(failingProvider, original, messages);
26
+ expect(result).toBe(original); // same reference — not mutated, not replaced
27
+ expect(result.summary).toBe('prior summary');
28
+ });
29
+ it('returns original memory when provider returns malformed JSON', async () => {
30
+ const badProvider = {
31
+ id: 'openai',
32
+ model: 'gpt-4o',
33
+ stream: async () => 'this is not json at all',
34
+ };
35
+ const original = { ...emptyMemory, decisions: ['keep this'] };
36
+ const result = await compressIntoMemory(badProvider, original, makeMessages(4));
37
+ expect(result).toBe(original);
38
+ expect(result.decisions).toEqual(['keep this']);
39
+ });
40
+ it('returns updated memory when provider returns valid JSON', async () => {
41
+ const goodProvider = {
42
+ id: 'openai',
43
+ model: 'gpt-4o',
44
+ stream: async () => '{"summary":"new summary","decisions":["decision A"]}',
45
+ };
46
+ const original = { ...emptyMemory };
47
+ const result = await compressIntoMemory(goodProvider, original, makeMessages(4));
48
+ expect(result).not.toBe(original);
49
+ expect(result.summary).toBe('new summary');
50
+ expect(result.decisions).toEqual(['decision A']);
51
+ });
52
+ });
@@ -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
+ });
@@ -5,9 +5,10 @@
5
5
  */
6
6
  import { ProviderError } from './types.js';
7
7
  import { sseEvents, truncate } from './openai-compat.js';
8
- const ANTHROPIC_API = 'https://api.anthropic.com/v1/messages';
8
+ const ANTHROPIC_BASE = 'https://api.anthropic.com/v1';
9
9
  const ANTHROPIC_VERSION = '2023-06-01';
10
- export function createAnthropicProvider(apiKey, model) {
10
+ export function createAnthropicProvider(apiKey, model, baseUrl) {
11
+ const messagesEndpoint = `${baseUrl ?? ANTHROPIC_BASE}/messages`;
11
12
  return {
12
13
  id: 'anthropic',
13
14
  model,
@@ -18,7 +19,7 @@ export function createAnthropicProvider(apiKey, model) {
18
19
  .map((m) => m.content)
19
20
  .join('\n\n');
20
21
  const chat = messages.filter((m) => m.role !== 'system');
21
- const res = await fetch(ANTHROPIC_API, {
22
+ const res = await fetch(messagesEndpoint, {
22
23
  method: 'POST',
23
24
  headers: {
24
25
  'Content-Type': 'application/json',
@@ -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
@@ -9,23 +9,31 @@ export { detectProvider, sanitizeKey, PROVIDER_PATTERNS } from './detect.js';
9
9
  export { PROVIDER_INFO, ProviderError } from './types.js';
10
10
  /** Base URLs for the OpenAI-compatible providers. */
11
11
  const BASE_URLS = {
12
+ // ── International ────────────────────────────────────────────────────
12
13
  openai: 'https://api.openai.com/v1',
13
14
  openrouter: 'https://openrouter.ai/api/v1',
14
15
  groq: 'https://api.groq.com/openai/v1',
15
- // Gemini's OpenAI-compatibility endpoint
16
16
  gemini: 'https://generativelanguage.googleapis.com/v1beta/openai',
17
+ mistral: 'https://api.mistral.ai/v1',
18
+ cerebras: 'https://api.cerebras.ai/v1',
19
+ // ── Chinese AI ───────────────────────────────────────────────────────
20
+ glm: 'https://open.bigmodel.cn/api/paas/v4', // Zhipu AI — GLM-4-Flash free
21
+ deepseek: 'https://api.deepseek.com/v1', // DeepSeek V3 — very cheap, free credits
22
+ qwen: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', // Alibaba Qwen — international
23
+ moonshot: 'https://api.moonshot.cn/v1', // Kimi — free quota on signup
24
+ minimax: 'https://api.minimax.chat/v1', // MiniMax-Text-01 — free tier
17
25
  };
18
26
  export function createProvider(id, apiKey, model) {
19
- const resolvedModel = model ?? PROVIDER_INFO[id].defaultModel;
27
+ const info = PROVIDER_INFO[id];
28
+ if (!info) {
29
+ throw new Error(`Unknown provider "${id}". Run \`vexi config reset\` to reconfigure.`);
30
+ }
31
+ const resolvedModel = model ?? info.defaultModel;
20
32
  if (id === 'anthropic') {
21
33
  return createAnthropicProvider(apiKey, resolvedModel);
22
34
  }
23
35
  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
- }
36
+ ? { 'HTTP-Referer': 'https://vexi.pro', 'X-Title': 'Vexi' }
29
37
  : undefined;
30
38
  return createOpenAICompatProvider({
31
39
  id,
@@ -35,3 +43,31 @@ export function createProvider(id, apiKey, model) {
35
43
  extraHeaders,
36
44
  });
37
45
  }
46
+ /**
47
+ * Create a provider from a VexiConfig produced by `vexi setup`.
48
+ * When config.baseUrl is set, uses it directly (URL-based routing).
49
+ * Falls back to the ProviderId-based createProvider for old configs.
50
+ */
51
+ export function createProviderFromConfig(config) {
52
+ if (!config.baseUrl) {
53
+ // Old-style config: route by ProviderId
54
+ return createProvider(config.provider, config.apiKey, config.model);
55
+ }
56
+ const model = config.model ?? 'gpt-4o';
57
+ if (config.provider === 'anthropic') {
58
+ return createAnthropicProvider(config.apiKey, model, config.baseUrl);
59
+ }
60
+ const extraHeaders = config.provider === 'openrouter'
61
+ ? { 'HTTP-Referer': 'https://vexi.pro', 'X-Title': 'Vexi' }
62
+ : undefined;
63
+ // Z.ai GLM-5 models support reasoning_effort for extended thinking
64
+ const extraBody = model.startsWith('z-ai/glm-5') ? { reasoning_effort: 'high' } : undefined;
65
+ return createOpenAICompatProvider({
66
+ id: config.provider,
67
+ baseUrl: config.baseUrl,
68
+ apiKey: config.apiKey,
69
+ model,
70
+ extraHeaders,
71
+ extraBody,
72
+ });
73
+ }
@@ -23,6 +23,7 @@ export function createOpenAICompatProvider(opts) {
23
23
  model: opts.model,
24
24
  messages,
25
25
  stream: true,
26
+ ...opts.extraBody,
26
27
  }),
27
28
  }).catch((err) => {
28
29
  throw new ProviderError(`Network error: ${err.message}`);