neoagent 3.2.1-beta.6 → 3.2.1-beta.8
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 +2 -2
- package/server/http/routes.js +7 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +5 -5
- package/server/services/ai/capabilityHealth.js +62 -96
- package/server/services/ai/loop/completion_judge.js +16 -7
- package/server/services/ai/loop/conversation_loop.js +131 -122
- package/server/services/ai/model_failure_cache.js +108 -0
- package/server/services/ai/models.js +1 -1
- package/server/services/ai/provider_selector.js +58 -3
- package/server/services/ai/providers/google.js +112 -92
- package/server/services/ai/terminal_reply.js +13 -1
- package/server/services/runtime/docker-vm-manager.js +9 -0
- package/server/services/runtime/guest_bootstrap.js +7 -0
- package/server/services/runtime/guest_image.js +39 -9
- package/server/services/runtime/manager.js +35 -0
|
@@ -59,6 +59,11 @@ const {
|
|
|
59
59
|
normalizeModelSelections,
|
|
60
60
|
resolveModelSelection,
|
|
61
61
|
} = require('../model_identity');
|
|
62
|
+
const {
|
|
63
|
+
isModelCoolingDown,
|
|
64
|
+
recordModelFailure,
|
|
65
|
+
recordModelSuccess,
|
|
66
|
+
} = require('../model_failure_cache');
|
|
62
67
|
const { getProviderForUser } = require('../provider_selector');
|
|
63
68
|
const { IterationBudget } = require('./iteration_budget');
|
|
64
69
|
const {
|
|
@@ -417,11 +422,21 @@ async function getFailureFallbackModelId(
|
|
|
417
422
|
preferredFallbackId = null,
|
|
418
423
|
failureError = null,
|
|
419
424
|
signal = null,
|
|
425
|
+
excludedModelIds = [],
|
|
420
426
|
) {
|
|
421
427
|
const { getSupportedModels } = require('../models');
|
|
422
428
|
const aiSettings = getAiSettings(userId, agentId);
|
|
423
429
|
const models = await getSupportedModels(userId, agentId, { signal });
|
|
424
|
-
const
|
|
430
|
+
const excluded = new Set(
|
|
431
|
+
[...excludedModelIds]
|
|
432
|
+
.map((id) => String(id || '').trim())
|
|
433
|
+
.filter(Boolean),
|
|
434
|
+
);
|
|
435
|
+
const availableModels = models.filter(
|
|
436
|
+
(model) => model.available !== false
|
|
437
|
+
&& !isModelCoolingDown(userId, agentId, model.id)
|
|
438
|
+
&& !excluded.has(model.id),
|
|
439
|
+
);
|
|
425
440
|
const configuredEnabledIds = Array.isArray(aiSettings.enabled_models)
|
|
426
441
|
? aiSettings.enabled_models.map((id) => String(id).trim()).filter(Boolean)
|
|
427
442
|
: [];
|
|
@@ -430,15 +445,26 @@ async function getFailureFallbackModelId(
|
|
|
430
445
|
? availableModels.filter((model) => enabledIds.includes(model.id))
|
|
431
446
|
: availableModels;
|
|
432
447
|
const fallbackSearchPool = pool;
|
|
433
|
-
const currentModel = resolveModelSelection(
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
|
|
439
|
-
|
|
448
|
+
const currentModel = resolveModelSelection(models, currentModelId);
|
|
449
|
+
|
|
450
|
+
// Provider-wide failures (credentials, throttling, or service outages) are
|
|
451
|
+
// likely to affect another model on the same provider. Prefer a different
|
|
452
|
+
// provider before consulting the configured fallback.
|
|
453
|
+
const failureStatus = Number(
|
|
454
|
+
failureError?.status
|
|
455
|
+
?? failureError?.statusCode
|
|
456
|
+
?? failureError?.response?.status,
|
|
457
|
+
);
|
|
458
|
+
const isProviderScopedFailure = (
|
|
459
|
+
failureStatus === 401
|
|
460
|
+
|| failureStatus === 403
|
|
461
|
+
|| failureStatus === 429
|
|
462
|
+
|| (failureStatus >= 500 && failureStatus < 600)
|
|
463
|
+
|| /rate.?limit|free-models-per|service unavailable|provider unavailable|authentication|api key/i
|
|
464
|
+
.test(String(failureError?.message || ''))
|
|
465
|
+
);
|
|
440
466
|
|
|
441
|
-
if (preferredFallbackId && !
|
|
467
|
+
if (preferredFallbackId && !isProviderScopedFailure) {
|
|
442
468
|
const preferred = resolveModelSelection(fallbackSearchPool, preferredFallbackId)
|
|
443
469
|
|| resolveModelSelection(availableModels, preferredFallbackId);
|
|
444
470
|
if (preferred && preferred.id !== currentModel?.id) return preferred.id;
|
|
@@ -450,8 +476,7 @@ async function getFailureFallbackModelId(
|
|
|
450
476
|
if (differentProvider) return differentProvider.id;
|
|
451
477
|
}
|
|
452
478
|
|
|
453
|
-
// If no different-provider model exists, still try the preferred fallback
|
|
454
|
-
// even on rate limits (it's better than nothing).
|
|
479
|
+
// If no different-provider model exists, still try the preferred fallback.
|
|
455
480
|
if (preferredFallbackId) {
|
|
456
481
|
const preferred = resolveModelSelection(fallbackSearchPool, preferredFallbackId)
|
|
457
482
|
|| resolveModelSelection(availableModels, preferredFallbackId);
|
|
@@ -483,6 +508,7 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
483
508
|
let model = null;
|
|
484
509
|
let modelSelectionId = null;
|
|
485
510
|
let providerName = null;
|
|
511
|
+
const modelTurnFailedModelSelectionIds = new Set();
|
|
486
512
|
let messages = [];
|
|
487
513
|
let iteration = 0;
|
|
488
514
|
let totalTokens = 0;
|
|
@@ -598,7 +624,17 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
598
624
|
}
|
|
599
625
|
db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
|
600
626
|
.run(modelSelectionId, runId);
|
|
601
|
-
const
|
|
627
|
+
const recordFailedModel = (failedSelectionId, error, excludedModels) => {
|
|
628
|
+
excludedModels.add(failedSelectionId);
|
|
629
|
+
recordModelFailure(userId, agentId, failedSelectionId, error);
|
|
630
|
+
};
|
|
631
|
+
const switchToFallbackModel = async (
|
|
632
|
+
failedSelectionId,
|
|
633
|
+
error,
|
|
634
|
+
phase,
|
|
635
|
+
excludedModels,
|
|
636
|
+
) => {
|
|
637
|
+
recordFailedModel(failedSelectionId, error, excludedModels);
|
|
602
638
|
const fallbackModelId = await getFailureFallbackModelId(
|
|
603
639
|
userId,
|
|
604
640
|
agentId,
|
|
@@ -606,9 +642,21 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
606
642
|
aiSettings.fallback_model_id,
|
|
607
643
|
error,
|
|
608
644
|
engine.getRunMeta(runId)?.abortController?.signal,
|
|
645
|
+
excludedModels,
|
|
609
646
|
);
|
|
610
647
|
if (!fallbackModelId || fallbackModelId === failedSelectionId) return false;
|
|
611
|
-
|
|
648
|
+
const failureSummary = summarizeForLog(error?.message || error, 180);
|
|
649
|
+
console.log(
|
|
650
|
+
`[Engine] ${phase} failed on ${failedSelectionId}: ${failureSummary}; attempting fallback to: ${fallbackModelId}`
|
|
651
|
+
);
|
|
652
|
+
engine.recordRunEvent(userId, runId, 'model_fallback', {
|
|
653
|
+
phase,
|
|
654
|
+
failedModel: failedSelectionId,
|
|
655
|
+
fallbackModel: fallbackModelId,
|
|
656
|
+
errorCode: error?.code || null,
|
|
657
|
+
errorStatus: error?.status || error?.statusCode || error?.response?.status || null,
|
|
658
|
+
error: failureSummary,
|
|
659
|
+
}, { agentId });
|
|
612
660
|
engine.emit(userId, 'run:interim', {
|
|
613
661
|
runId,
|
|
614
662
|
message: `Model service failed on ${failedSelectionId}; retrying with ${fallbackModelId}.`,
|
|
@@ -638,13 +686,28 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
638
686
|
return true;
|
|
639
687
|
};
|
|
640
688
|
const runWithModelFallback = async (phase, fn) => {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
689
|
+
let recoveries = 0;
|
|
690
|
+
const failedModels = new Set();
|
|
691
|
+
while (true) {
|
|
692
|
+
try {
|
|
693
|
+
const result = await fn();
|
|
694
|
+
recordModelSuccess(userId, agentId, modelSelectionId);
|
|
695
|
+
return result;
|
|
696
|
+
} catch (err) {
|
|
697
|
+
if (recoveries >= loopPolicy.maxModelFailureRecoveries) {
|
|
698
|
+
recordFailedModel(modelSelectionId, err, failedModels);
|
|
699
|
+
throw err;
|
|
700
|
+
}
|
|
701
|
+
const failedSelectionId = modelSelectionId;
|
|
702
|
+
const switched = await switchToFallbackModel(
|
|
703
|
+
failedSelectionId,
|
|
704
|
+
err,
|
|
705
|
+
phase,
|
|
706
|
+
failedModels,
|
|
707
|
+
);
|
|
708
|
+
if (!switched) throw err;
|
|
709
|
+
recoveries += 1;
|
|
710
|
+
}
|
|
648
711
|
}
|
|
649
712
|
};
|
|
650
713
|
|
|
@@ -1470,66 +1533,59 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
1470
1533
|
let responseModel = model;
|
|
1471
1534
|
let streamContent = '';
|
|
1472
1535
|
|
|
1473
|
-
const tryModelCall = async (
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1536
|
+
const tryModelCall = async () => {
|
|
1537
|
+
let requestMessages = messages;
|
|
1538
|
+
let requestPhase = 'model_turn';
|
|
1539
|
+
while (true) {
|
|
1540
|
+
try {
|
|
1541
|
+
const modelCall = await engine.requestModelResponse({
|
|
1542
|
+
provider,
|
|
1543
|
+
providerName,
|
|
1544
|
+
model,
|
|
1545
|
+
messages: requestMessages,
|
|
1546
|
+
tools,
|
|
1547
|
+
options: {
|
|
1548
|
+
...options,
|
|
1549
|
+
userId,
|
|
1550
|
+
agentId,
|
|
1551
|
+
runId,
|
|
1552
|
+
phase: requestPhase,
|
|
1553
|
+
signal: engine.getRunMeta(runId)?.abortController?.signal,
|
|
1554
|
+
},
|
|
1485
1555
|
runId,
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1556
|
+
iteration,
|
|
1557
|
+
});
|
|
1558
|
+
response = modelCall.response;
|
|
1559
|
+
responseModel = modelCall.responseModel;
|
|
1560
|
+
streamContent = modelCall.streamContent;
|
|
1561
|
+
recordModelSuccess(userId, agentId, modelSelectionId);
|
|
1562
|
+
return;
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
console.error(`[Engine] Model call failed (${model}):`, err.message);
|
|
1565
|
+
const runSignal = engine.getRunMeta(runId)?.abortController?.signal;
|
|
1566
|
+
if (isAbortError(err) || runSignal?.aborted) throw err;
|
|
1567
|
+
if (modelFailureRecoveries >= loopPolicy.maxModelFailureRecoveries) {
|
|
1568
|
+
recordFailedModel(
|
|
1569
|
+
modelSelectionId,
|
|
1570
|
+
err,
|
|
1571
|
+
modelTurnFailedModelSelectionIds,
|
|
1572
|
+
);
|
|
1573
|
+
throw err;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const failedModel = model;
|
|
1577
|
+
const switched = await switchToFallbackModel(
|
|
1501
1578
|
modelSelectionId,
|
|
1502
|
-
aiSettings.fallback_model_id,
|
|
1503
1579
|
err,
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
: null;
|
|
1507
|
-
if (fallbackModelId) {
|
|
1508
|
-
const failedModel = model;
|
|
1509
|
-
console.log(`[Engine] Attempting fallback to: ${fallbackModelId}`);
|
|
1510
|
-
const fallback = await getProviderForUser(
|
|
1511
|
-
userId,
|
|
1512
|
-
userMessage,
|
|
1513
|
-
triggerType === 'subagent',
|
|
1514
|
-
fallbackModelId,
|
|
1515
|
-
{
|
|
1516
|
-
...providerStatusConfig,
|
|
1517
|
-
signal: engine.getRunMeta(runId)?.abortController?.signal,
|
|
1518
|
-
}
|
|
1580
|
+
'model turn',
|
|
1581
|
+
modelTurnFailedModelSelectionIds,
|
|
1519
1582
|
);
|
|
1520
|
-
|
|
1521
|
-
model = fallback.model;
|
|
1522
|
-
modelSelectionId = fallback.modelSelectionId;
|
|
1523
|
-
providerName = fallback.providerName;
|
|
1524
|
-
db.prepare('UPDATE agent_runs SET model = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
|
1525
|
-
.run(modelSelectionId, runId);
|
|
1526
|
-
Object.assign(engine.getRunMeta(runId) || {}, {
|
|
1527
|
-
model,
|
|
1528
|
-
modelSelectionId,
|
|
1529
|
-
providerName,
|
|
1530
|
-
});
|
|
1583
|
+
if (!switched) throw err;
|
|
1531
1584
|
|
|
1532
|
-
|
|
1585
|
+
modelFailureRecoveries += 1;
|
|
1586
|
+
failedStepCount += 1;
|
|
1587
|
+
requestPhase = 'model_turn_fallback';
|
|
1588
|
+
requestMessages = sanitizeConversationMessages([
|
|
1533
1589
|
...messages,
|
|
1534
1590
|
{
|
|
1535
1591
|
role: 'system',
|
|
@@ -1540,29 +1596,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
1540
1596
|
})
|
|
1541
1597
|
}
|
|
1542
1598
|
]);
|
|
1543
|
-
|
|
1544
|
-
const fallbackCall = await engine.requestModelResponse({
|
|
1545
|
-
provider,
|
|
1546
|
-
providerName,
|
|
1547
|
-
model,
|
|
1548
|
-
messages: retryMessages,
|
|
1549
|
-
tools,
|
|
1550
|
-
options: {
|
|
1551
|
-
...options,
|
|
1552
|
-
userId,
|
|
1553
|
-
agentId,
|
|
1554
|
-
runId,
|
|
1555
|
-
phase: 'model_turn_fallback',
|
|
1556
|
-
signal: engine.getRunMeta(runId)?.abortController?.signal,
|
|
1557
|
-
},
|
|
1558
|
-
runId,
|
|
1559
|
-
iteration,
|
|
1560
|
-
});
|
|
1561
|
-
response = fallbackCall.response;
|
|
1562
|
-
responseModel = fallbackCall.responseModel;
|
|
1563
|
-
streamContent = fallbackCall.streamContent;
|
|
1564
|
-
} else {
|
|
1565
|
-
throw err;
|
|
1566
1599
|
}
|
|
1567
1600
|
}
|
|
1568
1601
|
};
|
|
@@ -1585,30 +1618,6 @@ async function runConversation(engine, userId, userMessage, options = {}, _model
|
|
|
1585
1618
|
continue;
|
|
1586
1619
|
}
|
|
1587
1620
|
if (lifecycleControl?.action === 'stop' || lifecycleControl?.action === 'interrupt') break;
|
|
1588
|
-
const modelError = String(err?.message || 'Model call failed');
|
|
1589
|
-
|
|
1590
|
-
if (modelFailureRecoveries < loopPolicy.maxModelFailureRecoveries) {
|
|
1591
|
-
const failedModel = model;
|
|
1592
|
-
const switched = await switchToFallbackModel(modelSelectionId, err, 'model turn');
|
|
1593
|
-
if (!switched) throw err;
|
|
1594
|
-
modelFailureRecoveries += 1;
|
|
1595
|
-
failedStepCount += 1;
|
|
1596
|
-
messages.push({
|
|
1597
|
-
role: 'system',
|
|
1598
|
-
content: buildModelFailureLoopPrompt({
|
|
1599
|
-
failedModel,
|
|
1600
|
-
nextModel: model,
|
|
1601
|
-
errorMessage: modelError
|
|
1602
|
-
})
|
|
1603
|
-
});
|
|
1604
|
-
engine.emit(userId, 'run:interim', {
|
|
1605
|
-
runId,
|
|
1606
|
-
message: 'Model call failed; adapting and retrying autonomously.',
|
|
1607
|
-
phase: 'recovering'
|
|
1608
|
-
});
|
|
1609
|
-
continue;
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
1621
|
throw err;
|
|
1613
1622
|
}
|
|
1614
1623
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isAbortError } = require('../../utils/abort');
|
|
4
|
+
const {
|
|
5
|
+
getErrorCode,
|
|
6
|
+
getHttpStatus,
|
|
7
|
+
isTransientIoError,
|
|
8
|
+
retryAfterMilliseconds,
|
|
9
|
+
} = require('../../utils/retry');
|
|
10
|
+
|
|
11
|
+
const DEFAULT_COOLDOWN_MS = 15 * 60 * 1000;
|
|
12
|
+
const DEFAULT_RECOVERY_COOLDOWN_MS = 60 * 1000;
|
|
13
|
+
const failures = new Map();
|
|
14
|
+
|
|
15
|
+
function cacheKey(userId, agentId, modelSelectionId) {
|
|
16
|
+
return [
|
|
17
|
+
String(userId ?? ''),
|
|
18
|
+
String(agentId ?? 'main'),
|
|
19
|
+
String(modelSelectionId || '').trim(),
|
|
20
|
+
].join(':');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readPermanentCooldownMs() {
|
|
24
|
+
const configured = Number(process.env.NEOAGENT_MODEL_NOT_FOUND_COOLDOWN_MS);
|
|
25
|
+
if (!Number.isFinite(configured)) return DEFAULT_COOLDOWN_MS;
|
|
26
|
+
return Math.max(1_000, Math.min(configured, 24 * 60 * 60 * 1000));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readRecoveryCooldownMs() {
|
|
30
|
+
const configured = Number(process.env.NEOAGENT_MODEL_RECOVERY_COOLDOWN_MS);
|
|
31
|
+
if (!Number.isFinite(configured)) return DEFAULT_RECOVERY_COOLDOWN_MS;
|
|
32
|
+
return Math.max(1_000, Math.min(configured, 15 * 60 * 1000));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPermanentModelFailure(error) {
|
|
36
|
+
const status = getHttpStatus(error);
|
|
37
|
+
if (status === 404) return true;
|
|
38
|
+
return /\b404\b.*\b(model|nim|provider|request)\b|\b(model|nim)\b.*\b404\b/i
|
|
39
|
+
.test(String(error?.message || ''));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isRecoverableModelFailure(error) {
|
|
43
|
+
if (!error || isAbortError(error) || isPermanentModelFailure(error)) return false;
|
|
44
|
+
const status = getHttpStatus(error);
|
|
45
|
+
if (status === 401 || status === 403 || status === 429 || (status >= 500 && status < 600)) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const code = String(getErrorCode(error) || '');
|
|
49
|
+
if (code === 'MODEL_EMPTY_RESPONSE' || code === 'MODEL_CALL_TIMEOUT') return true;
|
|
50
|
+
if (isTransientIoError(error)) return true;
|
|
51
|
+
return /\bempty response|temporarily unavailable|overloaded|rate.?limit|timed? ?out|network error\b/i
|
|
52
|
+
.test(String(error?.message || ''));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resolveCooldownMs(error, now) {
|
|
56
|
+
if (isPermanentModelFailure(error)) return readPermanentCooldownMs();
|
|
57
|
+
if (!isRecoverableModelFailure(error)) return 0;
|
|
58
|
+
|
|
59
|
+
const configured = readRecoveryCooldownMs();
|
|
60
|
+
const retryAfter = retryAfterMilliseconds(
|
|
61
|
+
error?.headers || error?.response?.headers,
|
|
62
|
+
now,
|
|
63
|
+
);
|
|
64
|
+
if (!Number.isFinite(retryAfter)) return configured;
|
|
65
|
+
return Math.max(configured, Math.min(retryAfter, 15 * 60 * 1000));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function recordModelFailure(userId, agentId, modelSelectionId, error, now = Date.now()) {
|
|
69
|
+
const modelId = String(modelSelectionId || '').trim();
|
|
70
|
+
const cooldownMs = resolveCooldownMs(error, now);
|
|
71
|
+
if (!modelId || cooldownMs <= 0) return false;
|
|
72
|
+
failures.set(cacheKey(userId, agentId, modelId), {
|
|
73
|
+
expiresAt: now + cooldownMs,
|
|
74
|
+
});
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function recordModelSuccess(userId, agentId, modelSelectionId) {
|
|
79
|
+
const modelId = String(modelSelectionId || '').trim();
|
|
80
|
+
if (!modelId) return false;
|
|
81
|
+
return failures.delete(cacheKey(userId, agentId, modelId));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isModelCoolingDown(userId, agentId, modelSelectionId, now = Date.now()) {
|
|
85
|
+
const modelId = String(modelSelectionId || '').trim();
|
|
86
|
+
if (!modelId) return false;
|
|
87
|
+
const key = cacheKey(userId, agentId, modelId);
|
|
88
|
+
const entry = failures.get(key);
|
|
89
|
+
if (!entry) return false;
|
|
90
|
+
if (entry.expiresAt <= now) {
|
|
91
|
+
failures.delete(key);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function clearModelFailureCache() {
|
|
98
|
+
failures.clear();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
clearModelFailureCache,
|
|
103
|
+
isModelCoolingDown,
|
|
104
|
+
isPermanentModelFailure,
|
|
105
|
+
isRecoverableModelFailure,
|
|
106
|
+
recordModelFailure,
|
|
107
|
+
recordModelSuccess,
|
|
108
|
+
};
|
|
@@ -264,7 +264,7 @@ async function getProviderHealthCatalog(userId, agentId = null, options = {}) {
|
|
|
264
264
|
let statusLabel = provider.statusLabel;
|
|
265
265
|
let availabilityReason = provider.availabilityReason;
|
|
266
266
|
|
|
267
|
-
if (provider.id === 'ollama' && provider.enabled) {
|
|
267
|
+
if (provider.id === 'ollama' && provider.enabled && options.probeLocal !== false) {
|
|
268
268
|
const probe = await probeOllama(
|
|
269
269
|
provider.baseUrl || AI_PROVIDER_DEFINITIONS.ollama.defaultBaseUrl,
|
|
270
270
|
1500,
|
|
@@ -7,6 +7,7 @@ const {
|
|
|
7
7
|
normalizeModelSelections,
|
|
8
8
|
resolveModelSelection,
|
|
9
9
|
} = require('./model_identity');
|
|
10
|
+
const { isModelCoolingDown } = require('./model_failure_cache');
|
|
10
11
|
|
|
11
12
|
function buildSelection(model, userId, providerConfig) {
|
|
12
13
|
return {
|
|
@@ -30,12 +31,29 @@ async function getProviderForUser(
|
|
|
30
31
|
signal: providerConfig.signal,
|
|
31
32
|
});
|
|
32
33
|
const selectableModels = models.filter((model) => model.available !== false);
|
|
34
|
+
const readyModels = selectableModels.filter(
|
|
35
|
+
(model) => !isModelCoolingDown(userId, agentId, model.id),
|
|
36
|
+
);
|
|
33
37
|
|
|
34
38
|
if (modelOverride && typeof modelOverride === 'string') {
|
|
35
39
|
const requested = resolveModelSelection(selectableModels, modelOverride);
|
|
36
40
|
if (!requested) {
|
|
37
41
|
throw new Error(`Requested model '${modelOverride.trim()}' is not available.`);
|
|
38
42
|
}
|
|
43
|
+
if (isModelCoolingDown(userId, agentId, requested.id) && readyModels.length > 0) {
|
|
44
|
+
const configuredFallback = resolveModelSelection(
|
|
45
|
+
readyModels,
|
|
46
|
+
aiSettings.fallback_model_id,
|
|
47
|
+
);
|
|
48
|
+
const fallback = configuredFallback
|
|
49
|
+
|| readyModels.find((model) => model.provider !== requested.provider)
|
|
50
|
+
|| readyModels[0];
|
|
51
|
+
providerConfig.onStatus?.({
|
|
52
|
+
phase: 'model_fallback',
|
|
53
|
+
message: `Skipping recently unavailable model ${requested.id}; using ${fallback.id}.`,
|
|
54
|
+
});
|
|
55
|
+
return buildSelection(fallback, userId, providerConfig);
|
|
56
|
+
}
|
|
39
57
|
return buildSelection(requested, userId, providerConfig);
|
|
40
58
|
}
|
|
41
59
|
|
|
@@ -43,9 +61,35 @@ async function getProviderForUser(
|
|
|
43
61
|
? aiSettings.enabled_models.map((id) => String(id).trim()).filter(Boolean)
|
|
44
62
|
: [];
|
|
45
63
|
const enabledIds = normalizeModelSelections(selectableModels, configuredEnabledIds);
|
|
46
|
-
|
|
47
|
-
?
|
|
48
|
-
:
|
|
64
|
+
let availableModels = configuredEnabledIds.length > 0
|
|
65
|
+
? readyModels.filter((model) => enabledIds.includes(model.id))
|
|
66
|
+
: readyModels;
|
|
67
|
+
|
|
68
|
+
if (
|
|
69
|
+
availableModels.length === 0
|
|
70
|
+
&& configuredEnabledIds.length > 0
|
|
71
|
+
&& enabledIds.length > 0
|
|
72
|
+
) {
|
|
73
|
+
const configuredFallback = resolveModelSelection(
|
|
74
|
+
readyModels,
|
|
75
|
+
aiSettings.fallback_model_id,
|
|
76
|
+
);
|
|
77
|
+
if (configuredFallback) {
|
|
78
|
+
providerConfig.onStatus?.({
|
|
79
|
+
phase: 'model_fallback',
|
|
80
|
+
message: `Enabled models are temporarily unavailable; using ${configuredFallback.id}.`,
|
|
81
|
+
});
|
|
82
|
+
availableModels = [configuredFallback];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// If every configured model is cooling down and there is no alternative,
|
|
87
|
+
// allow one retry instead of making the installation permanently unavailable.
|
|
88
|
+
if (availableModels.length === 0 && readyModels.length === 0) {
|
|
89
|
+
availableModels = configuredEnabledIds.length > 0
|
|
90
|
+
? selectableModels.filter((model) => enabledIds.includes(model.id))
|
|
91
|
+
: selectableModels;
|
|
92
|
+
}
|
|
49
93
|
|
|
50
94
|
if (availableModels.length === 0) {
|
|
51
95
|
const message = configuredEnabledIds.length > 0
|
|
@@ -64,6 +108,17 @@ async function getProviderForUser(
|
|
|
64
108
|
let selectedModel = fallbackModel;
|
|
65
109
|
if (userSelectedDefault !== 'auto') {
|
|
66
110
|
selectedModel = resolveModelSelection(availableModels, userSelectedDefault) || fallbackModel;
|
|
111
|
+
const configuredDefault = resolveModelSelection(selectableModels, userSelectedDefault);
|
|
112
|
+
if (
|
|
113
|
+
configuredDefault
|
|
114
|
+
&& configuredDefault.id !== selectedModel.id
|
|
115
|
+
&& isModelCoolingDown(userId, agentId, configuredDefault.id)
|
|
116
|
+
) {
|
|
117
|
+
providerConfig.onStatus?.({
|
|
118
|
+
phase: 'model_fallback',
|
|
119
|
+
message: `Skipping recently unavailable model ${configuredDefault.id}; using ${selectedModel.id}.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
67
122
|
} else {
|
|
68
123
|
const selectionHint = providerConfig.selectionHint && typeof providerConfig.selectionHint === 'object'
|
|
69
124
|
? providerConfig.selectionHint
|