kiku-agent 1.0.9 → 1.0.11

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.
@@ -1,209 +1,215 @@
1
- import { config, CLOUD_PROVIDERS, normalizeProviderModel } from './config.js';
2
-
3
- export class LLMClient {
4
- constructor(options = {}) {
1
+ import { config, CLOUD_PROVIDERS, normalizeProviderModel, savePersistedProviderKey, savePersistedActiveProvider, savePersistedProviderModel } from './config.js';
2
+ import { parseApiErrorText } from './errors.js';
3
+
4
+ export class LLMClient {
5
+ constructor(options = {}) {
5
6
  const providerId = (options.provider || config.activeProvider || 'lmstudio').toLowerCase();
6
7
  const providerDef = CLOUD_PROVIDERS[providerId];
7
8
  if (!providerDef) {
8
9
  throw new Error(`Unknown provider "${providerId}". Supported: ${Object.keys(CLOUD_PROVIDERS).join(', ')}`);
9
10
  }
10
-
11
- this.provider = providerId;
12
- this.providerName = providerDef.name || providerId;
13
- this.baseUrl = (options.baseUrl || providerDef.baseUrl || config.lmStudio.baseUrl).replace(/\/+$/, '');
14
- this.apiKey = options.apiKey !== undefined
15
- ? options.apiKey
16
- : (providerDef.apiKey !== undefined ? providerDef.apiKey : (providerDef.isLocal ? (config.lmStudio.apiKey || 'lm-studio') : ''));
11
+
12
+ this.provider = providerId;
13
+ this.providerName = providerDef.name || providerId;
14
+ this.baseUrl = (options.baseUrl || providerDef.baseUrl || config.lmStudio.baseUrl).replace(/\/+$/, '');
15
+ this.apiKey = options.apiKey !== undefined
16
+ ? options.apiKey
17
+ : (providerDef.apiKey !== undefined ? providerDef.apiKey : (providerDef.isLocal ? (config.lmStudio.apiKey || 'lm-studio') : ''));
17
18
  this.model = normalizeProviderModel(
18
19
  providerId,
19
20
  options.model || providerDef.defaultModel || config.lmStudio.model || 'google/gemma-4-e4b'
20
21
  );
21
- this.timeout = options.timeout || config.lmStudio.requestTimeoutMs || 180000;
22
- this.extraHeaders = { ...(providerDef.extraHeaders || {}), ...(options.extraHeaders || {}) };
23
- }
24
-
25
- switchProvider(providerId, options = {}) {
26
- const pId = String(providerId || '').toLowerCase().trim();
27
- const providerDef = CLOUD_PROVIDERS[pId];
28
- if (!providerDef) {
29
- throw new Error(`Unknown provider "${providerId}". Supported: ${Object.keys(CLOUD_PROVIDERS).join(', ')}`);
30
- }
31
-
32
- this.provider = pId;
33
- this.providerName = providerDef.name || pId;
34
- this.baseUrl = (options.baseUrl || providerDef.baseUrl).replace(/\/+$/, '');
35
- this.apiKey = options.apiKey !== undefined ? options.apiKey : (providerDef.apiKey || '');
22
+ this.timeout = options.timeout || config.lmStudio.requestTimeoutMs || 180000;
23
+ this.extraHeaders = { ...(providerDef.extraHeaders || {}), ...(options.extraHeaders || {}) };
24
+ }
25
+
26
+ switchProvider(providerId, options = {}) {
27
+ const pId = String(providerId || '').toLowerCase().trim();
28
+ const providerDef = CLOUD_PROVIDERS[pId];
29
+ if (!providerDef) {
30
+ throw new Error(`Unknown provider "${providerId}". Supported: ${Object.keys(CLOUD_PROVIDERS).join(', ')}`);
31
+ }
32
+
33
+ this.provider = pId;
34
+ this.providerName = providerDef.name || pId;
35
+ this.baseUrl = (options.baseUrl || providerDef.baseUrl).replace(/\/+$/, '');
36
+ this.apiKey = options.apiKey !== undefined ? options.apiKey : (providerDef.apiKey || '');
36
37
  this.model = normalizeProviderModel(pId, options.model || providerDef.defaultModel);
37
- this.extraHeaders = { ...(providerDef.extraHeaders || {}), ...(options.extraHeaders || {}) };
38
- return this.getProviderInfo();
39
- }
40
-
41
- setApiKey(apiKey) {
42
- this.apiKey = String(apiKey || '').trim();
43
- return this.getProviderInfo();
44
- }
45
-
46
- setModel(model) {
47
- const trimmed = String(model || '').trim();
48
- if (!trimmed) throw new Error('A model ID is required.');
38
+ this.extraHeaders = { ...(providerDef.extraHeaders || {}), ...(options.extraHeaders || {}) };
39
+
40
+ savePersistedActiveProvider(pId, this.model);
41
+ return this.getProviderInfo();
42
+ }
43
+
44
+ setApiKey(apiKey, providerId = null) {
45
+ const targetProvider = String(providerId || this.provider).toLowerCase().trim();
46
+ const cleanKey = String(apiKey || '').trim();
47
+ savePersistedProviderKey(targetProvider, cleanKey);
48
+ if (targetProvider === this.provider) {
49
+ this.apiKey = cleanKey;
50
+ }
51
+ return this.getProviderInfo();
52
+ }
53
+
54
+ setModel(model) {
55
+ const trimmed = String(model || '').trim();
56
+ if (!trimmed) throw new Error('A model ID is required.');
49
57
  this.model = normalizeProviderModel(this.provider, trimmed);
50
- return this.model;
51
- }
52
-
53
- getProviderInfo() {
54
- const def = CLOUD_PROVIDERS[this.provider] || {};
55
- return {
56
- provider: this.provider,
57
- providerName: this.providerName,
58
- baseUrl: this.baseUrl,
59
- model: this.model,
60
- hasApiKey: Boolean(this.apiKey && this.apiKey !== 'lm-studio'),
61
- isLocal: def.isLocal ?? false,
62
- envKey: def.envKey || null,
63
- docUrl: def.docUrl || null,
64
- apiRefUrl: def.apiRefUrl || null,
65
- pricingSummary: def.pricingSummary || null,
66
- features: def.features || [],
67
- recommendedModels: def.recommendedModels || [],
68
- };
69
- }
70
-
71
- _buildHeaders() {
72
- const headers = {
73
- 'Content-Type': 'application/json',
74
- ...this.extraHeaders,
75
- };
76
-
77
- if (this.apiKey) {
78
- headers['Authorization'] = `Bearer ${this.apiKey}`;
79
- if (this.provider === 'gemini') {
80
- headers['x-goog-api-key'] = this.apiKey;
81
- }
82
- } else if (this.provider === 'lmstudio') {
83
- headers['Authorization'] = 'Bearer lm-studio';
84
- }
85
-
86
- return headers;
87
- }
88
-
89
- async checkConnection() {
90
- const def = CLOUD_PROVIDERS[this.provider] || {};
91
- const startTime = Date.now();
92
-
93
- if (!def.isLocal && !this.apiKey) {
94
- return {
95
- connected: false,
96
- provider: this.provider,
97
- providerName: this.providerName,
98
- error: `No API key configured for ${this.providerName}. Set ${def.envKey || 'API_KEY'} in .env or run /provider setkey <key>`,
99
- models: [],
100
- latencyMs: 0
101
- };
102
- }
103
-
104
- try {
105
- const controller = new AbortController();
106
- const timeoutId = setTimeout(() => controller.abort(), 8000);
107
-
108
- const res = await fetch(`${this.baseUrl}/models`, {
109
- method: 'GET',
110
- headers: this._buildHeaders(),
111
- signal: controller.signal
112
- });
113
-
114
- clearTimeout(timeoutId);
115
- const latencyMs = Date.now() - startTime;
116
-
117
- if (!res.ok) {
118
- let errSnippet = res.statusText;
119
- try {
120
- const errBody = await res.text();
121
- if (errBody) {
122
- try {
123
- const parsed = JSON.parse(errBody);
124
- errSnippet = parsed.error?.message || parsed.message || errBody.slice(0, 150);
125
- } catch {
126
- errSnippet = errBody.slice(0, 150);
127
- }
128
- }
129
- } catch {}
130
-
131
- let formattedError = `${this.providerName} returned status ${res.status}: ${errSnippet}`;
132
- if (res.status === 401) {
133
- formattedError = `401 Unauthorized: Invalid API key for ${this.providerName}. Check ${def.envKey || 'API key'}.`;
134
- } else if (res.status === 429) {
135
- formattedError = `429 Rate Limit / Quota Exceeded: ${errSnippet}`;
136
- }
137
-
138
- return {
139
- connected: false,
140
- provider: this.provider,
141
- providerName: this.providerName,
142
- error: formattedError,
143
- models: [],
144
- latencyMs
145
- };
146
- }
147
-
148
- const data = await res.json();
149
- const rawList = Array.isArray(data) ? data : (data.data || []);
150
- const models = rawList.map(m => typeof m === 'string' ? m : (m.id || m.name)).filter(Boolean);
151
-
152
- const matchedGemma = models.find(m =>
153
- m === 'google/gemma-4-e4b' ||
154
- m === 'gemma-4-e4b' ||
155
- m.toLowerCase().includes('gemma-4-e4b') ||
156
- (m.toLowerCase().includes('gemma') && m.toLowerCase().includes('e4b'))
157
- );
158
-
159
- const isAvailable = models.length === 0 ? true : models.includes(this.model);
160
-
161
- return {
162
- connected: true,
163
- provider: this.provider,
164
- providerName: this.providerName,
165
- baseUrl: this.baseUrl,
166
- models,
167
- activeModel: models.includes(this.model) ? this.model : (matchedGemma || this.model),
168
- isModelAvailable: isAvailable,
169
- latencyMs
170
- };
171
- } catch (err) {
172
- const latencyMs = Date.now() - startTime;
173
- return {
174
- connected: false,
175
- provider: this.provider,
176
- providerName: this.providerName,
177
- error: err.name === 'AbortError'
178
- ? `Connection timed out at ${this.baseUrl} (>8s)`
179
- : `Cannot reach ${this.providerName} at ${this.baseUrl} (${err.message})`,
180
- models: [],
181
- latencyMs
182
- };
183
- }
184
- }
185
-
186
- async chat({
187
- messages,
188
- tools = null,
189
- temperature = 0.3,
190
- maxTokens = 1536,
191
- stream = true,
192
- signal = null,
193
- onToken = null,
194
- onThought = null
195
- }) {
58
+ savePersistedProviderModel(this.provider, this.model);
59
+ return this.model;
60
+ }
61
+
62
+ getProviderInfo() {
63
+ const def = CLOUD_PROVIDERS[this.provider] || {};
64
+ return {
65
+ provider: this.provider,
66
+ providerName: this.providerName,
67
+ baseUrl: this.baseUrl,
68
+ model: this.model,
69
+ hasApiKey: Boolean(this.apiKey && this.apiKey !== 'lm-studio'),
70
+ isLocal: def.isLocal ?? false,
71
+ envKey: def.envKey || null,
72
+ docUrl: def.docUrl || null,
73
+ apiRefUrl: def.apiRefUrl || null,
74
+ pricingSummary: def.pricingSummary || null,
75
+ features: def.features || [],
76
+ recommendedModels: def.recommendedModels || [],
77
+ };
78
+ }
79
+
80
+ _buildHeaders() {
81
+ const headers = {
82
+ 'Content-Type': 'application/json',
83
+ ...this.extraHeaders,
84
+ };
85
+
86
+ if (this.apiKey) {
87
+ headers['Authorization'] = `Bearer ${this.apiKey}`;
88
+ if (this.provider === 'gemini') {
89
+ headers['x-goog-api-key'] = this.apiKey;
90
+ }
91
+ } else if (this.provider === 'lmstudio') {
92
+ headers['Authorization'] = 'Bearer lm-studio';
93
+ }
94
+
95
+ return headers;
96
+ }
97
+
98
+ async checkConnection() {
99
+ const def = CLOUD_PROVIDERS[this.provider] || {};
100
+ const startTime = Date.now();
101
+
102
+ if (!def.isLocal && !this.apiKey) {
103
+ return {
104
+ connected: false,
105
+ provider: this.provider,
106
+ providerName: this.providerName,
107
+ error: `No API key configured for ${this.providerName}. Set ${def.envKey || 'API_KEY'} in .env or run /provider setkey <key>`,
108
+ models: [],
109
+ latencyMs: 0
110
+ };
111
+ }
112
+
113
+ try {
114
+ const controller = new AbortController();
115
+ const timeoutId = setTimeout(() => controller.abort(), 8000);
116
+
117
+ const res = await fetch(`${this.baseUrl}/models`, {
118
+ method: 'GET',
119
+ headers: this._buildHeaders(),
120
+ signal: controller.signal
121
+ });
122
+
123
+ clearTimeout(timeoutId);
124
+ const latencyMs = Date.now() - startTime;
125
+
126
+ if (!res.ok) {
127
+ let errSnippet = res.statusText;
128
+ try {
129
+ const errBody = await res.text();
130
+ if (errBody) {
131
+ errSnippet = parseApiErrorText(errBody);
132
+ }
133
+ } catch { }
134
+
135
+ let formattedError = `${this.providerName} returned status ${res.status}: ${errSnippet}`;
136
+ if (res.status === 401) {
137
+ formattedError = `401 Unauthorized: Invalid API key for ${this.providerName}. Check ${def.envKey || 'API key'}.`;
138
+ } else if (res.status === 402) {
139
+ formattedError = `402 Insufficient credits for ${this.providerName}: ${errSnippet}`;
140
+ } else if (res.status === 429) {
141
+ formattedError = `429 Rate Limit / Quota Exceeded: ${errSnippet}`;
142
+ }
143
+
144
+ return {
145
+ connected: false,
146
+ provider: this.provider,
147
+ providerName: this.providerName,
148
+ error: formattedError,
149
+ models: [],
150
+ latencyMs
151
+ };
152
+ }
153
+
154
+ const data = await res.json();
155
+ const rawList = Array.isArray(data) ? data : (data.data || []);
156
+ const models = rawList.map(m => typeof m === 'string' ? m : (m.id || m.name)).filter(Boolean);
157
+
158
+ const matchedGemma = models.find(m =>
159
+ m === 'google/gemma-4-e4b' ||
160
+ m === 'gemma-4-e4b' ||
161
+ m.toLowerCase().includes('gemma-4-e4b') ||
162
+ (m.toLowerCase().includes('gemma') && m.toLowerCase().includes('e4b'))
163
+ );
164
+
165
+ const isAvailable = models.length === 0 ? true : models.includes(this.model);
166
+
167
+ return {
168
+ connected: true,
169
+ provider: this.provider,
170
+ providerName: this.providerName,
171
+ baseUrl: this.baseUrl,
172
+ models,
173
+ activeModel: models.includes(this.model) ? this.model : (matchedGemma || this.model),
174
+ isModelAvailable: isAvailable,
175
+ latencyMs
176
+ };
177
+ } catch (err) {
178
+ const latencyMs = Date.now() - startTime;
179
+ return {
180
+ connected: false,
181
+ provider: this.provider,
182
+ providerName: this.providerName,
183
+ error: err.name === 'AbortError'
184
+ ? `Connection timed out at ${this.baseUrl} (>8s)`
185
+ : `Cannot reach ${this.providerName} at ${this.baseUrl} (${err.message})`,
186
+ models: [],
187
+ latencyMs
188
+ };
189
+ }
190
+ }
191
+
192
+ async chat({
193
+ messages,
194
+ tools = null,
195
+ temperature = 0.3,
196
+ maxTokens = 1536,
197
+ stream = true,
198
+ signal = null,
199
+ onToken = null,
200
+ onThought = null
201
+ }) {
196
202
  const isLegacyReasoningModel = /^(o1|o3|deepseek-reasoner)/i.test(this.model) || this.model.includes('reasoner');
197
203
  const usesCompletionTokenLimit = /^(o1|o3|gpt-5(?:\.|-|$))/i.test(this.model);
198
204
  const rejectsTemperature = /^(o1|o3|gpt-5(?:\.|-|$))/i.test(this.model);
199
-
200
- const buildPayload = (includeTools = true, includeTemp = true) => {
201
- const p = {
202
- model: this.model,
203
- messages,
204
- stream
205
- };
206
-
205
+
206
+ const buildPayload = (includeTools = true, includeTemp = true) => {
207
+ const p = {
208
+ model: this.model,
209
+ messages,
210
+ stream
211
+ };
212
+
207
213
  if (!rejectsTemperature && includeTemp && temperature !== undefined && temperature !== null) {
208
214
  p.temperature = temperature;
209
215
  }
@@ -215,162 +221,174 @@ export class LLMClient {
215
221
  }
216
222
 
217
223
  if (includeTools && tools && Array.isArray(tools) && tools.length > 0 && !isLegacyReasoningModel) {
218
- p.tools = tools;
219
- p.tool_choice = "auto";
220
- }
221
-
222
- return p;
223
- };
224
-
225
- let payload = buildPayload(true, true);
226
-
227
- const controller = new AbortController();
228
- let timeoutId = setTimeout(() => controller.abort(), this.timeout);
229
-
230
- const abortHandler = () => controller.abort();
231
- if (signal) signal.addEventListener('abort', abortHandler, { once: true });
232
-
233
- const resetTimeout = () => {
234
- clearTimeout(timeoutId);
235
- timeoutId = setTimeout(() => controller.abort(), this.timeout);
236
- };
237
-
238
- try {
239
- let res = await fetch(`${this.baseUrl}/chat/completions`, {
240
- method: 'POST',
241
- headers: this._buildHeaders(),
242
- body: JSON.stringify(payload),
243
- signal: controller.signal
244
- });
245
-
246
- resetTimeout();
247
-
248
- // Graceful auto-retry if provider rejects tools/temperature/max_tokens with 400
249
- if (!res.ok && res.status === 400) {
250
- const errText = await res.text();
251
- const lowerErr = errText.toLowerCase();
252
- let retryNeeded = false;
253
-
254
- if ((lowerErr.includes('tool') || lowerErr.includes('function') || lowerErr.includes('schema')) && payload.tools) {
255
- payload = buildPayload(false, true);
256
- retryNeeded = true;
257
- } else if (lowerErr.includes('temperature') && payload.temperature !== undefined) {
258
- payload = buildPayload(Boolean(payload.tools), false);
259
- retryNeeded = true;
260
- } else if (lowerErr.includes('max_tokens') && payload.max_tokens !== undefined) {
261
- delete payload.max_tokens;
262
- payload.max_completion_tokens = maxTokens;
263
- retryNeeded = true;
264
- }
265
-
266
- if (retryNeeded) {
267
- res = await fetch(`${this.baseUrl}/chat/completions`, {
268
- method: 'POST',
269
- headers: this._buildHeaders(),
270
- body: JSON.stringify(payload),
271
- signal: controller.signal
272
- });
273
- resetTimeout();
274
- }
275
-
276
- if (!res.ok) {
277
- const finalErr = retryNeeded ? await res.text() : errText;
278
- throw new Error(`${this.providerName} error (${res.status}): ${finalErr}`);
279
- }
280
- } else if (!res.ok) {
281
- const errorText = await res.text();
282
- throw new Error(`${this.providerName} error (${res.status}): ${errorText}`);
283
- }
284
-
285
- if (!stream) {
286
- const data = await res.json();
287
- clearTimeout(timeoutId);
288
- if (signal) signal.removeEventListener('abort', abortHandler);
289
- const msg = data.choices && data.choices[0] ? data.choices[0].message : null;
290
- if (msg && !msg.reasoning_content && (msg.reasoning || msg.thinking)) {
291
- msg.reasoning_content = msg.reasoning || msg.thinking;
292
- }
293
- return msg;
294
- }
295
-
296
- const reader = res.body.getReader();
297
- const decoder = new TextDecoder('utf-8');
298
- let fullContent = '';
299
- let fullReasoning = '';
300
- let toolCalls = [];
301
- let buffer = '';
302
-
303
- while (true) {
304
- const { done, value } = await reader.read();
305
- if (done) break;
306
-
307
- resetTimeout();
308
-
309
- buffer += decoder.decode(value, { stream: true });
310
- const lines = buffer.split('\n');
311
- buffer = lines.pop() || '';
312
-
313
- for (const line of lines) {
314
- const trimmed = line.trim();
315
- if (!trimmed || !trimmed.startsWith('data:')) continue;
316
- const jsonStr = trimmed.replace(/^data:\s*/, '');
317
- if (jsonStr === '[DONE]') continue;
318
-
319
- try {
320
- const parsed = JSON.parse(jsonStr);
321
- const delta = parsed.choices?.[0]?.delta;
322
- if (!delta) continue;
323
-
324
- const reasoning = delta.reasoning_content || delta.reasoning || delta.thinking;
325
- if (reasoning) {
326
- fullReasoning += reasoning;
327
- if (onThought) onThought(reasoning);
328
- }
329
-
330
- if (delta.content) {
331
- fullContent += delta.content;
332
- if (onToken) onToken(delta.content);
333
- }
334
-
335
- if (delta.tool_calls) {
336
- for (const tc of delta.tool_calls) {
337
- const idx = tc.index ?? 0;
338
- if (!toolCalls[idx]) {
339
- toolCalls[idx] = {
340
- id: tc.id || `call_${Date.now()}_${idx}`,
341
- type: 'function',
342
- function: { name: '', arguments: '' }
343
- };
344
- }
345
- if (tc.function?.name) toolCalls[idx].function.name += tc.function.name;
346
- if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments;
347
- }
348
- }
349
- } catch {
350
- }
351
- }
352
- }
353
-
354
- clearTimeout(timeoutId);
355
-
356
- const resultMessage = {
357
- role: 'assistant',
358
- content: fullContent,
359
- reasoning_content: fullReasoning
360
- };
361
-
362
- if (toolCalls.length > 0) {
363
- resultMessage.tool_calls = toolCalls.filter(Boolean);
364
- }
365
-
366
- if (signal) signal.removeEventListener('abort', abortHandler);
367
- return resultMessage;
368
- } catch (err) {
369
- clearTimeout(timeoutId);
370
- if (signal) signal.removeEventListener('abort', abortHandler);
371
- throw err;
372
- }
373
- }
374
- }
375
-
376
- export { LLMClient as LMStudioClient };
224
+ p.tools = tools;
225
+ p.tool_choice = "auto";
226
+ }
227
+
228
+ return p;
229
+ };
230
+
231
+ let payload = buildPayload(true, true);
232
+
233
+ const controller = new AbortController();
234
+ let timeoutId = setTimeout(() => controller.abort(), this.timeout);
235
+
236
+ const abortHandler = () => controller.abort();
237
+ if (signal) signal.addEventListener('abort', abortHandler, { once: true });
238
+
239
+ const resetTimeout = () => {
240
+ clearTimeout(timeoutId);
241
+ timeoutId = setTimeout(() => controller.abort(), this.timeout);
242
+ };
243
+
244
+ try {
245
+ let res = await fetch(`${this.baseUrl}/chat/completions`, {
246
+ method: 'POST',
247
+ headers: this._buildHeaders(),
248
+ body: JSON.stringify(payload),
249
+ signal: controller.signal
250
+ });
251
+
252
+ resetTimeout();
253
+
254
+
255
+ if (!res.ok && res.status === 400) {
256
+ const errText = await res.text();
257
+ const lowerErr = errText.toLowerCase();
258
+ let retryNeeded = false;
259
+
260
+ if ((lowerErr.includes('tool') || lowerErr.includes('function') || lowerErr.includes('schema')) && payload.tools) {
261
+ payload = buildPayload(false, true);
262
+ retryNeeded = true;
263
+ } else if (lowerErr.includes('temperature') && payload.temperature !== undefined) {
264
+ payload = buildPayload(Boolean(payload.tools), false);
265
+ retryNeeded = true;
266
+ } else if (lowerErr.includes('max_tokens') && payload.max_tokens !== undefined) {
267
+ delete payload.max_tokens;
268
+ payload.max_completion_tokens = maxTokens;
269
+ retryNeeded = true;
270
+ }
271
+
272
+ if (retryNeeded) {
273
+ res = await fetch(`${this.baseUrl}/chat/completions`, {
274
+ method: 'POST',
275
+ headers: this._buildHeaders(),
276
+ body: JSON.stringify(payload),
277
+ signal: controller.signal
278
+ });
279
+ resetTimeout();
280
+ }
281
+
282
+ if (!res.ok) {
283
+ const finalErr = retryNeeded ? await res.text() : errText;
284
+ const cleanMsg = parseApiErrorText(finalErr);
285
+ const err = new Error(`${this.providerName} error (${res.status}): ${cleanMsg}`);
286
+ err.status = res.status;
287
+ err.provider = this.provider;
288
+ err.providerName = this.providerName;
289
+ err.rawError = finalErr;
290
+ throw err;
291
+ }
292
+ } else if (!res.ok) {
293
+ const errorText = await res.text();
294
+ const cleanMsg = parseApiErrorText(errorText);
295
+ const err = new Error(`${this.providerName} error (${res.status}): ${cleanMsg}`);
296
+ err.status = res.status;
297
+ err.provider = this.provider;
298
+ err.providerName = this.providerName;
299
+ err.rawError = errorText;
300
+ throw err;
301
+ }
302
+
303
+ if (!stream) {
304
+ const data = await res.json();
305
+ clearTimeout(timeoutId);
306
+ if (signal) signal.removeEventListener('abort', abortHandler);
307
+ const msg = data.choices && data.choices[0] ? data.choices[0].message : null;
308
+ if (msg && !msg.reasoning_content && (msg.reasoning || msg.thinking)) {
309
+ msg.reasoning_content = msg.reasoning || msg.thinking;
310
+ }
311
+ return msg;
312
+ }
313
+
314
+ const reader = res.body.getReader();
315
+ const decoder = new TextDecoder('utf-8');
316
+ let fullContent = '';
317
+ let fullReasoning = '';
318
+ let toolCalls = [];
319
+ let buffer = '';
320
+
321
+ while (true) {
322
+ const { done, value } = await reader.read();
323
+ if (done) break;
324
+
325
+ resetTimeout();
326
+
327
+ buffer += decoder.decode(value, { stream: true });
328
+ const lines = buffer.split('\n');
329
+ buffer = lines.pop() || '';
330
+
331
+ for (const line of lines) {
332
+ const trimmed = line.trim();
333
+ if (!trimmed || !trimmed.startsWith('data:')) continue;
334
+ const jsonStr = trimmed.replace(/^data:\s*/, '');
335
+ if (jsonStr === '[DONE]') continue;
336
+
337
+ try {
338
+ const parsed = JSON.parse(jsonStr);
339
+ const delta = parsed.choices?.[0]?.delta;
340
+ if (!delta) continue;
341
+
342
+ const reasoning = delta.reasoning_content || delta.reasoning || delta.thinking;
343
+ if (reasoning) {
344
+ fullReasoning += reasoning;
345
+ if (onThought) onThought(reasoning);
346
+ }
347
+
348
+ if (delta.content) {
349
+ fullContent += delta.content;
350
+ if (onToken) onToken(delta.content);
351
+ }
352
+
353
+ if (delta.tool_calls) {
354
+ for (const tc of delta.tool_calls) {
355
+ const idx = tc.index ?? 0;
356
+ if (!toolCalls[idx]) {
357
+ toolCalls[idx] = {
358
+ id: tc.id || `call_${Date.now()}_${idx}`,
359
+ type: 'function',
360
+ function: { name: '', arguments: '' }
361
+ };
362
+ }
363
+ if (tc.function?.name) toolCalls[idx].function.name += tc.function.name;
364
+ if (tc.function?.arguments) toolCalls[idx].function.arguments += tc.function.arguments;
365
+ }
366
+ }
367
+ } catch {
368
+ }
369
+ }
370
+ }
371
+
372
+ clearTimeout(timeoutId);
373
+
374
+ const resultMessage = {
375
+ role: 'assistant',
376
+ content: fullContent,
377
+ reasoning_content: fullReasoning
378
+ };
379
+
380
+ if (toolCalls.length > 0) {
381
+ resultMessage.tool_calls = toolCalls.filter(Boolean);
382
+ }
383
+
384
+ if (signal) signal.removeEventListener('abort', abortHandler);
385
+ return resultMessage;
386
+ } catch (err) {
387
+ clearTimeout(timeoutId);
388
+ if (signal) signal.removeEventListener('abort', abortHandler);
389
+ throw err;
390
+ }
391
+ }
392
+ }
393
+
394
+ export { LLMClient as LMStudioClient };