@neetru/cli 2.11.4 → 2.12.1

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.
@@ -4,62 +4,153 @@ import { config } from '../config.js';
4
4
  import { NEETRU_SYSTEM_CONTEXT, loadProjectContext } from './context.js';
5
5
  import { discoveredKeys } from './auth-discovery.js';
6
6
  import { log } from '../../utils/logger.js';
7
+ // ── Endpoints + modelos default dos providers OpenAI-compatible ──────────────
8
+ // Groq e NVIDIA NIM expõem APIs OpenAI-compatible → reusamos o SDK `openai`
9
+ // só trocando `baseURL`. Modelos são overridáveis via `neetru config set
10
+ // groqModel|nvidiaModel <id>` (defaults aqui).
11
+ const GROQ_BASE_URL = 'https://api.groq.com/openai/v1';
12
+ const NVIDIA_BASE_URL = 'https://integrate.api.nvidia.com/v1';
13
+ const GROQ_DEFAULT_MODEL = 'llama-3.3-70b-versatile';
14
+ const NVIDIA_DEFAULT_MODEL = 'meta/llama-3.3-70b-instruct';
15
+ // ── Modelos default (2026-06-08) ─────────────────────────────────────────────
16
+ // OpenAI: gpt-4.1 (lançado 2025-04; mantém razão custo/capacidade melhor que
17
+ // gpt-4o para tasks de código e análise; overridável via `neetru config set
18
+ // openaiModel <id>`).
19
+ // Gemini: gemini-2.5-flash (lançado 2025-05; substitui 1.5-flash com thinking
20
+ // mode e contexto 1M; overridável via `neetru config set geminiModel <id>`).
21
+ // Claude: claude-sonnet-4-6 (já era; mantido).
22
+ const OPENAI_DEFAULT_MODEL = 'gpt-4.1';
23
+ const GEMINI_DEFAULT_MODEL = 'gemini-2.5-flash';
24
+ const CLAUDE_DEFAULT_MODEL = 'claude-sonnet-4-6';
25
+ // ── Timeout de stream (ms) ───────────────────────────────────────────────────
26
+ // Tempo máximo pra receber o 1º byte (ou completar) um stream de qualquer
27
+ // provider. Configurável via env NEETRU_STREAM_TIMEOUT_MS (ex: 120000 pra 2min).
28
+ // Default: 60 000ms (1 min) — saudável pra modelos com CoT que demoram mais.
29
+ const STREAM_TIMEOUT_MS = Number(process.env['NEETRU_STREAM_TIMEOUT_MS'] ?? 60_000);
30
+ /** Acumulador de budget em memória (por processo). */
31
+ const _budget = {
32
+ totalInputTokens: 0,
33
+ totalOutputTokens: 0,
34
+ calls: 0,
35
+ };
36
+ function recordUsage(usage) {
37
+ if (!usage)
38
+ return;
39
+ _budget.totalInputTokens += usage.inputTokens;
40
+ _budget.totalOutputTokens += usage.outputTokens;
41
+ _budget.calls += 1;
42
+ log.dim(`tokens: +${usage.inputTokens}in +${usage.outputTokens}out` +
43
+ ` (sessão: ${_budget.totalInputTokens}in ${_budget.totalOutputTokens}out, ${_budget.calls} chamadas)`);
44
+ }
45
+ /** Retorna snapshot do budget acumulado na sessão. Útil pra testes e relatórios. */
46
+ export function getSessionBudget() {
47
+ return { ..._budget };
48
+ }
49
+ /** Reseta o budget (útil em testes). */
50
+ export function resetSessionBudget() {
51
+ _budget.totalInputTokens = 0;
52
+ _budget.totalOutputTokens = 0;
53
+ _budget.calls = 0;
54
+ }
55
+ /** Mapeia provider → chave no config local do Neetru. */
56
+ function configKeyFor(provider) {
57
+ switch (provider) {
58
+ case 'anthropic':
59
+ return 'anthropicApiKey';
60
+ case 'openai':
61
+ return 'openaiApiKey';
62
+ case 'gemini':
63
+ return 'geminiApiKey';
64
+ case 'groq':
65
+ return 'groqApiKey';
66
+ case 'nvidia':
67
+ return 'nvidiaApiKey';
68
+ }
69
+ }
7
70
  /**
8
- * Resolve a apiKey efetiva pra um provider. Prioridade:
9
- * 1. Config local do Neetru (set via `neetru config set`).
10
- * 2. Cred descoberta de Claude Code / Codex / Gemini CLIs concorrentes
11
- * (auth-discovery).
12
- *
13
- * Não faz lookup repetido — caller pode chamar várias vezes sem custo
14
- * (discovery é cacheada via module-level evaluation no detect call mas
15
- * aqui rechamamos pra refletir mudanças em runtime).
71
+ * Parseia um valor de chave que PODE conter várias chaves separadas por
72
+ * vírgula (pool de rate-limit). Ex: "key1, key2,key3" ['key1','key2','key3'].
73
+ * Trim + filtra vazios. String única vira pool de 1.
16
74
  */
17
- function resolveKey(provider) {
18
- const configKey = provider === 'anthropic'
19
- ? 'anthropicApiKey'
20
- : provider === 'openai'
21
- ? 'openaiApiKey'
22
- : 'geminiApiKey';
23
- const localKey = config.get(configKey);
75
+ export function parseKeyPool(raw) {
76
+ if (!raw)
77
+ return [];
78
+ return raw
79
+ .split(',')
80
+ .map((s) => s.trim())
81
+ .filter(Boolean);
82
+ }
83
+ /**
84
+ * Resolve o POOL de chaves efetivo pra um provider. Prioridade:
85
+ * 1. Config local do Neetru (`neetru config set <provider>ApiKey`).
86
+ * 2. Cred descoberta (auth-discovery: CLIs concorrentes OU env var).
87
+ * O valor pode ser comma-separated → vira pool de N chaves.
88
+ */
89
+ function resolveKeyPool(provider) {
90
+ const localKey = config.get(configKeyFor(provider));
24
91
  if (localKey)
25
- return localKey;
26
- return discoveredKeys()[provider];
92
+ return parseKeyPool(localKey);
93
+ return parseKeyPool(discoveredKeys()[provider]);
94
+ }
95
+ /**
96
+ * Resolve a apiKey efetiva (a 1ª do pool) pra um provider — usado pelos
97
+ * checks de disponibilidade. Stream functions usam o pool inteiro.
98
+ */
99
+ function resolveKey(provider) {
100
+ return resolveKeyPool(provider)[0];
101
+ }
102
+ // ── Rotação de pool (round-robin entre chamadas) ─────────────────────────────
103
+ // Cursor por-provider em memória: cada chamada começa numa chave diferente,
104
+ // espalhando carga entre as chaves do pool. Em 401/403/429 numa chave, o
105
+ // streamer cai pra próxima do pool dentro da MESMA chamada (fallthrough).
106
+ const _poolCursor = {};
107
+ function nextPoolStart(provider, len) {
108
+ if (len <= 1)
109
+ return 0;
110
+ const cur = _poolCursor[provider] ?? 0;
111
+ _poolCursor[provider] = (cur + 1) % len;
112
+ return cur;
27
113
  }
28
114
  /**
29
115
  * Escolhe o modelo mais adequado para um dado tipo de tarefa.
30
116
  *
31
117
  * Heurísticas (em ordem):
32
118
  * 1. Se user forçou um modelo (!= auto), respeita — desde que tenha cred.
33
- * 2. Code generation pesado → OpenAI (gpt-4o codex-trained) se tiver.
34
- * 3. Análise/arquitetura/raciocínio longoClaude (sonnet 4.6).
35
- * 4. Sumarização rápida / Q&A factual → Gemini (mais barato) se tiver.
36
- * 5. Fallback: o primeiro provider com cred (claude > openai > gemini).
119
+ * 2. Code generation pesado → OpenAI (gpt-4.1); senão NVIDIA NIM (llama-70b).
120
+ * 3. Sumarização rápida / Q&A factual Groq (mais rápido); senão Gemini.
121
+ * 4. Default: Claude (arquitetura, análise, raciocínio longo).
122
+ * 5. Fallback: primeiro provider com cred (claude > openai > gemini > groq > nvidia).
37
123
  */
38
124
  export function routeModel(input, preferred) {
39
- // Provedores efetivamente disponíveis (config OU discovery).
40
125
  const available = {
41
126
  claude: !!resolveKey('anthropic'),
42
127
  openai: !!resolveKey('openai'),
43
128
  gemini: !!resolveKey('gemini'),
129
+ groq: !!resolveKey('groq'),
130
+ nvidia: !!resolveKey('nvidia'),
44
131
  };
45
132
  if (preferred !== 'auto') {
46
- if (preferred === 'claude' && available.claude)
47
- return 'claude';
48
- if (preferred === 'openai' && available.openai)
49
- return 'openai';
50
- if (preferred === 'gemini' && available.gemini)
51
- return 'gemini';
133
+ if (available[preferred])
134
+ return preferred;
52
135
  // Pediu mas não tem cred — cai pra auto.
53
136
  }
54
137
  const lower = input.toLowerCase();
55
- // Code generation pesado → OpenAI
138
+ // Code generation pesado → OpenAI; fallback NVIDIA NIM (llama grande, free).
56
139
  const isCodeGen = /\b(crie|gere|escreva|implemente|escreve|gera|cria)\s+(um|uma|o|a|essa|esse|esta|este)?\s*(componente|função|funcao|classe|módulo|modulo|arquivo|test|teste|endpoint|rota|hook|serviço|servico|api)\b/i.test(lower) || /^```|^class\s|^function\s|^const\s+\w+\s*=\s*\(/m.test(input);
57
- if (isCodeGen && available.openai)
58
- return 'openai';
59
- // Sumarização rápida / Q&A factual → Gemini
140
+ if (isCodeGen) {
141
+ if (available.openai)
142
+ return 'openai';
143
+ if (available.nvidia)
144
+ return 'nvidia';
145
+ }
146
+ // Sumarização rápida / Q&A factual → Groq (latência mais baixa); senão Gemini.
60
147
  const isQuickQa = /\b(o que é|o que e|defina|defina o|defina a|resuma|resume|sumariza|sumarize|que faz|para que serve)\b/i.test(lower) && input.length < 240;
61
- if (isQuickQa && available.gemini)
62
- return 'gemini';
148
+ if (isQuickQa) {
149
+ if (available.groq)
150
+ return 'groq';
151
+ if (available.gemini)
152
+ return 'gemini';
153
+ }
63
154
  // Default: Claude (arquitetura, análise, raciocínio).
64
155
  if (available.claude)
65
156
  return 'claude';
@@ -67,139 +158,350 @@ export function routeModel(input, preferred) {
67
158
  return 'openai';
68
159
  if (available.gemini)
69
160
  return 'gemini';
161
+ if (available.groq)
162
+ return 'groq';
163
+ if (available.nvidia)
164
+ return 'nvidia';
70
165
  // Sem creds em lugar nenhum — joga erro descritivo. Caller pode oferecer
71
166
  // `neetru auth setup` antes de tentar.
72
167
  throw new Error('Nenhum provedor de IA configurado. Rode: neetru auth setup');
73
168
  }
169
+ /**
170
+ * Streamer Claude/Anthropic com pool de chaves, timeout e usage tracking.
171
+ * Faz rotação round-robin igual ao streamOpenAICompatible: começa numa chave
172
+ * do pool e, em 401/403/429, cai pra próxima (fallthrough). Timeout via
173
+ * AbortController (STREAM_TIMEOUT_MS). Usage capturado do evento final_message.
174
+ */
74
175
  async function streamClaude(messages, systemExtra, onChunk) {
75
- const apiKey = resolveKey('anthropic');
76
- if (!apiKey) {
176
+ const keys = resolveKeyPool('anthropic');
177
+ if (keys.length === 0) {
77
178
  throw new Error('Cred Claude/Anthropic ausente. Rode `neetru auth setup` ou `neetru config set anthropicApiKey <sk-ant-...>`');
78
179
  }
79
- const client = new Anthropic({ apiKey });
80
180
  const system = [NEETRU_SYSTEM_CONTEXT, systemExtra].filter(Boolean).join('\n\n');
81
- let full = '';
82
- const stream = await client.messages.stream({
83
- model: 'claude-sonnet-4-6',
84
- max_tokens: 8096,
85
- system,
86
- messages: messages.map((m) => ({ role: m.role, content: m.content })),
87
- });
88
- for await (const chunk of stream) {
89
- if (chunk.type === 'content_block_delta' &&
90
- chunk.delta.type === 'text_delta') {
91
- onChunk(chunk.delta.text);
92
- full += chunk.delta.text;
181
+ const model = config.get('claudeModel') ?? CLAUDE_DEFAULT_MODEL;
182
+ const start = nextPoolStart('anthropic', keys.length);
183
+ let lastErr;
184
+ for (let i = 0; i < keys.length; i++) {
185
+ const apiKey = keys[(start + i) % keys.length];
186
+ const ctrl = new AbortController();
187
+ const timer = setTimeout(() => ctrl.abort(new Error(`Claude stream timeout (${STREAM_TIMEOUT_MS}ms)`)), STREAM_TIMEOUT_MS);
188
+ try {
189
+ const client = new Anthropic({ apiKey });
190
+ let full = '';
191
+ let usage;
192
+ const stream = await client.messages.stream({
193
+ model,
194
+ max_tokens: 8096,
195
+ system,
196
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
197
+ }, { signal: ctrl.signal });
198
+ for await (const chunk of stream) {
199
+ if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
200
+ onChunk(chunk.delta.text);
201
+ full += chunk.delta.text;
202
+ }
203
+ if (chunk.type === 'message_delta' && chunk.usage) {
204
+ // usage aparece no evento message_delta do streaming
205
+ const u = chunk.usage;
206
+ usage = {
207
+ inputTokens: u.input_tokens ?? 0,
208
+ outputTokens: u.output_tokens ?? 0,
209
+ totalTokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0),
210
+ };
211
+ }
212
+ }
213
+ // Captura usage do objeto final da stream se não veio via eventos.
214
+ if (!usage) {
215
+ const finalMsg = await stream.finalMessage().catch(() => null);
216
+ if (finalMsg?.usage) {
217
+ usage = {
218
+ inputTokens: finalMsg.usage.input_tokens ?? 0,
219
+ outputTokens: finalMsg.usage.output_tokens ?? 0,
220
+ totalTokens: (finalMsg.usage.input_tokens ?? 0) + (finalMsg.usage.output_tokens ?? 0),
221
+ };
222
+ }
223
+ }
224
+ recordUsage(usage);
225
+ return full;
226
+ }
227
+ catch (err) {
228
+ lastErr = err;
229
+ // Timeout → propaga direto (não adianta tentar outra chave).
230
+ if (ctrl.signal.aborted)
231
+ throw err;
232
+ const status = err?.status ?? err?.response?.status;
233
+ if (status === 401 || status === 403 || status === 429)
234
+ continue;
235
+ throw err;
236
+ }
237
+ finally {
238
+ clearTimeout(timer);
93
239
  }
94
240
  }
95
- return full;
241
+ const detail = lastErr?.message ?? String(lastErr);
242
+ throw new Error(`Claude: todas as ${keys.length} chave(s) do pool falharam (auth/rate-limit). Último erro: ${detail}`);
96
243
  }
97
- async function streamOpenAI(messages, systemExtra, onChunk) {
98
- const apiKey = resolveKey('openai');
99
- if (!apiKey) {
100
- throw new Error('Cred OpenAI ausente. Rode `neetru auth setup` ou `neetru config set openaiApiKey <sk-...>`');
244
+ /**
245
+ * Streamer genérico pra qualquer endpoint OpenAI-compatible (OpenAI, Groq,
246
+ * NVIDIA NIM). Faz rotação de pool: começa numa chave round-robin e, em
247
+ * 401/403/429 ANTES do chunk, cai pra próxima chave do pool (fallthrough).
248
+ * Erros não-auth/rate-limit propagam direto.
249
+ *
250
+ * Melhorias 2026-06-08:
251
+ * - AbortController com STREAM_TIMEOUT_MS (default 60s).
252
+ * - max_tokens passado em todas as chamadas (default 8096).
253
+ * - Usage capturado de stream final chunk (stream: true + usage field).
254
+ */
255
+ async function streamOpenAICompatible(opts) {
256
+ const { provider, label, baseURL, model, keys, messages, systemExtra, onChunk, maxTokens = 8096 } = opts;
257
+ if (keys.length === 0) {
258
+ throw new Error(`Cred ${label} ausente. Rode \`neetru config set ${configKeyFor(provider)} <chave>\``);
101
259
  }
102
- const client = new OpenAI({ apiKey });
103
260
  const systemContent = [NEETRU_SYSTEM_CONTEXT, systemExtra].filter(Boolean).join('\n\n');
104
- let full = '';
105
- const stream = await client.chat.completions.create({
106
- model: 'gpt-4o',
107
- stream: true,
108
- messages: [
109
- { role: 'system', content: systemContent },
110
- ...messages.map((m) => ({ role: m.role, content: m.content })),
111
- ],
112
- });
113
- for await (const chunk of stream) {
114
- const text = chunk.choices[0]?.delta?.content ?? '';
115
- if (text) {
116
- onChunk(text);
117
- full += text;
261
+ const start = nextPoolStart(provider, keys.length);
262
+ let lastErr;
263
+ for (let i = 0; i < keys.length; i++) {
264
+ const apiKey = keys[(start + i) % keys.length];
265
+ const ctrl = new AbortController();
266
+ const timer = setTimeout(() => ctrl.abort(new Error(`${label} stream timeout (${STREAM_TIMEOUT_MS}ms)`)), STREAM_TIMEOUT_MS);
267
+ const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
268
+ try {
269
+ let full = '';
270
+ let usage;
271
+ // stream_options.include_usage retorna usage no último chunk (OpenAI compat).
272
+ const stream = await client.chat.completions.create({
273
+ model,
274
+ stream: true,
275
+ stream_options: { include_usage: true },
276
+ max_tokens: maxTokens,
277
+ messages: [
278
+ { role: 'system', content: systemContent },
279
+ ...messages.map((m) => ({ role: m.role, content: m.content })),
280
+ ],
281
+ }, { signal: ctrl.signal });
282
+ for await (const chunk of stream) {
283
+ const text = chunk.choices[0]?.delta?.content ?? '';
284
+ if (text) {
285
+ onChunk(text);
286
+ full += text;
287
+ }
288
+ // O último chunk de stream (com choices[0].finish_reason) traz usage
289
+ // quando stream_options.include_usage=true.
290
+ if (chunk.usage) {
291
+ usage = {
292
+ inputTokens: chunk.usage.prompt_tokens ?? 0,
293
+ outputTokens: chunk.usage.completion_tokens ?? 0,
294
+ totalTokens: chunk.usage.total_tokens ?? 0,
295
+ };
296
+ }
297
+ }
298
+ recordUsage(usage);
299
+ return full;
300
+ }
301
+ catch (err) {
302
+ lastErr = err;
303
+ // Timeout → propaga direto (não adianta tentar outra chave).
304
+ if (ctrl.signal.aborted)
305
+ throw err;
306
+ const status = err?.status ?? err?.response?.status;
307
+ // Só rotaciona em auth/rate-limit. Outros erros (modelo inválido, rede,
308
+ // payload) propagam direto — rotacionar não ajudaria.
309
+ if (status === 401 || status === 403 || status === 429)
310
+ continue;
311
+ throw err;
312
+ }
313
+ finally {
314
+ clearTimeout(timer);
118
315
  }
119
316
  }
120
- return full;
317
+ const detail = lastErr?.message ?? String(lastErr);
318
+ throw new Error(`${label}: todas as ${keys.length} chave(s) do pool falharam (auth/rate-limit). Último erro: ${detail}`);
319
+ }
320
+ function streamOpenAI(messages, systemExtra, onChunk) {
321
+ // Modelo default: gpt-4.1 (lançado 2025-04; melhor custo/capacidade que gpt-4o).
322
+ // Override: neetru config set openaiModel <id>
323
+ return streamOpenAICompatible({
324
+ provider: 'openai',
325
+ label: 'OpenAI',
326
+ model: config.get('openaiModel') ?? OPENAI_DEFAULT_MODEL,
327
+ keys: resolveKeyPool('openai'),
328
+ messages,
329
+ systemExtra,
330
+ onChunk,
331
+ });
332
+ }
333
+ function streamGroq(messages, systemExtra, onChunk) {
334
+ return streamOpenAICompatible({
335
+ provider: 'groq',
336
+ label: 'Groq',
337
+ baseURL: GROQ_BASE_URL,
338
+ model: config.get('groqModel') ?? GROQ_DEFAULT_MODEL,
339
+ keys: resolveKeyPool('groq'),
340
+ messages,
341
+ systemExtra,
342
+ onChunk,
343
+ });
344
+ }
345
+ function streamNvidia(messages, systemExtra, onChunk) {
346
+ return streamOpenAICompatible({
347
+ provider: 'nvidia',
348
+ label: 'NVIDIA NIM',
349
+ baseURL: NVIDIA_BASE_URL,
350
+ model: config.get('nvidiaModel') ?? NVIDIA_DEFAULT_MODEL,
351
+ keys: resolveKeyPool('nvidia'),
352
+ messages,
353
+ systemExtra,
354
+ onChunk,
355
+ });
121
356
  }
122
357
  /**
123
- * Stream via Gemini. Usa a REST API REST `streamGenerateContent` direto
124
- * (sem dependência extra). Modelo: gemini-1.5-flash (rápido + barato).
358
+ * Stream via Gemini REST API `streamGenerateContent`.
125
359
  *
126
- * Cleanup: reader.cancel() + AbortController em finally — sem isso o body
127
- * fica dangling em erros/cancellation (Codex MEDIUM 2026-05-16).
360
+ * Melhorias 2026-06-08:
361
+ * - Pool de chaves (comma-separated em geminiApiKey): rotação round-robin +
362
+ * fallthrough em 401/403/429 (igual ao OpenAI-compatible).
363
+ * - Modelo default atualizado: gemini-2.5-flash (substitui gemini-1.5-flash).
364
+ * Override: neetru config set geminiModel <id>
365
+ * - Timeout via AbortController (STREAM_TIMEOUT_MS).
366
+ * - Usage tracking: captura usageMetadata do último objeto SSE.
367
+ * - Linka signal externo (parâmetro) com o AbortController interno.
368
+ * - Cleanup: reader.cancel() em finally.
128
369
  */
129
370
  async function streamGemini(messages, systemExtra, onChunk, signal) {
130
- const apiKey = resolveKey('gemini');
131
- if (!apiKey) {
371
+ const keys = resolveKeyPool('gemini');
372
+ if (keys.length === 0) {
132
373
  throw new Error('Cred Gemini ausente. Rode `neetru auth setup` ou `neetru config set geminiApiKey <key>`');
133
374
  }
134
375
  const systemContent = [NEETRU_SYSTEM_CONTEXT, systemExtra].filter(Boolean).join('\n\n');
376
+ const model = config.get('geminiModel') ?? GEMINI_DEFAULT_MODEL;
135
377
  // Gemini API expects user/model roles (não assistant). Converte.
136
378
  const contents = messages.map((m) => ({
137
379
  role: m.role === 'assistant' ? 'model' : 'user',
138
380
  parts: [{ text: m.content }],
139
381
  }));
140
- const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;
141
- const ctrl = new AbortController();
142
- // Encadeia signal externo (se fornecido) com o local.
143
- if (signal) {
144
- if (signal.aborted)
145
- ctrl.abort();
146
- else
147
- signal.addEventListener('abort', () => ctrl.abort(), { once: true });
148
- }
149
- const resp = await fetch(url, {
150
- method: 'POST',
151
- headers: { 'content-type': 'application/json' },
152
- body: JSON.stringify({
153
- systemInstruction: { parts: [{ text: systemContent }] },
154
- contents,
155
- }),
156
- signal: ctrl.signal,
157
- });
158
- if (!resp.ok || !resp.body) {
159
- const txt = await resp.text().catch(() => '');
160
- throw new Error(`Gemini API erro ${resp.status}: ${txt.slice(0, 200)}`);
161
- }
162
- const reader = resp.body.getReader();
163
- const dec = new TextDecoder();
164
- let buf = '';
165
- let full = '';
166
- try {
167
- while (true) {
168
- const { value, done } = await reader.read();
169
- if (done)
170
- break;
171
- buf += dec.decode(value, { stream: true });
172
- // SSE: linhas começando com "data: "
173
- const lines = buf.split('\n');
174
- buf = lines.pop() ?? '';
175
- for (const line of lines) {
176
- const m = line.match(/^data:\s*(.*)$/);
177
- if (!m)
382
+ const start = nextPoolStart('gemini', keys.length);
383
+ let lastErr;
384
+ for (let i = 0; i < keys.length; i++) {
385
+ const apiKey = keys[(start + i) % keys.length];
386
+ const ctrl = new AbortController();
387
+ const timer = setTimeout(() => ctrl.abort(new Error(`Gemini stream timeout (${STREAM_TIMEOUT_MS}ms)`)), STREAM_TIMEOUT_MS);
388
+ // Encadeia signal externo com o local.
389
+ if (signal) {
390
+ if (signal.aborted)
391
+ ctrl.abort();
392
+ else
393
+ signal.addEventListener('abort', () => ctrl.abort(), { once: true });
394
+ }
395
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;
396
+ try {
397
+ const resp = await fetch(url, {
398
+ method: 'POST',
399
+ headers: { 'content-type': 'application/json' },
400
+ body: JSON.stringify({
401
+ systemInstruction: { parts: [{ text: systemContent }] },
402
+ contents,
403
+ generationConfig: { maxOutputTokens: 8192 },
404
+ }),
405
+ signal: ctrl.signal,
406
+ });
407
+ // Erros de auth/rate-limit → rotaciona chave.
408
+ if (!resp.ok) {
409
+ if (resp.status === 401 || resp.status === 403 || resp.status === 429) {
410
+ lastErr = new Error(`Gemini API erro ${resp.status}`);
178
411
  continue;
179
- try {
180
- const obj = JSON.parse(m[1]);
181
- const txt = obj?.candidates?.[0]?.content?.parts?.[0]?.text;
182
- if (typeof txt === 'string' && txt) {
183
- onChunk(txt);
184
- full += txt;
412
+ }
413
+ const txt = await resp.text().catch(() => '');
414
+ throw new Error(`Gemini API erro ${resp.status}: ${txt.slice(0, 200)}`);
415
+ }
416
+ if (!resp.body)
417
+ throw new Error('Gemini: resposta sem body');
418
+ const reader = resp.body.getReader();
419
+ const dec = new TextDecoder();
420
+ let buf = '';
421
+ let full = '';
422
+ let usage;
423
+ try {
424
+ while (true) {
425
+ const { value, done } = await reader.read();
426
+ if (done)
427
+ break;
428
+ buf += dec.decode(value, { stream: true });
429
+ // SSE: linhas começando com "data: "
430
+ const lines = buf.split('\n');
431
+ buf = lines.pop() ?? '';
432
+ for (const line of lines) {
433
+ const matchData = line.match(/^data:\s*(.*)$/);
434
+ if (!matchData)
435
+ continue;
436
+ try {
437
+ const obj = JSON.parse(matchData[1]);
438
+ const txt = obj?.candidates?.[0]?.content?.parts?.[0]?.text;
439
+ if (typeof txt === 'string' && txt) {
440
+ onChunk(txt);
441
+ full += txt;
442
+ }
443
+ // usageMetadata aparece no último objeto da stream.
444
+ if (obj?.usageMetadata) {
445
+ const u = obj.usageMetadata;
446
+ usage = {
447
+ inputTokens: u.promptTokenCount ?? 0,
448
+ outputTokens: u.candidatesTokenCount ?? 0,
449
+ totalTokens: u.totalTokenCount ?? 0,
450
+ };
451
+ }
452
+ }
453
+ catch {
454
+ // ignora linha de keep-alive
455
+ }
185
456
  }
186
457
  }
458
+ }
459
+ finally {
460
+ // Libera o reader; idempotente — safe em erro, completion ou abort.
461
+ try {
462
+ await reader.cancel();
463
+ }
187
464
  catch {
188
- // ignora linha de keep-alive
465
+ // reader fechado, ignora.
189
466
  }
467
+ clearTimeout(timer);
190
468
  }
469
+ recordUsage(usage);
470
+ return full;
191
471
  }
192
- }
193
- finally {
194
- // Libera o reader; idempotente — safe em erro, completion ou abort.
195
- try {
196
- await reader.cancel();
197
- }
198
- catch {
199
- // reader fechado, ignora.
472
+ catch (err) {
473
+ clearTimeout(timer);
474
+ lastErr = err;
475
+ // Timeout → propaga direto (não adianta tentar outra chave).
476
+ if (ctrl.signal.aborted)
477
+ throw err;
478
+ // Auth/rate-limit → rotaciona pra próxima chave do pool
479
+ // (espelhando o comportamento de streamOpenAICompatible).
480
+ const status = err?.status ?? err?.response?.status;
481
+ if (status === 401 || status === 403 || status === 429)
482
+ continue;
483
+ // Outros erros (rede, modelo inválido, payload) propagam direto.
484
+ throw err;
200
485
  }
201
486
  }
202
- return full;
487
+ const detail = lastErr?.message ?? String(lastErr);
488
+ throw new Error(`Gemini: todas as ${keys.length} chave(s) do pool falharam (auth/rate-limit). Último erro: ${detail}`);
489
+ }
490
+ /** Dispatcher de stream por modelo resolvido. */
491
+ function streamFor(model, messages, systemExtra, onChunk) {
492
+ switch (model) {
493
+ case 'openai':
494
+ return streamOpenAI(messages, systemExtra, onChunk);
495
+ case 'gemini':
496
+ return streamGemini(messages, systemExtra, onChunk);
497
+ case 'groq':
498
+ return streamGroq(messages, systemExtra, onChunk);
499
+ case 'nvidia':
500
+ return streamNvidia(messages, systemExtra, onChunk);
501
+ case 'claude':
502
+ default:
503
+ return streamClaude(messages, systemExtra, onChunk);
504
+ }
203
505
  }
204
506
  /**
205
507
  * Orquestrador principal: decide o modelo, injeta contexto Neetru,
@@ -210,11 +512,7 @@ export async function chat(messages, onChunk, preferred) {
210
512
  const model = routeModel(lastUserMsg, preferred ?? config.get('defaultModel') ?? 'auto');
211
513
  const projectCtx = await loadProjectContext();
212
514
  log.dim(`modelo: ${model}`);
213
- if (model === 'openai')
214
- return streamOpenAI(messages, projectCtx, onChunk);
215
- if (model === 'gemini')
216
- return streamGemini(messages, projectCtx, onChunk);
217
- return streamClaude(messages, projectCtx, onChunk);
515
+ return streamFor(model, messages, projectCtx, onChunk);
218
516
  }
219
517
  /**
220
518
  * Spawn N agents concorrentes pra mesma pergunta. Útil pra "team mode":
@@ -223,28 +521,32 @@ export async function chat(messages, onChunk, preferred) {
223
521
  *
224
522
  * Limite: spawn só até a quantidade de providers com cred. Se pedir 5 mas
225
523
  * só tem 2 providers configurados, roda 2.
524
+ *
525
+ * Budget cap (2026-06-08): `maxProviders` limita o número de providers
526
+ * usados em paralelo (default: todos os disponíveis). Útil pra não queimar
527
+ * N tokens quando só 2-3 providers são necessários.
226
528
  */
227
- export async function chatParallel(prompt, systemExtra) {
228
- const targets = [];
229
- if (resolveKey('anthropic'))
230
- targets.push('claude');
231
- if (resolveKey('openai'))
232
- targets.push('openai');
233
- if (resolveKey('gemini'))
234
- targets.push('gemini');
529
+ export async function chatParallel(prompt, systemExtra, opts) {
530
+ const providerOf = {
531
+ claude: 'anthropic',
532
+ openai: 'openai',
533
+ gemini: 'gemini',
534
+ groq: 'groq',
535
+ nvidia: 'nvidia',
536
+ };
537
+ const order = ['claude', 'openai', 'gemini', 'groq', 'nvidia'];
538
+ let targets = order.filter((m) => !!resolveKey(providerOf[m]));
235
539
  if (targets.length === 0) {
236
540
  throw new Error('Nenhum provedor configurado. Rode: neetru auth setup');
237
541
  }
542
+ // Aplica cap de providers (budget).
543
+ if (opts?.maxProviders != null && opts.maxProviders > 0) {
544
+ targets = targets.slice(0, opts.maxProviders);
545
+ }
238
546
  const messages = [{ role: 'user', content: prompt }];
239
547
  const noop = () => { };
240
548
  const results = await Promise.allSettled(targets.map(async (m) => {
241
- let response = '';
242
- if (m === 'claude')
243
- response = await streamClaude(messages, systemExtra, noop);
244
- else if (m === 'openai')
245
- response = await streamOpenAI(messages, systemExtra, noop);
246
- else
247
- response = await streamGemini(messages, systemExtra, noop);
549
+ const response = await streamFor(m, messages, systemExtra, noop);
248
550
  return { model: m, response };
249
551
  }));
250
552
  return results.map((r, i) => r.status === 'fulfilled'