neoagent 3.2.1-beta.1 → 3.2.1-beta.3
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/extensions/chrome-browser/background.mjs +318 -88
- package/extensions/chrome-browser/http.mjs +136 -0
- package/extensions/chrome-browser/protocol.mjs +511 -89
- package/flutter_app/lib/main_chat.dart +118 -739
- package/flutter_app/lib/main_controller.dart +29 -20
- package/flutter_app/lib/main_models.dart +3 -0
- package/flutter_app/lib/main_operations.dart +334 -321
- package/flutter_app/lib/main_settings.dart +4 -3
- package/flutter_app/lib/main_shared.dart +14 -11
- package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
- package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
- package/flutter_app/windows/runner/flutter_window.cpp +143 -32
- package/lib/manager.js +106 -89
- package/lib/schema_migrations.js +67 -13
- package/package.json +20 -13
- package/runtime/paths.js +49 -5
- package/server/guest_agent.js +52 -30
- package/server/http/middleware.js +24 -0
- package/server/http/routes.js +4 -6
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +30803 -30805
- package/server/routes/admin.js +1 -1
- package/server/routes/android.js +30 -34
- package/server/routes/browser.js +23 -15
- package/server/routes/desktop.js +18 -1
- package/server/routes/integrations.js +5 -1
- package/server/routes/memory.js +1 -0
- package/server/routes/settings.js +16 -5
- package/server/routes/social_reach.js +12 -3
- package/server/routes/social_video.js +4 -0
- package/server/services/ai/compaction.js +7 -2
- package/server/services/ai/history.js +44 -5
- package/server/services/ai/integrated_tools/http_request.js +8 -0
- package/server/services/ai/loop/agent_engine_core.js +358 -162
- package/server/services/ai/loop/callbacks.js +1 -0
- package/server/services/ai/loop/completion_judge.js +12 -0
- package/server/services/ai/loop/conversation_loop.js +348 -242
- package/server/services/ai/loop/messaging_delivery.js +129 -57
- package/server/services/ai/loop/model_call_guard.js +91 -0
- package/server/services/ai/loop/model_io.js +20 -45
- package/server/services/ai/loop/tool_dispatch.js +19 -8
- package/server/services/ai/loopPolicy.js +24 -19
- package/server/services/ai/model_discovery.js +227 -0
- package/server/services/ai/model_identity.js +71 -0
- package/server/services/ai/models.js +67 -162
- package/server/services/ai/providerRetry.js +17 -59
- package/server/services/ai/provider_selector.js +111 -0
- package/server/services/ai/providers/anthropic.js +2 -2
- package/server/services/ai/providers/claudeCode.js +21 -33
- package/server/services/ai/providers/githubCopilot.js +41 -20
- package/server/services/ai/providers/google.js +23 -5
- package/server/services/ai/providers/grok.js +4 -3
- package/server/services/ai/providers/grokOauth.js +19 -27
- package/server/services/ai/providers/nvidia.js +10 -5
- package/server/services/ai/providers/ollama.js +111 -84
- package/server/services/ai/providers/ollama_stream.js +142 -0
- package/server/services/ai/providers/openai.js +39 -5
- package/server/services/ai/providers/openaiCodex.js +11 -4
- package/server/services/ai/providers/openrouter.js +29 -7
- package/server/services/ai/providers/provider_error.js +36 -0
- package/server/services/ai/settings.js +26 -2
- package/server/services/ai/taskAnalysis.js +5 -1
- package/server/services/ai/terminal_reply.js +45 -0
- package/server/services/ai/toolEvidence.js +58 -29
- package/server/services/ai/tools.js +124 -111
- package/server/services/android/controller.js +770 -237
- package/server/services/android/process.js +140 -0
- package/server/services/android/sdk_download.js +143 -0
- package/server/services/android/uia.js +6 -5
- package/server/services/artifacts/store.js +24 -0
- package/server/services/browser/controller.js +736 -385
- package/server/services/browser/extension/gateway.js +40 -16
- package/server/services/browser/extension/protocol.js +12 -1
- package/server/services/browser/extension/provider.js +59 -47
- package/server/services/browser/extension/registry.js +155 -34
- package/server/services/cli/executor.js +62 -9
- package/server/services/desktop/gateway.js +41 -4
- package/server/services/desktop/protocol.js +3 -0
- package/server/services/desktop/provider.js +39 -42
- package/server/services/desktop/registry.js +137 -52
- package/server/services/integrations/figma/provider.js +78 -12
- package/server/services/integrations/github/common.js +11 -6
- package/server/services/integrations/github/provider.js +52 -53
- package/server/services/integrations/google/provider.js +55 -19
- package/server/services/integrations/home_assistant/network.js +17 -20
- package/server/services/integrations/home_assistant/provider.js +7 -5
- package/server/services/integrations/home_assistant/tools.js +17 -5
- package/server/services/integrations/http.js +51 -0
- package/server/services/integrations/manager.js +158 -53
- package/server/services/integrations/microsoft/provider.js +80 -13
- package/server/services/integrations/neoarchive/provider.js +55 -29
- package/server/services/integrations/neorecall/client.js +17 -10
- package/server/services/integrations/neorecall/provider.js +20 -11
- package/server/services/integrations/notion/provider.js +16 -13
- package/server/services/integrations/oauth_provider.js +115 -51
- package/server/services/integrations/slack/provider.js +98 -9
- package/server/services/integrations/spotify/provider.js +67 -71
- package/server/services/integrations/trello/provider.js +21 -7
- package/server/services/integrations/weather/provider.js +18 -12
- package/server/services/integrations/whatsapp/provider.js +76 -16
- package/server/services/manager.js +87 -1
- package/server/services/memory/embedding_index.js +20 -8
- package/server/services/memory/embeddings.js +151 -90
- package/server/services/memory/ingestion.js +50 -9
- package/server/services/memory/ingestion_documents.js +13 -3
- package/server/services/memory/manager.js +52 -19
- package/server/services/messaging/automation.js +84 -9
- package/server/services/messaging/http_platforms.js +33 -13
- package/server/services/messaging/inbound_queue.js +78 -24
- package/server/services/messaging/inbound_store.js +224 -0
- package/server/services/messaging/manager.js +326 -51
- package/server/services/messaging/typing_keepalive.js +5 -2
- package/server/services/network/http.js +210 -0
- package/server/services/network/safe_request.js +307 -0
- package/server/services/runtime/backends/local-vm.js +214 -66
- package/server/services/runtime/manager.js +17 -12
- package/server/services/social_reach/channels/github.js +10 -4
- package/server/services/social_reach/channels/reddit.js +4 -4
- package/server/services/social_reach/channels/rss.js +2 -2
- package/server/services/social_reach/channels/social_video.js +12 -7
- package/server/services/social_reach/channels/v2ex.js +21 -8
- package/server/services/social_reach/channels/x.js +2 -2
- package/server/services/social_reach/channels/xueqiu.js +5 -5
- package/server/services/social_reach/service.js +9 -6
- package/server/services/social_reach/utils.js +65 -14
- package/server/services/social_video/service.js +160 -50
- package/server/services/tasks/integration_runtime.js +18 -8
- package/server/services/tasks/runtime.js +39 -4
- package/server/services/voice/agentBridge.js +17 -4
- package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
- package/server/services/voice/liveSession.js +31 -0
- package/server/services/voice/openaiSpeech.js +33 -8
- package/server/services/voice/providers.js +233 -151
- package/server/services/voice/runtimeManager.js +118 -20
- package/server/services/voice/turnRunner.js +6 -0
- package/server/services/wearable/firmware_manifest.js +51 -13
- package/server/services/wearable/service.js +1 -0
- package/server/utils/abort.js +96 -0
- package/server/utils/cloud-security.js +110 -3
- package/server/utils/files.js +31 -0
- package/server/utils/image_payload.js +95 -0
- package/server/utils/retry.js +107 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
|
6
|
+
const PERMANENT_ERROR_BACKOFF_MS = 30 * 60 * 1000;
|
|
7
|
+
const DISCOVERY_TIMEOUT_MS = 10_000;
|
|
8
|
+
const OLLAMA_REFRESH_INTERVAL_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
const providerModelCache = new Map();
|
|
11
|
+
const providerRefreshes = new Map();
|
|
12
|
+
const ollamaModelCache = new Map();
|
|
13
|
+
const ollamaRefreshes = new Map();
|
|
14
|
+
const openrouterPricingCache = new Map();
|
|
15
|
+
|
|
16
|
+
function inferModelPurpose(id) {
|
|
17
|
+
const value = id.toLowerCase();
|
|
18
|
+
if (/flash|nano|lite|tiny|haiku|scout|mini(?!max)|small/.test(value)) return 'fast';
|
|
19
|
+
if (/r1|qwq|o[0-9]|reasoning|thinking/.test(value)) return 'planning';
|
|
20
|
+
if (/code|coder|starcoder|devstral|codex|codegemma/.test(value)) return 'coding';
|
|
21
|
+
return 'general';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PROVIDER_LABELS = Object.freeze({
|
|
25
|
+
openai: (model) => `${model.id} (OpenAI)`,
|
|
26
|
+
anthropic: (model) => `${model.name || model.id} (Anthropic)`,
|
|
27
|
+
google: (model) => `${model.name || model.id} (Google)`,
|
|
28
|
+
nvidia: (model) => `${model.id} (NVIDIA NIM)`,
|
|
29
|
+
grok: (model) => `${model.id} (xAI)`,
|
|
30
|
+
'grok-oauth': (model) => `${model.id} (xAI OAuth)`,
|
|
31
|
+
openrouter: (model) => `${model.name || model.id} (OpenRouter)`,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function cacheKeyForProvider(providerId, apiKey, baseUrl) {
|
|
35
|
+
return crypto
|
|
36
|
+
.createHash('sha256')
|
|
37
|
+
.update(String(providerId || ''))
|
|
38
|
+
.update('\0')
|
|
39
|
+
.update(String(apiKey || ''))
|
|
40
|
+
.update('\0')
|
|
41
|
+
.update(String(baseUrl || ''))
|
|
42
|
+
.digest('hex');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function abortError(signal, fallback = 'Model discovery aborted.') {
|
|
46
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
47
|
+
const error = new Error(String(signal?.reason || fallback));
|
|
48
|
+
error.name = 'AbortError';
|
|
49
|
+
error.code = 'ABORT_ERR';
|
|
50
|
+
return error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function waitForSharedResult(promise, signal) {
|
|
54
|
+
if (!signal) return promise;
|
|
55
|
+
if (signal.aborted) return Promise.reject(abortError(signal));
|
|
56
|
+
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const onAbort = () => reject(abortError(signal));
|
|
59
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
60
|
+
promise.then(resolve, reject).finally(() => {
|
|
61
|
+
signal.removeEventListener('abort', onAbort);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runDiscovery(factory, timeoutMs = DISCOVERY_TIMEOUT_MS) {
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
let timer = null;
|
|
69
|
+
const timeout = new Promise((_, reject) => {
|
|
70
|
+
timer = setTimeout(() => {
|
|
71
|
+
const error = new Error(`Model discovery timed out after ${timeoutMs}ms.`);
|
|
72
|
+
error.code = 'MODEL_DISCOVERY_TIMEOUT';
|
|
73
|
+
controller.abort(error);
|
|
74
|
+
reject(error);
|
|
75
|
+
}, timeoutMs);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
return await Promise.race([
|
|
80
|
+
Promise.resolve().then(() => factory(controller.signal)),
|
|
81
|
+
timeout,
|
|
82
|
+
]);
|
|
83
|
+
} finally {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeRawModels(rawModels, providerId, fallbackIds = []) {
|
|
89
|
+
const source = Array.isArray(rawModels) && rawModels.length > 0
|
|
90
|
+
? rawModels
|
|
91
|
+
: fallbackIds;
|
|
92
|
+
const labelModel = PROVIDER_LABELS[providerId] || ((model) => model.name || model.id);
|
|
93
|
+
const normalized = [];
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
|
|
96
|
+
for (const raw of source) {
|
|
97
|
+
const model = typeof raw === 'string' ? { id: raw, name: raw } : raw;
|
|
98
|
+
const id = String(model?.id || '').trim();
|
|
99
|
+
if (!id || seen.has(id)) continue;
|
|
100
|
+
seen.add(id);
|
|
101
|
+
normalized.push({
|
|
102
|
+
id,
|
|
103
|
+
label: labelModel({ ...model, id }),
|
|
104
|
+
provider: providerId,
|
|
105
|
+
purpose: inferModelPurpose(id),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return normalized;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function updateOpenRouterPricing(rawModels) {
|
|
112
|
+
if (!Array.isArray(rawModels)) return;
|
|
113
|
+
for (const model of rawModels) {
|
|
114
|
+
if (!model || typeof model !== 'object' || model.pricing?.prompt == null) continue;
|
|
115
|
+
const inputPerM = Number.parseFloat(model.pricing.prompt) * 1_000_000;
|
|
116
|
+
if (!Number.isFinite(inputPerM) || inputPerM < 0) continue;
|
|
117
|
+
openrouterPricingCache.set(model.id, inputPerM);
|
|
118
|
+
if (!model.id.includes('/')) continue;
|
|
119
|
+
const bareId = model.id.slice(model.id.indexOf('/') + 1);
|
|
120
|
+
if (!openrouterPricingCache.has(bareId)) {
|
|
121
|
+
openrouterPricingCache.set(bareId, inputPerM);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isPermanentDiscoveryError(error) {
|
|
127
|
+
return /401|403|unauthorized|forbidden|credits|spending/i.test(String(error?.message || ''));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function loadProviderModels({ providerId, factory, apiKey, baseUrl, existing }) {
|
|
131
|
+
let provider = null;
|
|
132
|
+
try {
|
|
133
|
+
const config = {};
|
|
134
|
+
if (factory.apiKey) config.apiKey = apiKey;
|
|
135
|
+
if (factory.baseUrl) config.baseUrl = baseUrl;
|
|
136
|
+
provider = new factory.Provider(config);
|
|
137
|
+
const rawModels = await runDiscovery((signal) => provider.listModels(signal));
|
|
138
|
+
if (providerId === 'openrouter') updateOpenRouterPricing(rawModels);
|
|
139
|
+
const models = normalizeRawModels(rawModels, providerId, provider.models);
|
|
140
|
+
const entry = { models, expiresAt: Date.now() + REFRESH_INTERVAL_MS };
|
|
141
|
+
return entry;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
console.warn(`[Models] Failed to refresh ${providerId} models:`, error.message);
|
|
144
|
+
const models = existing?.models?.length
|
|
145
|
+
? existing.models
|
|
146
|
+
: normalizeRawModels([], providerId, provider?.models || []);
|
|
147
|
+
const retryAfterMs = isPermanentDiscoveryError(error)
|
|
148
|
+
? PERMANENT_ERROR_BACKOFF_MS
|
|
149
|
+
: REFRESH_INTERVAL_MS;
|
|
150
|
+
return { models, expiresAt: Date.now() + retryAfterMs };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function refreshProviderModelList({ providerId, factory, apiKey, baseUrl, signal }) {
|
|
155
|
+
const cacheKey = cacheKeyForProvider(providerId, apiKey, baseUrl);
|
|
156
|
+
const existing = providerModelCache.get(cacheKey);
|
|
157
|
+
if (existing && existing.expiresAt > Date.now()) return existing.models;
|
|
158
|
+
|
|
159
|
+
let refresh = providerRefreshes.get(cacheKey);
|
|
160
|
+
if (!refresh) {
|
|
161
|
+
refresh = loadProviderModels({ providerId, factory, apiKey, baseUrl, existing })
|
|
162
|
+
.then((entry) => {
|
|
163
|
+
providerModelCache.set(cacheKey, entry);
|
|
164
|
+
return entry.models;
|
|
165
|
+
})
|
|
166
|
+
.finally(() => providerRefreshes.delete(cacheKey));
|
|
167
|
+
providerRefreshes.set(cacheKey, refresh);
|
|
168
|
+
}
|
|
169
|
+
return waitForSharedResult(refresh, signal);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function loadOllamaModels({ baseUrl, Provider, existing }) {
|
|
173
|
+
try {
|
|
174
|
+
const provider = new Provider({ baseUrl });
|
|
175
|
+
const rawModels = await runDiscovery((signal) => provider.listModels(signal));
|
|
176
|
+
const models = normalizeRawModels(rawModels, 'ollama').map((model) => ({
|
|
177
|
+
...model,
|
|
178
|
+
label: `${model.id} (Ollama / Local)`,
|
|
179
|
+
purpose: 'general',
|
|
180
|
+
}));
|
|
181
|
+
return { models, expiresAt: Date.now() + OLLAMA_REFRESH_INTERVAL_MS };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.warn('[Models] Failed to refresh Ollama models:', error.message);
|
|
184
|
+
return {
|
|
185
|
+
models: existing?.models || [],
|
|
186
|
+
expiresAt: Date.now() + OLLAMA_REFRESH_INTERVAL_MS,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function refreshOllamaModels({ baseUrl, Provider, signal }) {
|
|
192
|
+
const cacheKey = String(baseUrl || '');
|
|
193
|
+
const existing = ollamaModelCache.get(cacheKey);
|
|
194
|
+
if (existing && existing.expiresAt > Date.now()) return existing.models;
|
|
195
|
+
|
|
196
|
+
let refresh = ollamaRefreshes.get(cacheKey);
|
|
197
|
+
if (!refresh) {
|
|
198
|
+
refresh = loadOllamaModels({ baseUrl, Provider, existing })
|
|
199
|
+
.then((entry) => {
|
|
200
|
+
ollamaModelCache.set(cacheKey, entry);
|
|
201
|
+
return entry.models;
|
|
202
|
+
})
|
|
203
|
+
.finally(() => ollamaRefreshes.delete(cacheKey));
|
|
204
|
+
ollamaRefreshes.set(cacheKey, refresh);
|
|
205
|
+
}
|
|
206
|
+
return waitForSharedResult(refresh, signal);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function getInputCostPerM(modelId) {
|
|
210
|
+
return openrouterPricingCache.get(modelId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function classifyPriceTier(modelId) {
|
|
214
|
+
const costPerM = getInputCostPerM(modelId);
|
|
215
|
+
if (costPerM === undefined) return null;
|
|
216
|
+
if (costPerM === 0) return 'free';
|
|
217
|
+
if (costPerM < 0.5) return 'cheap';
|
|
218
|
+
if (costPerM < 5) return 'medium';
|
|
219
|
+
return 'expensive';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
classifyPriceTier,
|
|
224
|
+
getInputCostPerM,
|
|
225
|
+
refreshOllamaModels,
|
|
226
|
+
refreshProviderModelList,
|
|
227
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MODEL_SELECTION_SEPARATOR = '::';
|
|
4
|
+
|
|
5
|
+
function createModelSelectionId(provider, modelId) {
|
|
6
|
+
return `${String(provider).trim().toLowerCase()}${MODEL_SELECTION_SEPARATOR}${String(modelId).trim()}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getRawModelId(model) {
|
|
10
|
+
return String(model?.modelId || model?.id || '').trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toSelectableModel(model) {
|
|
14
|
+
const provider = String(model?.provider || '').trim().toLowerCase();
|
|
15
|
+
const modelId = getRawModelId(model);
|
|
16
|
+
return {
|
|
17
|
+
...model,
|
|
18
|
+
id: createModelSelectionId(provider, modelId),
|
|
19
|
+
modelId,
|
|
20
|
+
provider,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveModelSelection(models, value, options = {}) {
|
|
25
|
+
const requested = String(value || '').trim();
|
|
26
|
+
if (!requested) return null;
|
|
27
|
+
|
|
28
|
+
const exact = models.find((model) => model.id === requested);
|
|
29
|
+
if (exact) return exact;
|
|
30
|
+
|
|
31
|
+
const legacyMatches = models.filter((model) => getRawModelId(model) === requested);
|
|
32
|
+
if (legacyMatches.length <= 1) return legacyMatches[0] || null;
|
|
33
|
+
|
|
34
|
+
const preferredProvider = String(options.preferredProvider || '').trim().toLowerCase();
|
|
35
|
+
if (preferredProvider) {
|
|
36
|
+
const preferred = legacyMatches.find((model) => model.provider === preferredProvider);
|
|
37
|
+
if (preferred) return preferred;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Preserve the catalog's stable ordering for legacy, unscoped settings. New
|
|
41
|
+
// selections always use the provider-scoped id and are therefore unambiguous.
|
|
42
|
+
return legacyMatches[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeModelSelections(models, values) {
|
|
46
|
+
if (!Array.isArray(values)) return [];
|
|
47
|
+
const normalized = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const value of values) {
|
|
50
|
+
const model = resolveModelSelection(models, value);
|
|
51
|
+
if (!model || seen.has(model.id)) continue;
|
|
52
|
+
seen.add(model.id);
|
|
53
|
+
normalized.push(model.id);
|
|
54
|
+
}
|
|
55
|
+
return normalized;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function modelMatchesConfiguredId(model, configuredIds) {
|
|
59
|
+
if (!configuredIds) return false;
|
|
60
|
+
return configuredIds.has(model.id) || configuredIds.has(getRawModelId(model));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
MODEL_SELECTION_SEPARATOR,
|
|
65
|
+
createModelSelectionId,
|
|
66
|
+
getRawModelId,
|
|
67
|
+
modelMatchesConfiguredId,
|
|
68
|
+
normalizeModelSelections,
|
|
69
|
+
resolveModelSelection,
|
|
70
|
+
toSelectableModel,
|
|
71
|
+
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
1
3
|
const { AnthropicProvider } = require('./providers/anthropic');
|
|
2
4
|
const { GoogleProvider } = require('./providers/google');
|
|
3
5
|
const { GrokProvider } = require('./providers/grok');
|
|
@@ -14,6 +16,19 @@ const {
|
|
|
14
16
|
getProviderConfigs,
|
|
15
17
|
getProviderSecrets,
|
|
16
18
|
} = require('./settings');
|
|
19
|
+
const {
|
|
20
|
+
createModelSelectionId,
|
|
21
|
+
modelMatchesConfiguredId,
|
|
22
|
+
toSelectableModel,
|
|
23
|
+
} = require('./model_identity');
|
|
24
|
+
const {
|
|
25
|
+
classifyPriceTier,
|
|
26
|
+
getInputCostPerM,
|
|
27
|
+
refreshOllamaModels,
|
|
28
|
+
refreshProviderModelList,
|
|
29
|
+
} = require('./model_discovery');
|
|
30
|
+
const { fetchResponseText } = require('../network/http');
|
|
31
|
+
const { createAbortError, isAbortError, throwIfAborted } = require('../../utils/abort');
|
|
17
32
|
|
|
18
33
|
const STATIC_MODELS = [
|
|
19
34
|
// — xAI OAuth — fallback entries shown when grok-oauth token is invalid/exhausted.
|
|
@@ -130,127 +145,27 @@ const PROVIDER_FACTORIES = Object.freeze({
|
|
|
130
145
|
openrouter: { Provider: OpenRouterProvider, apiKey: true, baseUrl: true },
|
|
131
146
|
});
|
|
132
147
|
|
|
133
|
-
const dynamicModelsByBaseUrl = new Map();
|
|
134
|
-
const REFRESH_INTERVAL = 30000; // 30 seconds
|
|
135
|
-
|
|
136
|
-
// Unified dynamic model cache for all API-backed providers.
|
|
137
|
-
// Keyed by `${providerId}:${apiKey.slice(0,8)}` to handle per-user keys.
|
|
138
|
-
const providerModelCache = new Map();
|
|
139
|
-
const DYNAMIC_REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
|
140
|
-
|
|
141
|
-
// Populated from OpenRouter's /models response; used to price-classify models
|
|
142
|
-
// from all providers. Keyed by both the full OpenRouter ID ("openai/gpt-5-mini")
|
|
143
|
-
// and the bare model ID ("gpt-5-mini") for cross-provider lookup.
|
|
144
|
-
const openrouterPricingCache = new Map();
|
|
145
|
-
|
|
146
148
|
// Providers whose full model list is fetched from their API at runtime.
|
|
147
149
|
// grok-oauth inherits listModels() from GrokProvider and uses the same xAI endpoint.
|
|
148
150
|
const DYNAMIC_PROVIDERS = ['openai', 'anthropic', 'google', 'nvidia', 'grok', 'grok-oauth', 'openrouter'];
|
|
149
151
|
|
|
150
|
-
function
|
|
151
|
-
const s = id.toLowerCase();
|
|
152
|
-
if (/flash|nano|lite|tiny|haiku|scout|mini(?!max)|small/.test(s)) return 'fast';
|
|
153
|
-
if (/r1|qwq|o[0-9]|reasoning|thinking/.test(s)) return 'planning';
|
|
154
|
-
if (/code|coder|starcoder|devstral|codex|codegemma/.test(s)) return 'coding';
|
|
155
|
-
return 'general';
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Pricing tiers: free=$0 cheap<$0.50/1M medium=$0.50–$5/1M expensive>$5/1M
|
|
159
|
-
// Uses live prices from openrouterPricingCache; returns null when unknown.
|
|
160
|
-
function classifyPriceTier(modelId) {
|
|
161
|
-
const costPerM = openrouterPricingCache.get(modelId);
|
|
162
|
-
if (costPerM === undefined) return null;
|
|
163
|
-
if (costPerM === 0) return 'free';
|
|
164
|
-
if (costPerM < 0.5) return 'cheap';
|
|
165
|
-
if (costPerM < 5) return 'medium';
|
|
166
|
-
return 'expensive';
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Per-provider functions that turn a raw model object from listModels() into a display label.
|
|
170
|
-
const PROVIDER_LABEL_FN = {
|
|
171
|
-
openai: (m) => `${m.id} (OpenAI)`,
|
|
172
|
-
anthropic: (m) => `${m.name || m.id} (Anthropic)`,
|
|
173
|
-
google: (m) => `${m.name || m.id} (Google)`,
|
|
174
|
-
nvidia: (m) => `${m.id} (NVIDIA NIM)`,
|
|
175
|
-
grok: (m) => `${m.id} (xAI)`,
|
|
176
|
-
openrouter:(m) => `${m.name || m.id} (OpenRouter)`,
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
async function refreshProviderModelList(providerId, apiKey, baseUrl) {
|
|
180
|
-
const cacheKey = `${providerId}:${(apiKey || '').slice(0, 8)}`;
|
|
181
|
-
const existing = providerModelCache.get(cacheKey);
|
|
182
|
-
const now = Date.now();
|
|
183
|
-
|
|
184
|
-
if (existing && now - existing.lastRefresh <= DYNAMIC_REFRESH_INTERVAL) {
|
|
185
|
-
return existing.models;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
const factory = PROVIDER_FACTORIES[providerId];
|
|
190
|
-
const config = {};
|
|
191
|
-
if (factory.apiKey) config.apiKey = apiKey;
|
|
192
|
-
if (factory.baseUrl) config.baseUrl = baseUrl;
|
|
193
|
-
const provider = new factory.Provider(config);
|
|
194
|
-
|
|
195
|
-
const raw = await provider.listModels();
|
|
196
|
-
|
|
197
|
-
// OpenRouter returns live pricing — populate the shared cache so all
|
|
198
|
-
// other providers can resolve their price tier without a lookup table.
|
|
199
|
-
if (providerId === 'openrouter') {
|
|
200
|
-
for (const m of raw) {
|
|
201
|
-
if (m.pricing?.prompt == null) continue;
|
|
202
|
-
const inputPerM = parseFloat(m.pricing.prompt) * 1_000_000;
|
|
203
|
-
openrouterPricingCache.set(m.id, inputPerM);
|
|
204
|
-
// Also index by the bare model ID (everything after the first "/")
|
|
205
|
-
// so that e.g. "gpt-5-mini" resolves from "openai/gpt-5-mini".
|
|
206
|
-
if (m.id.includes('/')) {
|
|
207
|
-
const bareId = m.id.slice(m.id.indexOf('/') + 1);
|
|
208
|
-
if (!openrouterPricingCache.has(bareId)) {
|
|
209
|
-
openrouterPricingCache.set(bareId, inputPerM);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const labelFn = PROVIDER_LABEL_FN[providerId] || ((m) => m.id);
|
|
216
|
-
const models = raw.map((m) => ({
|
|
217
|
-
id: m.id,
|
|
218
|
-
label: labelFn(m),
|
|
219
|
-
provider: providerId,
|
|
220
|
-
purpose: inferModelPurpose(m.id),
|
|
221
|
-
}));
|
|
222
|
-
|
|
223
|
-
providerModelCache.set(cacheKey, { models, lastRefresh: now });
|
|
224
|
-
return models;
|
|
225
|
-
} catch (err) {
|
|
226
|
-
console.warn(`[Models] Failed to refresh ${providerId} models:`, err.message);
|
|
227
|
-
// Always record a lastRefresh so we don't hammer the API on every request.
|
|
228
|
-
// Permanent errors (auth/billing/credits) get a longer backoff.
|
|
229
|
-
const isPermanent = /401|403|unauthorized|forbidden|credits|spending/i.test(err.message);
|
|
230
|
-
const backoff = isPermanent ? 30 * 60 * 1000 : DYNAMIC_REFRESH_INTERVAL;
|
|
231
|
-
providerModelCache.set(cacheKey, {
|
|
232
|
-
models: existing?.models || [],
|
|
233
|
-
lastRefresh: now - DYNAMIC_REFRESH_INTERVAL + backoff,
|
|
234
|
-
});
|
|
235
|
-
return existing?.models || [];
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async function probeOllama(baseUrl, timeoutMs = 1500) {
|
|
240
|
-
const controller = new AbortController();
|
|
241
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
152
|
+
async function probeOllama(baseUrl, timeoutMs = 1500, signal = null) {
|
|
242
153
|
try {
|
|
243
|
-
const
|
|
154
|
+
const { response, text } = await fetchResponseText(`${baseUrl}/api/tags`, {
|
|
244
155
|
method: 'GET',
|
|
245
|
-
|
|
156
|
+
maxResponseBytes: 2 * 1024 * 1024,
|
|
157
|
+
serviceName: 'Ollama health check',
|
|
158
|
+
signal,
|
|
159
|
+
timeoutMs,
|
|
246
160
|
});
|
|
247
|
-
if (!
|
|
161
|
+
if (!response.ok) {
|
|
248
162
|
return {
|
|
249
163
|
healthy: false,
|
|
250
|
-
reason: `Ollama returned HTTP ${
|
|
164
|
+
reason: `Ollama returned HTTP ${response.status}.`
|
|
251
165
|
};
|
|
252
166
|
}
|
|
253
|
-
|
|
167
|
+
let data = {};
|
|
168
|
+
try { data = JSON.parse(text || '{}'); } catch {}
|
|
254
169
|
const modelCount = Array.isArray(data?.models) ? data.models.length : 0;
|
|
255
170
|
return {
|
|
256
171
|
healthy: true,
|
|
@@ -259,12 +174,11 @@ async function probeOllama(baseUrl, timeoutMs = 1500) {
|
|
|
259
174
|
: 'Connected to Ollama, but no local models were reported.'
|
|
260
175
|
};
|
|
261
176
|
} catch (err) {
|
|
262
|
-
|
|
177
|
+
if (isAbortError(err, signal)) throw createAbortError(signal);
|
|
178
|
+
const reason = err?.code === 'HTTP_TIMEOUT'
|
|
263
179
|
? `Ollama did not respond within ${timeoutMs}ms.`
|
|
264
180
|
: `Could not reach Ollama at ${baseUrl}.`;
|
|
265
181
|
return { healthy: false, reason };
|
|
266
|
-
} finally {
|
|
267
|
-
clearTimeout(timer);
|
|
268
182
|
}
|
|
269
183
|
}
|
|
270
184
|
|
|
@@ -337,11 +251,12 @@ function getProviderCatalog(userId, agentId = null) {
|
|
|
337
251
|
});
|
|
338
252
|
}
|
|
339
253
|
|
|
340
|
-
async function getProviderHealthCatalog(userId, agentId = null) {
|
|
254
|
+
async function getProviderHealthCatalog(userId, agentId = null, options = {}) {
|
|
341
255
|
const providers = getProviderCatalog(userId, agentId);
|
|
342
256
|
const enriched = [];
|
|
343
257
|
|
|
344
258
|
for (const provider of providers) {
|
|
259
|
+
throwIfAborted(options.signal);
|
|
345
260
|
let connected = null;
|
|
346
261
|
let healthy = provider.available;
|
|
347
262
|
let degraded = false;
|
|
@@ -350,7 +265,11 @@ async function getProviderHealthCatalog(userId, agentId = null) {
|
|
|
350
265
|
let availabilityReason = provider.availabilityReason;
|
|
351
266
|
|
|
352
267
|
if (provider.id === 'ollama' && provider.enabled) {
|
|
353
|
-
const probe = await probeOllama(
|
|
268
|
+
const probe = await probeOllama(
|
|
269
|
+
provider.baseUrl || AI_PROVIDER_DEFINITIONS.ollama.defaultBaseUrl,
|
|
270
|
+
1500,
|
|
271
|
+
options.signal,
|
|
272
|
+
);
|
|
354
273
|
connected = probe.healthy;
|
|
355
274
|
healthy = provider.enabled && probe.healthy;
|
|
356
275
|
degraded = provider.enabled && !probe.healthy;
|
|
@@ -393,21 +312,30 @@ async function getProviderHealthCatalog(userId, agentId = null) {
|
|
|
393
312
|
return enriched;
|
|
394
313
|
}
|
|
395
314
|
|
|
396
|
-
async function getSupportedModels(userId, agentId = null) {
|
|
397
|
-
|
|
315
|
+
async function getSupportedModels(userId, agentId = null, options = {}) {
|
|
316
|
+
throwIfAborted(options.signal);
|
|
317
|
+
const providerCatalog = options.providerCatalog
|
|
318
|
+
|| await getProviderHealthCatalog(userId, agentId, { signal: options.signal });
|
|
398
319
|
const providerById = new Map(providerCatalog.map((provider) => [provider.id, provider]));
|
|
399
320
|
|
|
400
321
|
const all = [...STATIC_MODELS];
|
|
401
|
-
const
|
|
322
|
+
const seenModelIds = new Set(
|
|
323
|
+
STATIC_MODELS.map((model) => createModelSelectionId(model.provider, model.id)),
|
|
324
|
+
);
|
|
402
325
|
|
|
403
326
|
// Ollama: dynamic list from local server
|
|
404
327
|
const ollama = providerById.get('ollama');
|
|
405
328
|
if (ollama?.available) {
|
|
406
|
-
const dynamicModels = await
|
|
329
|
+
const dynamicModels = await refreshOllamaModels({
|
|
330
|
+
baseUrl: ollama.baseUrl || AI_PROVIDER_DEFINITIONS.ollama.defaultBaseUrl,
|
|
331
|
+
Provider: OllamaProvider,
|
|
332
|
+
signal: options.signal,
|
|
333
|
+
});
|
|
407
334
|
for (const model of dynamicModels) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
335
|
+
const selectionId = createModelSelectionId(model.provider, model.id);
|
|
336
|
+
if (seenModelIds.has(selectionId)) continue;
|
|
337
|
+
seenModelIds.add(selectionId);
|
|
338
|
+
all.push(model);
|
|
411
339
|
}
|
|
412
340
|
}
|
|
413
341
|
|
|
@@ -416,16 +344,24 @@ async function getSupportedModels(userId, agentId = null) {
|
|
|
416
344
|
.filter((id) => providerById.get(id)?.available)
|
|
417
345
|
.map(async (id) => {
|
|
418
346
|
const runtime = getProviderRuntimeConfig(userId, id, agentId);
|
|
419
|
-
return refreshProviderModelList(
|
|
347
|
+
return refreshProviderModelList({
|
|
348
|
+
providerId: id,
|
|
349
|
+
factory: PROVIDER_FACTORIES[id],
|
|
350
|
+
apiKey: runtime.apiKey,
|
|
351
|
+
baseUrl: runtime.baseUrl,
|
|
352
|
+
signal: options.signal,
|
|
353
|
+
});
|
|
420
354
|
});
|
|
421
355
|
|
|
422
356
|
const dynamicResults = await Promise.allSettled(dynamicFetches);
|
|
357
|
+
throwIfAborted(options.signal);
|
|
423
358
|
for (const result of dynamicResults) {
|
|
424
359
|
if (result.status === 'fulfilled') {
|
|
425
360
|
for (const model of result.value) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
361
|
+
const selectionId = createModelSelectionId(model.provider, model.id);
|
|
362
|
+
if (seenModelIds.has(selectionId)) continue;
|
|
363
|
+
seenModelIds.add(selectionId);
|
|
364
|
+
all.push(model);
|
|
429
365
|
}
|
|
430
366
|
}
|
|
431
367
|
}
|
|
@@ -449,6 +385,7 @@ async function getSupportedModels(userId, agentId = null) {
|
|
|
449
385
|
}
|
|
450
386
|
|
|
451
387
|
return all.map((model) => {
|
|
388
|
+
const selectableModel = toSelectableModel(model);
|
|
452
389
|
const provider = providerById.get(model.provider);
|
|
453
390
|
// Ollama models are always local/free; all others look up the OpenRouter
|
|
454
391
|
// pricing cache (populated above by Promise.allSettled).
|
|
@@ -457,20 +394,20 @@ async function getSupportedModels(userId, agentId = null) {
|
|
|
457
394
|
: (model.priceTier ?? classifyPriceTier(model.id));
|
|
458
395
|
|
|
459
396
|
let available = provider?.available !== false;
|
|
460
|
-
if (available && globalDisabledSet
|
|
397
|
+
if (available && modelMatchesConfiguredId(selectableModel, globalDisabledSet)) {
|
|
461
398
|
available = false;
|
|
462
399
|
}
|
|
463
|
-
if (available && planAllowedModels !== null && !planAllowedModels
|
|
400
|
+
if (available && planAllowedModels !== null && !modelMatchesConfiguredId(selectableModel, planAllowedModels)) {
|
|
464
401
|
available = false;
|
|
465
402
|
}
|
|
466
403
|
|
|
467
404
|
const bareId = model.id.includes('/') ? model.id.slice(model.id.indexOf('/') + 1) : null;
|
|
468
405
|
const inputCostPerM = model.provider === 'ollama'
|
|
469
406
|
? 0
|
|
470
|
-
: (
|
|
407
|
+
: (getInputCostPerM(model.id) ?? (bareId ? getInputCostPerM(bareId) : undefined) ?? null);
|
|
471
408
|
|
|
472
409
|
return {
|
|
473
|
-
...
|
|
410
|
+
...selectableModel,
|
|
474
411
|
priceTier,
|
|
475
412
|
inputCostPerM,
|
|
476
413
|
available,
|
|
@@ -480,37 +417,6 @@ async function getSupportedModels(userId, agentId = null) {
|
|
|
480
417
|
});
|
|
481
418
|
}
|
|
482
419
|
|
|
483
|
-
async function refreshDynamicModels(baseUrl) {
|
|
484
|
-
const cacheKey = baseUrl || AI_PROVIDER_DEFINITIONS.ollama.defaultBaseUrl;
|
|
485
|
-
const existing = dynamicModelsByBaseUrl.get(cacheKey);
|
|
486
|
-
const now = Date.now();
|
|
487
|
-
|
|
488
|
-
if (existing && now - existing.lastRefresh <= REFRESH_INTERVAL) {
|
|
489
|
-
return existing.models;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
try {
|
|
493
|
-
const ollama = new OllamaProvider({ baseUrl: cacheKey });
|
|
494
|
-
const models = await ollama.listModels();
|
|
495
|
-
const normalized = models.map((name) => ({
|
|
496
|
-
id: name,
|
|
497
|
-
label: `${name} (Ollama / Local)`,
|
|
498
|
-
provider: 'ollama',
|
|
499
|
-
purpose: 'general',
|
|
500
|
-
}));
|
|
501
|
-
|
|
502
|
-
dynamicModelsByBaseUrl.set(cacheKey, {
|
|
503
|
-
models: normalized,
|
|
504
|
-
lastRefresh: now
|
|
505
|
-
});
|
|
506
|
-
return normalized;
|
|
507
|
-
} catch (err) {
|
|
508
|
-
console.warn('[Models] Failed to refresh Ollama models:', err.message);
|
|
509
|
-
const cached = dynamicModelsByBaseUrl.get(cacheKey);
|
|
510
|
-
return cached?.models || [];
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
420
|
function createProviderInstance(providerStr, userId = null, configOverrides = {}) {
|
|
515
421
|
const factory = PROVIDER_FACTORIES[providerStr];
|
|
516
422
|
if (!factory) {
|
|
@@ -537,7 +443,6 @@ function createProviderInstance(providerStr, userId = null, configOverrides = {}
|
|
|
537
443
|
module.exports = {
|
|
538
444
|
AI_PROVIDER_DEFINITIONS,
|
|
539
445
|
PROVIDER_FACTORIES,
|
|
540
|
-
SUPPORTED_MODELS: STATIC_MODELS, // Backward compatibility
|
|
541
446
|
createProviderInstance,
|
|
542
447
|
getProviderCatalog,
|
|
543
448
|
getProviderHealthCatalog,
|