@neetru/cli 2.11.3 → 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.
- package/CHANGELOG.md +561 -547
- package/README.md +33 -5
- package/dist/commands/ai.js +40 -15
- package/dist/commands/ai.js.map +1 -1
- package/dist/commands/auth.js +8 -0
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/autocomplete.js +34 -34
- package/dist/commands/config.js +13 -3
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/db.d.ts +6 -0
- package/dist/commands/db.js +58 -0
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/deploy.js +64 -7
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev-up.d.ts +70 -0
- package/dist/commands/dev-up.js +224 -0
- package/dist/commands/dev-up.js.map +1 -0
- package/dist/commands/doctor.js +14 -2
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/dr.js +8 -0
- package/dist/commands/dr.js.map +1 -1
- package/dist/commands/fn.d.ts +6 -0
- package/dist/commands/fn.js +88 -0
- package/dist/commands/fn.js.map +1 -0
- package/dist/commands/init.js +164 -121
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/marketplace.d.ts +36 -0
- package/dist/commands/marketplace.js +585 -0
- package/dist/commands/marketplace.js.map +1 -0
- package/dist/commands/ops.d.ts +41 -9
- package/dist/commands/ops.js +371 -21
- package/dist/commands/ops.js.map +1 -1
- package/dist/index.js +38 -5
- package/dist/index.js.map +1 -1
- package/dist/lib/ai/auth-discovery.d.ts +7 -1
- package/dist/lib/ai/auth-discovery.js +23 -1
- package/dist/lib/ai/auth-discovery.js.map +1 -1
- package/dist/lib/ai/context.d.ts +0 -3
- package/dist/lib/ai/context.js +109 -90
- package/dist/lib/ai/context.js.map +1 -1
- package/dist/lib/ai/orchestrator.d.ts +43 -8
- package/dist/lib/ai/orchestrator.js +452 -150
- package/dist/lib/ai/orchestrator.js.map +1 -1
- package/dist/lib/api-client.js +9 -2
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/config.d.ts +5 -1
- package/dist/lib/config.js +5 -1
- package/dist/lib/config.js.map +1 -1
- package/package.json +1 -1
- package/templates/auth/callback.ts +22 -22
- package/templates/auth/sign-in.tsx +41 -41
- package/templates/billing/checkout.ts +22 -22
- package/templates/billing/page.tsx +43 -43
- package/templates/support/ticket-form.tsx +68 -68
- package/templates/usage/track.ts +30 -30
- package/templates/users/profile.tsx +43 -43
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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-
|
|
34
|
-
* 3.
|
|
35
|
-
* 4.
|
|
36
|
-
* 5. Fallback:
|
|
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
|
|
47
|
-
return
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
62
|
-
|
|
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
|
|
76
|
-
if (
|
|
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
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 1º 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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
* (sem dependência extra). Modelo: gemini-1.5-flash (rápido + barato).
|
|
358
|
+
* Stream via Gemini REST API `streamGenerateContent`.
|
|
125
359
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
131
|
-
if (
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
//
|
|
465
|
+
// reader já fechado, ignora.
|
|
189
466
|
}
|
|
467
|
+
clearTimeout(timer);
|
|
190
468
|
}
|
|
469
|
+
recordUsage(usage);
|
|
470
|
+
return full;
|
|
191
471
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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'
|