neoagent 3.2.1-beta.1 → 3.2.1-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +358 -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 +20 -45
  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 +21 -33
  52. package/server/services/ai/providers/githubCopilot.js +41 -20
  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 +19 -27
  56. package/server/services/ai/providers/nvidia.js +10 -5
  57. package/server/services/ai/providers/ollama.js +111 -84
  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
@@ -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,111 @@
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
+
11
+ function buildSelection(model, userId, providerConfig) {
12
+ return {
13
+ provider: createProviderInstance(model.provider, userId, providerConfig),
14
+ model: getRawModelId(model),
15
+ modelSelectionId: model.id,
16
+ providerName: model.provider,
17
+ };
18
+ }
19
+
20
+ async function getProviderForUser(
21
+ userId,
22
+ _task = '',
23
+ isSubagent = false,
24
+ modelOverride = null,
25
+ providerConfig = {},
26
+ ) {
27
+ const agentId = providerConfig.agentId || null;
28
+ const aiSettings = getAiSettings(userId, agentId);
29
+ const models = await getSupportedModels(userId, agentId, {
30
+ signal: providerConfig.signal,
31
+ });
32
+ const selectableModels = models.filter((model) => model.available !== false);
33
+
34
+ if (modelOverride && typeof modelOverride === 'string') {
35
+ const requested = resolveModelSelection(selectableModels, modelOverride);
36
+ if (!requested) {
37
+ throw new Error(`Requested model '${modelOverride.trim()}' is not available.`);
38
+ }
39
+ return buildSelection(requested, userId, providerConfig);
40
+ }
41
+
42
+ const configuredEnabledIds = Array.isArray(aiSettings.enabled_models)
43
+ ? aiSettings.enabled_models.map((id) => String(id).trim()).filter(Boolean)
44
+ : [];
45
+ const enabledIds = normalizeModelSelections(selectableModels, configuredEnabledIds);
46
+ const availableModels = configuredEnabledIds.length > 0
47
+ ? selectableModels.filter((model) => enabledIds.includes(model.id))
48
+ : selectableModels;
49
+
50
+ if (availableModels.length === 0) {
51
+ const message = configuredEnabledIds.length > 0
52
+ ? 'None of the enabled AI models are currently available. Check model and provider settings.'
53
+ : 'No AI providers are currently available. Open Settings and configure at least one provider.';
54
+ throw new Error(message);
55
+ }
56
+
57
+ const fallbackModel = availableModels[0];
58
+ const userSelectedDefault = isSubagent
59
+ ? (aiSettings.default_subagent_model || 'auto')
60
+ : (aiSettings.default_chat_model || 'auto');
61
+ const smarterSelection = aiSettings.smarter_model_selector !== false
62
+ && aiSettings.smarter_model_selector !== 'false';
63
+
64
+ let selectedModel = fallbackModel;
65
+ if (userSelectedDefault !== 'auto') {
66
+ selectedModel = resolveModelSelection(availableModels, userSelectedDefault) || fallbackModel;
67
+ } else {
68
+ const selectionHint = providerConfig.selectionHint && typeof providerConfig.selectionHint === 'object'
69
+ ? providerConfig.selectionHint
70
+ : {};
71
+ const preferredPurpose = String(selectionHint.purpose || '').trim().toLowerCase();
72
+ const highAutonomy = selectionHint.autonomyLevel === 'high'
73
+ || selectionHint.complexity === 'complex';
74
+ const requiredConfidence = String(selectionHint.requiredConfidence || '').trim().toLowerCase();
75
+ const costMode = String(selectionHint.costMode || aiSettings.cost_mode || 'balanced_auto')
76
+ .trim()
77
+ .toLowerCase();
78
+ const requestedPurpose = ['planning', 'coding', 'general', 'fast'].includes(preferredPurpose)
79
+ ? preferredPurpose
80
+ : '';
81
+ const priceRank = { free: 0, cheap: 1, medium: 2, expensive: 3 };
82
+ const chooseForPurpose = (purpose) => {
83
+ const candidates = availableModels.filter((model) => model.purpose === purpose);
84
+ if (candidates.length === 0) return null;
85
+ if (['economy', 'cost_saver', 'lowest_cost'].includes(costMode)) {
86
+ return [...candidates].sort((left, right) => (
87
+ (priceRank[left.priceTier] ?? 99) - (priceRank[right.priceTier] ?? 99)
88
+ ))[0];
89
+ }
90
+ if (['quality', 'highest_quality'].includes(costMode) || requiredConfidence === 'high') {
91
+ return candidates.find((model) => model.priceTier !== 'free' && model.priceTier !== 'cheap')
92
+ || candidates[0];
93
+ }
94
+ return candidates[0];
95
+ };
96
+
97
+ if (smarterSelection && requestedPurpose) {
98
+ selectedModel = chooseForPurpose(requestedPurpose) || fallbackModel;
99
+ } else if (smarterSelection && highAutonomy) {
100
+ selectedModel = chooseForPurpose('planning') || chooseForPurpose('general') || fallbackModel;
101
+ } else if (isSubagent) {
102
+ selectedModel = chooseForPurpose('fast') || fallbackModel;
103
+ } else {
104
+ selectedModel = chooseForPurpose('general') || fallbackModel;
105
+ }
106
+ }
107
+
108
+ return buildSelection(selectedModel, userId, providerConfig);
109
+ }
110
+
111
+ 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
 
@@ -2,6 +2,8 @@ const os = require('os');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const Anthropic = require('@anthropic-ai/sdk');
5
+ const { ENV_FILE, upsertEnvValue } = require('../../../../runtime/paths');
6
+ const { fetchResponseText } = require('../../network/http');
5
7
  const { AnthropicProvider } = require('./anthropic');
6
8
 
7
9
  const CLAUDE_CLI_CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
@@ -12,6 +14,8 @@ const CLAUDE_CODE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI
12
14
  const CLAUDE_CODE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
13
15
  const CLAUDE_CODE_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
14
16
  const CLAUDE_CODE_SCOPES = 'user:inference user:profile org:create_api_key user:sessions:claude_code user:mcp_servers';
17
+ const OAUTH_REFRESH_TIMEOUT_MS = 30000;
18
+ const OAUTH_MAX_RESPONSE_BYTES = 256 * 1024;
15
19
 
16
20
  function readTokenRecord(data) {
17
21
  const tokens = data?.claudeAiOauthTokens || data?.claudeAiOauth || {};
@@ -71,40 +75,17 @@ function normalizeExpiresAt(data) {
71
75
  return null;
72
76
  }
73
77
 
74
- function sanitizeEnvKey(key) {
75
- return String(key).replace(/[\r\n]/g, '');
76
- }
77
-
78
- function sanitizeEnvValue(value) {
79
- return String(value).replace(/[\r\n]/g, '');
80
- }
81
-
82
78
  function persistEnvValue(key, value) {
83
79
  if (!value) return;
84
80
  try {
85
- const { ENV_FILE } = require('../../../../runtime/paths');
86
- const safeKey = sanitizeEnvKey(key);
87
- const safeValue = sanitizeEnvValue(value);
88
- const raw = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, 'utf8') : '';
89
- const lines = raw ? raw.split('\n') : [];
90
- let replaced = false;
91
- for (let i = 0; i < lines.length; i++) {
92
- if (lines[i].startsWith(`${safeKey}=`)) {
93
- lines[i] = `${safeKey}=${safeValue}`;
94
- replaced = true;
95
- break;
96
- }
97
- }
98
- if (!replaced) lines.push(`${safeKey}=${safeValue}`);
99
- const output = lines.filter((_, idx, arr) => idx !== arr.length - 1 || arr[idx] !== '').join('\n') + '\n';
100
- fs.mkdirSync(path.dirname(ENV_FILE), { recursive: true });
101
- fs.writeFileSync(ENV_FILE, output, { mode: 0o600 });
81
+ upsertEnvValue(ENV_FILE, key, value);
102
82
  } catch { }
103
83
  }
104
84
 
105
- async function refreshClaudeCodeAccessToken(refreshToken, fetchImpl = fetch) {
85
+ async function refreshClaudeCodeAccessToken(refreshToken, fetchImpl = fetch, signal = null) {
106
86
  if (!refreshToken) return null;
107
- const response = await fetchImpl(CLAUDE_CODE_TOKEN_URL, {
87
+ const { response, text } = await fetchResponseText(CLAUDE_CODE_TOKEN_URL, {
88
+ fetchImpl,
108
89
  method: 'POST',
109
90
  headers: {
110
91
  'Content-Type': 'application/json',
@@ -116,9 +97,14 @@ async function refreshClaudeCodeAccessToken(refreshToken, fetchImpl = fetch) {
116
97
  refresh_token: refreshToken,
117
98
  client_id: CLAUDE_CODE_CLIENT_ID,
118
99
  }),
100
+ signal,
101
+ timeoutMs: OAUTH_REFRESH_TIMEOUT_MS,
102
+ maxResponseBytes: OAUTH_MAX_RESPONSE_BYTES,
103
+ serviceName: 'Claude Code OAuth refresh',
104
+ timeoutCode: 'PROVIDER_OAUTH_TIMEOUT',
105
+ tooLargeCode: 'PROVIDER_OAUTH_RESPONSE_TOO_LARGE',
119
106
  });
120
107
 
121
- const text = await response.text();
122
108
  let data = {};
123
109
  try {
124
110
  data = text ? JSON.parse(text) : {};
@@ -127,7 +113,9 @@ async function refreshClaudeCodeAccessToken(refreshToken, fetchImpl = fetch) {
127
113
  }
128
114
 
129
115
  if (!response.ok) {
130
- const detail = data?.error?.message || data?.error_description || data?.error || text || 'Unknown error';
116
+ const detail = String(
117
+ data?.error?.message || data?.error_description || data?.error || text || 'Unknown error',
118
+ ).slice(0, 2000);
131
119
  throw new Error(`Claude Code OAuth refresh failed: HTTP ${response.status} ${detail}`);
132
120
  }
133
121
  if (!data.access_token) {
@@ -209,8 +197,8 @@ class ClaudeCodeProvider extends AnthropicProvider {
209
197
  });
210
198
  }
211
199
 
212
- async refreshClient() {
213
- const refreshed = await refreshClaudeCodeAccessToken(this.refreshToken, this.fetchImpl);
200
+ async refreshClient(signal = null) {
201
+ const refreshed = await refreshClaudeCodeAccessToken(this.refreshToken, this.fetchImpl, signal);
214
202
  if (!refreshed?.access) return false;
215
203
  this.authToken = refreshed.access;
216
204
  this.refreshToken = refreshed.refresh || this.refreshToken;
@@ -244,7 +232,7 @@ class ClaudeCodeProvider extends AnthropicProvider {
244
232
  if ((!isAuthenticationError(err) && !isInferenceScopeError(err)) || !this.refreshToken) {
245
233
  throw formatClaudeCodeCredentialError(err);
246
234
  }
247
- await this.refreshClient();
235
+ await this.refreshClient(options.signal);
248
236
  try {
249
237
  return await super.chat(messages, tools, options);
250
238
  } catch (retryErr) {
@@ -260,7 +248,7 @@ class ClaudeCodeProvider extends AnthropicProvider {
260
248
  if ((!isAuthenticationError(err) && !isInferenceScopeError(err)) || !this.refreshToken) {
261
249
  throw formatClaudeCodeCredentialError(err);
262
250
  }
263
- await this.refreshClient();
251
+ await this.refreshClient(options.signal);
264
252
  try {
265
253
  yield* super.stream(messages, tools, options);
266
254
  } catch (retryErr) {
@@ -1,4 +1,9 @@
1
1
  const { OpenAIProvider } = require('./openai');
2
+ const {
3
+ fetchResponseText,
4
+ waitForAbortableResult,
5
+ } = require('../../network/http');
6
+ const { sanitizeProviderErrorDetail } = require('./provider_error');
2
7
 
3
8
  class GithubCopilotProvider extends OpenAIProvider {
4
9
  constructor(config = {}) {
@@ -26,35 +31,47 @@ class GithubCopilotProvider extends OpenAIProvider {
26
31
  this._refreshPromise = null;
27
32
  }
28
33
 
29
- async _refreshCopilotToken() {
30
- if (this._refreshPromise) return this._refreshPromise;
31
-
34
+ async _refreshCopilotToken(signal = null) {
32
35
  const now = Math.floor(Date.now() / 1000);
33
36
  // Refresh token if missing or expiring in less than 5 minutes
34
37
  if (this.copilotToken && this.tokenExpiresAt >= now + 300) {
35
38
  return;
36
39
  }
37
40
 
38
- this._refreshPromise = (async () => {
39
- try {
41
+ if (!this._refreshPromise) {
42
+ this._refreshPromise = (async () => {
40
43
  if (!this.githubToken) {
41
44
  throw new Error('GitHub Copilot access token is missing. Please run `neoagent login github-copilot`.');
42
45
  }
43
46
 
44
- const res = await fetch('https://api.github.com/copilot_internal/v2/token', {
47
+ const { response, text } = await fetchResponseText(
48
+ 'https://api.github.com/copilot_internal/v2/token',
49
+ {
45
50
  headers: {
46
51
  'Authorization': `token ${this.githubToken}`,
47
52
  'Accept': 'application/json',
48
53
  'User-Agent': 'NeoAgent/1.0.0'
49
- }
50
- });
51
-
52
- if (!res.ok) {
53
- const errorText = await res.text().catch(() => 'Unknown error');
54
- throw new Error(`Failed to refresh GitHub Copilot token: HTTP ${res.status} - ${errorText}`);
54
+ },
55
+ maxResponseBytes: 1024 * 1024,
56
+ serviceName: 'GitHub Copilot token refresh',
57
+ },
58
+ );
59
+
60
+ if (!response.ok) {
61
+ const error = new Error(
62
+ `Failed to refresh GitHub Copilot token: HTTP ${response.status} - ${sanitizeProviderErrorDetail(text.slice(0, 500))}`,
63
+ );
64
+ error.status = response.status;
65
+ error.headers = response.headers;
66
+ throw error;
55
67
  }
56
68
 
57
- const data = await res.json();
69
+ let data;
70
+ try {
71
+ data = JSON.parse(text || '{}');
72
+ } catch {
73
+ throw new Error('Invalid token response from GitHub Copilot.');
74
+ }
58
75
  if (!data || typeof data.token !== 'string' || !data.token) {
59
76
  throw new Error('Invalid token response from GitHub Copilot.');
60
77
  }
@@ -70,26 +87,30 @@ class GithubCopilotProvider extends OpenAIProvider {
70
87
 
71
88
  // Update the client's API key
72
89
  this.client.apiKey = this.copilotToken;
73
- } finally {
90
+ })().finally(() => {
74
91
  this._refreshPromise = null;
75
- }
76
- })();
92
+ });
93
+ }
77
94
 
78
- return this._refreshPromise;
95
+ return waitForAbortableResult(
96
+ this._refreshPromise,
97
+ signal,
98
+ 'GitHub Copilot token refresh aborted.',
99
+ );
79
100
  }
80
101
 
81
102
  async chat(messages, tools = [], options = {}) {
82
- await this._refreshCopilotToken();
103
+ await this._refreshCopilotToken(options.signal);
83
104
  return super.chat(messages, tools, options);
84
105
  }
85
106
 
86
107
  async *stream(messages, tools = [], options = {}) {
87
- await this._refreshCopilotToken();
108
+ await this._refreshCopilotToken(options.signal);
88
109
  yield* super.stream(messages, tools, options);
89
110
  }
90
111
 
91
112
  async analyzeImage(options = {}) {
92
- await this._refreshCopilotToken();
113
+ await this._refreshCopilotToken(options.signal);
93
114
  return super.analyzeImage(options);
94
115
  }
95
116
  }
@@ -1,5 +1,6 @@
1
1
  const { GoogleGenerativeAI } = require('@google/generative-ai');
2
2
  const { BaseProvider } = require('./base');
3
+ const { fetchResponseText } = require('../../network/http');
3
4
 
4
5
  class GoogleProvider extends BaseProvider {
5
6
  constructor(config = {}) {
@@ -31,13 +32,30 @@ class GoogleProvider extends BaseProvider {
31
32
  this.genAI = new GoogleGenerativeAI(this.apiKey);
32
33
  }
33
34
 
34
- async listModels() {
35
+ async listModels(signal = null) {
35
36
  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`
37
+ const { response, text } = await fetchResponseText(
38
+ 'https://generativelanguage.googleapis.com/v1beta/models?pageSize=200',
39
+ {
40
+ headers: { 'x-goog-api-key': this.apiKey },
41
+ maxResponseBytes: 5 * 1024 * 1024,
42
+ serviceName: 'Google model catalog',
43
+ signal,
44
+ },
38
45
  );
39
- if (!res.ok) throw new Error(`Google models API returned ${res.status}`);
40
- const { models = [] } = await res.json();
46
+ if (!response.ok) {
47
+ const error = new Error(`Google models API returned ${response.status}`);
48
+ error.status = response.status;
49
+ error.headers = response.headers;
50
+ throw error;
51
+ }
52
+ let payload;
53
+ try {
54
+ payload = JSON.parse(text || '{}');
55
+ } catch {
56
+ throw new Error('Google models API returned invalid JSON.');
57
+ }
58
+ const { models = [] } = payload;
41
59
  return models
42
60
  .filter((m) => {
43
61
  const id = m.name.replace('models/', '');
@@ -1,5 +1,6 @@
1
1
  const OpenAI = require('openai');
2
2
  const { OpenAICompatibleProvider } = require('./openaiCompatible');
3
+ const { wrapProviderError } = require('./provider_error');
3
4
 
4
5
  class GrokProvider extends OpenAICompatibleProvider {
5
6
  constructor(config = {}) {
@@ -11,15 +12,15 @@ class GrokProvider extends OpenAICompatibleProvider {
11
12
  });
12
13
  }
13
14
 
14
- async listModels() {
15
+ async listModels(signal = null) {
15
16
  try {
16
- const res = await this.client.models.list();
17
+ const res = await this.client.models.list({ signal });
17
18
  const DROP = /imagine|diffus|embed|-tts/i;
18
19
  return res.data
19
20
  .filter((m) => !DROP.test(m.id))
20
21
  .map((m) => ({ id: m.id, name: m.id }));
21
22
  } catch (err) {
22
- throw new Error(`Failed to list Grok models: ${err.message || String(err)}`);
23
+ throw wrapProviderError(err, 'Failed to list Grok models', { signal });
23
24
  }
24
25
  }
25
26
 
@@ -1,12 +1,14 @@
1
- const fs = require('fs');
2
- const path = require('path');
3
1
  const OpenAI = require('openai');
2
+ const { ENV_FILE, upsertEnvValue } = require('../../../../runtime/paths');
3
+ const { fetchResponseText } = require('../../network/http');
4
4
  const { GrokProvider } = require('./grok');
5
5
 
6
6
  const GROK_OAUTH_BASE_URL = 'https://api.x.ai/v1';
7
7
  const GROK_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828';
8
8
  const GROK_OAUTH_TOKEN_URL = 'https://auth.x.ai/oauth2/token';
9
9
  const GROK_OAUTH_SCOPES = 'openid profile email offline_access grok-cli:access api:access';
10
+ const OAUTH_REFRESH_TIMEOUT_MS = 30000;
11
+ const OAUTH_MAX_RESPONSE_BYTES = 256 * 1024;
10
12
 
11
13
  function normalizeExpiresAt(data) {
12
14
  if (typeof data.expires_at === 'number' && Number.isFinite(data.expires_at)) {
@@ -21,29 +23,14 @@ function normalizeExpiresAt(data) {
21
23
  function persistEnvValue(key, value) {
22
24
  if (!value) return;
23
25
  try {
24
- const { ENV_FILE } = require('../../../../runtime/paths');
25
- const safeKey = String(key).replace(/[\r\n]/g, '');
26
- const safeValue = String(value).replace(/[\r\n]/g, '');
27
- const raw = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, 'utf8') : '';
28
- const lines = raw ? raw.split('\n') : [];
29
- let replaced = false;
30
- for (let i = 0; i < lines.length; i++) {
31
- if (lines[i].startsWith(`${safeKey}=`)) {
32
- lines[i] = `${safeKey}=${safeValue}`;
33
- replaced = true;
34
- break;
35
- }
36
- }
37
- if (!replaced) lines.push(`${safeKey}=${safeValue}`);
38
- const output = lines.filter((_, idx, arr) => idx !== arr.length - 1 || arr[idx] !== '').join('\n') + '\n';
39
- fs.mkdirSync(path.dirname(ENV_FILE), { recursive: true });
40
- fs.writeFileSync(ENV_FILE, output, { mode: 0o600 });
26
+ upsertEnvValue(ENV_FILE, key, value);
41
27
  } catch { }
42
28
  }
43
29
 
44
- async function refreshGrokOAuthAccessToken(refreshToken, fetchImpl = fetch) {
30
+ async function refreshGrokOAuthAccessToken(refreshToken, fetchImpl = fetch, signal = null) {
45
31
  if (!refreshToken) return null;
46
- const response = await fetchImpl(GROK_OAUTH_TOKEN_URL, {
32
+ const { response, text } = await fetchResponseText(GROK_OAUTH_TOKEN_URL, {
33
+ fetchImpl,
47
34
  method: 'POST',
48
35
  headers: {
49
36
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -54,9 +41,14 @@ async function refreshGrokOAuthAccessToken(refreshToken, fetchImpl = fetch) {
54
41
  refresh_token: refreshToken,
55
42
  client_id: GROK_OAUTH_CLIENT_ID,
56
43
  }),
44
+ signal,
45
+ timeoutMs: OAUTH_REFRESH_TIMEOUT_MS,
46
+ maxResponseBytes: OAUTH_MAX_RESPONSE_BYTES,
47
+ serviceName: 'Grok OAuth refresh',
48
+ timeoutCode: 'PROVIDER_OAUTH_TIMEOUT',
49
+ tooLargeCode: 'PROVIDER_OAUTH_RESPONSE_TOO_LARGE',
57
50
  });
58
51
 
59
- const text = await response.text();
60
52
  let data = {};
61
53
  try {
62
54
  data = text ? JSON.parse(text) : {};
@@ -68,7 +60,7 @@ async function refreshGrokOAuthAccessToken(refreshToken, fetchImpl = fetch) {
68
60
  if (data?.error === 'invalid_grant') {
69
61
  throw new Error('Grok OAuth refresh token is invalid or expired. Re-run `neoagent login grok-oauth` to re-authenticate.');
70
62
  }
71
- const detail = data?.error_description || data?.error || text || 'Unknown error';
63
+ const detail = String(data?.error_description || data?.error || text || 'Unknown error').slice(0, 2000);
72
64
  throw new Error(`Grok OAuth refresh failed: HTTP ${response.status} ${detail}`);
73
65
  }
74
66
  if (!data.access_token) {
@@ -102,8 +94,8 @@ class GrokOAuthProvider extends GrokProvider {
102
94
  this.fetchImpl = config.fetch || fetch;
103
95
  }
104
96
 
105
- async refreshClient() {
106
- const refreshed = await refreshGrokOAuthAccessToken(this.refreshToken, this.fetchImpl);
97
+ async refreshClient(signal = null) {
98
+ const refreshed = await refreshGrokOAuthAccessToken(this.refreshToken, this.fetchImpl, signal);
107
99
  if (!refreshed?.access) return false;
108
100
  this.authToken = refreshed.access;
109
101
  this.refreshToken = refreshed.refresh || this.refreshToken;
@@ -122,7 +114,7 @@ class GrokOAuthProvider extends GrokProvider {
122
114
  return await super.chat(messages, tools, options);
123
115
  } catch (err) {
124
116
  if (err?.status !== 401 || !this.refreshToken) throw err;
125
- await this.refreshClient();
117
+ await this.refreshClient(options.signal);
126
118
  return await super.chat(messages, tools, options);
127
119
  }
128
120
  }
@@ -132,7 +124,7 @@ class GrokOAuthProvider extends GrokProvider {
132
124
  yield* super.stream(messages, tools, options);
133
125
  } catch (err) {
134
126
  if (err?.status !== 401 || !this.refreshToken) throw err;
135
- await this.refreshClient();
127
+ await this.refreshClient(options.signal);
136
128
  yield* super.stream(messages, tools, options);
137
129
  }
138
130
  }