brains-cli 1.0.1 → 1.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brains-cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "🧠 Brains.io — Install AI-powered dev agents. Build, review, architect, and ship with one command.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -25,6 +25,7 @@
25
25
  "license": "MIT",
26
26
  "dependencies": {
27
27
  "@anthropic-ai/sdk": "^0.39.0",
28
+ "openai": "^4.78.0",
28
29
  "chalk": "^4.1.2",
29
30
  "commander": "^11.1.0",
30
31
  "inquirer": "^8.2.6",
package/src/api.js CHANGED
@@ -1,36 +1,28 @@
1
1
  'use strict';
2
2
 
3
- const Anthropic = require('@anthropic-ai/sdk');
4
- const { getApiKey, getModel } = require('./config');
5
-
6
- let client = null;
7
-
8
3
  /**
9
- * Initialize or return existing Anthropic client
4
+ * API Router delegates to the active provider.
5
+ *
6
+ * All brain commands call streamMessage/sendMessage from here.
7
+ * This module resolves which provider to use and routes accordingly.
10
8
  */
11
- function getClient() {
12
- if (client) return client;
13
- const apiKey = getApiKey();
14
- if (!apiKey) {
15
- throw new Error('NO_API_KEY');
16
- }
17
- client = new Anthropic({ apiKey });
18
- return client;
19
- }
9
+
10
+ const { getActiveProvider, getProviderApiKey, getProviderModel, getProviderBaseUrl } = require('./config');
11
+ const { getProviderDef } = require('./providers/registry');
20
12
 
21
13
  /**
22
- * Stream a Claude response to the terminal in real-time.
14
+ * Stream a response from the active AI provider.
23
15
  * Returns the full assistant message text.
24
16
  *
25
17
  * @param {Object} opts
26
- * @param {string} opts.systemPrompt - System prompt
27
- * @param {Array} opts.messages - Conversation history [{role, content}]
28
- * @param {number} [opts.maxTokens] - Max tokens (default from config)
29
- * @param {string} [opts.model] - Model override
30
- * @param {Function} [opts.onText] - Callback for each text chunk
31
- * @param {Function} [opts.onStart] - Called when streaming starts
32
- * @param {Function} [opts.onEnd] - Called when streaming ends
33
- * @returns {Promise<string>} Full response text
18
+ * @param {string} opts.systemPrompt
19
+ * @param {Array} opts.messages - [{role, content}]
20
+ * @param {number} [opts.maxTokens]
21
+ * @param {string} [opts.model] - Override model
22
+ * @param {Function} [opts.onText]
23
+ * @param {Function} [opts.onStart]
24
+ * @param {Function} [opts.onEnd]
25
+ * @returns {Promise<string>}
34
26
  */
35
27
  async function streamMessage(opts) {
36
28
  const {
@@ -43,34 +35,44 @@ async function streamMessage(opts) {
43
35
  onEnd = () => {},
44
36
  } = opts;
45
37
 
46
- const anthropic = getClient();
47
- const modelId = model || getModel();
38
+ const providerName = getActiveProvider();
39
+ const providerDef = getProviderDef(providerName);
48
40
 
49
- let fullText = '';
41
+ if (!providerDef) {
42
+ throw new Error(`Unknown provider "${providerName}". Run: brains config set provider <name>`);
43
+ }
50
44
 
51
- const stream = await anthropic.messages.stream({
52
- model: modelId,
53
- max_tokens: maxTokens,
54
- system: systemPrompt,
55
- messages: messages,
56
- });
57
-
58
- onStart();
59
-
60
- for await (const event of stream) {
61
- if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
62
- const text = event.delta.text;
63
- fullText += text;
64
- onText(text);
65
- }
45
+ const apiKey = getProviderApiKey(providerName);
46
+ const modelId = model || getProviderModel(providerName);
47
+ const baseURL = getProviderBaseUrl(providerName);
48
+
49
+ if (!apiKey && providerDef.requires_key !== false) {
50
+ throw new Error('NO_API_KEY');
66
51
  }
67
52
 
68
- onEnd();
69
- return fullText;
53
+ const callOpts = {
54
+ apiKey,
55
+ systemPrompt,
56
+ messages,
57
+ maxTokens,
58
+ model: modelId,
59
+ onText,
60
+ onStart,
61
+ onEnd,
62
+ };
63
+
64
+ if (providerDef.type === 'anthropic') {
65
+ const provider = require('./providers/anthropic');
66
+ return provider.streamMessage(callOpts);
67
+ } else {
68
+ const provider = require('./providers/openai-compat');
69
+ callOpts.baseURL = baseURL;
70
+ return provider.streamMessage(callOpts);
71
+ }
70
72
  }
71
73
 
72
74
  /**
73
- * Send a single message (non-streaming). Used for short tasks.
75
+ * Send a single message (non-streaming).
74
76
  */
75
77
  async function sendMessage(opts) {
76
78
  const {
@@ -80,24 +82,34 @@ async function sendMessage(opts) {
80
82
  model,
81
83
  } = opts;
82
84
 
83
- const anthropic = getClient();
84
- const modelId = model || getModel();
85
+ const providerName = getActiveProvider();
86
+ const providerDef = getProviderDef(providerName);
85
87
 
86
- const response = await anthropic.messages.create({
87
- model: modelId,
88
- max_tokens: maxTokens,
89
- system: systemPrompt,
90
- messages: messages,
91
- });
92
-
93
- return response.content
94
- .filter((block) => block.type === 'text')
95
- .map((block) => block.text)
96
- .join('');
88
+ if (!providerDef) {
89
+ throw new Error(`Unknown provider "${providerName}".`);
90
+ }
91
+
92
+ const apiKey = getProviderApiKey(providerName);
93
+ const modelId = model || getProviderModel(providerName);
94
+ const baseURL = getProviderBaseUrl(providerName);
95
+
96
+ if (!apiKey && providerDef.requires_key !== false) {
97
+ throw new Error('NO_API_KEY');
98
+ }
99
+
100
+ const callOpts = { apiKey, systemPrompt, messages, maxTokens, model: modelId };
101
+
102
+ if (providerDef.type === 'anthropic') {
103
+ const provider = require('./providers/anthropic');
104
+ return provider.sendMessage(callOpts);
105
+ } else {
106
+ const provider = require('./providers/openai-compat');
107
+ callOpts.baseURL = baseURL;
108
+ return provider.sendMessage(callOpts);
109
+ }
97
110
  }
98
111
 
99
112
  module.exports = {
100
- getClient,
101
113
  streamMessage,
102
114
  sendMessage,
103
115
  };
@@ -2,46 +2,101 @@
2
2
 
3
3
  const chalk = require('chalk');
4
4
  const Table = require('cli-table3');
5
- const { loadConfig, setConfigValue, getApiKey, maskApiKey, DEFAULTS } = require('../config');
6
- const { showSuccess, showError, showInfo, ACCENT, DIM } = require('../utils/ui');
5
+ const {
6
+ loadConfig, saveConfig, setConfigValue,
7
+ getActiveProvider, getProviderApiKey, getProviderModel, getProviderBaseUrl,
8
+ setProviderApiKey, setProviderModel, setActiveProvider,
9
+ maskApiKey, DEFAULTS,
10
+ } = require('../config');
11
+ const { getAllProviders, getProviderDef, getAllProviderNames } = require('../providers/registry');
12
+ const { showSuccess, showError, showInfo, showWarning, ACCENT, DIM, SUCCESS } = require('../utils/ui');
7
13
 
8
14
  async function configCommand(action, args) {
9
15
  if (!action) {
10
- // Show all config
11
16
  showCurrentConfig();
12
17
  return;
13
18
  }
14
19
 
20
+ // ─── brains config set <key> <value> ───
15
21
  if (action === 'set') {
16
22
  if (args.length < 2) {
17
23
  showError('Usage: brains config set <key> <value>');
18
- showInfo(`Example: ${ACCENT('brains config set api_key sk-ant-...')}`);
19
- showInfo(`Example: ${ACCENT('brains config set model claude-sonnet-4-20250514')}`);
24
+ console.log('');
25
+ showInfo(`${ACCENT('provider')} — Switch AI provider (groq, anthropic, gemini, openrouter, ollama)`);
26
+ showInfo(`${ACCENT('api_key')} — Set API key for current provider`);
27
+ showInfo(`${ACCENT('model')} — Set model for current provider`);
28
+ showInfo(`${ACCENT('max_tokens')} — Set max tokens`);
29
+ console.log('');
30
+ showInfo(`Example: ${ACCENT('brains config set provider groq')}`);
31
+ showInfo(`Example: ${ACCENT('brains config set api_key gsk_...')}`);
20
32
  return;
21
33
  }
22
34
 
23
35
  const key = args[0];
24
36
  let value = args.slice(1).join(' ');
25
37
 
26
- // Validate known keys
27
- const validKeys = ['api_key', 'model', 'max_tokens', 'theme', 'telemetry'];
38
+ // ── Switch provider ──
39
+ if (key === 'provider') {
40
+ const providerDef = getProviderDef(value);
41
+ if (!providerDef) {
42
+ showError(`Unknown provider "${value}".`);
43
+ showInfo(`Available: ${getAllProviderNames().map(n => ACCENT(n)).join(', ')}`);
44
+ return;
45
+ }
46
+
47
+ setActiveProvider(value);
48
+ showSuccess(`Provider switched to ${chalk.bold(providerDef.name)}`);
49
+
50
+ // Check if key is configured
51
+ const apiKey = getProviderApiKey(value);
52
+ if (!apiKey && providerDef.requires_key !== false) {
53
+ console.log('');
54
+ showWarning(`No API key set for ${providerDef.name}.`);
55
+ showInfo(`Get one at: ${ACCENT(providerDef.key_url)}`);
56
+ showInfo(`Then run: ${ACCENT(`brains config set api_key <your-key>`)}`);
57
+ } else {
58
+ showInfo(`Model: ${DIM(getProviderModel(value))}`);
59
+ }
60
+ console.log('');
61
+ return;
62
+ }
63
+
64
+ // ── Set API key (for current provider) ──
65
+ if (key === 'api_key') {
66
+ const currentProvider = getActiveProvider();
67
+ setProviderApiKey(currentProvider, value);
68
+ showSuccess(`API key saved for ${getProviderDef(currentProvider).name}: ${maskApiKey(value)}`);
69
+ console.log('');
70
+ return;
71
+ }
72
+
73
+ // ── Set model (for current provider) ──
74
+ if (key === 'model') {
75
+ const currentProvider = getActiveProvider();
76
+ setProviderModel(currentProvider, value);
77
+ showSuccess(`Model set for ${getProviderDef(currentProvider).name}: ${value}`);
78
+ console.log('');
79
+ return;
80
+ }
81
+
82
+ // ── Generic config keys ──
83
+ const validKeys = ['max_tokens', 'theme', 'telemetry'];
28
84
  if (!validKeys.includes(key)) {
29
85
  showError(`Unknown config key "${key}".`);
30
- showInfo(`Valid keys: ${validKeys.map(k => ACCENT(k)).join(', ')}`);
86
+ showInfo(`Valid keys: provider, api_key, model, ${validKeys.map(k => ACCENT(k)).join(', ')}`);
31
87
  return;
32
88
  }
33
89
 
34
- // Type coercion
35
90
  if (key === 'max_tokens') value = parseInt(value, 10);
36
91
  if (key === 'telemetry') value = value === 'true';
37
92
 
38
93
  setConfigValue(key, value);
39
- const displayValue = key === 'api_key' ? maskApiKey(value) : value;
40
- showSuccess(`${key} = ${displayValue}`);
94
+ showSuccess(`${key} = ${value}`);
41
95
  console.log('');
42
96
  return;
43
97
  }
44
98
 
99
+ // ─── brains config get <key> ───
45
100
  if (action === 'get') {
46
101
  if (args.length < 1) {
47
102
  showError('Usage: brains config get <key>');
@@ -49,13 +104,29 @@ async function configCommand(action, args) {
49
104
  }
50
105
 
51
106
  const key = args[0];
52
- const config = loadConfig();
53
- let value = config[key];
107
+
108
+ if (key === 'provider') {
109
+ const p = getActiveProvider();
110
+ const def = getProviderDef(p);
111
+ console.log(`\n ${chalk.bold('provider')} = ${p} (${def ? def.name : 'unknown'})\n`);
112
+ return;
113
+ }
54
114
 
55
115
  if (key === 'api_key') {
56
- value = maskApiKey(getApiKey());
116
+ const p = getActiveProvider();
117
+ const k = getProviderApiKey(p);
118
+ console.log(`\n ${chalk.bold('api_key')} [${p}] = ${k ? maskApiKey(k) : chalk.red('(not set)')}\n`);
119
+ return;
57
120
  }
58
121
 
122
+ if (key === 'model') {
123
+ const p = getActiveProvider();
124
+ console.log(`\n ${chalk.bold('model')} [${p}] = ${getProviderModel(p)}\n`);
125
+ return;
126
+ }
127
+
128
+ const config = loadConfig();
129
+ const value = config[key];
59
130
  if (value === undefined) {
60
131
  showInfo(`${key} is not set (default: ${DEFAULTS[key] || 'none'})`);
61
132
  } else {
@@ -64,20 +135,33 @@ async function configCommand(action, args) {
64
135
  return;
65
136
  }
66
137
 
138
+ // ─── brains config providers ───
139
+ if (action === 'providers') {
140
+ showProviderList();
141
+ return;
142
+ }
143
+
144
+ // ─── brains config reset ───
67
145
  if (action === 'reset') {
68
- const { saveConfig } = require('../config');
69
- saveConfig({ ...DEFAULTS });
70
- showSuccess('Config reset to defaults.');
146
+ saveConfig({ ...DEFAULTS, providers: {} });
147
+ showSuccess('Config reset to defaults (provider: groq).');
71
148
  console.log('');
72
149
  return;
73
150
  }
74
151
 
75
- showError(`Unknown action "${action}". Use: set, get, reset, or no action to view all.`);
152
+ showError(`Unknown action "${action}". Use: set, get, providers, reset, or no action to view all.`);
76
153
  }
77
154
 
155
+ // ═══════════════════════════════════════════
156
+ // Display helpers
157
+ // ═══════════════════════════════════════════
158
+
78
159
  function showCurrentConfig() {
79
160
  const config = loadConfig();
80
- const apiKey = getApiKey();
161
+ const currentProvider = getActiveProvider();
162
+ const providerDef = getProviderDef(currentProvider);
163
+ const apiKey = getProviderApiKey(currentProvider);
164
+ const model = getProviderModel(currentProvider);
81
165
 
82
166
  console.log(`\n ${chalk.bold('Brains Configuration')}\n`);
83
167
 
@@ -87,22 +171,66 @@ function showCurrentConfig() {
87
171
  });
88
172
 
89
173
  table.push(
90
- [chalk.bold('api_key'), apiKey ? maskApiKey(apiKey) : chalk.red('(not set)')],
91
- [chalk.bold('model'), config.model || DEFAULTS.model],
174
+ [chalk.bold('provider'), `${currentProvider} ${DIM(`(${providerDef ? providerDef.name : 'unknown'})`)}`],
175
+ [chalk.bold('api_key'), apiKey ? maskApiKey(apiKey) : (providerDef && providerDef.requires_key === false ? DIM('(not required)') : chalk.red('(not set)'))],
176
+ [chalk.bold('model'), model],
92
177
  [chalk.bold('max_tokens'), String(config.max_tokens || DEFAULTS.max_tokens)],
93
- [chalk.bold('theme'), config.theme || DEFAULTS.theme],
94
178
  [chalk.bold('telemetry'), String(config.telemetry ?? DEFAULTS.telemetry)]
95
179
  );
96
180
 
97
181
  console.log(table.toString());
98
182
 
99
- if (!apiKey) {
183
+ if (!apiKey && providerDef && providerDef.requires_key !== false) {
100
184
  console.log('');
101
- showInfo(`Set your API key: ${ACCENT('brains config set api_key sk-ant-...')}`);
102
- showInfo(`Or set env var: ${ACCENT('export ANTHROPIC_API_KEY=sk-ant-...')}`);
185
+ showInfo(`Set API key: ${ACCENT(`brains config set api_key <your-${currentProvider}-key>`)}`);
186
+ if (providerDef.key_url) {
187
+ showInfo(`Get one at: ${ACCENT(providerDef.key_url)}`);
188
+ }
103
189
  }
104
190
 
105
191
  console.log('');
192
+ showInfo(`Switch provider: ${ACCENT('brains config set provider <name>')}`);
193
+ showInfo(`See all providers: ${ACCENT('brains config providers')}`);
194
+ console.log('');
195
+ }
196
+
197
+ function showProviderList() {
198
+ const currentProvider = getActiveProvider();
199
+ const allProviders = getAllProviders();
200
+
201
+ console.log(`\n ${chalk.bold('Available Providers')}\n`);
202
+
203
+ const table = new Table({
204
+ head: [
205
+ chalk.white.bold('Provider'),
206
+ chalk.white.bold('Type'),
207
+ chalk.white.bold('Default Model'),
208
+ chalk.white.bold('Free?'),
209
+ chalk.white.bold('Status'),
210
+ ],
211
+ style: { head: [], border: ['dim'] },
212
+ colWidths: [16, 12, 36, 8, 14],
213
+ });
214
+
215
+ for (const [name, def] of Object.entries(allProviders)) {
216
+ const isActive = name === currentProvider;
217
+ const hasKey = !!getProviderApiKey(name) || def.requires_key === false;
218
+
219
+ table.push([
220
+ isActive ? chalk.hex('#7B2FFF').bold(`▸ ${name}`) : ` ${name}`,
221
+ DIM(def.type),
222
+ DIM(def.default_model),
223
+ def.free ? SUCCESS('Yes') : DIM('Paid'),
224
+ hasKey ? SUCCESS('Ready') : chalk.yellow('Need key'),
225
+ ]);
226
+ }
227
+
228
+ console.log(table.toString());
229
+
230
+ console.log('');
231
+ showInfo(`Active: ${ACCENT(currentProvider)}`);
232
+ showInfo(`Switch: ${ACCENT('brains config set provider <name>')}`);
233
+ console.log('');
106
234
  }
107
235
 
108
236
  module.exports = { config: configCommand };
@@ -7,7 +7,12 @@ const path = require('path');
7
7
  const fs = require('fs');
8
8
  const readline = require('readline');
9
9
  const { getBrainById } = require('../registry');
10
- const { getApiKey, getModel, saveSession, setConfigValue } = require('../config');
10
+ const {
11
+ getActiveProvider, getProviderApiKey, getProviderModel,
12
+ setProviderApiKey, setActiveProvider,
13
+ getModel, saveSession,
14
+ } = require('../config');
15
+ const { getProviderDef, getAllProviderNames } = require('../providers/registry');
11
16
  const { streamMessage } = require('../api');
12
17
  const { readFilesForReview, parseFileBlocks, writeFileBlocks, generateTreeView } = require('../utils/files');
13
18
  const { showSuccess, showError, showInfo, showWarning, ACCENT, DIM, SUCCESS, WARN } = require('../utils/ui');
@@ -33,23 +38,45 @@ function loadBrainConfig(brainId) {
33
38
  }
34
39
 
35
40
  /**
36
- * Ensure API key is available. Prompt user if missing.
41
+ * Ensure API key is available for the active provider.
42
+ * Prompts user if missing. Offers provider switching if no key.
37
43
  */
38
44
  async function ensureApiKey() {
39
- let key = getApiKey();
45
+ const providerName = getActiveProvider();
46
+ const providerDef = getProviderDef(providerName);
47
+
48
+ // Ollama doesn't need a key
49
+ if (providerDef && providerDef.requires_key === false) {
50
+ return 'not-required';
51
+ }
52
+
53
+ let key = getProviderApiKey(providerName);
40
54
  if (key) return key;
41
55
 
42
56
  console.log('');
43
- showWarning('No Anthropic API key found.');
44
- showInfo(`Get one at ${ACCENT('https://console.anthropic.com/')}\n`);
57
+ showWarning(`No API key found for ${chalk.bold(providerDef ? providerDef.name : providerName)}.`);
58
+
59
+ if (providerDef && providerDef.key_url) {
60
+ showInfo(`Get one at ${ACCENT(providerDef.key_url)}\n`);
61
+ }
62
+
63
+ const keyHint = providerDef && providerDef.key_prefix
64
+ ? `Key should start with ${providerDef.key_prefix}`
65
+ : '';
45
66
 
46
67
  const { apiKey } = await inquirer.prompt([
47
68
  {
48
69
  type: 'password',
49
70
  name: 'apiKey',
50
- message: 'Enter your Anthropic API key:',
71
+ message: `Enter your ${providerDef ? providerDef.name : providerName} API key:`,
51
72
  mask: '*',
52
- validate: (v) => v.startsWith('sk-ant-') ? true : 'Key should start with sk-ant-',
73
+ validate: (v) => {
74
+ if (!v || v.length < 5) return 'Please enter a valid API key';
75
+ if (providerDef && providerDef.key_prefix && !v.startsWith(providerDef.key_prefix)) {
76
+ return keyHint;
77
+ }
78
+ return true;
79
+ },
53
80
  },
54
81
  ]);
55
82
 
@@ -63,12 +90,15 @@ async function ensureApiKey() {
63
90
  ]);
64
91
 
65
92
  if (save) {
66
- setConfigValue('api_key', apiKey);
93
+ setProviderApiKey(providerName, apiKey);
67
94
  showSuccess('API key saved.');
68
95
  }
69
96
 
70
97
  // Set in environment for this session
71
- process.env.ANTHROPIC_API_KEY = apiKey;
98
+ if (providerDef && providerDef.key_env) {
99
+ process.env[providerDef.key_env] = apiKey;
100
+ }
101
+
72
102
  return apiKey;
73
103
  }
74
104
 
@@ -126,8 +156,12 @@ function showSessionHeader(brain, stackedBrains) {
126
156
  chalk.hex(color).bold(' │ ') + DIM(`+ ${stackedBrains.map((b) => b.name).join(', ')}`)
127
157
  );
128
158
  }
159
+ const providerName = getActiveProvider();
160
+ const providerDef = getProviderDef(providerName);
161
+ const providerLabel = providerDef ? providerDef.name : providerName;
162
+
129
163
  console.log(chalk.hex(color).bold(' │ ') + DIM(`Stack: ${brain.stack.join(', ')}`));
130
- console.log(chalk.hex(color).bold(' │ ') + DIM(`Model: ${getModel()}`));
164
+ console.log(chalk.hex(color).bold(' │ ') + DIM(`Provider: ${providerLabel} • Model: ${getModel()}`));
131
165
  console.log(chalk.hex(color).bold(' └─────────────────────────────────────────────────────'));
132
166
  console.log('');
133
167
  }
@@ -553,15 +587,24 @@ async function runConversationalBrain(brain, config, stackedBrains, options) {
553
587
  function handleApiError(err) {
554
588
  console.log('');
555
589
 
590
+ const providerName = getActiveProvider();
591
+ const providerDef = getProviderDef(providerName);
592
+ const providerLabel = providerDef ? providerDef.name : providerName;
593
+
556
594
  if (err.message === 'NO_API_KEY') {
557
- showError('No API key configured.');
558
- showInfo(`Run ${ACCENT('brains config set api_key <your-key>')} or set ANTHROPIC_API_KEY env var.`);
595
+ showError(`No API key configured for ${providerLabel}.`);
596
+ showInfo(`Run ${ACCENT('brains config set api_key <your-key>')}`);
597
+ if (providerDef && providerDef.key_env) {
598
+ showInfo(`Or set env var: ${ACCENT(`export ${providerDef.key_env}=<your-key>`)}`);
599
+ }
559
600
  return;
560
601
  }
561
602
 
562
- if (err.status === 401) {
563
- showError('Invalid API key.');
564
- showInfo(`Check your key at ${ACCENT('https://console.anthropic.com/')}`);
603
+ if (err.status === 401 || err.code === 'invalid_api_key') {
604
+ showError(`Invalid API key for ${providerLabel}.`);
605
+ if (providerDef && providerDef.key_url) {
606
+ showInfo(`Check your key at ${ACCENT(providerDef.key_url)}`);
607
+ }
565
608
  showInfo(`Update with: ${ACCENT('brains config set api_key <your-key>')}`);
566
609
  return;
567
610
  }
@@ -569,18 +612,26 @@ function handleApiError(err) {
569
612
  if (err.status === 429) {
570
613
  showError('Rate limit exceeded.');
571
614
  showInfo('Wait a moment and try again, or check your API usage limits.');
615
+ if (providerDef && providerDef.free) {
616
+ showInfo(`${providerLabel} free tier has rate limits. Consider upgrading or switching providers.`);
617
+ showInfo(`Switch: ${ACCENT('brains config set provider <name>')}`);
618
+ }
572
619
  return;
573
620
  }
574
621
 
575
622
  if (err.status === 529 || err.status === 503) {
576
- showError('Claude API is temporarily overloaded.');
623
+ showError(`${providerLabel} is temporarily overloaded.`);
577
624
  showInfo('Try again in a few seconds.');
578
625
  return;
579
626
  }
580
627
 
581
628
  if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT') {
582
- showError('Network error — cannot reach the Claude API.');
583
- showInfo('Check your internet connection and try again.');
629
+ showError(`Network error — cannot reach ${providerLabel}.`);
630
+ if (providerName === 'ollama') {
631
+ showInfo('Is Ollama running? Start it with: ollama serve');
632
+ } else {
633
+ showInfo('Check your internet connection and try again.');
634
+ }
584
635
  return;
585
636
  }
586
637
 
@@ -589,6 +640,7 @@ function handleApiError(err) {
589
640
  if (err.status) {
590
641
  showInfo(`Status: ${err.status}`);
591
642
  }
643
+ showInfo(`Provider: ${providerLabel} | Model: ${getModel()}`);
592
644
  console.log('');
593
645
  }
594
646
 
package/src/config.js CHANGED
@@ -3,13 +3,14 @@
3
3
  const path = require('path');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
+ const { getProviderDef } = require('./providers/registry');
6
7
 
7
8
  const BRAINS_DIR = path.join(os.homedir(), '.brains');
8
9
  const CONFIG_FILE = path.join(BRAINS_DIR, 'config.json');
9
10
  const SESSIONS_DIR = path.join(BRAINS_DIR, 'sessions');
10
11
 
11
12
  const DEFAULTS = {
12
- model: 'claude-sonnet-4-20250514',
13
+ provider: 'groq',
13
14
  max_tokens: 8096,
14
15
  theme: 'dark',
15
16
  telemetry: false,
@@ -28,12 +29,50 @@ function loadConfig() {
28
29
  ensureDirs();
29
30
  try {
30
31
  if (fs.existsSync(CONFIG_FILE)) {
31
- return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')) };
32
+ const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
33
+ return migrateConfig(raw);
32
34
  }
33
35
  } catch (e) {
34
36
  // corrupted config, reset
35
37
  }
36
- return { ...DEFAULTS };
38
+ return { ...DEFAULTS, providers: {} };
39
+ }
40
+
41
+ /**
42
+ * Migrate old config format (flat api_key/model) to new provider-based format.
43
+ */
44
+ function migrateConfig(raw) {
45
+ const config = { ...DEFAULTS, ...raw };
46
+
47
+ // Ensure providers object exists
48
+ if (!config.providers) {
49
+ config.providers = {};
50
+ }
51
+
52
+ // Migrate old flat api_key → providers.anthropic.api_key
53
+ if (config.api_key && !config.providers.anthropic) {
54
+ config.providers.anthropic = {
55
+ api_key: config.api_key,
56
+ model: config.model || 'claude-sonnet-4-20250514',
57
+ };
58
+ delete config.api_key;
59
+ }
60
+
61
+ // Migrate old flat model if it was an anthropic model
62
+ if (config.model && config.model.startsWith('claude-')) {
63
+ if (!config.providers.anthropic) config.providers.anthropic = {};
64
+ config.providers.anthropic.model = config.model;
65
+ delete config.model;
66
+ } else if (config.model) {
67
+ delete config.model;
68
+ }
69
+
70
+ // If no provider is set, default to groq
71
+ if (!config.provider) {
72
+ config.provider = DEFAULTS.provider;
73
+ }
74
+
75
+ return config;
37
76
  }
38
77
 
39
78
  function saveConfig(config) {
@@ -52,23 +91,109 @@ function setConfigValue(key, value) {
52
91
  saveConfig(config);
53
92
  }
54
93
 
94
+ // ═══════════════════════════════════════════
95
+ // PROVIDER-AWARE GETTERS
96
+ // ═══════════════════════════════════════════
97
+
55
98
  /**
56
- * Resolve API key from: env var > config file > null
99
+ * Get the currently active provider name.
57
100
  */
58
- function getApiKey() {
59
- if (process.env.ANTHROPIC_API_KEY) {
60
- return process.env.ANTHROPIC_API_KEY;
101
+ function getActiveProvider() {
102
+ const config = loadConfig();
103
+ return config.provider || DEFAULTS.provider;
104
+ }
105
+
106
+ /**
107
+ * Resolve API key for a provider: env var > config > null
108
+ */
109
+ function getProviderApiKey(providerName) {
110
+ const providerDef = getProviderDef(providerName);
111
+
112
+ // 1. Check environment variable
113
+ if (providerDef && providerDef.key_env && process.env[providerDef.key_env]) {
114
+ return process.env[providerDef.key_env];
61
115
  }
116
+
117
+ // 2. Check config file
62
118
  const config = loadConfig();
63
- if (config.api_key) {
64
- return config.api_key;
119
+ const providerConfig = config.providers && config.providers[providerName];
120
+ if (providerConfig && providerConfig.api_key) {
121
+ return providerConfig.api_key;
65
122
  }
123
+
66
124
  return null;
67
125
  }
68
126
 
69
- function getModel() {
127
+ /**
128
+ * Get the model for a provider: config > default
129
+ */
130
+ function getProviderModel(providerName) {
131
+ const config = loadConfig();
132
+ const providerConfig = config.providers && config.providers[providerName];
133
+ if (providerConfig && providerConfig.model) {
134
+ return providerConfig.model;
135
+ }
136
+
137
+ // Fallback to provider's default model
138
+ const providerDef = getProviderDef(providerName);
139
+ return providerDef ? providerDef.default_model : 'unknown';
140
+ }
141
+
142
+ /**
143
+ * Get the base URL for a provider: config override > provider default
144
+ */
145
+ function getProviderBaseUrl(providerName) {
146
+ const config = loadConfig();
147
+ const providerConfig = config.providers && config.providers[providerName];
148
+ if (providerConfig && providerConfig.base_url) {
149
+ return providerConfig.base_url;
150
+ }
151
+
152
+ const providerDef = getProviderDef(providerName);
153
+ return providerDef ? providerDef.base_url : null;
154
+ }
155
+
156
+ /**
157
+ * Save API key for a specific provider.
158
+ */
159
+ function setProviderApiKey(providerName, apiKey) {
160
+ const config = loadConfig();
161
+ if (!config.providers) config.providers = {};
162
+ if (!config.providers[providerName]) config.providers[providerName] = {};
163
+ config.providers[providerName].api_key = apiKey;
164
+ saveConfig(config);
165
+ }
166
+
167
+ /**
168
+ * Save model for a specific provider.
169
+ */
170
+ function setProviderModel(providerName, model) {
171
+ const config = loadConfig();
172
+ if (!config.providers) config.providers = {};
173
+ if (!config.providers[providerName]) config.providers[providerName] = {};
174
+ config.providers[providerName].model = model;
175
+ saveConfig(config);
176
+ }
177
+
178
+ /**
179
+ * Switch the active provider.
180
+ */
181
+ function setActiveProvider(providerName) {
70
182
  const config = loadConfig();
71
- return config.model || DEFAULTS.model;
183
+ config.provider = providerName;
184
+ saveConfig(config);
185
+ }
186
+
187
+ // ═══════════════════════════════════════════
188
+ // BACKWARD-COMPAT GETTERS (used by run.js session header)
189
+ // ═══════════════════════════════════════════
190
+
191
+ function getModel() {
192
+ return getProviderModel(getActiveProvider());
193
+ }
194
+
195
+ function getApiKey() {
196
+ return getProviderApiKey(getActiveProvider());
72
197
  }
73
198
 
74
199
  function maskApiKey(key) {
@@ -97,6 +222,13 @@ module.exports = {
97
222
  saveConfig,
98
223
  getConfigValue,
99
224
  setConfigValue,
225
+ getActiveProvider,
226
+ getProviderApiKey,
227
+ getProviderModel,
228
+ getProviderBaseUrl,
229
+ setProviderApiKey,
230
+ setProviderModel,
231
+ setActiveProvider,
100
232
  getApiKey,
101
233
  getModel,
102
234
  maskApiKey,
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ const Anthropic = require('@anthropic-ai/sdk');
4
+
5
+ let client = null;
6
+ let cachedKey = null;
7
+
8
+ function getClient(apiKey) {
9
+ // Re-create if key changed
10
+ if (client && cachedKey === apiKey) return client;
11
+ client = new Anthropic({ apiKey });
12
+ cachedKey = apiKey;
13
+ return client;
14
+ }
15
+
16
+ /**
17
+ * Stream a message using Anthropic's native API.
18
+ * System prompt is a separate parameter (not a message).
19
+ */
20
+ async function streamMessage(opts) {
21
+ const {
22
+ apiKey,
23
+ systemPrompt,
24
+ messages,
25
+ maxTokens,
26
+ model,
27
+ onText,
28
+ onStart,
29
+ onEnd,
30
+ } = opts;
31
+
32
+ const anthropic = getClient(apiKey);
33
+ let fullText = '';
34
+
35
+ const stream = await anthropic.messages.stream({
36
+ model,
37
+ max_tokens: maxTokens,
38
+ system: systemPrompt,
39
+ messages,
40
+ });
41
+
42
+ onStart();
43
+
44
+ for await (const event of stream) {
45
+ if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
46
+ const text = event.delta.text;
47
+ fullText += text;
48
+ onText(text);
49
+ }
50
+ }
51
+
52
+ onEnd();
53
+ return fullText;
54
+ }
55
+
56
+ /**
57
+ * Send a single message (non-streaming).
58
+ */
59
+ async function sendMessage(opts) {
60
+ const {
61
+ apiKey,
62
+ systemPrompt,
63
+ messages,
64
+ maxTokens,
65
+ model,
66
+ } = opts;
67
+
68
+ const anthropic = getClient(apiKey);
69
+
70
+ const response = await anthropic.messages.create({
71
+ model,
72
+ max_tokens: maxTokens,
73
+ system: systemPrompt,
74
+ messages,
75
+ });
76
+
77
+ return response.content
78
+ .filter((block) => block.type === 'text')
79
+ .map((block) => block.text)
80
+ .join('');
81
+ }
82
+
83
+ module.exports = { streamMessage, sendMessage };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const OpenAI = require('openai');
4
+
5
+ const clients = {};
6
+
7
+ function getClient(baseURL, apiKey) {
8
+ const cacheKey = baseURL + '::' + (apiKey || 'nokey');
9
+ if (clients[cacheKey]) return clients[cacheKey];
10
+
11
+ clients[cacheKey] = new OpenAI({
12
+ apiKey: apiKey || 'ollama', // Ollama doesn't need a key but SDK requires one
13
+ baseURL,
14
+ });
15
+
16
+ return clients[cacheKey];
17
+ }
18
+
19
+ /**
20
+ * Stream a message using OpenAI-compatible API (Groq, Gemini, OpenRouter, Ollama).
21
+ * System prompt becomes a system message.
22
+ */
23
+ async function streamMessage(opts) {
24
+ const {
25
+ apiKey,
26
+ baseURL,
27
+ systemPrompt,
28
+ messages,
29
+ maxTokens,
30
+ model,
31
+ onText,
32
+ onStart,
33
+ onEnd,
34
+ } = opts;
35
+
36
+ const openai = getClient(baseURL, apiKey);
37
+
38
+ // Convert Anthropic-style (separate system) to OpenAI-style (system message)
39
+ const fullMessages = [
40
+ { role: 'system', content: systemPrompt },
41
+ ...messages,
42
+ ];
43
+
44
+ let fullText = '';
45
+
46
+ const stream = await openai.chat.completions.create({
47
+ model,
48
+ max_tokens: maxTokens,
49
+ messages: fullMessages,
50
+ stream: true,
51
+ });
52
+
53
+ onStart();
54
+
55
+ for await (const chunk of stream) {
56
+ const text = chunk.choices[0]?.delta?.content;
57
+ if (text) {
58
+ fullText += text;
59
+ onText(text);
60
+ }
61
+ }
62
+
63
+ onEnd();
64
+ return fullText;
65
+ }
66
+
67
+ /**
68
+ * Send a single message (non-streaming).
69
+ */
70
+ async function sendMessage(opts) {
71
+ const {
72
+ apiKey,
73
+ baseURL,
74
+ systemPrompt,
75
+ messages,
76
+ maxTokens,
77
+ model,
78
+ } = opts;
79
+
80
+ const openai = getClient(baseURL, apiKey);
81
+
82
+ const fullMessages = [
83
+ { role: 'system', content: systemPrompt },
84
+ ...messages,
85
+ ];
86
+
87
+ const response = await openai.chat.completions.create({
88
+ model,
89
+ max_tokens: maxTokens,
90
+ messages: fullMessages,
91
+ });
92
+
93
+ return response.choices[0]?.message?.content || '';
94
+ }
95
+
96
+ module.exports = { streamMessage, sendMessage };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Provider Registry — defines all supported AI providers.
5
+ *
6
+ * Each provider has:
7
+ * type — 'anthropic' or 'openai' (OpenAI-compatible API)
8
+ * name — Display name
9
+ * base_url — API endpoint (for openai-type providers)
10
+ * default_model — Fallback model
11
+ * models — Available models
12
+ * key_prefix — Expected API key prefix (for validation hint)
13
+ * key_env — Environment variable to check for API key
14
+ * key_url — Where to get an API key
15
+ * requires_key — false for providers that don't need a key (Ollama)
16
+ * free — true if provider has a free tier
17
+ */
18
+ const PROVIDERS = {
19
+ groq: {
20
+ name: 'Groq',
21
+ type: 'openai',
22
+ base_url: 'https://api.groq.com/openai/v1',
23
+ default_model: 'llama-3.3-70b-versatile',
24
+ models: [
25
+ 'llama-3.3-70b-versatile',
26
+ 'llama-3.1-8b-instant',
27
+ 'mixtral-8x7b-32768',
28
+ 'gemma2-9b-it',
29
+ ],
30
+ key_prefix: 'gsk_',
31
+ key_env: 'GROQ_API_KEY',
32
+ key_url: 'https://console.groq.com/',
33
+ requires_key: true,
34
+ free: true,
35
+ },
36
+
37
+ anthropic: {
38
+ name: 'Anthropic (Claude)',
39
+ type: 'anthropic',
40
+ base_url: null,
41
+ default_model: 'claude-sonnet-4-20250514',
42
+ models: [
43
+ 'claude-sonnet-4-20250514',
44
+ 'claude-haiku-4-20250514',
45
+ ],
46
+ key_prefix: 'sk-ant-',
47
+ key_env: 'ANTHROPIC_API_KEY',
48
+ key_url: 'https://console.anthropic.com/',
49
+ requires_key: true,
50
+ free: false,
51
+ },
52
+
53
+ gemini: {
54
+ name: 'Google Gemini',
55
+ type: 'openai',
56
+ base_url: 'https://generativelanguage.googleapis.com/v1beta/openai/',
57
+ default_model: 'gemini-2.0-flash',
58
+ models: [
59
+ 'gemini-2.0-flash',
60
+ 'gemini-1.5-flash',
61
+ 'gemini-1.5-pro',
62
+ ],
63
+ key_prefix: '',
64
+ key_env: 'GEMINI_API_KEY',
65
+ key_url: 'https://aistudio.google.com/',
66
+ requires_key: true,
67
+ free: true,
68
+ },
69
+
70
+ openrouter: {
71
+ name: 'OpenRouter',
72
+ type: 'openai',
73
+ base_url: 'https://openrouter.ai/api/v1',
74
+ default_model: 'meta-llama/llama-3.3-70b-instruct:free',
75
+ models: [
76
+ 'meta-llama/llama-3.3-70b-instruct:free',
77
+ 'mistralai/mistral-7b-instruct:free',
78
+ 'google/gemma-2-9b-it:free',
79
+ ],
80
+ key_prefix: 'sk-or-',
81
+ key_env: 'OPENROUTER_API_KEY',
82
+ key_url: 'https://openrouter.ai/',
83
+ requires_key: true,
84
+ free: true,
85
+ },
86
+
87
+ ollama: {
88
+ name: 'Ollama (Local)',
89
+ type: 'openai',
90
+ base_url: 'http://localhost:11434/v1',
91
+ default_model: 'llama3',
92
+ models: [
93
+ 'llama3',
94
+ 'llama3.1',
95
+ 'codellama',
96
+ 'mistral',
97
+ 'mixtral',
98
+ 'phi3',
99
+ ],
100
+ key_prefix: '',
101
+ key_env: '',
102
+ key_url: 'https://ollama.com/',
103
+ requires_key: false,
104
+ free: true,
105
+ },
106
+ };
107
+
108
+ function getProviderDef(name) {
109
+ return PROVIDERS[name] || null;
110
+ }
111
+
112
+ function getAllProviderNames() {
113
+ return Object.keys(PROVIDERS);
114
+ }
115
+
116
+ function getAllProviders() {
117
+ return PROVIDERS;
118
+ }
119
+
120
+ module.exports = {
121
+ PROVIDERS,
122
+ getProviderDef,
123
+ getAllProviderNames,
124
+ getAllProviders,
125
+ };