neoagent 3.2.1-beta.2 → 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.
Files changed (144) 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 +511 -89
  4. package/flutter_app/lib/main_chat.dart +118 -739
  5. package/flutter_app/lib/main_controller.dart +29 -20
  6. package/flutter_app/lib/main_models.dart +3 -0
  7. package/flutter_app/lib/main_operations.dart +334 -321
  8. package/flutter_app/lib/main_settings.dart +4 -3
  9. package/flutter_app/lib/main_shared.dart +14 -11
  10. package/flutter_app/lib/src/desktop_companion_actions.dart +185 -31
  11. package/flutter_app/lib/src/desktop_companion_io.dart +319 -86
  12. package/flutter_app/windows/runner/flutter_window.cpp +143 -32
  13. package/lib/manager.js +106 -89
  14. package/lib/schema_migrations.js +67 -13
  15. package/package.json +20 -13
  16. package/runtime/paths.js +49 -5
  17. package/server/guest_agent.js +52 -30
  18. package/server/http/middleware.js +24 -0
  19. package/server/http/routes.js +4 -6
  20. package/server/public/.last_build_id +1 -1
  21. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  22. package/server/public/flutter_bootstrap.js +1 -1
  23. package/server/public/main.dart.js +30803 -30805
  24. package/server/routes/admin.js +1 -1
  25. package/server/routes/android.js +30 -34
  26. package/server/routes/browser.js +23 -15
  27. package/server/routes/desktop.js +18 -1
  28. package/server/routes/integrations.js +5 -1
  29. package/server/routes/memory.js +1 -0
  30. package/server/routes/settings.js +16 -5
  31. package/server/routes/social_reach.js +12 -3
  32. package/server/routes/social_video.js +4 -0
  33. package/server/services/ai/compaction.js +7 -2
  34. package/server/services/ai/history.js +44 -5
  35. package/server/services/ai/integrated_tools/http_request.js +8 -0
  36. package/server/services/ai/loop/agent_engine_core.js +347 -162
  37. package/server/services/ai/loop/callbacks.js +1 -0
  38. package/server/services/ai/loop/completion_judge.js +12 -0
  39. package/server/services/ai/loop/conversation_loop.js +348 -242
  40. package/server/services/ai/loop/messaging_delivery.js +129 -57
  41. package/server/services/ai/loop/model_call_guard.js +91 -0
  42. package/server/services/ai/loop/model_io.js +8 -41
  43. package/server/services/ai/loop/tool_dispatch.js +19 -8
  44. package/server/services/ai/loopPolicy.js +24 -19
  45. package/server/services/ai/model_discovery.js +227 -0
  46. package/server/services/ai/model_identity.js +71 -0
  47. package/server/services/ai/models.js +67 -162
  48. package/server/services/ai/providerRetry.js +17 -59
  49. package/server/services/ai/provider_selector.js +111 -0
  50. package/server/services/ai/providers/anthropic.js +2 -2
  51. package/server/services/ai/providers/claudeCode.js +15 -28
  52. package/server/services/ai/providers/githubCopilot.js +36 -16
  53. package/server/services/ai/providers/google.js +23 -5
  54. package/server/services/ai/providers/grok.js +4 -3
  55. package/server/services/ai/providers/grokOauth.js +13 -22
  56. package/server/services/ai/providers/nvidia.js +10 -5
  57. package/server/services/ai/providers/ollama.js +102 -82
  58. package/server/services/ai/providers/ollama_stream.js +142 -0
  59. package/server/services/ai/providers/openai.js +39 -5
  60. package/server/services/ai/providers/openaiCodex.js +11 -4
  61. package/server/services/ai/providers/openrouter.js +29 -7
  62. package/server/services/ai/providers/provider_error.js +36 -0
  63. package/server/services/ai/settings.js +26 -2
  64. package/server/services/ai/taskAnalysis.js +5 -1
  65. package/server/services/ai/terminal_reply.js +45 -0
  66. package/server/services/ai/toolEvidence.js +58 -29
  67. package/server/services/ai/tools.js +124 -111
  68. package/server/services/android/controller.js +770 -237
  69. package/server/services/android/process.js +140 -0
  70. package/server/services/android/sdk_download.js +143 -0
  71. package/server/services/android/uia.js +6 -5
  72. package/server/services/artifacts/store.js +24 -0
  73. package/server/services/browser/controller.js +736 -385
  74. package/server/services/browser/extension/gateway.js +40 -16
  75. package/server/services/browser/extension/protocol.js +12 -1
  76. package/server/services/browser/extension/provider.js +59 -47
  77. package/server/services/browser/extension/registry.js +155 -34
  78. package/server/services/cli/executor.js +62 -9
  79. package/server/services/desktop/gateway.js +41 -4
  80. package/server/services/desktop/protocol.js +3 -0
  81. package/server/services/desktop/provider.js +39 -42
  82. package/server/services/desktop/registry.js +137 -52
  83. package/server/services/integrations/figma/provider.js +78 -12
  84. package/server/services/integrations/github/common.js +11 -6
  85. package/server/services/integrations/github/provider.js +52 -53
  86. package/server/services/integrations/google/provider.js +55 -19
  87. package/server/services/integrations/home_assistant/network.js +17 -20
  88. package/server/services/integrations/home_assistant/provider.js +7 -5
  89. package/server/services/integrations/home_assistant/tools.js +17 -5
  90. package/server/services/integrations/http.js +51 -0
  91. package/server/services/integrations/manager.js +158 -53
  92. package/server/services/integrations/microsoft/provider.js +80 -13
  93. package/server/services/integrations/neoarchive/provider.js +55 -29
  94. package/server/services/integrations/neorecall/client.js +17 -10
  95. package/server/services/integrations/neorecall/provider.js +20 -11
  96. package/server/services/integrations/notion/provider.js +16 -13
  97. package/server/services/integrations/oauth_provider.js +115 -51
  98. package/server/services/integrations/slack/provider.js +98 -9
  99. package/server/services/integrations/spotify/provider.js +67 -71
  100. package/server/services/integrations/trello/provider.js +21 -7
  101. package/server/services/integrations/weather/provider.js +18 -12
  102. package/server/services/integrations/whatsapp/provider.js +76 -16
  103. package/server/services/manager.js +87 -1
  104. package/server/services/memory/embedding_index.js +20 -8
  105. package/server/services/memory/embeddings.js +151 -90
  106. package/server/services/memory/ingestion.js +50 -9
  107. package/server/services/memory/ingestion_documents.js +13 -3
  108. package/server/services/memory/manager.js +52 -19
  109. package/server/services/messaging/automation.js +84 -9
  110. package/server/services/messaging/http_platforms.js +33 -13
  111. package/server/services/messaging/inbound_queue.js +78 -24
  112. package/server/services/messaging/inbound_store.js +224 -0
  113. package/server/services/messaging/manager.js +326 -51
  114. package/server/services/messaging/typing_keepalive.js +5 -2
  115. package/server/services/network/http.js +210 -0
  116. package/server/services/network/safe_request.js +307 -0
  117. package/server/services/runtime/backends/local-vm.js +214 -66
  118. package/server/services/runtime/manager.js +17 -12
  119. package/server/services/social_reach/channels/github.js +10 -4
  120. package/server/services/social_reach/channels/reddit.js +4 -4
  121. package/server/services/social_reach/channels/rss.js +2 -2
  122. package/server/services/social_reach/channels/social_video.js +12 -7
  123. package/server/services/social_reach/channels/v2ex.js +21 -8
  124. package/server/services/social_reach/channels/x.js +2 -2
  125. package/server/services/social_reach/channels/xueqiu.js +5 -5
  126. package/server/services/social_reach/service.js +9 -6
  127. package/server/services/social_reach/utils.js +65 -14
  128. package/server/services/social_video/service.js +160 -50
  129. package/server/services/tasks/integration_runtime.js +18 -8
  130. package/server/services/tasks/runtime.js +39 -4
  131. package/server/services/voice/agentBridge.js +17 -4
  132. package/server/services/voice/bufferedLiveRelayAdapter.js +5 -0
  133. package/server/services/voice/liveSession.js +31 -0
  134. package/server/services/voice/openaiSpeech.js +33 -8
  135. package/server/services/voice/providers.js +233 -151
  136. package/server/services/voice/runtimeManager.js +118 -20
  137. package/server/services/voice/turnRunner.js +6 -0
  138. package/server/services/wearable/firmware_manifest.js +51 -13
  139. package/server/services/wearable/service.js +1 -0
  140. package/server/utils/abort.js +96 -0
  141. package/server/utils/cloud-security.js +110 -3
  142. package/server/utils/files.js +31 -0
  143. package/server/utils/image_payload.js +95 -0
  144. 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 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;
@@ -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(provider.baseUrl || AI_PROVIDER_DEFINITIONS.ollama.defaultBaseUrl);
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,