neuro-cli 4.3.0 → 5.0.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/dist/agents/base.d.ts +21 -2
- package/dist/agents/base.js +174 -31
- package/dist/agents/orchestrator.d.ts +3 -1
- package/dist/agents/orchestrator.js +32 -16
- package/dist/api/openrouter.d.ts +7 -1
- package/dist/api/openrouter.js +152 -37
- package/dist/commands/commands.js +11 -0
- package/dist/config/config.js +17 -17
- package/dist/core/engine.d.ts +8 -3
- package/dist/core/engine.js +79 -57
- package/dist/core/fallback.d.ts +7 -1
- package/dist/core/fallback.js +68 -3
- package/dist/core/model-router.d.ts +6 -0
- package/dist/core/model-router.js +43 -4
- package/dist/core/rate-limiter.d.ts +162 -0
- package/dist/core/rate-limiter.js +589 -0
- package/dist/index.js +2 -2
- package/dist/ui/renderer.d.ts +1 -1
- package/dist/ui/renderer.js +3 -2
- package/package.json +1 -1
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// NeuroCLI - Rate Limit Manager
|
|
3
|
+
// Intelligent rate limiting with token bucket, per-model
|
|
4
|
+
// cooldown tracking, model rotation, and adaptive backoff
|
|
5
|
+
// ============================================================
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { MODELS, getFreeModelsWithTools } from '../api/models.js';
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// RateLimitManager
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
export class RateLimitManager {
|
|
12
|
+
config;
|
|
13
|
+
modelStates = new Map();
|
|
14
|
+
globalRequestTimestamps = [];
|
|
15
|
+
requestQueue = [];
|
|
16
|
+
queueProcessTimer = null;
|
|
17
|
+
rotationPool = [];
|
|
18
|
+
currentRotationIndex = 0;
|
|
19
|
+
requestIdCounter = 0;
|
|
20
|
+
// Adaptive learning data
|
|
21
|
+
rateLimitPatterns = new Map();
|
|
22
|
+
constructor(config) {
|
|
23
|
+
this.config = {
|
|
24
|
+
globalRpm: 20,
|
|
25
|
+
perModelRpm: 10,
|
|
26
|
+
baseCooldownMs: 10000,
|
|
27
|
+
maxCooldownMs: 120000,
|
|
28
|
+
backoffMultiplier: 2.0,
|
|
29
|
+
rotationPoolSize: 5,
|
|
30
|
+
enableQueue: true,
|
|
31
|
+
maxQueueSize: 50,
|
|
32
|
+
maxQueueWaitMs: 60000,
|
|
33
|
+
adaptiveLearning: true,
|
|
34
|
+
verbose: true,
|
|
35
|
+
...config,
|
|
36
|
+
};
|
|
37
|
+
// Initialize rotation pool with free models that support tools
|
|
38
|
+
this.initializeRotationPool();
|
|
39
|
+
// Start queue processor
|
|
40
|
+
this.startQueueProcessor();
|
|
41
|
+
}
|
|
42
|
+
// =========================================================================
|
|
43
|
+
// Pre-Request Checking
|
|
44
|
+
// =========================================================================
|
|
45
|
+
/**
|
|
46
|
+
* Check if a request can be made to a specific model.
|
|
47
|
+
* Returns a RateLimitCheck with the decision and optional alternative model.
|
|
48
|
+
*/
|
|
49
|
+
checkRateLimit(modelId) {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
// 1. Check global RPM
|
|
52
|
+
this.cleanupGlobalTimestamps(now);
|
|
53
|
+
if (this.globalRequestTimestamps.length >= this.config.globalRpm) {
|
|
54
|
+
const oldestInWindow = this.globalRequestTimestamps[0];
|
|
55
|
+
const waitMs = Math.max(0, 60000 - (now - oldestInWindow));
|
|
56
|
+
const alternative = this.findAvailableModel(modelId);
|
|
57
|
+
if (alternative) {
|
|
58
|
+
return {
|
|
59
|
+
allowed: false,
|
|
60
|
+
waitMs: 0,
|
|
61
|
+
suggestedModel: alternative,
|
|
62
|
+
reason: `Global RPM limit reached (${this.config.globalRpm}/min). Suggesting alternative: ${MODELS[alternative]?.name || alternative}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
allowed: false,
|
|
67
|
+
waitMs,
|
|
68
|
+
suggestedModel: null,
|
|
69
|
+
reason: `Global RPM limit reached (${this.config.globalRpm}/min). Wait ${Math.ceil(waitMs / 1000)}s or try a different model.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
// 2. Check per-model cooldown
|
|
73
|
+
const state = this.getOrCreateState(modelId);
|
|
74
|
+
if (state.cooldownUntil > now) {
|
|
75
|
+
const waitMs = state.cooldownUntil - now;
|
|
76
|
+
const alternative = this.findAvailableModel(modelId);
|
|
77
|
+
if (alternative) {
|
|
78
|
+
return {
|
|
79
|
+
allowed: false,
|
|
80
|
+
waitMs: 0,
|
|
81
|
+
suggestedModel: alternative,
|
|
82
|
+
reason: `${MODELS[modelId]?.name || modelId} is in cooldown (${Math.ceil(waitMs / 1000)}s left). Rotating to ${MODELS[alternative]?.name || alternative}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
allowed: false,
|
|
87
|
+
waitMs,
|
|
88
|
+
suggestedModel: null,
|
|
89
|
+
reason: `${MODELS[modelId]?.name || modelId} is in cooldown. Wait ${Math.ceil(waitMs / 1000)}s.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// 3. Check per-model RPM
|
|
93
|
+
this.cleanupModelTimestamps(state, now);
|
|
94
|
+
if (state.requestsThisMinute >= this.config.perModelRpm) {
|
|
95
|
+
const alternative = this.findAvailableModel(modelId);
|
|
96
|
+
if (alternative) {
|
|
97
|
+
return {
|
|
98
|
+
allowed: false,
|
|
99
|
+
waitMs: 0,
|
|
100
|
+
suggestedModel: alternative,
|
|
101
|
+
reason: `${MODELS[modelId]?.name || modelId} RPM limit (${this.config.perModelRpm}/min). Rotating to ${MODELS[alternative]?.name || alternative}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const minuteElapsed = now - state.minuteWindowStart;
|
|
105
|
+
const waitMs = Math.max(0, 60000 - minuteElapsed);
|
|
106
|
+
return {
|
|
107
|
+
allowed: false,
|
|
108
|
+
waitMs,
|
|
109
|
+
suggestedModel: null,
|
|
110
|
+
reason: `${MODELS[modelId]?.name || modelId} RPM limit reached. Wait ${Math.ceil(waitMs / 1000)}s.`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// 4. Check health score - if model is unhealthy, suggest alternative
|
|
114
|
+
if (state.healthScore < 30 && state.consecutive429s > 3) {
|
|
115
|
+
const alternative = this.findAvailableModel(modelId);
|
|
116
|
+
if (alternative) {
|
|
117
|
+
return {
|
|
118
|
+
allowed: true,
|
|
119
|
+
waitMs: 0,
|
|
120
|
+
suggestedModel: alternative,
|
|
121
|
+
reason: `${MODELS[modelId]?.name || modelId} health is low (${state.healthScore}/100). Suggesting ${MODELS[alternative]?.name || alternative} for better reliability.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// All checks passed
|
|
126
|
+
return {
|
|
127
|
+
allowed: true,
|
|
128
|
+
waitMs: 0,
|
|
129
|
+
suggestedModel: null,
|
|
130
|
+
reason: 'OK',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Acquire permission to make a request. Resolves when the request can proceed.
|
|
135
|
+
* If queuing is enabled, waits in queue. Otherwise, rejects if rate limited.
|
|
136
|
+
*/
|
|
137
|
+
async acquire(modelId) {
|
|
138
|
+
const check = this.checkRateLimit(modelId);
|
|
139
|
+
if (check.allowed) {
|
|
140
|
+
// If there's a suggested model due to low health, we still allow but suggest
|
|
141
|
+
this.recordRequestStart(modelId);
|
|
142
|
+
return check;
|
|
143
|
+
}
|
|
144
|
+
// If there's an alternative model suggested, switch to it
|
|
145
|
+
if (check.suggestedModel) {
|
|
146
|
+
const altCheck = this.checkRateLimit(check.suggestedModel);
|
|
147
|
+
if (altCheck.allowed) {
|
|
148
|
+
this.recordRequestStart(check.suggestedModel);
|
|
149
|
+
if (this.config.verbose) {
|
|
150
|
+
console.log(chalk.cyan(` 🔄 Rate limiter: switching ${MODELS[modelId]?.name || modelId} → ${MODELS[check.suggestedModel]?.name || check.suggestedModel}`));
|
|
151
|
+
}
|
|
152
|
+
return { ...altCheck, suggestedModel: check.suggestedModel, reason: check.reason };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// If queuing is enabled, queue the request
|
|
156
|
+
if (this.config.enableQueue && check.waitMs > 0) {
|
|
157
|
+
return this.enqueueRequest(modelId);
|
|
158
|
+
}
|
|
159
|
+
// Can't proceed, return the check with wait info
|
|
160
|
+
if (this.config.verbose) {
|
|
161
|
+
console.log(chalk.yellow(` ⏳ Rate limited: ${check.reason}`));
|
|
162
|
+
}
|
|
163
|
+
return check;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Record a successful request completion
|
|
167
|
+
*/
|
|
168
|
+
recordSuccess(modelId, responseTimeMs) {
|
|
169
|
+
const state = this.getOrCreateState(modelId);
|
|
170
|
+
state.consecutive429s = 0;
|
|
171
|
+
state.totalSuccesses++;
|
|
172
|
+
state.lastRequestAt = Date.now();
|
|
173
|
+
// Update average response time (exponential moving average)
|
|
174
|
+
if (state.avgResponseMs === 0) {
|
|
175
|
+
state.avgResponseMs = responseTimeMs;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
state.avgResponseMs = state.avgResponseMs * 0.8 + responseTimeMs * 0.2;
|
|
179
|
+
}
|
|
180
|
+
// Recover health score
|
|
181
|
+
state.healthScore = Math.min(100, state.healthScore + 5);
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Record a rate limit (429) response
|
|
185
|
+
*/
|
|
186
|
+
recordRateLimit(modelId, retryAfterSeconds) {
|
|
187
|
+
const state = this.getOrCreateState(modelId);
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
state.consecutive429s++;
|
|
190
|
+
state.total429s++;
|
|
191
|
+
state.lastRequestAt = now;
|
|
192
|
+
// Calculate cooldown duration
|
|
193
|
+
let cooldownMs;
|
|
194
|
+
if (retryAfterSeconds && retryAfterSeconds > 0) {
|
|
195
|
+
// Respect server-provided retry-after
|
|
196
|
+
cooldownMs = retryAfterSeconds * 1000;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
// Exponential backoff based on consecutive 429s
|
|
200
|
+
cooldownMs = Math.min(this.config.baseCooldownMs * Math.pow(this.config.backoffMultiplier, state.consecutive429s - 1), this.config.maxCooldownMs);
|
|
201
|
+
}
|
|
202
|
+
state.cooldownUntil = now + cooldownMs;
|
|
203
|
+
// Decrease health score
|
|
204
|
+
state.healthScore = Math.max(0, state.healthScore - (10 + state.consecutive429s * 5));
|
|
205
|
+
// Adaptive learning: record the pattern
|
|
206
|
+
if (this.config.adaptiveLearning) {
|
|
207
|
+
this.recordRateLimitPattern(modelId, cooldownMs);
|
|
208
|
+
}
|
|
209
|
+
if (this.config.verbose) {
|
|
210
|
+
console.log(chalk.yellow(` 🚫 Rate limited on ${MODELS[modelId]?.name || modelId}: ` +
|
|
211
|
+
`cooldown ${Math.ceil(cooldownMs / 1000)}s ` +
|
|
212
|
+
`(consecutive: ${state.consecutive429s}, health: ${state.healthScore}/100)`));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Record a server error (5xx) - treat as potential overload
|
|
217
|
+
*/
|
|
218
|
+
recordServerError(modelId) {
|
|
219
|
+
const state = this.getOrCreateState(modelId);
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
// Apply a shorter cooldown for server errors
|
|
222
|
+
state.cooldownUntil = now + this.config.baseCooldownMs / 2;
|
|
223
|
+
state.healthScore = Math.max(0, state.healthScore - 15);
|
|
224
|
+
if (this.config.verbose) {
|
|
225
|
+
console.log(chalk.yellow(` ⚠️ Server error on ${MODELS[modelId]?.name || modelId}: ` +
|
|
226
|
+
`cooldown ${Math.ceil(this.config.baseCooldownMs / 2000)}s, health: ${state.healthScore}/100`));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// =========================================================================
|
|
230
|
+
// Model Rotation
|
|
231
|
+
// =========================================================================
|
|
232
|
+
/**
|
|
233
|
+
* Get the next available model from the rotation pool.
|
|
234
|
+
* Skips models that are in cooldown or have low health.
|
|
235
|
+
*/
|
|
236
|
+
getNextRotationModel(excludeModel) {
|
|
237
|
+
const now = Date.now();
|
|
238
|
+
const poolSize = this.rotationPool.length;
|
|
239
|
+
if (poolSize === 0)
|
|
240
|
+
return null;
|
|
241
|
+
// Try each model in the pool, starting from current index
|
|
242
|
+
for (let i = 0; i < poolSize; i++) {
|
|
243
|
+
const idx = (this.currentRotationIndex + i) % poolSize;
|
|
244
|
+
const model = this.rotationPool[idx];
|
|
245
|
+
if (model === excludeModel)
|
|
246
|
+
continue;
|
|
247
|
+
const state = this.getOrCreateState(model);
|
|
248
|
+
// Skip if in cooldown
|
|
249
|
+
if (state.cooldownUntil > now)
|
|
250
|
+
continue;
|
|
251
|
+
// Skip if health is too low
|
|
252
|
+
if (state.healthScore < 20)
|
|
253
|
+
continue;
|
|
254
|
+
// Move rotation index forward
|
|
255
|
+
this.currentRotationIndex = (idx + 1) % poolSize;
|
|
256
|
+
return model;
|
|
257
|
+
}
|
|
258
|
+
// All models in pool are rate limited or unhealthy
|
|
259
|
+
// Find the one with the shortest wait time
|
|
260
|
+
let bestModel = null;
|
|
261
|
+
let shortestWait = Infinity;
|
|
262
|
+
for (const model of this.rotationPool) {
|
|
263
|
+
if (model === excludeModel)
|
|
264
|
+
continue;
|
|
265
|
+
const state = this.getOrCreateState(model);
|
|
266
|
+
const waitMs = Math.max(0, state.cooldownUntil - now);
|
|
267
|
+
if (waitMs < shortestWait) {
|
|
268
|
+
shortestWait = waitMs;
|
|
269
|
+
bestModel = model;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return bestModel;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Get the best available model for a given requested model.
|
|
276
|
+
* Tries the requested model first, then falls back through the rotation pool.
|
|
277
|
+
*/
|
|
278
|
+
getBestAvailableModel(requestedModel) {
|
|
279
|
+
// Check if requested model is available
|
|
280
|
+
const check = this.checkRateLimit(requestedModel);
|
|
281
|
+
if (check.allowed)
|
|
282
|
+
return requestedModel;
|
|
283
|
+
// Try rotation pool
|
|
284
|
+
const alternative = this.findAvailableModel(requestedModel);
|
|
285
|
+
if (alternative)
|
|
286
|
+
return alternative;
|
|
287
|
+
// Last resort: return requested model (will wait)
|
|
288
|
+
return requestedModel;
|
|
289
|
+
}
|
|
290
|
+
// =========================================================================
|
|
291
|
+
// Status & Diagnostics
|
|
292
|
+
// =========================================================================
|
|
293
|
+
/**
|
|
294
|
+
* Get rate limit status for all tracked models
|
|
295
|
+
*/
|
|
296
|
+
getStatus() {
|
|
297
|
+
const now = Date.now();
|
|
298
|
+
const result = [];
|
|
299
|
+
for (const [modelId, state] of this.modelStates) {
|
|
300
|
+
this.cleanupModelTimestamps(state, now);
|
|
301
|
+
result.push({
|
|
302
|
+
model: modelId,
|
|
303
|
+
name: MODELS[modelId]?.name || modelId,
|
|
304
|
+
health: state.healthScore,
|
|
305
|
+
cooldown: Math.max(0, Math.ceil((state.cooldownUntil - now) / 1000)),
|
|
306
|
+
consecutive429s: state.consecutive429s,
|
|
307
|
+
total429s: state.total429s,
|
|
308
|
+
totalSuccesses: state.totalSuccesses,
|
|
309
|
+
rpm: state.requestsThisMinute,
|
|
310
|
+
avgResponseMs: Math.round(state.avgResponseMs),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
return result.sort((a, b) => b.health - a.health);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Get queue status
|
|
317
|
+
*/
|
|
318
|
+
getQueueStatus() {
|
|
319
|
+
if (this.requestQueue.length === 0)
|
|
320
|
+
return { size: 0, oldestWaitMs: 0 };
|
|
321
|
+
return {
|
|
322
|
+
size: this.requestQueue.length,
|
|
323
|
+
oldestWaitMs: Date.now() - this.requestQueue[0].enqueuedAt,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Reset all rate limit state (useful for testing)
|
|
328
|
+
*/
|
|
329
|
+
reset() {
|
|
330
|
+
for (const state of this.modelStates.values()) {
|
|
331
|
+
state.cooldownUntil = 0;
|
|
332
|
+
state.consecutive429s = 0;
|
|
333
|
+
state.healthScore = 100;
|
|
334
|
+
state.requestsThisMinute = 0;
|
|
335
|
+
}
|
|
336
|
+
this.globalRequestTimestamps = [];
|
|
337
|
+
this.requestQueue.forEach(r => r.reject(new Error('Rate limiter reset')));
|
|
338
|
+
this.requestQueue = [];
|
|
339
|
+
if (this.config.verbose) {
|
|
340
|
+
console.log(chalk.gray(' ↻ Rate limiter state reset'));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Reset a specific model's rate limit state
|
|
345
|
+
*/
|
|
346
|
+
resetModel(modelId) {
|
|
347
|
+
const state = this.modelStates.get(modelId);
|
|
348
|
+
if (state) {
|
|
349
|
+
state.cooldownUntil = 0;
|
|
350
|
+
state.consecutive429s = 0;
|
|
351
|
+
state.healthScore = 100;
|
|
352
|
+
state.requestsThisMinute = 0;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Update configuration at runtime
|
|
357
|
+
*/
|
|
358
|
+
updateConfig(updates) {
|
|
359
|
+
Object.assign(this.config, updates);
|
|
360
|
+
if (updates.rotationPoolSize !== undefined) {
|
|
361
|
+
this.initializeRotationPool();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Print a formatted status table
|
|
366
|
+
*/
|
|
367
|
+
printStatus() {
|
|
368
|
+
const status = this.getStatus();
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
console.log(chalk.bold('\n📊 Rate Limiter Status'));
|
|
371
|
+
console.log(chalk.gray('─'.repeat(80)));
|
|
372
|
+
for (const s of status) {
|
|
373
|
+
const healthBar = this.getHealthBar(s.health);
|
|
374
|
+
const cooldownStr = s.cooldown > 0 ? chalk.red(`${s.cooldown}s`) : chalk.green('✓');
|
|
375
|
+
const rpmStr = `${s.rpm}/${this.config.perModelRpm}`;
|
|
376
|
+
console.log(` ${s.name.padEnd(35)} ${healthBar} ${cooldownStr.padEnd(8)} ` +
|
|
377
|
+
`429s: ${chalk.yellow(String(s.total429s).padEnd(4))} ` +
|
|
378
|
+
`OK: ${chalk.green(String(s.totalSuccesses).padEnd(4))} ` +
|
|
379
|
+
`RPM: ${rpmStr.padEnd(6)} ` +
|
|
380
|
+
`avg: ${s.avgResponseMs}ms`);
|
|
381
|
+
}
|
|
382
|
+
const queueStatus = this.getQueueStatus();
|
|
383
|
+
if (queueStatus.size > 0) {
|
|
384
|
+
console.log(chalk.yellow(`\n Queue: ${queueStatus.size} requests waiting (oldest: ${Math.ceil(queueStatus.oldestWaitMs / 1000)}s)`));
|
|
385
|
+
}
|
|
386
|
+
console.log(chalk.gray('─'.repeat(80)));
|
|
387
|
+
}
|
|
388
|
+
// =========================================================================
|
|
389
|
+
// Private Methods
|
|
390
|
+
// =========================================================================
|
|
391
|
+
getOrCreateState(modelId) {
|
|
392
|
+
if (!this.modelStates.has(modelId)) {
|
|
393
|
+
this.modelStates.set(modelId, {
|
|
394
|
+
modelId,
|
|
395
|
+
cooldownUntil: 0,
|
|
396
|
+
consecutive429s: 0,
|
|
397
|
+
total429s: 0,
|
|
398
|
+
totalSuccesses: 0,
|
|
399
|
+
requestsThisMinute: 0,
|
|
400
|
+
minuteWindowStart: Date.now(),
|
|
401
|
+
lastRequestAt: 0,
|
|
402
|
+
avgResponseMs: 0,
|
|
403
|
+
inRotationPool: this.rotationPool.includes(modelId),
|
|
404
|
+
healthScore: 100,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return this.modelStates.get(modelId);
|
|
408
|
+
}
|
|
409
|
+
recordRequestStart(modelId) {
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
const state = this.getOrCreateState(modelId);
|
|
412
|
+
// Update per-model minute window
|
|
413
|
+
this.cleanupModelTimestamps(state, now);
|
|
414
|
+
state.requestsThisMinute++;
|
|
415
|
+
state.lastRequestAt = now;
|
|
416
|
+
// Update global timestamps
|
|
417
|
+
this.cleanupGlobalTimestamps(now);
|
|
418
|
+
this.globalRequestTimestamps.push(now);
|
|
419
|
+
}
|
|
420
|
+
cleanupGlobalTimestamps(now) {
|
|
421
|
+
const oneMinuteAgo = now - 60000;
|
|
422
|
+
while (this.globalRequestTimestamps.length > 0 && this.globalRequestTimestamps[0] < oneMinuteAgo) {
|
|
423
|
+
this.globalRequestTimestamps.shift();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
cleanupModelTimestamps(state, now) {
|
|
427
|
+
if (now - state.minuteWindowStart >= 60000) {
|
|
428
|
+
state.requestsThisMinute = 0;
|
|
429
|
+
state.minuteWindowStart = now;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
findAvailableModel(excludeModel) {
|
|
433
|
+
const now = Date.now();
|
|
434
|
+
// First, try the rotation pool
|
|
435
|
+
for (const model of this.rotationPool) {
|
|
436
|
+
if (model === excludeModel)
|
|
437
|
+
continue;
|
|
438
|
+
const state = this.getOrCreateState(model);
|
|
439
|
+
if (state.cooldownUntil <= now && state.healthScore >= 20) {
|
|
440
|
+
this.cleanupModelTimestamps(state, now);
|
|
441
|
+
if (state.requestsThisMinute < this.config.perModelRpm) {
|
|
442
|
+
return model;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// Then try any tracked model that's available
|
|
447
|
+
for (const [modelId, state] of this.modelStates) {
|
|
448
|
+
if (modelId === excludeModel)
|
|
449
|
+
continue;
|
|
450
|
+
if (state.cooldownUntil <= now && state.healthScore >= 20) {
|
|
451
|
+
this.cleanupModelTimestamps(state, now);
|
|
452
|
+
if (state.requestsThisMinute < this.config.perModelRpm) {
|
|
453
|
+
return modelId;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
initializeRotationPool() {
|
|
460
|
+
const freeModels = getFreeModelsWithTools();
|
|
461
|
+
// Sort by context window (larger is better for coding)
|
|
462
|
+
const sorted = freeModels.sort((a, b) => b.contextWindow - a.contextWindow);
|
|
463
|
+
// Take top N models for rotation
|
|
464
|
+
this.rotationPool = sorted
|
|
465
|
+
.slice(0, Math.min(this.config.rotationPoolSize, sorted.length))
|
|
466
|
+
.map(m => m.id);
|
|
467
|
+
// Mark all as in rotation pool
|
|
468
|
+
for (const modelId of this.rotationPool) {
|
|
469
|
+
const state = this.getOrCreateState(modelId);
|
|
470
|
+
state.inRotationPool = true;
|
|
471
|
+
}
|
|
472
|
+
if (this.config.verbose) {
|
|
473
|
+
console.log(chalk.gray(` 🔄 Rate limiter rotation pool: ${this.rotationPool.map(m => MODELS[m]?.name || m).join(', ')}`));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
enqueueRequest(modelId) {
|
|
477
|
+
return new Promise((resolve, reject) => {
|
|
478
|
+
if (this.requestQueue.length >= this.config.maxQueueSize) {
|
|
479
|
+
reject(new Error(`Rate limiter queue full (${this.config.maxQueueSize}). Try again later.`));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const id = `req_${++this.requestIdCounter}`;
|
|
483
|
+
const queued = {
|
|
484
|
+
id,
|
|
485
|
+
model: modelId,
|
|
486
|
+
enqueuedAt: Date.now(),
|
|
487
|
+
resolve,
|
|
488
|
+
reject,
|
|
489
|
+
};
|
|
490
|
+
this.requestQueue.push(queued);
|
|
491
|
+
if (this.config.verbose) {
|
|
492
|
+
console.log(chalk.gray(` ⏳ Request queued for ${MODELS[modelId]?.name || modelId} (queue: ${this.requestQueue.length})`));
|
|
493
|
+
}
|
|
494
|
+
// Set a timeout to reject if waiting too long
|
|
495
|
+
setTimeout(() => {
|
|
496
|
+
const idx = this.requestQueue.findIndex(r => r.id === id);
|
|
497
|
+
if (idx !== -1) {
|
|
498
|
+
this.requestQueue.splice(idx, 1);
|
|
499
|
+
reject(new Error(`Request timed out waiting in rate limiter queue (${this.config.maxQueueWaitMs / 1000}s)`));
|
|
500
|
+
}
|
|
501
|
+
}, this.config.maxQueueWaitMs);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
startQueueProcessor() {
|
|
505
|
+
// Process queue every 2 seconds
|
|
506
|
+
this.queueProcessTimer = setInterval(() => {
|
|
507
|
+
this.processQueue();
|
|
508
|
+
}, 2000);
|
|
509
|
+
}
|
|
510
|
+
processQueue() {
|
|
511
|
+
if (this.requestQueue.length === 0)
|
|
512
|
+
return;
|
|
513
|
+
const now = Date.now();
|
|
514
|
+
const toProcess = [];
|
|
515
|
+
// Find requests that can now proceed
|
|
516
|
+
for (let i = this.requestQueue.length - 1; i >= 0; i--) {
|
|
517
|
+
const req = this.requestQueue[i];
|
|
518
|
+
// Check if request has been waiting too long
|
|
519
|
+
if (now - req.enqueuedAt > this.config.maxQueueWaitMs) {
|
|
520
|
+
this.requestQueue.splice(i, 1);
|
|
521
|
+
req.reject(new Error('Request timed out in rate limiter queue'));
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const check = this.checkRateLimit(req.model);
|
|
525
|
+
if (check.allowed) {
|
|
526
|
+
toProcess.push(req);
|
|
527
|
+
this.requestQueue.splice(i, 1);
|
|
528
|
+
}
|
|
529
|
+
else if (check.suggestedModel) {
|
|
530
|
+
// Try alternative model
|
|
531
|
+
const altCheck = this.checkRateLimit(check.suggestedModel);
|
|
532
|
+
if (altCheck.allowed) {
|
|
533
|
+
this.recordRequestStart(check.suggestedModel);
|
|
534
|
+
req.resolve({ ...altCheck, suggestedModel: check.suggestedModel, reason: check.reason });
|
|
535
|
+
this.requestQueue.splice(i, 1);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// Process approved requests
|
|
540
|
+
for (const req of toProcess) {
|
|
541
|
+
this.recordRequestStart(req.model);
|
|
542
|
+
req.resolve({ allowed: true, waitMs: 0, suggestedModel: null, reason: 'Queued request approved' });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
recordRateLimitPattern(modelId, cooldownMs) {
|
|
546
|
+
const hour = new Date().getHours();
|
|
547
|
+
let patterns = this.rateLimitPatterns.get(modelId);
|
|
548
|
+
if (!patterns) {
|
|
549
|
+
patterns = [];
|
|
550
|
+
this.rateLimitPatterns.set(modelId, patterns);
|
|
551
|
+
}
|
|
552
|
+
let existing = patterns.find(p => p.hourOfDay === hour);
|
|
553
|
+
if (!existing) {
|
|
554
|
+
existing = { hourOfDay: hour, avgCooldownMs: cooldownMs, count: 1 };
|
|
555
|
+
patterns.push(existing);
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
existing.avgCooldownMs = (existing.avgCooldownMs * existing.count + cooldownMs) / (existing.count + 1);
|
|
559
|
+
existing.count++;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
getHealthBar(health) {
|
|
563
|
+
const filled = Math.round(health / 10);
|
|
564
|
+
const empty = 10 - filled;
|
|
565
|
+
let color;
|
|
566
|
+
if (health >= 70)
|
|
567
|
+
color = chalk.green;
|
|
568
|
+
else if (health >= 40)
|
|
569
|
+
color = chalk.yellow;
|
|
570
|
+
else
|
|
571
|
+
color = chalk.red;
|
|
572
|
+
return color('█'.repeat(filled) + '░'.repeat(empty));
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Cleanup - call when shutting down
|
|
576
|
+
*/
|
|
577
|
+
dispose() {
|
|
578
|
+
if (this.queueProcessTimer) {
|
|
579
|
+
clearInterval(this.queueProcessTimer);
|
|
580
|
+
this.queueProcessTimer = null;
|
|
581
|
+
}
|
|
582
|
+
// Reject all queued requests
|
|
583
|
+
for (const req of this.requestQueue) {
|
|
584
|
+
req.reject(new Error('Rate limiter shutting down'));
|
|
585
|
+
}
|
|
586
|
+
this.requestQueue = [];
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
//# sourceMappingURL=rate-limiter.js.map
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// ============================================================
|
|
3
3
|
// NeuroCLI - Advanced AI Terminal Coding Assistant
|
|
4
|
-
// Main Entry Point -
|
|
4
|
+
// Main Entry Point - v5.0 (Claude Code-style Agentic Loop)
|
|
5
5
|
// ============================================================
|
|
6
6
|
import { Command } from 'commander';
|
|
7
7
|
import { createInterface } from 'readline';
|
|
@@ -16,7 +16,7 @@ import { HeadlessMode } from './core/headless.js';
|
|
|
16
16
|
import { ShellCompletionGenerator } from './core/shell-completion.js';
|
|
17
17
|
import chalk from 'chalk';
|
|
18
18
|
import { AutoUpdater } from './core/updater.js';
|
|
19
|
-
const VERSION = '
|
|
19
|
+
const VERSION = '5.0.0';
|
|
20
20
|
// ---- Global Error Handlers (prevent crashes) ----
|
|
21
21
|
process.on('unhandledRejection', (reason) => {
|
|
22
22
|
console.error(chalk.red('\n⚠️ Unhandled promise rejection:'), reason);
|
package/dist/ui/renderer.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export declare class TerminalUI {
|
|
|
21
21
|
approvalRequest(toolName: string, args: Record<string, unknown>, risk: 'low' | 'medium' | 'high'): boolean;
|
|
22
22
|
tokenUsage(usage: TokenUsage, modelId: string): void;
|
|
23
23
|
sessionStats(totalInput: number, totalOutput: number, totalCost: number): void;
|
|
24
|
-
agentActivity(agentName: string, status: 'starting' | 'working' | 'done' | 'error'): void;
|
|
24
|
+
agentActivity(agentName: string, status: 'starting' | 'working' | 'done' | 'error', detail?: string): void;
|
|
25
25
|
error(message: string): void;
|
|
26
26
|
info(message: string): void;
|
|
27
27
|
success(message: string): void;
|
package/dist/ui/renderer.js
CHANGED
|
@@ -165,7 +165,7 @@ export class TerminalUI {
|
|
|
165
165
|
console.log(` ${this.theme.muted('Session:')}`, this.theme.dim(`${totalInput.toLocaleString()} in · ${totalOutput.toLocaleString()} out · $${totalCost.toFixed(4)}`));
|
|
166
166
|
}
|
|
167
167
|
// ── Agent Activity (subtle indicator) ───────────────────
|
|
168
|
-
agentActivity(agentName, status) {
|
|
168
|
+
agentActivity(agentName, status, detail) {
|
|
169
169
|
const indicators = {
|
|
170
170
|
starting: this.theme.muted('○'),
|
|
171
171
|
working: this.theme.accent('◎'),
|
|
@@ -178,7 +178,8 @@ export class TerminalUI {
|
|
|
178
178
|
done: 'done',
|
|
179
179
|
error: 'failed',
|
|
180
180
|
};
|
|
181
|
-
|
|
181
|
+
const detailStr = detail ? ` ${this.theme.dim(detail)}` : '';
|
|
182
|
+
console.log(` ${indicators[status]} ${this.theme.muted(agentName)} ${this.theme.dim(statusText[status])}${detailStr}`);
|
|
182
183
|
}
|
|
183
184
|
// ── Status Messages (minimal) ───────────────────────────
|
|
184
185
|
error(message) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuro-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.1",
|
|
4
4
|
"description": "Advanced AI-powered terminal coding assistant with multi-agent orchestration, sub-agent spawning, ACP protocol, OS-level sandboxing, spec-driven development, smart monitoring, multi-model routing, MCP Apps, auto-updater, and 23+ free models",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|