neoagent 2.4.1-beta.36 → 2.4.1-beta.38

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.
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ // Small, dependency-free helpers for shaping values into safe log lines and
4
+ // for tolerant JSON parsing. Shared across the AI engine and its sibling
5
+ // modules so log formatting stays consistent and testable.
6
+
7
+ function shortenRunId(runId) {
8
+ const value = String(runId || '').trim();
9
+ if (!value) return 'unknown';
10
+ return value.length <= 8 ? value : value.slice(0, 8);
11
+ }
12
+
13
+ function summarizeForLog(value, maxChars = 220) {
14
+ if (value == null) return '';
15
+
16
+ let text = '';
17
+ if (typeof value === 'string') {
18
+ text = value;
19
+ } else {
20
+ try {
21
+ text = JSON.stringify(value);
22
+ } catch {
23
+ text = String(value);
24
+ }
25
+ }
26
+
27
+ const normalized = text.replace(/\s+/g, ' ').trim();
28
+ if (normalized.length <= maxChars) return normalized;
29
+ return `${normalized.slice(0, maxChars)}...`;
30
+ }
31
+
32
+ function parseMaybeJson(value, fallback = null) {
33
+ if (!value) return fallback;
34
+ if (typeof value === 'object') return value;
35
+ try {
36
+ return JSON.parse(value);
37
+ } catch {
38
+ return fallback;
39
+ }
40
+ }
41
+
42
+ module.exports = {
43
+ shortenRunId,
44
+ summarizeForLog,
45
+ parseMaybeJson,
46
+ };
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ // Deterministic, model-free helpers for shaping outgoing messages and for
4
+ // constructing honest fallback replies when a run fails or the model returns a
5
+ // blank message. Kept free of engine state so the behavior is pure and unit
6
+ // testable: every function derives its output from its arguments alone.
7
+
8
+ const {
9
+ buildPlatformFormattingGuide,
10
+ normalizeOutgoingMessageForPlatform,
11
+ } = require('../messaging/formatting_guides');
12
+ const { summarizeForLog } = require('./logFormat');
13
+
14
+ function normalizeOutgoingMessage(content, platform = null, options = {}) {
15
+ const normalized = normalizeOutgoingMessageForPlatform(platform, content);
16
+ if (options.collapseWhitespace === false) {
17
+ return normalized;
18
+ }
19
+ return normalized.replace(/\s+/g, ' ').trim();
20
+ }
21
+
22
+ function clampRunContext(text, maxChars) {
23
+ const value = normalizeOutgoingMessage(text);
24
+ if (!value) return '';
25
+ if (value.length <= maxChars) return value;
26
+ return `${value.slice(0, maxChars)}...`;
27
+ }
28
+
29
+ function joinSentMessages(messages = []) {
30
+ if (!Array.isArray(messages)) return '';
31
+ return messages
32
+ .map((message) => String(message || '').trim())
33
+ .filter(Boolean)
34
+ .join('\n\n');
35
+ }
36
+
37
+ function normalizeInterimText(content, platform = null) {
38
+ return normalizeOutgoingMessageForPlatform(platform, content, {
39
+ stripNoResponseMarker: false,
40
+ }).trim();
41
+ }
42
+
43
+ function buildBlankMessagingReplyPrompt(attempt, platform = null) {
44
+ const formattingGuide = buildPlatformFormattingGuide(platform);
45
+ if (attempt <= 1) {
46
+ return `You must send one non-empty reply for the external messaging user right now. Do not call tools. Give either: (a) the concrete outcome, or (b) a clear blocker. If tool work already happened, summarize what you actually tried and where it got blocked. Do not ask the user to repeat the original request. Do not promise future work unless that work already happened in this run or will happen automatically before this reply is sent.\n\n${formattingGuide}`;
47
+ }
48
+
49
+ return `Your previous reply was empty. Return one non-empty message now. Do not call tools. If needed, apologize briefly and explain the blocker in one sentence. Use the run evidence already in the conversation instead of asking the user to restate the task. Do not promise future work unless that work already happened in this run or will happen automatically before this reply is sent.\n\n${formattingGuide}`;
50
+ }
51
+
52
+ function parseToolExecutionSummary(item) {
53
+ if (!item?.summary) return null;
54
+ try {
55
+ const parsed = JSON.parse(item.summary);
56
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function toolWorkDescription(toolName) {
63
+ const name = String(toolName || '');
64
+ if (name === 'execute_command') return 'ran shell commands';
65
+ if (name === 'read_file' || name === 'search_files' || name === 'list_directory') return 'checked files';
66
+ if (name === 'web_search' || name === 'http_request') return 'looked up supporting information';
67
+ if (name.startsWith('browser_')) return 'checked the browser state';
68
+ if (name.startsWith('android_')) return 'checked the Android state';
69
+ if (name === 'read_health_data' || name.startsWith('recordings_')) return 'checked stored data';
70
+ return '';
71
+ }
72
+
73
+ function summarizeRecentWork(toolExecutions = []) {
74
+ const descriptions = [];
75
+ for (const item of toolExecutions.slice(-6)) {
76
+ const description = toolWorkDescription(item?.toolName);
77
+ if (!description || descriptions.includes(description)) continue;
78
+ descriptions.push(description);
79
+ if (descriptions.length >= 2) break;
80
+ }
81
+
82
+ if (descriptions.length === 0) return '';
83
+ if (descriptions.length === 1) return `I ${descriptions[0]}`;
84
+ return `I ${descriptions[0]} and ${descriptions[1]}`;
85
+ }
86
+
87
+ function hasFailureSignal(text) {
88
+ const normalized = normalizeOutgoingMessage(text);
89
+ if (!normalized) return false;
90
+ return /\b(error|failed|failure|traceback|exception|timed out|timeout|not found|no such file|permission denied|unable to|cannot|could not|module not found)\b/i.test(normalized);
91
+ }
92
+
93
+ function extractToolFailureMessage(item) {
94
+ const directError = normalizeOutgoingMessage(item?.error || '');
95
+ if (directError) return directError;
96
+
97
+ const summary = parseToolExecutionSummary(item);
98
+ if (!summary) return '';
99
+
100
+ const candidates = [
101
+ summary.message,
102
+ summary.note,
103
+ summary.stderr,
104
+ summary.stdout,
105
+ summary.content,
106
+ summary.excerpt,
107
+ summary.result,
108
+ summary.summary,
109
+ ];
110
+
111
+ if (summary.status === 'error') {
112
+ for (const candidate of candidates) {
113
+ const normalized = normalizeOutgoingMessage(candidate || '');
114
+ if (normalized) return normalized;
115
+ }
116
+ if (summary.exitCode != null) {
117
+ return `The last shell command exited with code ${summary.exitCode}`;
118
+ }
119
+ }
120
+
121
+ for (const candidate of candidates) {
122
+ const normalized = normalizeOutgoingMessage(candidate || '');
123
+ if (hasFailureSignal(normalized)) return normalized;
124
+ }
125
+
126
+ return '';
127
+ }
128
+
129
+ function buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions = [] }) {
130
+ const workSummary = summarizeRecentWork(toolExecutions);
131
+ const blocker = [...toolExecutions].reverse()
132
+ .map((item) => extractToolFailureMessage(item))
133
+ .find(Boolean);
134
+
135
+ if (workSummary && blocker) {
136
+ return `${workSummary}, but I got blocked: ${blocker}. I do not have a confirmed finished result yet.`;
137
+ }
138
+ if (blocker) {
139
+ return `I got blocked while working on this: ${blocker}. I do not have a confirmed finished result yet.`;
140
+ }
141
+ if (workSummary && stepIndex > 0) {
142
+ return `${workSummary}, but I do not have a confirmed finished result yet.`;
143
+ }
144
+ if (failedStepCount > 0) {
145
+ return 'I ran into a tool problem while working on your request, so I do not have a confirmed finished result yet.';
146
+ }
147
+ if (stepIndex > 0) {
148
+ return 'I completed part of the work, but I do not have a confirmed finished result yet.';
149
+ }
150
+ return 'I could not produce a reliable final reply just now.';
151
+ }
152
+
153
+ function buildMessagingFailureScenario({ err, failedStepCount, stepIndex, toolExecutions = [] }) {
154
+ const parts = [];
155
+ const runtimeError = normalizeOutgoingMessage(err?.message || '');
156
+ const workSummary = summarizeRecentWork(toolExecutions);
157
+ const blocker = [...toolExecutions].reverse()
158
+ .map((item) => extractToolFailureMessage(item))
159
+ .find(Boolean);
160
+
161
+ if (runtimeError) {
162
+ parts.push(`Runtime error: ${summarizeForLog(runtimeError, 260)}.`);
163
+ }
164
+ if (workSummary) {
165
+ parts.push(`Observed work before failure: ${workSummary}.`);
166
+ }
167
+ if (blocker) {
168
+ parts.push(`Most specific blocker from run evidence: ${summarizeForLog(blocker, 260)}.`);
169
+ }
170
+ if (stepIndex > 0) {
171
+ parts.push(`Completed steps before failure: ${stepIndex}.`);
172
+ }
173
+ if (failedStepCount > 0) {
174
+ parts.push(`Failed tool steps: ${failedStepCount}.`);
175
+ }
176
+
177
+ return parts.join(' ');
178
+ }
179
+
180
+ function buildDeterministicMessagingErrorReply({ err, failedStepCount, stepIndex, toolExecutions = [] }) {
181
+ const message = normalizeOutgoingMessage(err?.message || '');
182
+ if (/no ai providers? are currently available/i.test(message)) {
183
+ return 'I cannot continue right now because no AI provider is available for this account. Please check the provider settings.';
184
+ }
185
+
186
+ if (/(timeout|timed out)/i.test(message)) {
187
+ return 'I hit a timeout while processing your request and could not finish it reliably.';
188
+ }
189
+
190
+ const blocker = [...toolExecutions].reverse()
191
+ .map((item) => extractToolFailureMessage(item))
192
+ .find(Boolean);
193
+ if (blocker) {
194
+ return `I got blocked while checking this: ${blocker}.`;
195
+ }
196
+
197
+ if (message) {
198
+ return `I got blocked while working on this: ${message}.`;
199
+ }
200
+
201
+ return buildDeterministicMessagingFallback({ failedStepCount, stepIndex, toolExecutions });
202
+ }
203
+
204
+ function buildModelFailureLoopPrompt({ failedModel, nextModel, errorMessage }) {
205
+ return [
206
+ `The previous model call on "${failedModel}" failed with: ${summarizeForLog(errorMessage, 220)}.`,
207
+ `Continue on "${nextModel}" and recover autonomously.`,
208
+ 'If a previous plan depended on that failed call, adjust your approach and proceed end-to-end.',
209
+ 'Only ask the user for help if no safe path remains.'
210
+ ].join(' ');
211
+ }
212
+
213
+ module.exports = {
214
+ normalizeOutgoingMessage,
215
+ clampRunContext,
216
+ joinSentMessages,
217
+ normalizeInterimText,
218
+ buildBlankMessagingReplyPrompt,
219
+ parseToolExecutionSummary,
220
+ toolWorkDescription,
221
+ summarizeRecentWork,
222
+ hasFailureSignal,
223
+ extractToolFailureMessage,
224
+ buildDeterministicMessagingFallback,
225
+ buildMessagingFailureScenario,
226
+ buildDeterministicMessagingErrorReply,
227
+ buildModelFailureLoopPrompt,
228
+ };
@@ -161,6 +161,12 @@ const STATIC_MODELS = [
161
161
  provider: 'google',
162
162
  purpose: 'general'
163
163
  },
164
+ {
165
+ id: 'gemini-3.1-pro',
166
+ label: 'Gemini 3.1 Pro (High)',
167
+ provider: 'google',
168
+ purpose: 'general'
169
+ },
164
170
  {
165
171
  id: 'MiniMax-M2.7',
166
172
  label: 'MiniMax M2.7 (Coding Plan)',
@@ -172,9 +178,35 @@ const STATIC_MODELS = [
172
178
  label: 'Qwen 3.5 4B (Local / Ollama)',
173
179
  provider: 'ollama',
174
180
  purpose: 'general'
181
+ },
182
+ {
183
+ id: 'gemma4:12b',
184
+ label: 'Gemma 4 12B (Local / Ollama)',
185
+ provider: 'ollama',
186
+ purpose: 'general'
175
187
  }
176
188
  ];
177
189
 
190
+ // Maps a provider id to its class and which runtime fields its constructor takes.
191
+ // Adding a provider is a one-line entry here instead of another dispatch branch.
192
+ // `apiKey`/`baseUrl` mirror exactly what each constructor was historically given;
193
+ // they intentionally do not derive from AI_PROVIDER_DEFINITIONS.supportsBaseUrl,
194
+ // which disagrees for github-copilot/openai-codex (those read their base URL from
195
+ // env, not from per-user config).
196
+ const PROVIDER_FACTORIES = Object.freeze({
197
+ grok: { Provider: GrokProvider, apiKey: true, baseUrl: true },
198
+ openai: { Provider: OpenAIProvider, apiKey: true, baseUrl: true },
199
+ anthropic: { Provider: AnthropicProvider, apiKey: true, baseUrl: true },
200
+ google: { Provider: GoogleProvider, apiKey: true, baseUrl: false },
201
+ minimax: { Provider: AnthropicProvider, apiKey: true, baseUrl: true },
202
+ ollama: { Provider: OllamaProvider, apiKey: false, baseUrl: true },
203
+ 'github-copilot': { Provider: GithubCopilotProvider, apiKey: true, baseUrl: false },
204
+ 'openai-codex': { Provider: OpenAICodexProvider, apiKey: true, baseUrl: false },
205
+ 'claude-code': { Provider: ClaudeCodeProvider, apiKey: true, baseUrl: false },
206
+ 'grok-oauth': { Provider: GrokOAuthProvider, apiKey: true, baseUrl: false },
207
+ nvidia: { Provider: NvidiaProvider, apiKey: true, baseUrl: true },
208
+ });
209
+
178
210
  const dynamicModelsByBaseUrl = new Map();
179
211
  const REFRESH_INTERVAL = 30000; // 30 seconds
180
212
 
@@ -395,6 +427,11 @@ async function refreshDynamicModels(baseUrl) {
395
427
  }
396
428
 
397
429
  function createProviderInstance(providerStr, userId = null, configOverrides = {}) {
430
+ const factory = PROVIDER_FACTORIES[providerStr];
431
+ if (!factory) {
432
+ throw new Error(`Unknown provider: ${providerStr}`);
433
+ }
434
+
398
435
  const { agentId = null, ...providerOverrides } = configOverrides || {};
399
436
  const runtime = getProviderRuntimeConfig(userId, providerStr, agentId);
400
437
 
@@ -405,34 +442,16 @@ function createProviderInstance(providerStr, userId = null, configOverrides = {}
405
442
  throw new Error(`Provider '${providerStr}' is not configured on this deployment.`);
406
443
  }
407
444
 
408
- if (providerStr === 'grok') {
409
- return new GrokProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl, ...providerOverrides });
410
- } else if (providerStr === 'openai') {
411
- return new OpenAIProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl, ...providerOverrides });
412
- } else if (providerStr === 'anthropic') {
413
- return new AnthropicProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl, ...providerOverrides });
414
- } else if (providerStr === 'google') {
415
- return new GoogleProvider({ apiKey: runtime.apiKey, ...providerOverrides });
416
- } else if (providerStr === 'minimax') {
417
- return new AnthropicProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl, ...providerOverrides });
418
- } else if (providerStr === 'ollama') {
419
- return new OllamaProvider({ baseUrl: runtime.baseUrl, ...providerOverrides });
420
- } else if (providerStr === 'github-copilot') {
421
- return new GithubCopilotProvider({ apiKey: runtime.apiKey, ...providerOverrides });
422
- } else if (providerStr === 'openai-codex') {
423
- return new OpenAICodexProvider({ apiKey: runtime.apiKey, ...providerOverrides });
424
- } else if (providerStr === 'claude-code') {
425
- return new ClaudeCodeProvider({ apiKey: runtime.apiKey, ...providerOverrides });
426
- } else if (providerStr === 'grok-oauth') {
427
- return new GrokOAuthProvider({ apiKey: runtime.apiKey, ...providerOverrides });
428
- } else if (providerStr === 'nvidia') {
429
- return new NvidiaProvider({ apiKey: runtime.apiKey, baseUrl: runtime.baseUrl, ...providerOverrides });
430
- }
431
- throw new Error(`Unknown provider: ${providerStr}`);
445
+ const config = {};
446
+ if (factory.apiKey) config.apiKey = runtime.apiKey;
447
+ if (factory.baseUrl) config.baseUrl = runtime.baseUrl;
448
+
449
+ return new factory.Provider({ ...config, ...providerOverrides });
432
450
  }
433
451
 
434
452
  module.exports = {
435
453
  AI_PROVIDER_DEFINITIONS,
454
+ PROVIDER_FACTORIES,
436
455
  SUPPORTED_MODELS: STATIC_MODELS, // Backward compatibility
437
456
  createProviderInstance,
438
457
  getProviderCatalog,
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+
3
+ // Centralized transient-error retry for AI provider calls.
4
+ //
5
+ // A transient blip (rate limit, provider overload, brief network failure) should
6
+ // retry the SAME model with a short backoff. Only after these retries are
7
+ // exhausted does the engine fall back to a different (often weaker) model. This
8
+ // keeps response quality high and avoids burning the fallback chain on errors a
9
+ // one-second wait would have resolved.
10
+
11
+ const DEFAULTS = {
12
+ maxAttempts: 3, // total attempts including the first
13
+ baseDelayMs: 500,
14
+ maxDelayMs: 8000,
15
+ };
16
+
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
+ function readNumberEnv(name, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
29
+ const raw = process.env[name];
30
+ if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
31
+ const parsed = Number(raw);
32
+ if (!Number.isFinite(parsed)) return fallback;
33
+ return Math.min(max, Math.max(min, parsed));
34
+ }
35
+
36
+ function resolveConfig(overrides = {}) {
37
+ return {
38
+ maxAttempts: overrides.maxAttempts
39
+ ?? readNumberEnv('NEOAGENT_AI_RETRY_MAX_ATTEMPTS', DEFAULTS.maxAttempts, { min: 1, max: 8 }),
40
+ baseDelayMs: overrides.baseDelayMs
41
+ ?? readNumberEnv('NEOAGENT_AI_RETRY_BASE_MS', DEFAULTS.baseDelayMs, { min: 0, max: 60000 }),
42
+ maxDelayMs: overrides.maxDelayMs
43
+ ?? readNumberEnv('NEOAGENT_AI_RETRY_MAX_MS', DEFAULTS.maxDelayMs, { min: 0, max: 120000 }),
44
+ };
45
+ }
46
+
47
+ // SDKs disagree on where they put the HTTP status: OpenAI/Anthropic expose
48
+ // `.status`, raw http clients use `.statusCode`, and some nest it under
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
+ function isTransientError(err) {
66
+ if (!err) return false;
67
+
68
+ const status = getStatus(err);
69
+ if (status !== null) return RETRYABLE_STATUS.has(status);
70
+
71
+ const code = getErrorCode(err);
72
+ if (code && RETRYABLE_CODES.has(String(code))) return true;
73
+
74
+ // SDK connection wrappers that don't carry a status or code.
75
+ const name = String(err.name || '');
76
+ if (name === 'APIConnectionError' || name === 'APIConnectionTimeoutError') return true;
77
+
78
+ const message = String(err.message || '').toLowerCase();
79
+ if (!message) return false;
80
+ return /\b(overloaded|rate limit|timed? ?out|timeout|temporarily unavailable|connection (?:reset|refused|error)|socket hang up|network (?:error|timeout)|service unavailable)\b/.test(message);
81
+ }
82
+
83
+ // Honor a server-provided Retry-After when present; it is authoritative over our
84
+ // own backoff. Supports both delta-seconds and `retry-after-ms` style headers.
85
+ function retryAfterMs(err) {
86
+ 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));
116
+ }
117
+
118
+ function delay(ms) {
119
+ return new Promise((resolve) => setTimeout(resolve, ms));
120
+ }
121
+
122
+ /**
123
+ * Run `fn` with transient-error retries.
124
+ *
125
+ * @param {(attempt: number) => Promise<any>} fn The provider call to attempt;
126
+ * invoked with the current 1-based attempt number.
127
+ * @param {object} [options]
128
+ * @param {(err: any) => boolean} [options.isRetryable] Override transient classification.
129
+ * @param {(info: {attempt:number, delayMs:number, error:any}) => void} [options.onRetry]
130
+ * Called before each wait so callers can surface progress to the user.
131
+ * @param {string} [options.label] Prefix for diagnostic logs.
132
+ */
133
+ async function withProviderRetry(fn, options = {}) {
134
+ const { maxAttempts, baseDelayMs, maxDelayMs } = resolveConfig(options);
135
+ const isRetryable = typeof options.isRetryable === 'function' ? options.isRetryable : isTransientError;
136
+ const label = options.label || 'ProviderRetry';
137
+
138
+ let attempt = 0;
139
+ while (true) {
140
+ attempt += 1;
141
+ try {
142
+ return await fn(attempt);
143
+ } catch (err) {
144
+ const exhausted = attempt >= maxAttempts;
145
+ if (exhausted || !isRetryable(err)) throw err;
146
+
147
+ const waitMs = retryAfterMs(err) ?? computeBackoffMs(attempt, baseDelayMs, maxDelayMs);
148
+ console.warn(
149
+ `[${label}] transient failure on attempt ${attempt}/${maxAttempts}; retrying in ${waitMs}ms: ${String(err?.message || err).slice(0, 200)}`
150
+ );
151
+ if (typeof options.onRetry === 'function') {
152
+ try {
153
+ options.onRetry({ attempt, delayMs: waitMs, error: err });
154
+ } catch { /* a misbehaving progress callback must not abort the retry */ }
155
+ }
156
+ await delay(waitMs);
157
+ }
158
+ }
159
+ }
160
+
161
+ module.exports = {
162
+ withProviderRetry,
163
+ isTransientError,
164
+ retryAfterMs,
165
+ computeBackoffMs,
166
+ resolveConfig,
167
+ RETRYABLE_STATUS,
168
+ RETRYABLE_CODES,
169
+ };
@@ -10,14 +10,16 @@ class GoogleProvider extends BaseProvider {
10
10
  'gemini-2.0-pro',
11
11
  'gemini-1.5-pro',
12
12
  'gemini-1.5-flash',
13
- 'gemini-3.1-flash-lite-preview'
13
+ 'gemini-3.1-flash-lite-preview',
14
+ 'gemini-3.1-pro'
14
15
  ];
15
16
  this.contextWindows = {
16
17
  'gemini-2.0-flash': 1048576,
17
18
  'gemini-2.0-pro': 2097152,
18
19
  'gemini-1.5-pro': 2097152,
19
20
  'gemini-1.5-flash': 1048576,
20
- 'gemini-3.1-flash-lite-preview': 1048576
21
+ 'gemini-3.1-flash-lite-preview': 1048576,
22
+ 'gemini-3.1-pro': 2097152
21
23
  };
22
24
  this.genAI = new GoogleGenerativeAI(config.apiKey || process.env.GOOGLE_AI_KEY);
23
25
  }
@@ -177,14 +179,12 @@ class GoogleProvider extends BaseProvider {
177
179
  const toolCalls = [];
178
180
 
179
181
  for await (const chunk of result.stream) {
180
- const text = chunk.text();
181
- if (text) {
182
- content += text;
183
- yield { type: 'content', content: text };
184
- }
185
-
186
182
  for (const candidate of chunk.candidates || []) {
187
183
  for (const part of candidate.content?.parts || []) {
184
+ if (part.text) {
185
+ content += part.text;
186
+ yield { type: 'content', content: part.text };
187
+ }
188
188
  if (part.functionCall) {
189
189
  toolCalls.push({
190
190
  id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
@@ -1,7 +1,7 @@
1
1
  const OpenAI = require('openai');
2
- const { BaseProvider } = require('./base');
2
+ const { OpenAICompatibleProvider } = require('./openaiCompatible');
3
3
 
4
- class GrokProvider extends BaseProvider {
4
+ class GrokProvider extends OpenAICompatibleProvider {
5
5
  constructor(config = {}) {
6
6
  super(config);
7
7
  this.name = 'grok';
@@ -68,7 +68,7 @@ class GrokProvider extends BaseProvider {
68
68
 
69
69
  for await (const chunk of stream) {
70
70
  if (chunk.usage && (!chunk.choices || chunk.choices.length === 0)) {
71
- finalUsage = this.#normalizeUsage(chunk.usage);
71
+ finalUsage = this.normalizeUsage(chunk.usage);
72
72
  continue;
73
73
  }
74
74
 
@@ -97,7 +97,7 @@ class GrokProvider extends BaseProvider {
97
97
  type: 'tool_calls',
98
98
  toolCalls,
99
99
  content,
100
- usage: this.#normalizeUsage(chunk.usage) || finalUsage
100
+ usage: this.normalizeUsage(chunk.usage) || finalUsage
101
101
  };
102
102
  return;
103
103
  }
@@ -105,7 +105,7 @@ class GrokProvider extends BaseProvider {
105
105
  yield {
106
106
  type: 'done',
107
107
  content,
108
- usage: this.#normalizeUsage(chunk.usage) || finalUsage
108
+ usage: this.normalizeUsage(chunk.usage) || finalUsage
109
109
  };
110
110
  return;
111
111
  }
@@ -118,68 +118,6 @@ class GrokProvider extends BaseProvider {
118
118
  }
119
119
  }
120
120
 
121
- normalizeResponse(response) {
122
- const choice = response.choices[0];
123
- const msg = choice.message;
124
- return {
125
- content: msg.content || '',
126
- toolCalls: msg.tool_calls?.map(tc => ({
127
- id: tc.id,
128
- type: 'function',
129
- function: { name: tc.function.name, arguments: tc.function.arguments }
130
- })) || [],
131
- finishReason: choice.finish_reason,
132
- usage: this.#normalizeUsage(response.usage)
133
- };
134
- }
135
-
136
- #normalizeUsage(usage) {
137
- if (!usage) {
138
- return null;
139
- }
140
- return {
141
- promptTokens: usage.prompt_tokens ?? usage.promptTokens ?? 0,
142
- completionTokens: usage.completion_tokens ?? usage.completionTokens ?? 0,
143
- totalTokens: usage.total_tokens ?? usage.totalTokens ?? 0,
144
- };
145
- }
146
-
147
- formatTools(tools) {
148
- return tools.map(tool => ({
149
- type: 'function',
150
- function: {
151
- name: tool.name,
152
- description: tool.description,
153
- parameters: tool.parameters
154
- }
155
- }));
156
- }
157
-
158
- async analyzeImage(options = {}) {
159
- const model = options.model || this.getDefaultVisionModel();
160
- const b64 = BaseProvider.readImageAsBase64(options.imagePath);
161
- const response = await this.client.chat.completions.create({
162
- model,
163
- max_tokens: options.maxTokens || 4096,
164
- messages: [{
165
- role: 'user',
166
- content: [
167
- { type: 'text', text: options.question || 'Describe this image in detail.' },
168
- {
169
- type: 'image_url',
170
- image_url: {
171
- url: `data:${options.mimeType || 'image/jpeg'};base64,${b64}`
172
- }
173
- }
174
- ]
175
- }]
176
- });
177
-
178
- return {
179
- content: response.choices[0]?.message?.content || '',
180
- model: response.model || model,
181
- };
182
- }
183
121
  }
184
122
 
185
123
  module.exports = { GrokProvider };