banana-code 1.2.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 +246 -0
- package/banana.js +5464 -0
- package/lib/agenticRunner.js +1884 -0
- package/lib/borderRenderer.js +41 -0
- package/lib/commandRunner.js +205 -0
- package/lib/completer.js +286 -0
- package/lib/config.js +301 -0
- package/lib/contextBuilder.js +324 -0
- package/lib/diffViewer.js +295 -0
- package/lib/fileManager.js +224 -0
- package/lib/historyManager.js +124 -0
- package/lib/hookManager.js +1143 -0
- package/lib/imageHandler.js +268 -0
- package/lib/inlineComplete.js +192 -0
- package/lib/interactivePicker.js +254 -0
- package/lib/lmStudio.js +226 -0
- package/lib/markdownRenderer.js +423 -0
- package/lib/mcpClient.js +288 -0
- package/lib/modelRegistry.js +350 -0
- package/lib/monkeyModels.js +97 -0
- package/lib/oauthOpenAI.js +167 -0
- package/lib/parser.js +134 -0
- package/lib/promptManager.js +96 -0
- package/lib/providerClients.js +1014 -0
- package/lib/providerManager.js +130 -0
- package/lib/providerStore.js +413 -0
- package/lib/statusBar.js +283 -0
- package/lib/streamHandler.js +306 -0
- package/lib/subAgentManager.js +406 -0
- package/lib/tokenCounter.js +132 -0
- package/lib/visionAnalyzer.js +163 -0
- package/lib/watcher.js +138 -0
- package/models.json +57 -0
- package/package.json +42 -0
- package/prompts/base.md +23 -0
- package/prompts/code-agent-glm.md +16 -0
- package/prompts/code-agent-gptoss.md +25 -0
- package/prompts/code-agent-nemotron.md +17 -0
- package/prompts/code-agent-qwen.md +20 -0
- package/prompts/code-agent.md +70 -0
- package/prompts/plan.md +44 -0
package/lib/mcpClient.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Client for Banana Code
|
|
3
|
+
* Calls an MCP server via HTTP.
|
|
4
|
+
* Used both for curated tools and the call_mcp escape hatch.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const DEFAULT_MCP_URL = '';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse SSE text into individual data payloads.
|
|
11
|
+
* Handles multi-event streams, finds the JSON-RPC result (has `id` + `result`),
|
|
12
|
+
* ignoring notifications (have `method` but no matching `id`).
|
|
13
|
+
*/
|
|
14
|
+
function parseSSE(sseText, requestId) {
|
|
15
|
+
const events = [];
|
|
16
|
+
for (const line of sseText.split('\n')) {
|
|
17
|
+
if (line.startsWith('data: ')) {
|
|
18
|
+
const payload = line.slice(6).trim();
|
|
19
|
+
if (payload) events.push(payload);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Find the JSON-RPC result event (has id + result or id + error)
|
|
24
|
+
for (const evt of events) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(evt);
|
|
27
|
+
// Match by id if provided, otherwise look for any result/error response
|
|
28
|
+
if (requestId && parsed.id === requestId) return evt;
|
|
29
|
+
if (parsed.result !== undefined || parsed.error !== undefined) return evt;
|
|
30
|
+
} catch { /* skip malformed events */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fallback: return last event (original behavior)
|
|
34
|
+
return events.length > 0 ? events[events.length - 1] : sseText;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class McpClient {
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
this.url = options.url || process.env.MCP_SERVER_URL || DEFAULT_MCP_URL;
|
|
40
|
+
this.sessionId = null;
|
|
41
|
+
this.initialized = false;
|
|
42
|
+
this.timeout = options.timeout || 30000;
|
|
43
|
+
this.toolTimeout = options.toolTimeout || 60000;
|
|
44
|
+
this.serverInfo = null; // Populated on initialize
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Update the server URL (e.g., from config)
|
|
49
|
+
*/
|
|
50
|
+
setUrl(url) {
|
|
51
|
+
if (url && url !== this.url) {
|
|
52
|
+
this.url = url;
|
|
53
|
+
this.sessionId = null;
|
|
54
|
+
this.initialized = false;
|
|
55
|
+
this.serverInfo = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Initialize MCP session (get sessionId)
|
|
61
|
+
*/
|
|
62
|
+
async initialize() {
|
|
63
|
+
if (this.initialized) return true;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(this.url, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
'Accept': 'application/json, text/event-stream'
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
jsonrpc: '2.0',
|
|
74
|
+
id: 1,
|
|
75
|
+
method: 'initialize',
|
|
76
|
+
params: {
|
|
77
|
+
protocolVersion: '2024-11-05',
|
|
78
|
+
capabilities: {},
|
|
79
|
+
clientInfo: { name: 'banana', version: '1.2.0' }
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
signal: AbortSignal.timeout(this.timeout)
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!res.ok) return false;
|
|
86
|
+
|
|
87
|
+
const sessionId = res.headers.get('mcp-session-id') || res.headers.get('x-session-id');
|
|
88
|
+
if (sessionId) this.sessionId = sessionId;
|
|
89
|
+
|
|
90
|
+
// Parse server info from initialize response
|
|
91
|
+
const contentType = res.headers.get('content-type') || '';
|
|
92
|
+
let text = await res.text();
|
|
93
|
+
if (contentType.includes('text/event-stream')) {
|
|
94
|
+
text = parseSSE(text, 1);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const data = JSON.parse(text);
|
|
98
|
+
if (data.result?.serverInfo) {
|
|
99
|
+
this.serverInfo = data.result.serverInfo;
|
|
100
|
+
}
|
|
101
|
+
} catch { /* ignore parse errors */ }
|
|
102
|
+
|
|
103
|
+
// MCP protocol requires sending notifications/initialized before any other requests.
|
|
104
|
+
// Without this, the server keeps the session in a pending state and rejects tool calls.
|
|
105
|
+
await this._sendInitializedNotification();
|
|
106
|
+
|
|
107
|
+
this.initialized = true;
|
|
108
|
+
return true;
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Send the required notifications/initialized notification to complete the MCP handshake.
|
|
116
|
+
*/
|
|
117
|
+
async _sendInitializedNotification() {
|
|
118
|
+
const headers = {
|
|
119
|
+
'Content-Type': 'application/json',
|
|
120
|
+
'Accept': 'application/json, text/event-stream'
|
|
121
|
+
};
|
|
122
|
+
if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await fetch(this.url, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers,
|
|
128
|
+
body: JSON.stringify({
|
|
129
|
+
jsonrpc: '2.0',
|
|
130
|
+
method: 'notifications/initialized'
|
|
131
|
+
}),
|
|
132
|
+
signal: AbortSignal.timeout(this.timeout)
|
|
133
|
+
});
|
|
134
|
+
} catch {
|
|
135
|
+
// Best-effort; some servers don't require it
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* List available tools from the MCP server
|
|
141
|
+
*/
|
|
142
|
+
async listTools(canRetry = true) {
|
|
143
|
+
if (!this.initialized) {
|
|
144
|
+
const ok = await this.initialize();
|
|
145
|
+
if (!ok) throw new Error('MCP server unreachable');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const headers = {
|
|
149
|
+
'Content-Type': 'application/json',
|
|
150
|
+
'Accept': 'application/json, text/event-stream'
|
|
151
|
+
};
|
|
152
|
+
if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
|
|
153
|
+
|
|
154
|
+
const requestId = Date.now();
|
|
155
|
+
const res = await fetch(this.url, {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers,
|
|
158
|
+
body: JSON.stringify({
|
|
159
|
+
jsonrpc: '2.0',
|
|
160
|
+
id: requestId,
|
|
161
|
+
method: 'tools/list',
|
|
162
|
+
params: {}
|
|
163
|
+
}),
|
|
164
|
+
signal: AbortSignal.timeout(this.toolTimeout)
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// 404 = stale/expired session. Re-initialize and retry once.
|
|
168
|
+
if (res.status === 404 && canRetry) {
|
|
169
|
+
this.initialized = false;
|
|
170
|
+
this.sessionId = null;
|
|
171
|
+
this.serverInfo = null;
|
|
172
|
+
return this.listTools(false);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!res.ok) {
|
|
176
|
+
throw new Error(`MCP error ${res.status}: ${await res.text()}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const contentType = res.headers.get('content-type') || '';
|
|
180
|
+
let text = await res.text();
|
|
181
|
+
if (contentType.includes('text/event-stream')) {
|
|
182
|
+
text = parseSSE(text, requestId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const data = JSON.parse(text);
|
|
186
|
+
if (data.error) throw new Error(data.error.message || 'Failed to list tools');
|
|
187
|
+
return data.result?.tools || [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Call any MCP tool by name with args
|
|
192
|
+
*/
|
|
193
|
+
async callTool(toolName, args = {}) {
|
|
194
|
+
return this._callToolWithRetry(toolName, args, true);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async _callToolWithRetry(toolName, args, canRetry) {
|
|
198
|
+
if (!this.initialized) {
|
|
199
|
+
const ok = await this.initialize();
|
|
200
|
+
if (!ok) throw new Error('MCP server unreachable');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const headers = {
|
|
204
|
+
'Content-Type': 'application/json',
|
|
205
|
+
'Accept': 'application/json, text/event-stream'
|
|
206
|
+
};
|
|
207
|
+
if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
|
|
208
|
+
|
|
209
|
+
const requestId = Date.now();
|
|
210
|
+
const body = JSON.stringify({
|
|
211
|
+
jsonrpc: '2.0',
|
|
212
|
+
id: requestId,
|
|
213
|
+
method: 'tools/call',
|
|
214
|
+
params: { name: toolName, arguments: args }
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const res = await fetch(this.url, {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
headers,
|
|
220
|
+
body,
|
|
221
|
+
signal: AbortSignal.timeout(this.toolTimeout)
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// 404 = stale/expired session. Re-initialize and retry once.
|
|
225
|
+
if (res.status === 404 && canRetry) {
|
|
226
|
+
this.initialized = false;
|
|
227
|
+
this.sessionId = null;
|
|
228
|
+
this.serverInfo = null;
|
|
229
|
+
return this._callToolWithRetry(toolName, args, false);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!res.ok) {
|
|
233
|
+
throw new Error(`MCP error ${res.status}: ${await res.text()}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Handle SSE or JSON response
|
|
237
|
+
const contentType = res.headers.get('content-type') || '';
|
|
238
|
+
let text = await res.text();
|
|
239
|
+
|
|
240
|
+
if (contentType.includes('text/event-stream')) {
|
|
241
|
+
text = parseSSE(text, requestId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const data = JSON.parse(text);
|
|
246
|
+
if (data.error) throw new Error(data.error.message || 'MCP tool error');
|
|
247
|
+
const result = data.result;
|
|
248
|
+
// Extract text content from MCP content array
|
|
249
|
+
if (result?.content) {
|
|
250
|
+
return result.content
|
|
251
|
+
.filter(c => c.type === 'text')
|
|
252
|
+
.map(c => c.text)
|
|
253
|
+
.join('\n');
|
|
254
|
+
}
|
|
255
|
+
return JSON.stringify(result);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
if (e.message.includes('MCP')) throw e;
|
|
258
|
+
return text;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Health check - try initialize, which validates the full MCP handshake
|
|
264
|
+
*/
|
|
265
|
+
async isConnected() {
|
|
266
|
+
try {
|
|
267
|
+
if (this.initialized) return true;
|
|
268
|
+
return await this.initialize();
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get connection status info
|
|
276
|
+
*/
|
|
277
|
+
getStatus() {
|
|
278
|
+
return {
|
|
279
|
+
url: this.url,
|
|
280
|
+
connected: this.initialized,
|
|
281
|
+
sessionId: this.sessionId,
|
|
282
|
+
serverName: this.serverInfo?.name || null,
|
|
283
|
+
serverVersion: this.serverInfo?.version || null
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = McpClient;
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Registry for Banana Code v4
|
|
3
|
+
* Handles local models from models.json + connected remote provider models.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
class ModelRegistry {
|
|
9
|
+
constructor(registryPath, lmStudio, providerStore = null) {
|
|
10
|
+
this.registryPath = registryPath;
|
|
11
|
+
this.lmStudio = lmStudio;
|
|
12
|
+
this.providerStore = providerStore;
|
|
13
|
+
this.localRegistry = { default: 'silverback', models: {} };
|
|
14
|
+
this.registry = { default: 'silverback', models: {} };
|
|
15
|
+
this.currentModel = null;
|
|
16
|
+
this.load();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
load() {
|
|
20
|
+
try {
|
|
21
|
+
if (fs.existsSync(this.registryPath)) {
|
|
22
|
+
const parsed = JSON.parse(fs.readFileSync(this.registryPath, 'utf-8'));
|
|
23
|
+
this.localRegistry = {
|
|
24
|
+
default: parsed.default || 'silverback',
|
|
25
|
+
models: parsed.models || {}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
this.localRegistry = { default: 'silverback', models: {} };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this._normalizeLocalModels();
|
|
33
|
+
this.refreshRemoteModels();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
save() {
|
|
37
|
+
fs.writeFileSync(this.registryPath, JSON.stringify(this.localRegistry, null, 2));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_normalizeLocalModels() {
|
|
41
|
+
for (const model of Object.values(this.localRegistry.models)) {
|
|
42
|
+
if (!model.provider) model.provider = 'local';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
refreshRemoteModels() {
|
|
47
|
+
const mergedModels = {};
|
|
48
|
+
|
|
49
|
+
for (const [key, model] of Object.entries(this.localRegistry.models)) {
|
|
50
|
+
mergedModels[key] = { ...model, provider: model.provider || 'local' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (this.providerStore) {
|
|
54
|
+
for (const remoteModel of this.providerStore.getConnectedRemoteModels()) {
|
|
55
|
+
const key = remoteModel.key;
|
|
56
|
+
mergedModels[key] = {
|
|
57
|
+
name: remoteModel.name,
|
|
58
|
+
id: remoteModel.id,
|
|
59
|
+
providerModelId: remoteModel.id,
|
|
60
|
+
provider: remoteModel.provider,
|
|
61
|
+
reasoningEffort: remoteModel.reasoningEffort,
|
|
62
|
+
contextLimit: remoteModel.contextLimit || 128000,
|
|
63
|
+
supportsThinking: remoteModel.supportsThinking !== false,
|
|
64
|
+
prompt: remoteModel.prompt || 'code-agent',
|
|
65
|
+
inferenceSettings: {
|
|
66
|
+
temperature: 0.5,
|
|
67
|
+
topP: 0.9
|
|
68
|
+
},
|
|
69
|
+
tags: remoteModel.tags || ['remote', remoteModel.provider],
|
|
70
|
+
tier: 'remote',
|
|
71
|
+
description: `${remoteModel.name} via ${remoteModel.provider}`,
|
|
72
|
+
remote: true
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.registry = {
|
|
78
|
+
default: this.localRegistry.default,
|
|
79
|
+
models: mergedModels
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (this.currentModel && !this.registry.models[this.currentModel]) {
|
|
83
|
+
this.currentModel = this.registry.default;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get all models
|
|
89
|
+
*/
|
|
90
|
+
list() {
|
|
91
|
+
return Object.entries(this.registry.models).map(([key, model]) => ({
|
|
92
|
+
key,
|
|
93
|
+
...model,
|
|
94
|
+
active: key === this.currentModel
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get a model by key
|
|
100
|
+
*/
|
|
101
|
+
get(name) {
|
|
102
|
+
return this.registry.models[name] || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get current active model key
|
|
107
|
+
*/
|
|
108
|
+
getCurrent() {
|
|
109
|
+
if (this.currentModel && this.registry.models[this.currentModel]) {
|
|
110
|
+
return this.currentModel;
|
|
111
|
+
}
|
|
112
|
+
return this.registry.default || Object.keys(this.registry.models)[0] || null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get active provider key for current model
|
|
117
|
+
*/
|
|
118
|
+
getCurrentProvider() {
|
|
119
|
+
const model = this.getCurrentModel();
|
|
120
|
+
return model?.provider || 'local';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Get current model identifier
|
|
125
|
+
*/
|
|
126
|
+
getCurrentId() {
|
|
127
|
+
const model = this.getCurrentModel();
|
|
128
|
+
if (!model) return undefined;
|
|
129
|
+
return model.providerModelId || model.id;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get current model metadata
|
|
134
|
+
*/
|
|
135
|
+
getCurrentModel() {
|
|
136
|
+
const name = this.getCurrent();
|
|
137
|
+
if (!name) return null;
|
|
138
|
+
return { key: name, ...this.registry.models[name] };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Set active model
|
|
143
|
+
*/
|
|
144
|
+
setCurrent(name) {
|
|
145
|
+
if (!this.registry.models[name]) {
|
|
146
|
+
throw new Error(`Unknown model: "${name}". Use /model to see available models.`);
|
|
147
|
+
}
|
|
148
|
+
this.currentModel = name;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Check if a model has a specific tag
|
|
153
|
+
*/
|
|
154
|
+
hasTag(name, tag) {
|
|
155
|
+
const model = this.registry.models[name];
|
|
156
|
+
return model?.tags?.includes(tag) || false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if current model supports vision
|
|
161
|
+
*/
|
|
162
|
+
currentSupportsVision() {
|
|
163
|
+
const name = this.getCurrent();
|
|
164
|
+
return name ? this.hasTag(name, 'vision') : false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Check if current model supports thinking mode
|
|
169
|
+
*/
|
|
170
|
+
currentSupportsThinking() {
|
|
171
|
+
const name = this.getCurrent();
|
|
172
|
+
if (!name) return false;
|
|
173
|
+
return this.registry.models[name]?.supportsThinking === true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get context limit for current model
|
|
178
|
+
*/
|
|
179
|
+
getContextLimit() {
|
|
180
|
+
const name = this.getCurrent();
|
|
181
|
+
if (!name) return 32768;
|
|
182
|
+
return this.registry.models[name]?.contextLimit || 32768;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get preferred prompt name for current model
|
|
187
|
+
*/
|
|
188
|
+
getPrompt() {
|
|
189
|
+
const name = this.getCurrent();
|
|
190
|
+
if (!name) return 'code-agent';
|
|
191
|
+
return this.registry.models[name]?.prompt || 'code-agent';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get inference settings for current model
|
|
196
|
+
*/
|
|
197
|
+
getInferenceSettings() {
|
|
198
|
+
const name = this.getCurrent();
|
|
199
|
+
if (!name) return { temperature: 0.6, topP: undefined, repeatPenalty: undefined };
|
|
200
|
+
const settings = this.registry.models[name]?.inferenceSettings;
|
|
201
|
+
return {
|
|
202
|
+
temperature: settings?.temperature ?? 0.6,
|
|
203
|
+
topP: settings?.topP ?? undefined,
|
|
204
|
+
repeatPenalty: settings?.repeatPenalty ?? undefined
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check if current model requires reasoning preservation
|
|
210
|
+
*/
|
|
211
|
+
requiresReasoningPreservation() {
|
|
212
|
+
const name = this.getCurrent();
|
|
213
|
+
if (!name) return false;
|
|
214
|
+
return this.registry.models[name]?.preserveReasoning === true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Auto-discover local model IDs from LM Studio and update local registry
|
|
219
|
+
*/
|
|
220
|
+
async discover() {
|
|
221
|
+
if (!this.lmStudio) return { matched: 0, total: 0 };
|
|
222
|
+
|
|
223
|
+
const lmModels = await this.lmStudio.listModels();
|
|
224
|
+
if (lmModels.length === 0) return { matched: 0, total: 0 };
|
|
225
|
+
|
|
226
|
+
let matched = 0;
|
|
227
|
+
const usedLmIds = new Set();
|
|
228
|
+
for (const [key, model] of Object.entries(this.localRegistry.models)) {
|
|
229
|
+
if ((model.provider || 'local') !== 'local') continue;
|
|
230
|
+
|
|
231
|
+
// Do not overwrite explicit model IDs from models.json.
|
|
232
|
+
const existingId = String(model.id || '').trim();
|
|
233
|
+
if (existingId) continue;
|
|
234
|
+
|
|
235
|
+
const keywords = this._getMatchKeywords(key, model.name);
|
|
236
|
+
for (const lmModel of lmModels) {
|
|
237
|
+
const lmId = (lmModel.id || '').toLowerCase();
|
|
238
|
+
if (!lmId || usedLmIds.has(lmId)) continue;
|
|
239
|
+
if (keywords.some(kw => lmId.includes(kw))) {
|
|
240
|
+
model.id = lmModel.id;
|
|
241
|
+
usedLmIds.add(lmId);
|
|
242
|
+
matched++;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (matched > 0) {
|
|
249
|
+
this.save();
|
|
250
|
+
this.refreshRemoteModels();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { matched, total: lmModels.length };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Generate match keywords from local model key and name
|
|
258
|
+
*/
|
|
259
|
+
_getMatchKeywords(key, name) {
|
|
260
|
+
const keywords = [];
|
|
261
|
+
const keyMap = {
|
|
262
|
+
'silverback': ['silverback', 'gpt_oss'],
|
|
263
|
+
'qwen35': ['qwen3.5-35b', 'qwen3.5-35'],
|
|
264
|
+
'nemotron': ['nemotron'],
|
|
265
|
+
'coder': ['qwen3-coder-30b', 'qwen3-coder-30'],
|
|
266
|
+
'glm': ['glm-4.7', 'glm-4-flash', 'glm4'],
|
|
267
|
+
'max': ['qwen3-coder-next', 'coder-next'],
|
|
268
|
+
'mistral': ['mistral-small']
|
|
269
|
+
};
|
|
270
|
+
if (keyMap[key]) keywords.push(...keyMap[key]);
|
|
271
|
+
|
|
272
|
+
if (typeof name === 'string' && name.trim()) {
|
|
273
|
+
const parts = name.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
274
|
+
keywords.push(...parts.slice(0, 3));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return [...new Set(keywords)];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Get default model key
|
|
282
|
+
*/
|
|
283
|
+
getDefault() {
|
|
284
|
+
if (this.registry.models[this.registry.default]) {
|
|
285
|
+
return this.registry.default;
|
|
286
|
+
}
|
|
287
|
+
return Object.keys(this.registry.models)[0] || null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Resolve a "provider:alias" model key string to full model metadata.
|
|
292
|
+
*
|
|
293
|
+
* Supported formats:
|
|
294
|
+
* - "anthropic:claude-sonnet-4.6" -> looks up by key in merged registry
|
|
295
|
+
* - "local:current" -> resolves to the currently active local model
|
|
296
|
+
* - "nemotron" -> plain key lookup (no colon)
|
|
297
|
+
*
|
|
298
|
+
* @param {string} key - Model key to resolve
|
|
299
|
+
* @returns {Object|null} - Full model metadata with key, provider, providerModelId, etc.
|
|
300
|
+
*/
|
|
301
|
+
resolveModelKey(key) {
|
|
302
|
+
if (!key || typeof key !== 'string') return null;
|
|
303
|
+
const trimmed = key.trim();
|
|
304
|
+
|
|
305
|
+
// Handle "local:current" special case
|
|
306
|
+
if (trimmed.toLowerCase() === 'local:current') {
|
|
307
|
+
return this.getCurrentModel();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Check if it's a "provider:alias" format
|
|
311
|
+
const colonIdx = trimmed.indexOf(':');
|
|
312
|
+
if (colonIdx > 0) {
|
|
313
|
+
const provider = trimmed.slice(0, colonIdx).toLowerCase();
|
|
314
|
+
const alias = trimmed.slice(colonIdx + 1);
|
|
315
|
+
|
|
316
|
+
// Direct key lookup first (the merged key is "provider:alias")
|
|
317
|
+
if (this.registry.models[trimmed]) {
|
|
318
|
+
return { key: trimmed, ...this.registry.models[trimmed] };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Try matching by provider + partial alias
|
|
322
|
+
for (const [k, model] of Object.entries(this.registry.models)) {
|
|
323
|
+
if ((model.provider || 'local') === provider) {
|
|
324
|
+
if (k === trimmed || k === alias || k.includes(alias) ||
|
|
325
|
+
(model.name && model.name.toLowerCase().includes(alias.toLowerCase()))) {
|
|
326
|
+
return { key: k, ...model };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Plain key lookup (no colon)
|
|
335
|
+
if (this.registry.models[trimmed]) {
|
|
336
|
+
return { key: trimmed, ...this.registry.models[trimmed] };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Fuzzy match by name or partial key
|
|
340
|
+
for (const [k, model] of Object.entries(this.registry.models)) {
|
|
341
|
+
if (k.includes(trimmed) || (model.name && model.name.toLowerCase().includes(trimmed.toLowerCase()))) {
|
|
342
|
+
return { key: k, ...model };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
module.exports = ModelRegistry;
|