neoagent 3.2.1-beta.1 → 3.2.1-beta.11

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.
Files changed (198) hide show
  1. package/extensions/chrome-browser/background.mjs +318 -88
  2. package/extensions/chrome-browser/http.mjs +136 -0
  3. package/extensions/chrome-browser/protocol.mjs +654 -90
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +157 -24
  6. package/flutter_app/lib/main_integrations.dart +607 -8
  7. package/flutter_app/lib/main_models.dart +7 -2
  8. package/flutter_app/lib/main_operations.dart +334 -370
  9. package/flutter_app/lib/main_security.dart +266 -112
  10. package/flutter_app/lib/main_settings.dart +342 -3
  11. package/flutter_app/lib/main_shared.dart +14 -11
  12. package/flutter_app/lib/src/backend_client.dart +97 -0
  13. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  14. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  15. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  16. package/landing/index.html +3 -1
  17. package/lib/manager.js +106 -89
  18. package/lib/schema_migrations.js +115 -13
  19. package/package.json +30 -15
  20. package/runtime/paths.js +49 -5
  21. package/server/db/database.js +2 -2
  22. package/server/guest-agent.cli.package.json +13 -0
  23. package/server/guest_agent.js +85 -40
  24. package/server/http/middleware.js +24 -0
  25. package/server/http/routes.js +12 -6
  26. package/server/public/.last_build_id +1 -1
  27. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  28. package/server/public/flutter_bootstrap.js +2 -2
  29. package/server/public/main.dart.js +81930 -80509
  30. package/server/routes/admin.js +1 -1
  31. package/server/routes/android.js +30 -34
  32. package/server/routes/behavior.js +80 -0
  33. package/server/routes/browser.js +23 -15
  34. package/server/routes/desktop.js +18 -1
  35. package/server/routes/integrations.js +107 -1
  36. package/server/routes/memory.js +1 -0
  37. package/server/routes/settings.js +20 -5
  38. package/server/routes/social_reach.js +12 -3
  39. package/server/routes/social_video.js +4 -0
  40. package/server/services/agents/manager.js +1 -1
  41. package/server/services/ai/capabilityHealth.js +62 -96
  42. package/server/services/ai/compaction.js +7 -2
  43. package/server/services/ai/history.js +45 -6
  44. package/server/services/ai/integrated_tools/http_request.js +8 -0
  45. package/server/services/ai/loop/agent_engine_core.js +452 -176
  46. package/server/services/ai/loop/blank_recovery.js +5 -4
  47. package/server/services/ai/loop/callbacks.js +1 -0
  48. package/server/services/ai/loop/completion_judge.js +291 -8
  49. package/server/services/ai/loop/conversation_loop.js +515 -342
  50. package/server/services/ai/loop/messaging_delivery.js +176 -57
  51. package/server/services/ai/loop/model_call_guard.js +91 -0
  52. package/server/services/ai/loop/model_io.js +20 -45
  53. package/server/services/ai/loop/progress_classification.js +2 -0
  54. package/server/services/ai/loop/tool_dispatch.js +19 -8
  55. package/server/services/ai/loopPolicy.js +48 -21
  56. package/server/services/ai/messagingFallback.js +17 -17
  57. package/server/services/ai/model_discovery.js +227 -0
  58. package/server/services/ai/model_failure_cache.js +108 -0
  59. package/server/services/ai/model_identity.js +71 -0
  60. package/server/services/ai/models.js +68 -163
  61. package/server/services/ai/providerRetry.js +17 -59
  62. package/server/services/ai/provider_selector.js +166 -0
  63. package/server/services/ai/providers/anthropic.js +2 -2
  64. package/server/services/ai/providers/claudeCode.js +21 -33
  65. package/server/services/ai/providers/githubCopilot.js +41 -20
  66. package/server/services/ai/providers/google.js +135 -97
  67. package/server/services/ai/providers/grok.js +4 -3
  68. package/server/services/ai/providers/grokOauth.js +19 -27
  69. package/server/services/ai/providers/nvidia.js +10 -5
  70. package/server/services/ai/providers/ollama.js +111 -84
  71. package/server/services/ai/providers/ollama_stream.js +142 -0
  72. package/server/services/ai/providers/openai.js +39 -5
  73. package/server/services/ai/providers/openaiCodex.js +11 -4
  74. package/server/services/ai/providers/openrouter.js +29 -7
  75. package/server/services/ai/providers/provider_error.js +36 -0
  76. package/server/services/ai/settings.js +26 -2
  77. package/server/services/ai/systemPrompt.js +32 -123
  78. package/server/services/ai/taskAnalysis.js +104 -11
  79. package/server/services/ai/toolEvidence.js +256 -29
  80. package/server/services/ai/tools.js +248 -117
  81. package/server/services/android/controller.js +770 -237
  82. package/server/services/android/process.js +140 -0
  83. package/server/services/android/sdk_download.js +143 -0
  84. package/server/services/android/uia.js +6 -5
  85. package/server/services/artifacts/store.js +24 -0
  86. package/server/services/behavior/config.js +251 -0
  87. package/server/services/behavior/defaults.js +68 -0
  88. package/server/services/behavior/delivery.js +176 -0
  89. package/server/services/behavior/index.js +43 -0
  90. package/server/services/behavior/model_client.js +35 -0
  91. package/server/services/behavior/modules/agent_identity.js +37 -0
  92. package/server/services/behavior/modules/channel_style.js +28 -0
  93. package/server/services/behavior/modules/index.js +29 -0
  94. package/server/services/behavior/modules/norms.js +101 -0
  95. package/server/services/behavior/modules/persona.js +48 -0
  96. package/server/services/behavior/modules/persona_prompt.js +33 -0
  97. package/server/services/behavior/modules/social_memory.js +86 -0
  98. package/server/services/behavior/modules/social_observability.js +94 -0
  99. package/server/services/behavior/modules/social_signals.js +41 -0
  100. package/server/services/behavior/modules/theory_of_mind.js +110 -0
  101. package/server/services/behavior/modules/turn_taking.js +237 -0
  102. package/server/services/behavior/pipeline.js +285 -0
  103. package/server/services/behavior/registry.js +78 -0
  104. package/server/services/behavior/signals.js +107 -0
  105. package/server/services/behavior/state.js +99 -0
  106. package/server/services/behavior/system_prompt.js +75 -0
  107. package/server/services/browser/controller.js +843 -385
  108. package/server/services/browser/extension/gateway.js +40 -16
  109. package/server/services/browser/extension/protocol.js +15 -1
  110. package/server/services/browser/extension/provider.js +71 -47
  111. package/server/services/browser/extension/registry.js +155 -34
  112. package/server/services/cli/executor.js +62 -9
  113. package/server/services/credentials/bitwarden_cli.js +322 -0
  114. package/server/services/credentials/broker.js +594 -0
  115. package/server/services/desktop/gateway.js +41 -4
  116. package/server/services/desktop/protocol.js +3 -0
  117. package/server/services/desktop/provider.js +39 -42
  118. package/server/services/desktop/registry.js +137 -52
  119. package/server/services/integrations/bitwarden/constants.js +14 -0
  120. package/server/services/integrations/bitwarden/provider.js +197 -0
  121. package/server/services/integrations/bitwarden/snapshot.js +65 -0
  122. package/server/services/integrations/figma/provider.js +78 -12
  123. package/server/services/integrations/github/common.js +11 -6
  124. package/server/services/integrations/github/provider.js +52 -53
  125. package/server/services/integrations/google/provider.js +55 -19
  126. package/server/services/integrations/home_assistant/network.js +17 -20
  127. package/server/services/integrations/home_assistant/provider.js +7 -5
  128. package/server/services/integrations/home_assistant/tools.js +17 -5
  129. package/server/services/integrations/http.js +51 -0
  130. package/server/services/integrations/manager.js +159 -53
  131. package/server/services/integrations/microsoft/provider.js +80 -13
  132. package/server/services/integrations/neoarchive/provider.js +55 -29
  133. package/server/services/integrations/neorecall/client.js +17 -10
  134. package/server/services/integrations/neorecall/provider.js +20 -11
  135. package/server/services/integrations/notion/provider.js +16 -13
  136. package/server/services/integrations/oauth_provider.js +115 -51
  137. package/server/services/integrations/registry.js +2 -0
  138. package/server/services/integrations/slack/provider.js +98 -9
  139. package/server/services/integrations/spotify/provider.js +67 -71
  140. package/server/services/integrations/trello/provider.js +21 -7
  141. package/server/services/integrations/weather/provider.js +18 -12
  142. package/server/services/integrations/whatsapp/provider.js +76 -16
  143. package/server/services/manager.js +110 -1
  144. package/server/services/memory/embedding_index.js +20 -8
  145. package/server/services/memory/embeddings.js +151 -90
  146. package/server/services/memory/ingestion.js +50 -9
  147. package/server/services/memory/ingestion_documents.js +13 -3
  148. package/server/services/memory/manager.js +66 -52
  149. package/server/services/messaging/access_policy.js +10 -6
  150. package/server/services/messaging/automation.js +240 -34
  151. package/server/services/messaging/discord.js +11 -1
  152. package/server/services/messaging/formatting_guides.js +5 -4
  153. package/server/services/messaging/http_platforms.js +37 -16
  154. package/server/services/messaging/inbound_queue.js +143 -28
  155. package/server/services/messaging/inbound_store.js +257 -0
  156. package/server/services/messaging/manager.js +344 -51
  157. package/server/services/messaging/telegram.js +10 -1
  158. package/server/services/messaging/typing_keepalive.js +5 -2
  159. package/server/services/messaging/whatsapp.js +33 -14
  160. package/server/services/network/http.js +210 -0
  161. package/server/services/network/safe_request.js +307 -0
  162. package/server/services/runtime/backends/local-vm.js +227 -67
  163. package/server/services/runtime/docker-vm-manager.js +9 -0
  164. package/server/services/runtime/guest_bootstrap.js +30 -4
  165. package/server/services/runtime/guest_image.js +43 -12
  166. package/server/services/runtime/manager.js +77 -23
  167. package/server/services/runtime/validation.js +7 -6
  168. package/server/services/security/tool_categories.js +6 -0
  169. package/server/services/social_reach/channels/github.js +10 -4
  170. package/server/services/social_reach/channels/reddit.js +4 -4
  171. package/server/services/social_reach/channels/rss.js +2 -2
  172. package/server/services/social_reach/channels/social_video.js +13 -8
  173. package/server/services/social_reach/channels/v2ex.js +21 -8
  174. package/server/services/social_reach/channels/x.js +2 -2
  175. package/server/services/social_reach/channels/xueqiu.js +5 -5
  176. package/server/services/social_reach/service.js +9 -6
  177. package/server/services/social_reach/utils.js +65 -14
  178. package/server/services/social_video/captions.js +2 -2
  179. package/server/services/social_video/service.js +343 -68
  180. package/server/services/tasks/integration_runtime.js +18 -8
  181. package/server/services/tasks/runtime.js +39 -4
  182. package/server/services/voice/agentBridge.js +17 -4
  183. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  184. package/server/services/voice/liveSession.js +31 -0
  185. package/server/services/voice/message.js +1 -1
  186. package/server/services/voice/openaiSpeech.js +33 -8
  187. package/server/services/voice/providers.js +233 -151
  188. package/server/services/voice/runtime.js +2 -2
  189. package/server/services/voice/runtimeManager.js +118 -20
  190. package/server/services/voice/turnRunner.js +6 -0
  191. package/server/services/wearable/firmware_manifest.js +51 -13
  192. package/server/services/wearable/service.js +1 -0
  193. package/server/utils/abort.js +96 -0
  194. package/server/utils/cloud-security.js +110 -3
  195. package/server/utils/files.js +31 -0
  196. package/server/utils/image_payload.js +95 -0
  197. package/server/utils/logger.js +19 -0
  198. package/server/utils/retry.js +107 -0
@@ -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 inferModelPurpose(id) {
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 res = await fetch(`${baseUrl}/api/tags`, {
154
+ const { response, text } = await fetchResponseText(`${baseUrl}/api/tags`, {
244
155
  method: 'GET',
245
- signal: controller.signal
156
+ maxResponseBytes: 2 * 1024 * 1024,
157
+ serviceName: 'Ollama health check',
158
+ signal,
159
+ timeoutMs,
246
160
  });
247
- if (!res.ok) {
161
+ if (!response.ok) {
248
162
  return {
249
163
  healthy: false,
250
- reason: `Ollama returned HTTP ${res.status}.`
164
+ reason: `Ollama returned HTTP ${response.status}.`
251
165
  };
252
166
  }
253
- const data = await res.json().catch(() => ({}));
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
- const reason = err?.name === 'AbortError'
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;
@@ -349,8 +264,12 @@ async function getProviderHealthCatalog(userId, agentId = null) {
349
264
  let statusLabel = provider.statusLabel;
350
265
  let availabilityReason = provider.availabilityReason;
351
266
 
352
- if (provider.id === 'ollama' && provider.enabled) {
353
- const probe = await probeOllama(provider.baseUrl || AI_PROVIDER_DEFINITIONS.ollama.defaultBaseUrl);
267
+ if (provider.id === 'ollama' && provider.enabled && options.probeLocal !== false) {
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
- const providerCatalog = await getProviderHealthCatalog(userId, agentId);
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 staticIds = new Set(STATIC_MODELS.map((model) => model.id));
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 refreshDynamicModels(ollama.baseUrl);
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
- if (!staticIds.has(model.id)) {
409
- all.push(model);
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(id, runtime.apiKey, runtime.baseUrl);
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
- if (!staticIds.has(model.id)) {
427
- all.push(model);
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?.has(model.id)) {
397
+ if (available && modelMatchesConfiguredId(selectableModel, globalDisabledSet)) {
461
398
  available = false;
462
399
  }
463
- if (available && planAllowedModels !== null && !planAllowedModels.has(model.id)) {
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
- : (openrouterPricingCache.get(model.id) ?? (bareId ? openrouterPricingCache.get(bareId) : undefined) ?? null);
407
+ : (getInputCostPerM(model.id) ?? (bareId ? getInputCostPerM(bareId) : undefined) ?? null);
471
408
 
472
409
  return {
473
- ...model,
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,
@@ -1,5 +1,16 @@
1
1
  'use strict';
2
2
 
3
+ const { createAbortError } = require('../../utils/abort');
4
+ const {
5
+ RETRYABLE_HTTP_STATUS: RETRYABLE_STATUS,
6
+ RETRYABLE_NETWORK_CODES: RETRYABLE_CODES,
7
+ abortableDelay,
8
+ computeBackoffMs,
9
+ getErrorCode,
10
+ getHttpStatus,
11
+ retryAfterMilliseconds,
12
+ } = require('../../utils/retry');
13
+
3
14
  // Centralized transient-error retry for AI provider calls.
4
15
  //
5
16
  // A transient blip (rate limit, provider overload, brief network failure) should
@@ -14,17 +25,6 @@ const DEFAULTS = {
14
25
  maxDelayMs: 8000,
15
26
  };
16
27
 
17
- // HTTP statuses worth retrying: request timeout, conflict, rate limit, and the
18
- // 5xx family including Anthropic's 529 "overloaded" and common CDN edge codes.
19
- const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504, 520, 521, 522, 524, 529]);
20
-
21
- // Low-level socket / DNS errors surfaced by Node and undici.
22
- const RETRYABLE_CODES = new Set([
23
- 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN', 'ENOTFOUND',
24
- 'ENETUNREACH', 'EHOSTUNREACH', 'EAGAIN',
25
- 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET', 'UND_ERR_HEADERS_TIMEOUT',
26
- ]);
27
-
28
28
  function readNumberEnv(name, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
29
29
  const raw = process.env[name];
30
30
  if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
@@ -47,25 +47,10 @@ function resolveConfig(overrides = {}) {
47
47
  // SDKs disagree on where they put the HTTP status: OpenAI/Anthropic expose
48
48
  // `.status`, raw http clients use `.statusCode`, and some nest it under
49
49
  // `.response.status`. Check all of them.
50
- function getStatus(err) {
51
- if (!err || typeof err !== 'object') return null;
52
- const candidates = [err.status, err.statusCode, err.response?.status, err.cause?.status];
53
- for (const value of candidates) {
54
- const num = Number(value);
55
- if (Number.isFinite(num) && num >= 100 && num < 600) return num;
56
- }
57
- return null;
58
- }
59
-
60
- function getErrorCode(err) {
61
- if (!err || typeof err !== 'object') return null;
62
- return err.code || err.errno || err.cause?.code || null;
63
- }
64
-
65
50
  function isTransientError(err) {
66
51
  if (!err) return false;
67
52
 
68
- const status = getStatus(err);
53
+ const status = getHttpStatus(err);
69
54
  if (status !== null) return RETRYABLE_STATUS.has(status);
70
55
 
71
56
  const code = getErrorCode(err);
@@ -84,39 +69,11 @@ function isTransientError(err) {
84
69
  // own backoff. Supports both delta-seconds and `retry-after-ms` style headers.
85
70
  function retryAfterMs(err) {
86
71
  if (!err || typeof err !== 'object') return null;
87
- const headers = err.headers || err.response?.headers;
88
- const read = (name) => {
89
- if (!headers) return undefined;
90
- if (typeof headers.get === 'function') return headers.get(name);
91
- return headers[name] ?? headers[name.toLowerCase()];
92
- };
93
-
94
- const ms = read('retry-after-ms');
95
- if (ms !== undefined && ms !== null && String(ms).trim() !== '') {
96
- const parsed = Number(ms);
97
- if (Number.isFinite(parsed) && parsed >= 0) return parsed;
98
- }
99
-
100
- const after = read('retry-after');
101
- if (after !== undefined && after !== null && String(after).trim() !== '') {
102
- const seconds = Number(after);
103
- if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
104
- const date = Date.parse(String(after));
105
- if (Number.isFinite(date)) return Math.max(0, date - Date.now());
106
- }
107
-
108
- return null;
109
- }
110
-
111
- // Exponential backoff with equal-jitter: half the window is fixed, half random,
112
- // which spreads retries out without ever collapsing the delay to zero.
113
- function computeBackoffMs(attempt, baseDelayMs, maxDelayMs) {
114
- const exp = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
115
- return Math.round(exp / 2 + Math.random() * (exp / 2));
72
+ return retryAfterMilliseconds(err.headers || err.response?.headers);
116
73
  }
117
74
 
118
- function delay(ms) {
119
- return new Promise((resolve) => setTimeout(resolve, ms));
75
+ function abortError(signal) {
76
+ return createAbortError(signal, 'Provider retry aborted.');
120
77
  }
121
78
 
122
79
  /**
@@ -137,6 +94,7 @@ async function withProviderRetry(fn, options = {}) {
137
94
 
138
95
  let attempt = 0;
139
96
  while (true) {
97
+ if (options.signal?.aborted) throw abortError(options.signal);
140
98
  attempt += 1;
141
99
  try {
142
100
  return await fn(attempt);
@@ -153,7 +111,7 @@ async function withProviderRetry(fn, options = {}) {
153
111
  options.onRetry({ attempt, delayMs: waitMs, error: err });
154
112
  } catch { /* a misbehaving progress callback must not abort the retry */ }
155
113
  }
156
- await delay(waitMs);
114
+ await abortableDelay(waitMs, options.signal);
157
115
  }
158
116
  }
159
117
  }
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+
3
+ const { getAiSettings } = require('./settings');
4
+ const { createProviderInstance, getSupportedModels } = require('./models');
5
+ const {
6
+ getRawModelId,
7
+ normalizeModelSelections,
8
+ resolveModelSelection,
9
+ } = require('./model_identity');
10
+ const { isModelCoolingDown } = require('./model_failure_cache');
11
+
12
+ function buildSelection(model, userId, providerConfig) {
13
+ return {
14
+ provider: createProviderInstance(model.provider, userId, providerConfig),
15
+ model: getRawModelId(model),
16
+ modelSelectionId: model.id,
17
+ providerName: model.provider,
18
+ };
19
+ }
20
+
21
+ async function getProviderForUser(
22
+ userId,
23
+ _task = '',
24
+ isSubagent = false,
25
+ modelOverride = null,
26
+ providerConfig = {},
27
+ ) {
28
+ const agentId = providerConfig.agentId || null;
29
+ const aiSettings = getAiSettings(userId, agentId);
30
+ const models = await getSupportedModels(userId, agentId, {
31
+ signal: providerConfig.signal,
32
+ });
33
+ const selectableModels = models.filter((model) => model.available !== false);
34
+ const readyModels = selectableModels.filter(
35
+ (model) => !isModelCoolingDown(userId, agentId, model.id),
36
+ );
37
+
38
+ if (modelOverride && typeof modelOverride === 'string') {
39
+ const requested = resolveModelSelection(selectableModels, modelOverride);
40
+ if (!requested) {
41
+ throw new Error(`Requested model '${modelOverride.trim()}' is not available.`);
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
+ }
57
+ return buildSelection(requested, userId, providerConfig);
58
+ }
59
+
60
+ const configuredEnabledIds = Array.isArray(aiSettings.enabled_models)
61
+ ? aiSettings.enabled_models.map((id) => String(id).trim()).filter(Boolean)
62
+ : [];
63
+ const enabledIds = normalizeModelSelections(selectableModels, configuredEnabledIds);
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
+ }
93
+
94
+ if (availableModels.length === 0) {
95
+ const message = configuredEnabledIds.length > 0
96
+ ? 'None of the enabled AI models are currently available. Check model and provider settings.'
97
+ : 'No AI providers are currently available. Open Settings and configure at least one provider.';
98
+ throw new Error(message);
99
+ }
100
+
101
+ const fallbackModel = availableModels[0];
102
+ const userSelectedDefault = isSubagent
103
+ ? (aiSettings.default_subagent_model || 'auto')
104
+ : (aiSettings.default_chat_model || 'auto');
105
+ const smarterSelection = aiSettings.smarter_model_selector !== false
106
+ && aiSettings.smarter_model_selector !== 'false';
107
+
108
+ let selectedModel = fallbackModel;
109
+ if (userSelectedDefault !== 'auto') {
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
+ }
122
+ } else {
123
+ const selectionHint = providerConfig.selectionHint && typeof providerConfig.selectionHint === 'object'
124
+ ? providerConfig.selectionHint
125
+ : {};
126
+ const preferredPurpose = String(selectionHint.purpose || '').trim().toLowerCase();
127
+ const highAutonomy = selectionHint.autonomyLevel === 'high'
128
+ || selectionHint.complexity === 'complex';
129
+ const requiredConfidence = String(selectionHint.requiredConfidence || '').trim().toLowerCase();
130
+ const costMode = String(selectionHint.costMode || aiSettings.cost_mode || 'balanced_auto')
131
+ .trim()
132
+ .toLowerCase();
133
+ const requestedPurpose = ['planning', 'coding', 'general', 'fast'].includes(preferredPurpose)
134
+ ? preferredPurpose
135
+ : '';
136
+ const priceRank = { free: 0, cheap: 1, medium: 2, expensive: 3 };
137
+ const chooseForPurpose = (purpose) => {
138
+ const candidates = availableModels.filter((model) => model.purpose === purpose);
139
+ if (candidates.length === 0) return null;
140
+ if (['economy', 'cost_saver', 'lowest_cost'].includes(costMode)) {
141
+ return [...candidates].sort((left, right) => (
142
+ (priceRank[left.priceTier] ?? 99) - (priceRank[right.priceTier] ?? 99)
143
+ ))[0];
144
+ }
145
+ if (['quality', 'highest_quality'].includes(costMode) || requiredConfidence === 'high') {
146
+ return candidates.find((model) => model.priceTier !== 'free' && model.priceTier !== 'cheap')
147
+ || candidates[0];
148
+ }
149
+ return candidates[0];
150
+ };
151
+
152
+ if (smarterSelection && requestedPurpose) {
153
+ selectedModel = chooseForPurpose(requestedPurpose) || fallbackModel;
154
+ } else if (smarterSelection && highAutonomy) {
155
+ selectedModel = chooseForPurpose('planning') || chooseForPurpose('general') || fallbackModel;
156
+ } else if (isSubagent) {
157
+ selectedModel = chooseForPurpose('fast') || fallbackModel;
158
+ } else {
159
+ selectedModel = chooseForPurpose('general') || fallbackModel;
160
+ }
161
+ }
162
+
163
+ return buildSelection(selectedModel, userId, providerConfig);
164
+ }
165
+
166
+ module.exports = { getProviderForUser };
@@ -31,8 +31,8 @@ class AnthropicProvider extends BaseProvider {
31
31
  });
32
32
  }
33
33
 
34
- async listModels() {
35
- const res = await this.client.models.list({ limit: 100 });
34
+ async listModels(signal = null) {
35
+ const res = await this.client.models.list({ limit: 100 }, { signal });
36
36
  return (res.data || []).map((m) => ({ id: m.id, name: m.display_name || m.id }));
37
37
  }
38
38