nex-code 0.3.0

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,249 @@
1
+ /**
2
+ * cli/providers/local.js — Local Ollama Server Provider
3
+ * Connects to localhost:11434 (default Ollama install). No auth required.
4
+ * Auto-detects available models via /api/tags.
5
+ */
6
+
7
+ const axios = require('axios');
8
+ const { BaseProvider } = require('./base');
9
+
10
+ const DEFAULT_LOCAL_URL = 'http://localhost:11434';
11
+
12
+ class LocalProvider extends BaseProvider {
13
+ constructor(config = {}) {
14
+ super({
15
+ name: 'local',
16
+ baseUrl: config.baseUrl || process.env.OLLAMA_HOST || process.env.OLLAMA_LOCAL_URL || DEFAULT_LOCAL_URL,
17
+ models: config.models || {},
18
+ defaultModel: config.defaultModel || null,
19
+ ...config,
20
+ });
21
+ this.timeout = config.timeout || 300000;
22
+ this.temperature = config.temperature ?? 0.2;
23
+ this._modelsLoaded = false;
24
+ }
25
+
26
+ isConfigured() {
27
+ return true; // No API key needed
28
+ }
29
+
30
+ /**
31
+ * Fetch available models from local Ollama server.
32
+ * Caches result after first call.
33
+ */
34
+ async loadModels() {
35
+ if (this._modelsLoaded) return this.models;
36
+
37
+ try {
38
+ const response = await axios.get(`${this.baseUrl}/api/tags`, { timeout: 5000 });
39
+ const tags = response.data?.models || [];
40
+
41
+ this.models = {};
42
+ for (const m of tags) {
43
+ const name = m.name || m.model;
44
+ if (!name) continue;
45
+ const id = name.replace(/:latest$/, '');
46
+
47
+ // Try to get actual context window from model metadata
48
+ let contextWindow = 32768; // Conservative fallback
49
+ try {
50
+ const showResp = await axios.post(
51
+ `${this.baseUrl}/api/show`,
52
+ { name },
53
+ { timeout: 5000 }
54
+ );
55
+ const params = showResp.data?.model_info || showResp.data?.details || {};
56
+ // Ollama exposes context length in model_info
57
+ contextWindow = params['general.context_length']
58
+ || params['llama.context_length']
59
+ || this._parseContextFromModelfile(showResp.data?.modelfile)
60
+ || 32768;
61
+ } catch {
62
+ // /api/show failed — use fallback
63
+ }
64
+
65
+ this.models[id] = {
66
+ id,
67
+ name: m.name,
68
+ maxTokens: Math.min(8192, Math.floor(contextWindow * 0.1)),
69
+ contextWindow,
70
+ };
71
+ }
72
+
73
+ if (!this.defaultModel && Object.keys(this.models).length > 0) {
74
+ this.defaultModel = Object.keys(this.models)[0];
75
+ }
76
+
77
+ this._modelsLoaded = true;
78
+ } catch {
79
+ // Server not running or unreachable
80
+ this.models = {};
81
+ this._modelsLoaded = false;
82
+ }
83
+
84
+ return this.models;
85
+ }
86
+
87
+ getModels() {
88
+ return this.models;
89
+ }
90
+
91
+ getModelNames() {
92
+ return Object.keys(this.models);
93
+ }
94
+
95
+ async chat(messages, tools, options = {}) {
96
+ if (!this._modelsLoaded) await this.loadModels();
97
+
98
+ const model = options.model || this.defaultModel;
99
+ if (!model) throw new Error('No local model available. Is Ollama running?');
100
+
101
+ const response = await axios.post(
102
+ `${this.baseUrl}/api/chat`,
103
+ {
104
+ model,
105
+ messages,
106
+ tools: tools && tools.length > 0 ? tools : undefined,
107
+ stream: false,
108
+ options: {
109
+ temperature: options.temperature ?? this.temperature,
110
+ num_predict: options.maxTokens || 8192,
111
+ },
112
+ },
113
+ { timeout: options.timeout || this.timeout }
114
+ );
115
+
116
+ return this.normalizeResponse(response.data);
117
+ }
118
+
119
+ async stream(messages, tools, options = {}) {
120
+ if (!this._modelsLoaded) await this.loadModels();
121
+
122
+ const model = options.model || this.defaultModel;
123
+ if (!model) throw new Error('No local model available. Is Ollama running?');
124
+ const onToken = options.onToken || (() => {});
125
+
126
+ let response;
127
+ try {
128
+ response = await axios.post(
129
+ `${this.baseUrl}/api/chat`,
130
+ {
131
+ model,
132
+ messages,
133
+ tools: tools && tools.length > 0 ? tools : undefined,
134
+ stream: true,
135
+ options: {
136
+ temperature: options.temperature ?? this.temperature,
137
+ num_predict: options.maxTokens || 8192,
138
+ },
139
+ },
140
+ {
141
+ timeout: options.timeout || this.timeout,
142
+ responseType: 'stream',
143
+ signal: options.signal,
144
+ }
145
+ );
146
+ } catch (err) {
147
+ if (err.name === 'CanceledError' || err.name === 'AbortError' || err.code === 'ERR_CANCELED') throw err;
148
+ const msg = err.response?.data?.error || err.message;
149
+ throw new Error(`API Error: ${msg}`);
150
+ }
151
+
152
+ return new Promise((resolve, reject) => {
153
+ let content = '';
154
+ let toolCalls = [];
155
+ let buffer = '';
156
+
157
+ // Abort listener: destroy stream on signal
158
+ if (options.signal) {
159
+ options.signal.addEventListener('abort', () => {
160
+ response.data.destroy();
161
+ reject(new DOMException('The operation was aborted', 'AbortError'));
162
+ }, { once: true });
163
+ }
164
+
165
+ response.data.on('data', (chunk) => {
166
+ buffer += chunk.toString();
167
+ const lines = buffer.split('\n');
168
+ buffer = lines.pop() || '';
169
+
170
+ for (const line of lines) {
171
+ if (!line.trim()) continue;
172
+ let parsed;
173
+ try {
174
+ parsed = JSON.parse(line);
175
+ } catch {
176
+ continue;
177
+ }
178
+
179
+ if (parsed.message?.content) {
180
+ onToken(parsed.message.content);
181
+ content += parsed.message.content;
182
+ }
183
+
184
+ if (parsed.message?.tool_calls) {
185
+ toolCalls = toolCalls.concat(parsed.message.tool_calls);
186
+ }
187
+
188
+ if (parsed.done) {
189
+ resolve({ content, tool_calls: this._normalizeToolCalls(toolCalls) });
190
+ return;
191
+ }
192
+ }
193
+ });
194
+
195
+ response.data.on('error', (err) => {
196
+ if (options.signal?.aborted) return; // Ignore errors after abort
197
+ reject(new Error(`Stream error: ${err.message}`));
198
+ });
199
+
200
+ response.data.on('end', () => {
201
+ if (buffer.trim()) {
202
+ try {
203
+ const parsed = JSON.parse(buffer);
204
+ if (parsed.message?.content) {
205
+ onToken(parsed.message.content);
206
+ content += parsed.message.content;
207
+ }
208
+ if (parsed.message?.tool_calls) {
209
+ toolCalls = toolCalls.concat(parsed.message.tool_calls);
210
+ }
211
+ } catch {
212
+ /* ignore */
213
+ }
214
+ }
215
+ resolve({ content, tool_calls: this._normalizeToolCalls(toolCalls) });
216
+ });
217
+ });
218
+ }
219
+
220
+ normalizeResponse(data) {
221
+ const msg = data.message || {};
222
+ return {
223
+ content: msg.content || '',
224
+ tool_calls: this._normalizeToolCalls(msg.tool_calls || []),
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Parse num_ctx from Ollama modelfile string.
230
+ * Modelfiles contain lines like: PARAMETER num_ctx 131072
231
+ */
232
+ _parseContextFromModelfile(modelfile) {
233
+ if (!modelfile) return null;
234
+ const match = modelfile.match(/PARAMETER\s+num_ctx\s+(\d+)/i);
235
+ return match ? parseInt(match[1], 10) : null;
236
+ }
237
+
238
+ _normalizeToolCalls(toolCalls) {
239
+ return toolCalls.map((tc, i) => ({
240
+ id: tc.id || `local-${Date.now()}-${i}`,
241
+ function: {
242
+ name: tc.function?.name || tc.name || 'unknown',
243
+ arguments: tc.function?.arguments || tc.arguments || {},
244
+ },
245
+ }));
246
+ }
247
+ }
248
+
249
+ module.exports = { LocalProvider, DEFAULT_LOCAL_URL };
@@ -0,0 +1,214 @@
1
+ /**
2
+ * cli/providers/ollama.js — Ollama Cloud Provider
3
+ * Connects to https://ollama.com API with Bearer auth and NDJSON streaming.
4
+ */
5
+
6
+ const axios = require('axios');
7
+ const { BaseProvider } = require('./base');
8
+
9
+ const OLLAMA_MODELS = {
10
+ // Primary: Best coding model for agentic workflows
11
+ 'qwen3-coder': { id: 'qwen3-coder', name: 'Qwen3 Coder', maxTokens: 16384, contextWindow: 131072 },
12
+ 'qwen3-coder-next': { id: 'qwen3-coder-next', name: 'Qwen3 Coder Next', maxTokens: 16384, contextWindow: 131072 },
13
+ // Reasoning specialists
14
+ 'deepseek-r1': { id: 'deepseek-r1', name: 'DeepSeek R1', maxTokens: 16384, contextWindow: 131072 },
15
+ 'deepseek-r1:14b': { id: 'deepseek-r1:14b', name: 'DeepSeek R1 14B', maxTokens: 8192, contextWindow: 128000 },
16
+ // Agent-focused models
17
+ 'devstral': { id: 'devstral', name: 'Devstral', maxTokens: 16384, contextWindow: 131072 },
18
+ 'minimax-m2.5': { id: 'minimax-m2.5', name: 'MiniMax M2.5', maxTokens: 16384, contextWindow: 131072 },
19
+ 'glm-4.7': { id: 'glm-4.7', name: 'GLM 4.7', maxTokens: 16384, contextWindow: 128000 },
20
+ // General purpose / large context fallback
21
+ 'kimi-k2.5': { id: 'kimi-k2.5', name: 'Kimi K2.5', maxTokens: 16384, contextWindow: 256000 },
22
+ 'llama4': { id: 'llama4', name: 'Llama 4 Scout', maxTokens: 16384, contextWindow: 131072 },
23
+ 'qwen3:30b-a3b': { id: 'qwen3:30b-a3b', name: 'Qwen3 30B A3B', maxTokens: 16384, contextWindow: 131072 },
24
+ };
25
+
26
+ class OllamaProvider extends BaseProvider {
27
+ constructor(config = {}) {
28
+ super({
29
+ name: 'ollama',
30
+ baseUrl: config.baseUrl || 'https://ollama.com',
31
+ models: config.models || OLLAMA_MODELS,
32
+ defaultModel: config.defaultModel || 'qwen3-coder',
33
+ ...config,
34
+ });
35
+ this.timeout = config.timeout || 180000;
36
+ this.temperature = config.temperature ?? 0.2;
37
+ this._discovered = false;
38
+ }
39
+
40
+ /**
41
+ * Discover available models from the Ollama API.
42
+ * Merges discovered models with the hardcoded fallback list.
43
+ * Cached after first call.
44
+ */
45
+ async discoverModels() {
46
+ if (this._discovered) return;
47
+ this._discovered = true;
48
+ try {
49
+ const resp = await axios.get(`${this.baseUrl}/api/tags`, {
50
+ timeout: 5000, headers: this._getHeaders(),
51
+ });
52
+ const tags = resp.data?.models || [];
53
+ for (const m of tags) {
54
+ const id = (m.name || m.model || '').replace(/:latest$/, '');
55
+ if (!id || this.models[id]) continue;
56
+ this.models[id] = { id, name: m.name || id, maxTokens: 16384, contextWindow: 131072 };
57
+ }
58
+ } catch { /* API unavailable — use hardcoded list */ }
59
+ }
60
+
61
+ isConfigured() {
62
+ return !!this.getApiKey();
63
+ }
64
+
65
+ getApiKey() {
66
+ return process.env.OLLAMA_API_KEY || null;
67
+ }
68
+
69
+ _getHeaders() {
70
+ const key = this.getApiKey();
71
+ if (!key) throw new Error('OLLAMA_API_KEY not set');
72
+ return { Authorization: `Bearer ${key}` };
73
+ }
74
+
75
+ async chat(messages, tools, options = {}) {
76
+ await this.discoverModels();
77
+ const model = options.model || this.defaultModel;
78
+ const modelInfo = this.getModel(model);
79
+ const maxTokens = options.maxTokens || modelInfo?.maxTokens || 16384;
80
+
81
+ const response = await axios.post(
82
+ `${this.baseUrl}/api/chat`,
83
+ {
84
+ model,
85
+ messages,
86
+ tools: tools && tools.length > 0 ? tools : undefined,
87
+ stream: false,
88
+ options: { temperature: options.temperature ?? this.temperature, num_predict: maxTokens },
89
+ },
90
+ { timeout: options.timeout || this.timeout, headers: this._getHeaders() }
91
+ );
92
+
93
+ return this.normalizeResponse(response.data);
94
+ }
95
+
96
+ async stream(messages, tools, options = {}) {
97
+ await this.discoverModels();
98
+ const model = options.model || this.defaultModel;
99
+ const modelInfo = this.getModel(model);
100
+ const maxTokens = options.maxTokens || modelInfo?.maxTokens || 16384;
101
+ const onToken = options.onToken || (() => {});
102
+
103
+ let response;
104
+ try {
105
+ response = await axios.post(
106
+ `${this.baseUrl}/api/chat`,
107
+ {
108
+ model,
109
+ messages,
110
+ tools: tools && tools.length > 0 ? tools : undefined,
111
+ stream: true,
112
+ options: { temperature: options.temperature ?? this.temperature, num_predict: maxTokens },
113
+ },
114
+ {
115
+ timeout: options.timeout || this.timeout,
116
+ headers: this._getHeaders(),
117
+ responseType: 'stream',
118
+ signal: options.signal,
119
+ }
120
+ );
121
+ } catch (err) {
122
+ if (err.name === 'CanceledError' || err.name === 'AbortError' || err.code === 'ERR_CANCELED') throw err;
123
+ const msg = err.response?.data?.error || err.message;
124
+ throw new Error(`API Error: ${msg}`);
125
+ }
126
+
127
+ return new Promise((resolve, reject) => {
128
+ let content = '';
129
+ let toolCalls = [];
130
+ let buffer = '';
131
+
132
+ // Abort listener: destroy stream on signal
133
+ if (options.signal) {
134
+ options.signal.addEventListener('abort', () => {
135
+ response.data.destroy();
136
+ reject(new DOMException('The operation was aborted', 'AbortError'));
137
+ }, { once: true });
138
+ }
139
+
140
+ response.data.on('data', (chunk) => {
141
+ buffer += chunk.toString();
142
+ const lines = buffer.split('\n');
143
+ buffer = lines.pop() || '';
144
+
145
+ for (const line of lines) {
146
+ if (!line.trim()) continue;
147
+ let parsed;
148
+ try {
149
+ parsed = JSON.parse(line);
150
+ } catch {
151
+ continue;
152
+ }
153
+
154
+ if (parsed.message?.content) {
155
+ onToken(parsed.message.content);
156
+ content += parsed.message.content;
157
+ }
158
+
159
+ if (parsed.message?.tool_calls) {
160
+ toolCalls = toolCalls.concat(parsed.message.tool_calls);
161
+ }
162
+
163
+ if (parsed.done) {
164
+ resolve({ content, tool_calls: this._normalizeToolCalls(toolCalls) });
165
+ return;
166
+ }
167
+ }
168
+ });
169
+
170
+ response.data.on('error', (err) => {
171
+ if (options.signal?.aborted) return; // Ignore errors after abort
172
+ reject(new Error(`Stream error: ${err.message}`));
173
+ });
174
+
175
+ response.data.on('end', () => {
176
+ if (buffer.trim()) {
177
+ try {
178
+ const parsed = JSON.parse(buffer);
179
+ if (parsed.message?.content) {
180
+ onToken(parsed.message.content);
181
+ content += parsed.message.content;
182
+ }
183
+ if (parsed.message?.tool_calls) {
184
+ toolCalls = toolCalls.concat(parsed.message.tool_calls);
185
+ }
186
+ } catch {
187
+ /* ignore */
188
+ }
189
+ }
190
+ resolve({ content, tool_calls: this._normalizeToolCalls(toolCalls) });
191
+ });
192
+ });
193
+ }
194
+
195
+ normalizeResponse(data) {
196
+ const msg = data.message || {};
197
+ return {
198
+ content: msg.content || '',
199
+ tool_calls: this._normalizeToolCalls(msg.tool_calls || []),
200
+ };
201
+ }
202
+
203
+ _normalizeToolCalls(toolCalls) {
204
+ return toolCalls.map((tc, i) => ({
205
+ id: tc.id || `ollama-${Date.now()}-${i}`,
206
+ function: {
207
+ name: tc.function?.name || tc.name || 'unknown',
208
+ arguments: tc.function?.arguments || tc.arguments || {},
209
+ },
210
+ }));
211
+ }
212
+ }
213
+
214
+ module.exports = { OllamaProvider, OLLAMA_MODELS };
@@ -0,0 +1,237 @@
1
+ /**
2
+ * cli/providers/openai.js — OpenAI-compatible Provider
3
+ * Supports GPT-4o, o1, o3, GPT-4o-mini via OpenAI API with SSE streaming.
4
+ */
5
+
6
+ const axios = require('axios');
7
+ const { BaseProvider } = require('./base');
8
+
9
+ const OPENAI_MODELS = {
10
+ 'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o', maxTokens: 16384, contextWindow: 128000 },
11
+ 'gpt-4o-mini': { id: 'gpt-4o-mini', name: 'GPT-4o Mini', maxTokens: 16384, contextWindow: 128000 },
12
+ 'gpt-4.1': { id: 'gpt-4.1', name: 'GPT-4.1', maxTokens: 32768, contextWindow: 128000 },
13
+ 'gpt-4.1-mini': { id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini', maxTokens: 32768, contextWindow: 128000 },
14
+ 'gpt-4.1-nano': { id: 'gpt-4.1-nano', name: 'GPT-4.1 Nano', maxTokens: 16384, contextWindow: 128000 },
15
+ 'o1': { id: 'o1', name: 'o1', maxTokens: 100000, contextWindow: 200000 },
16
+ 'o3': { id: 'o3', name: 'o3', maxTokens: 100000, contextWindow: 200000 },
17
+ 'o3-mini': { id: 'o3-mini', name: 'o3 Mini', maxTokens: 65536, contextWindow: 200000 },
18
+ 'o4-mini': { id: 'o4-mini', name: 'o4 Mini', maxTokens: 100000, contextWindow: 200000 },
19
+ };
20
+
21
+ class OpenAIProvider extends BaseProvider {
22
+ constructor(config = {}) {
23
+ super({
24
+ name: 'openai',
25
+ baseUrl: config.baseUrl || 'https://api.openai.com/v1',
26
+ models: config.models || OPENAI_MODELS,
27
+ defaultModel: config.defaultModel || 'gpt-4o',
28
+ ...config,
29
+ });
30
+ this.timeout = config.timeout || 180000;
31
+ this.temperature = config.temperature ?? 0.2;
32
+ }
33
+
34
+ isConfigured() {
35
+ return !!this.getApiKey();
36
+ }
37
+
38
+ getApiKey() {
39
+ return process.env.OPENAI_API_KEY || null;
40
+ }
41
+
42
+ _getHeaders() {
43
+ const key = this.getApiKey();
44
+ if (!key) throw new Error('OPENAI_API_KEY not set');
45
+ return {
46
+ Authorization: `Bearer ${key}`,
47
+ 'Content-Type': 'application/json',
48
+ };
49
+ }
50
+
51
+ formatMessages(messages) {
52
+ return {
53
+ messages: messages.map((msg) => {
54
+ if (msg.role === 'assistant' && msg.tool_calls) {
55
+ return {
56
+ role: 'assistant',
57
+ content: msg.content || null,
58
+ tool_calls: msg.tool_calls.map((tc) => ({
59
+ id: tc.id || `call-${Date.now()}`,
60
+ type: 'function',
61
+ function: {
62
+ name: tc.function.name,
63
+ arguments:
64
+ typeof tc.function.arguments === 'string'
65
+ ? tc.function.arguments
66
+ : JSON.stringify(tc.function.arguments),
67
+ },
68
+ })),
69
+ };
70
+ }
71
+ if (msg.role === 'tool') {
72
+ return {
73
+ role: 'tool',
74
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
75
+ tool_call_id: msg.tool_call_id,
76
+ };
77
+ }
78
+ return { role: msg.role, content: msg.content };
79
+ }),
80
+ };
81
+ }
82
+
83
+ async chat(messages, tools, options = {}) {
84
+ const model = options.model || this.defaultModel;
85
+ const modelInfo = this.getModel(model);
86
+ const maxTokens = options.maxTokens || modelInfo?.maxTokens || 16384;
87
+ const { messages: formatted } = this.formatMessages(messages);
88
+
89
+ const body = {
90
+ model,
91
+ messages: formatted,
92
+ max_tokens: maxTokens,
93
+ temperature: options.temperature ?? this.temperature,
94
+ };
95
+
96
+ if (tools && tools.length > 0) {
97
+ body.tools = tools;
98
+ }
99
+
100
+ const response = await axios.post(`${this.baseUrl}/chat/completions`, body, {
101
+ timeout: options.timeout || this.timeout,
102
+ headers: this._getHeaders(),
103
+ });
104
+
105
+ return this.normalizeResponse(response.data);
106
+ }
107
+
108
+ async stream(messages, tools, options = {}) {
109
+ const model = options.model || this.defaultModel;
110
+ const modelInfo = this.getModel(model);
111
+ const maxTokens = options.maxTokens || modelInfo?.maxTokens || 16384;
112
+ const onToken = options.onToken || (() => {});
113
+ const { messages: formatted } = this.formatMessages(messages);
114
+
115
+ const body = {
116
+ model,
117
+ messages: formatted,
118
+ max_tokens: maxTokens,
119
+ temperature: options.temperature ?? this.temperature,
120
+ stream: true,
121
+ };
122
+
123
+ if (tools && tools.length > 0) {
124
+ body.tools = tools;
125
+ }
126
+
127
+ let response;
128
+ try {
129
+ response = await axios.post(`${this.baseUrl}/chat/completions`, body, {
130
+ timeout: options.timeout || this.timeout,
131
+ headers: this._getHeaders(),
132
+ responseType: 'stream',
133
+ signal: options.signal,
134
+ });
135
+ } catch (err) {
136
+ if (err.name === 'CanceledError' || err.name === 'AbortError' || err.code === 'ERR_CANCELED') throw err;
137
+ const msg = err.response?.data?.error?.message || err.message;
138
+ throw new Error(`API Error: ${msg}`);
139
+ }
140
+
141
+ return new Promise((resolve, reject) => {
142
+ let content = '';
143
+ const toolCallsMap = {}; // index -> { id, name, arguments }
144
+ let buffer = '';
145
+
146
+ // Abort listener: destroy stream on signal
147
+ if (options.signal) {
148
+ options.signal.addEventListener('abort', () => {
149
+ response.data.destroy();
150
+ reject(new DOMException('The operation was aborted', 'AbortError'));
151
+ }, { once: true });
152
+ }
153
+
154
+ response.data.on('data', (chunk) => {
155
+ buffer += chunk.toString();
156
+ const lines = buffer.split('\n');
157
+ buffer = lines.pop() || '';
158
+
159
+ for (const line of lines) {
160
+ const trimmed = line.trim();
161
+ if (!trimmed || !trimmed.startsWith('data: ')) continue;
162
+ const data = trimmed.slice(6);
163
+ if (data === '[DONE]') {
164
+ resolve({ content, tool_calls: this._buildToolCalls(toolCallsMap) });
165
+ return;
166
+ }
167
+
168
+ let parsed;
169
+ try {
170
+ parsed = JSON.parse(data);
171
+ } catch {
172
+ continue;
173
+ }
174
+
175
+ const delta = parsed.choices?.[0]?.delta;
176
+ if (!delta) continue;
177
+
178
+ if (delta.content) {
179
+ onToken(delta.content);
180
+ content += delta.content;
181
+ }
182
+
183
+ if (delta.tool_calls) {
184
+ for (const tc of delta.tool_calls) {
185
+ const idx = tc.index ?? 0;
186
+ if (!toolCallsMap[idx]) {
187
+ toolCallsMap[idx] = { id: tc.id || '', name: '', arguments: '' };
188
+ }
189
+ if (tc.id) toolCallsMap[idx].id = tc.id;
190
+ if (tc.function?.name) toolCallsMap[idx].name += tc.function.name;
191
+ if (tc.function?.arguments) toolCallsMap[idx].arguments += tc.function.arguments;
192
+ }
193
+ }
194
+ }
195
+ });
196
+
197
+ response.data.on('error', (err) => {
198
+ if (options.signal?.aborted) return; // Ignore errors after abort
199
+ reject(new Error(`Stream error: ${err.message}`));
200
+ });
201
+
202
+ response.data.on('end', () => {
203
+ resolve({ content, tool_calls: this._buildToolCalls(toolCallsMap) });
204
+ });
205
+ });
206
+ }
207
+
208
+ normalizeResponse(data) {
209
+ const choice = data.choices?.[0]?.message || {};
210
+ const toolCalls = (choice.tool_calls || []).map((tc) => ({
211
+ id: tc.id,
212
+ function: {
213
+ name: tc.function.name,
214
+ arguments: tc.function.arguments,
215
+ },
216
+ }));
217
+
218
+ return {
219
+ content: choice.content || '',
220
+ tool_calls: toolCalls,
221
+ };
222
+ }
223
+
224
+ _buildToolCalls(toolCallsMap) {
225
+ return Object.values(toolCallsMap)
226
+ .filter((tc) => tc.name)
227
+ .map((tc) => ({
228
+ id: tc.id || `openai-${Date.now()}`,
229
+ function: {
230
+ name: tc.name,
231
+ arguments: tc.arguments,
232
+ },
233
+ }));
234
+ }
235
+ }
236
+
237
+ module.exports = { OpenAIProvider, OPENAI_MODELS };