neoagent 2.4.1-beta.39 → 2.4.1-beta.40

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.
@@ -8,6 +8,7 @@ const { OpenAICodexProvider } = require('./providers/openaiCodex');
8
8
  const { ClaudeCodeProvider } = require('./providers/claudeCode');
9
9
  const { GrokOAuthProvider } = require('./providers/grokOauth');
10
10
  const { NvidiaProvider } = require('./providers/nvidia');
11
+ const { OpenRouterProvider } = require('./providers/openrouter');
11
12
  const {
12
13
  AI_PROVIDER_DEFINITIONS,
13
14
  getProviderConfigs,
@@ -15,24 +16,21 @@ const {
15
16
  } = require('./settings');
16
17
 
17
18
  const STATIC_MODELS = [
18
- {
19
- id: 'grok-4-1-fast-reasoning',
20
- label: 'Grok 4.1 (Personality / Default)',
21
- provider: 'grok',
22
- purpose: 'general'
23
- },
19
+ // — xAI OAuth — fallback entries shown when grok-oauth token is invalid/exhausted.
20
+ // When the token is valid, DYNAMIC_PROVIDERS will replace these with the live list.
24
21
  {
25
22
  id: 'grok-4',
26
- label: 'Grok 4 (xAI OAuth / Default)',
23
+ label: 'Grok 4 (xAI OAuth)',
27
24
  provider: 'grok-oauth',
28
- purpose: 'general'
25
+ purpose: 'general',
29
26
  },
30
27
  {
31
28
  id: 'grok-4-mini',
32
- label: 'Grok 4 Mini (xAI OAuth / Fast)',
29
+ label: 'Grok 4 Mini (xAI OAuth)',
33
30
  provider: 'grok-oauth',
34
- purpose: 'coding'
31
+ purpose: 'fast',
35
32
  },
33
+ // — GitHub Copilot (subscription; no public /models endpoint) ————————
36
34
  {
37
35
  id: 'gpt-5.3',
38
36
  label: 'GPT-5.3 (Copilot Default)',
@@ -45,6 +43,7 @@ const STATIC_MODELS = [
45
43
  provider: 'github-copilot',
46
44
  purpose: 'coding'
47
45
  },
46
+ // — OpenAI Codex ——————————————————————————————————————————————————————
48
47
  {
49
48
  id: 'gpt-5.5',
50
49
  label: 'GPT-5.5 (Codex Default)',
@@ -63,80 +62,13 @@ const STATIC_MODELS = [
63
62
  provider: 'openai-codex',
64
63
  purpose: 'general'
65
64
  },
66
- // — NVIDIA NIM — free tier ————————————————————————————————————————————
67
- {
68
- id: 'nvidia/nemotron-3-super-120b-a12b',
69
- label: 'Nemotron Super 120B (NVIDIA / Free)',
70
- provider: 'nvidia',
71
- purpose: 'general'
72
- },
73
- {
74
- id: 'moonshotai/kimi-k2.5',
75
- label: 'Kimi K2.5 (NVIDIA NIM / Free)',
76
- provider: 'nvidia',
77
- purpose: 'coding'
78
- },
79
- {
80
- id: 'minimaxai/minimax-m2.5',
81
- label: 'MiniMax M2.5 (NVIDIA NIM / Free)',
82
- provider: 'nvidia',
83
- purpose: 'planning'
84
- },
85
- {
86
- id: 'z-ai/glm5',
87
- label: 'GLM 5 (NVIDIA NIM / Free)',
88
- provider: 'nvidia',
89
- purpose: 'fast'
90
- },
91
- // — NVIDIA NIM — notable models ————————————————————————————————————————
92
- {
93
- id: 'meta/llama-4-maverick-17b-128e-instruct',
94
- label: 'Llama 4 Maverick 17B (NVIDIA NIM)',
95
- provider: 'nvidia',
96
- purpose: 'general'
97
- },
65
+ // — Claude Code ————————————————————————————————————————————————————————
98
66
  {
99
- id: 'meta/llama-4-scout-17b-16e-instruct',
100
- label: 'Llama 4 Scout 17B (NVIDIA NIM)',
101
- provider: 'nvidia',
102
- purpose: 'planning'
103
- },
104
- {
105
- id: 'deepseek-ai/deepseek-r1-0528',
106
- label: 'DeepSeek R1 0528 (NVIDIA NIM)',
107
- provider: 'nvidia',
67
+ id: 'claude-opus-4-8',
68
+ label: 'Claude Opus 4.8 (Claude Code / Flagship)',
69
+ provider: 'claude-code',
108
70
  purpose: 'general'
109
71
  },
110
- {
111
- id: 'qwen/qwq-32b',
112
- label: 'QwQ 32B (NVIDIA NIM)',
113
- provider: 'nvidia',
114
- purpose: 'planning'
115
- },
116
- {
117
- id: 'gpt-5-nano',
118
- label: 'GPT-5 Nano (Fast / Subagents)',
119
- provider: 'openai',
120
- purpose: 'fast'
121
- },
122
- {
123
- id: 'gpt-5-mini',
124
- label: 'GPT-5 Mini (Planning / Complex)',
125
- provider: 'openai',
126
- purpose: 'planning'
127
- },
128
- {
129
- id: 'claude-sonnet-4-20250514',
130
- label: 'Claude Sonnet 4 (Analysis / Writing)',
131
- provider: 'anthropic',
132
- purpose: 'planning'
133
- },
134
- {
135
- id: 'claude-3-5-haiku-20241022',
136
- label: 'Claude 3.5 Haiku (Fast)',
137
- provider: 'anthropic',
138
- purpose: 'fast'
139
- },
140
72
  {
141
73
  id: 'claude-opus-4-7',
142
74
  label: 'Claude Opus 4.7 (Claude Code / Default)',
@@ -155,35 +87,25 @@ const STATIC_MODELS = [
155
87
  provider: 'claude-code',
156
88
  purpose: 'fast'
157
89
  },
158
- {
159
- id: 'gemini-3.1-flash-lite-preview',
160
- label: 'Gemini 3.1 Flash Lite (Preview)',
161
- provider: 'google',
162
- purpose: 'general'
163
- },
164
- {
165
- id: 'gemini-3.1-pro',
166
- label: 'Gemini 3.1 Pro (High)',
167
- provider: 'google',
168
- purpose: 'general'
169
- },
90
+ // — MiniMax ————————————————————————————————————————————————————————————
170
91
  {
171
92
  id: 'MiniMax-M2.7',
172
93
  label: 'MiniMax M2.7 (Coding Plan)',
173
94
  provider: 'minimax',
174
95
  purpose: 'coding'
175
96
  },
97
+ // — Ollama — default suggestions (full list loaded dynamically) ————————
176
98
  {
177
99
  id: 'qwen3.5:4b',
178
100
  label: 'Qwen 3.5 4B (Local / Ollama)',
179
101
  provider: 'ollama',
180
- purpose: 'general'
102
+ purpose: 'general',
181
103
  },
182
104
  {
183
105
  id: 'gemma4:12b',
184
106
  label: 'Gemma 4 12B (Local / Ollama)',
185
107
  provider: 'ollama',
186
- purpose: 'general'
108
+ purpose: 'general',
187
109
  }
188
110
  ];
189
111
 
@@ -205,11 +127,107 @@ const PROVIDER_FACTORIES = Object.freeze({
205
127
  'claude-code': { Provider: ClaudeCodeProvider, apiKey: true, baseUrl: false },
206
128
  'grok-oauth': { Provider: GrokOAuthProvider, apiKey: true, baseUrl: false },
207
129
  nvidia: { Provider: NvidiaProvider, apiKey: true, baseUrl: true },
130
+ openrouter: { Provider: OpenRouterProvider, apiKey: true, baseUrl: true },
208
131
  });
209
132
 
210
133
  const dynamicModelsByBaseUrl = new Map();
211
134
  const REFRESH_INTERVAL = 30000; // 30 seconds
212
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
+ // Providers whose full model list is fetched from their API at runtime.
147
+ // grok-oauth inherits listModels() from GrokProvider and uses the same xAI endpoint.
148
+ const DYNAMIC_PROVIDERS = ['openai', 'anthropic', 'google', 'nvidia', 'grok', 'grok-oauth', 'openrouter'];
149
+
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
+ return existing?.models || [];
228
+ }
229
+ }
230
+
213
231
  async function probeOllama(baseUrl, timeoutMs = 1500) {
214
232
  const controller = new AbortController();
215
233
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -373,8 +391,9 @@ async function getSupportedModels(userId, agentId = null) {
373
391
 
374
392
  const all = [...STATIC_MODELS];
375
393
  const staticIds = new Set(STATIC_MODELS.map((model) => model.id));
376
- const ollama = providerById.get('ollama');
377
394
 
395
+ // Ollama: dynamic list from local server
396
+ const ollama = providerById.get('ollama');
378
397
  if (ollama?.enabled) {
379
398
  const dynamicModels = await refreshDynamicModels(ollama.baseUrl);
380
399
  for (const model of dynamicModels) {
@@ -384,10 +403,35 @@ async function getSupportedModels(userId, agentId = null) {
384
403
  }
385
404
  }
386
405
 
406
+ // API-backed providers: fetch model lists in parallel
407
+ const dynamicFetches = DYNAMIC_PROVIDERS
408
+ .filter((id) => providerById.get(id)?.available)
409
+ .map(async (id) => {
410
+ const runtime = getProviderRuntimeConfig(userId, id, agentId);
411
+ return refreshProviderModelList(id, runtime.apiKey, runtime.baseUrl);
412
+ });
413
+
414
+ const dynamicResults = await Promise.allSettled(dynamicFetches);
415
+ for (const result of dynamicResults) {
416
+ if (result.status === 'fulfilled') {
417
+ for (const model of result.value) {
418
+ if (!staticIds.has(model.id)) {
419
+ all.push(model);
420
+ }
421
+ }
422
+ }
423
+ }
424
+
387
425
  return all.map((model) => {
388
426
  const provider = providerById.get(model.provider);
427
+ // Ollama models are always local/free; all others look up the OpenRouter
428
+ // pricing cache (populated above by Promise.allSettled).
429
+ const priceTier = model.provider === 'ollama'
430
+ ? 'free'
431
+ : (model.priceTier ?? classifyPriceTier(model.id));
389
432
  return {
390
433
  ...model,
434
+ priceTier,
391
435
  available: provider?.available !== false,
392
436
  providerStatus: provider?.status || 'unknown',
393
437
  providerStatusLabel: provider?.statusLabel || 'Unknown'
@@ -411,7 +455,7 @@ async function refreshDynamicModels(baseUrl) {
411
455
  id: name,
412
456
  label: `${name} (Ollama / Local)`,
413
457
  provider: 'ollama',
414
- purpose: 'general'
458
+ purpose: 'general',
415
459
  }));
416
460
 
417
461
  dynamicModelsByBaseUrl.set(cacheKey, {
@@ -6,18 +6,24 @@ class AnthropicProvider extends BaseProvider {
6
6
  super(config);
7
7
  this.name = 'anthropic';
8
8
  this.models = [
9
+ 'claude-opus-4-8',
10
+ 'claude-opus-4-7',
11
+ 'claude-sonnet-4-6',
9
12
  'claude-sonnet-4-20250514',
10
- 'claude-3-5-sonnet-20241022',
13
+ 'claude-haiku-4-5-20251001',
11
14
  'claude-3-5-haiku-20241022',
15
+ 'claude-3-5-sonnet-20241022',
12
16
  'claude-3-opus-20240229',
13
- 'MiniMax-M2.7'
14
17
  ];
15
18
  this.contextWindows = {
19
+ 'claude-opus-4-8': 1000000,
20
+ 'claude-opus-4-7': 200000,
21
+ 'claude-sonnet-4-6': 200000,
16
22
  'claude-sonnet-4-20250514': 200000,
17
- 'claude-3-5-sonnet-20241022': 200000,
23
+ 'claude-haiku-4-5-20251001': 200000,
18
24
  'claude-3-5-haiku-20241022': 200000,
25
+ 'claude-3-5-sonnet-20241022': 200000,
19
26
  'claude-3-opus-20240229': 200000,
20
- 'MiniMax-M2.7': 204800
21
27
  };
22
28
  this.client = new Anthropic({
23
29
  apiKey: config.apiKey || process.env.ANTHROPIC_API_KEY,
@@ -25,6 +31,11 @@ class AnthropicProvider extends BaseProvider {
25
31
  });
26
32
  }
27
33
 
34
+ async listModels() {
35
+ const res = await this.client.models.list({ limit: 100 });
36
+ return (res.data || []).map((m) => ({ id: m.id, name: m.display_name || m.id }));
37
+ }
38
+
28
39
  getContextWindow(model) {
29
40
  return this.contextWindows[model] || 200000;
30
41
  }
@@ -6,22 +6,49 @@ class GoogleProvider extends BaseProvider {
6
6
  super(config);
7
7
  this.name = 'google';
8
8
  this.models = [
9
+ 'gemini-3.5-pro',
10
+ 'gemini-3.5-flash',
11
+ 'gemini-3.1-pro',
12
+ 'gemini-3.1-flash-lite-preview',
13
+ 'gemini-2.5-pro',
14
+ 'gemini-2.5-flash',
9
15
  'gemini-2.0-flash',
10
- 'gemini-2.0-pro',
11
16
  'gemini-1.5-pro',
12
17
  'gemini-1.5-flash',
13
- 'gemini-3.1-flash-lite-preview',
14
- 'gemini-3.1-pro'
15
18
  ];
16
19
  this.contextWindows = {
20
+ 'gemini-3.5-pro': 2097152,
21
+ 'gemini-3.5-flash': 1048576,
22
+ 'gemini-3.1-pro': 2097152,
23
+ 'gemini-3.1-flash-lite-preview': 1048576,
24
+ 'gemini-2.5-pro': 1048576,
25
+ 'gemini-2.5-flash': 1048576,
17
26
  'gemini-2.0-flash': 1048576,
18
- 'gemini-2.0-pro': 2097152,
19
27
  'gemini-1.5-pro': 2097152,
20
28
  'gemini-1.5-flash': 1048576,
21
- 'gemini-3.1-flash-lite-preview': 1048576,
22
- 'gemini-3.1-pro': 2097152
23
29
  };
24
- this.genAI = new GoogleGenerativeAI(config.apiKey || process.env.GOOGLE_AI_KEY);
30
+ this.apiKey = config.apiKey || process.env.GOOGLE_AI_KEY;
31
+ this.genAI = new GoogleGenerativeAI(this.apiKey);
32
+ }
33
+
34
+ async listModels() {
35
+ const DROP = /tts|lyria|robotics|deep-research|antigravity|computer-use|-image(?!.*it)/i;
36
+ const res = await fetch(
37
+ `https://generativelanguage.googleapis.com/v1beta/models?key=${this.apiKey}&pageSize=200`
38
+ );
39
+ if (!res.ok) throw new Error(`Google models API returned ${res.status}`);
40
+ const { models = [] } = await res.json();
41
+ return models
42
+ .filter((m) => {
43
+ const id = m.name.replace('models/', '');
44
+ return (m.supportedGenerationMethods || []).includes('generateContent')
45
+ && !DROP.test(id);
46
+ })
47
+ .map((m) => {
48
+ const id = m.name.replace('models/', '');
49
+ this.contextWindows[id] = m.inputTokenLimit || 1048576;
50
+ return { id, name: m.displayName || id };
51
+ });
25
52
  }
26
53
 
27
54
  getContextWindow(model) {
@@ -11,8 +11,21 @@ class GrokProvider extends OpenAICompatibleProvider {
11
11
  });
12
12
  }
13
13
 
14
+ async listModels() {
15
+ try {
16
+ const res = await this.client.models.list();
17
+ const DROP = /imagine|diffus|embed|-tts/i;
18
+ return res.data
19
+ .filter((m) => !DROP.test(m.id))
20
+ .map((m) => ({ id: m.id, name: m.id }));
21
+ } catch (err) {
22
+ throw new Error(`Failed to list Grok models: ${err.message || String(err)}`);
23
+ }
24
+ }
25
+
14
26
  getContextWindow(model) {
15
- return 131072; // grok-4 context window
27
+ if (model === 'grok-4.3') return 1000000;
28
+ return 131072;
16
29
  }
17
30
 
18
31
  supportsVision() {
@@ -32,6 +32,18 @@ class NvidiaProvider extends OpenAICompatibleProvider {
32
32
  });
33
33
  }
34
34
 
35
+ async listModels() {
36
+ try {
37
+ const res = await this.client.models.list();
38
+ const DROP = /embed|bge|e5-|rerank|guard|safety|moderat|diffus|flux|stable|imagen|vision-enc|whisper|tts|speech|paraphrase|classif/i;
39
+ return res.data
40
+ .filter((m) => !DROP.test(m.id))
41
+ .map((m) => ({ id: m.id, name: m.id }));
42
+ } catch (err) {
43
+ throw new Error(`NVIDIA NIM request failed: ${err?.message || String(err)}`);
44
+ }
45
+ }
46
+
35
47
  getContextWindow(model) {
36
48
  return CONTEXT_WINDOWS[model] ?? 131072;
37
49
  }
@@ -6,24 +6,26 @@ class OpenAIProvider extends OpenAICompatibleProvider {
6
6
  super(config);
7
7
  this.name = 'openai';
8
8
  this.models = [
9
+ 'gpt-5.5',
9
10
  'gpt-5',
10
11
  'gpt-5-mini',
11
12
  'gpt-5-nano',
12
13
  'gpt-5.2',
13
- 'gpt-4.1',
14
14
  'o3',
15
+ 'o3-pro',
15
16
  'o4-mini'
16
17
  ];
17
18
  // Reasoning models: no temperature, use max_completion_tokens, support reasoning_effort
18
- this.reasoningModels = new Set(['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5.2', 'gpt-5.1', 'o1', 'o3', 'o4-mini', 'o3-mini']);
19
+ this.reasoningModels = new Set(['gpt-5.5', 'gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5.2', 'gpt-5.1', 'o1', 'o3', 'o3-pro', 'o4-mini', 'o3-mini']);
19
20
  this.contextWindows = {
21
+ 'gpt-5.5': 1000000,
20
22
  'gpt-5': 400000,
21
- 'gpt-5-mini': 200000,
23
+ 'gpt-5-mini': 400000,
22
24
  'gpt-5-nano': 128000,
23
25
  'gpt-5.2': 400000,
24
26
  'gpt-5.1': 400000,
25
- 'gpt-4.1': 1047576,
26
27
  'o3': 200000,
28
+ 'o3-pro': 200000,
27
29
  'o4-mini': 200000,
28
30
  'o3-mini': 200000
29
31
  };
@@ -34,6 +36,18 @@ class OpenAIProvider extends OpenAICompatibleProvider {
34
36
  });
35
37
  }
36
38
 
39
+ async listModels() {
40
+ try {
41
+ const res = await this.client.models.list();
42
+ const DROP = /dall-e|whisper|tts|embed|moderat|realtime|audio|transcribe|search-api|-image-|babbage|davinci-002|^sora|-instruct/i;
43
+ return res.data
44
+ .filter((m) => !DROP.test(m.id))
45
+ .map((m) => ({ id: m.id, name: m.id }));
46
+ } catch (err) {
47
+ throw new Error(`Failed to list OpenAI models: ${err.message || String(err)}`);
48
+ }
49
+ }
50
+
37
51
  isReasoningModel(model) {
38
52
  // Match exact IDs and prefix variants (gpt-5-2025-08-07 etc)
39
53
  for (const id of this.reasoningModels) {
@@ -0,0 +1,139 @@
1
+ const OpenAI = require('openai');
2
+ const { OpenAICompatibleProvider } = require('./openaiCompatible');
3
+
4
+ const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
5
+
6
+ // Context windows fetched from the API are cached here so getContextWindow
7
+ // can serve them without a network call at inference time.
8
+ const contextWindowCache = new Map();
9
+
10
+ class OpenRouterProvider extends OpenAICompatibleProvider {
11
+ constructor(config = {}) {
12
+ super(config);
13
+ this.name = 'openrouter';
14
+ this.models = [];
15
+ this.baseURL = config.baseUrl || OPENROUTER_BASE_URL;
16
+ this.client = new OpenAI({
17
+ apiKey: config.apiKey || process.env.OPENROUTER_API_KEY,
18
+ baseURL: this.baseURL,
19
+ defaultHeaders: {
20
+ 'HTTP-Referer': 'https://github.com/NeoLabs-Systems/NeoAgent',
21
+ 'X-Title': 'NeoAgent',
22
+ },
23
+ });
24
+ }
25
+
26
+ async listModels() {
27
+ const res = await fetch(`${this.baseURL}/models`, {
28
+ headers: { 'Authorization': `Bearer ${this.client.apiKey}` },
29
+ });
30
+ if (!res.ok) throw new Error(`OpenRouter /models returned HTTP ${res.status}`);
31
+ const { data } = await res.json();
32
+ const models = data || [];
33
+ for (const m of models) {
34
+ if (m.context_length) contextWindowCache.set(m.id, m.context_length);
35
+ }
36
+ this.models = models.map((m) => m.id);
37
+ return models;
38
+ }
39
+
40
+ getContextWindow(model) {
41
+ return contextWindowCache.get(model) ?? 128000;
42
+ }
43
+
44
+ _buildParams(model, messages, tools, options) {
45
+ const params = {
46
+ model,
47
+ messages,
48
+ temperature: options.temperature ?? 0.7,
49
+ max_tokens: options.maxTokens || 16384,
50
+ };
51
+
52
+ if (tools && tools.length > 0) {
53
+ params.tools = this.formatTools(tools);
54
+ params.tool_choice = 'auto';
55
+ }
56
+
57
+ return params;
58
+ }
59
+
60
+ async chat(messages, tools = [], options = {}) {
61
+ const model = options.model || this.getDefaultModel();
62
+ const params = this._buildParams(model, messages, tools, options);
63
+ let response;
64
+ try {
65
+ response = await this.client.chat.completions.create(params);
66
+ } catch (err) {
67
+ throw new Error(`OpenRouter request failed: ${err?.message || String(err)}`);
68
+ }
69
+ return this.normalizeResponse(response);
70
+ }
71
+
72
+ async *stream(messages, tools = [], options = {}) {
73
+ const model = options.model || this.getDefaultModel();
74
+ const params = {
75
+ ...this._buildParams(model, messages, tools, options),
76
+ stream: true,
77
+ stream_options: { include_usage: true },
78
+ };
79
+
80
+ let stream;
81
+ try {
82
+ stream = await this.client.chat.completions.create(params);
83
+ } catch (err) {
84
+ throw new Error(`OpenRouter request failed: ${err?.message || String(err)}`);
85
+ }
86
+
87
+ let toolCalls = [];
88
+ let content = '';
89
+ let finalUsage = null;
90
+
91
+ for await (const chunk of stream) {
92
+ if (chunk.usage && (!chunk.choices || chunk.choices.length === 0)) {
93
+ finalUsage = this.normalizeUsage(chunk.usage);
94
+ continue;
95
+ }
96
+
97
+ const delta = chunk.choices?.[0]?.delta;
98
+ if (!delta) continue;
99
+
100
+ if (delta.content) {
101
+ content += delta.content;
102
+ yield { type: 'content', content: delta.content };
103
+ }
104
+
105
+ if (delta.tool_calls) {
106
+ for (const tc of delta.tool_calls) {
107
+ if (!toolCalls[tc.index]) {
108
+ toolCalls[tc.index] = {
109
+ id: tc.id || '',
110
+ type: 'function',
111
+ function: { name: tc.function?.name || '', arguments: '' },
112
+ };
113
+ }
114
+ if (tc.id) toolCalls[tc.index].id = tc.id;
115
+ if (tc.function?.name) toolCalls[tc.index].function.name = tc.function.name;
116
+ if (tc.function?.arguments) toolCalls[tc.index].function.arguments += tc.function.arguments;
117
+ }
118
+ }
119
+
120
+ const finishReason = chunk.choices[0]?.finish_reason;
121
+ if (finishReason === 'tool_calls' || (finishReason === 'stop' && toolCalls.length > 0)) {
122
+ yield { type: 'tool_calls', toolCalls, content, usage: this.normalizeUsage(chunk.usage) || finalUsage };
123
+ return;
124
+ }
125
+ if (finishReason === 'stop') {
126
+ yield { type: 'done', content, usage: this.normalizeUsage(chunk.usage) || finalUsage };
127
+ return;
128
+ }
129
+ }
130
+
131
+ if (toolCalls.length > 0) {
132
+ yield { type: 'tool_calls', toolCalls, content, usage: finalUsage };
133
+ } else {
134
+ yield { type: 'done', content, usage: finalUsage };
135
+ }
136
+ }
137
+ }
138
+
139
+ module.exports = { OpenRouterProvider };
@@ -109,6 +109,16 @@ const AI_PROVIDER_DEFINITIONS = Object.freeze({
109
109
  defaultEnabled: false,
110
110
  defaultBaseUrl: ''
111
111
  },
112
+ openrouter: {
113
+ id: 'openrouter',
114
+ label: 'OpenRouter',
115
+ description: 'Access 300+ models through one API, including free-tier models. Get a key at openrouter.ai.',
116
+ envKey: 'OPENROUTER_API_KEY',
117
+ supportsApiKey: true,
118
+ supportsBaseUrl: true,
119
+ defaultEnabled: false,
120
+ defaultBaseUrl: 'https://openrouter.ai/api/v1'
121
+ },
112
122
  ollama: {
113
123
  id: 'ollama',
114
124
  label: 'Ollama',