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.
- package/LICENSE +21 -0
- package/README.md +687 -0
- package/bin/nex-code.js +99 -0
- package/cli/agent.js +835 -0
- package/cli/compactor.js +85 -0
- package/cli/context-engine.js +507 -0
- package/cli/context.js +81 -0
- package/cli/costs.js +285 -0
- package/cli/diff.js +366 -0
- package/cli/file-history.js +94 -0
- package/cli/format.js +211 -0
- package/cli/fuzzy-match.js +270 -0
- package/cli/git.js +202 -0
- package/cli/hooks.js +173 -0
- package/cli/index.js +1281 -0
- package/cli/mcp.js +284 -0
- package/cli/memory.js +170 -0
- package/cli/ollama.js +130 -0
- package/cli/permissions.js +124 -0
- package/cli/picker.js +201 -0
- package/cli/planner.js +282 -0
- package/cli/providers/anthropic.js +333 -0
- package/cli/providers/base.js +116 -0
- package/cli/providers/gemini.js +239 -0
- package/cli/providers/local.js +249 -0
- package/cli/providers/ollama.js +214 -0
- package/cli/providers/openai.js +237 -0
- package/cli/providers/registry.js +450 -0
- package/cli/render.js +495 -0
- package/cli/safety.js +241 -0
- package/cli/session.js +133 -0
- package/cli/skills.js +412 -0
- package/cli/spinner.js +371 -0
- package/cli/sub-agent.js +425 -0
- package/cli/tasks.js +179 -0
- package/cli/tool-tiers.js +148 -0
- package/cli/tool-validator.js +138 -0
- package/cli/tools.js +1050 -0
- package/cli/ui.js +93 -0
- package/package.json +54 -0
|
@@ -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 };
|