sonar-autofix 0.1.1 → 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.
@@ -1,419 +0,0 @@
1
- 'use strict';
2
-
3
- const axios = require('axios');
4
-
5
- const DEFAULT_TIMEOUT = 360000;
6
- const MAX_RETRIES = 3;
7
- const RETRY_STATUS_CODES = [429, 500, 503];
8
-
9
- class LLMClient {
10
- /**
11
- * @param {object} config
12
- * @param {object} config.llm
13
- * @param {string} config.llm.provider - lmstudio | openai | azure | anthropic | ollama | openrouter
14
- * @param {string} [config.llm.endpoint] - Custom endpoint URL
15
- * @param {string} config.llm.model - Model name or deployment name
16
- * @param {string} [config.llm.apiKey] - API key (required for openai, azure, anthropic, openrouter)
17
- * @param {number} [config.llm.timeout] - Request timeout in ms
18
- * @param {number} [config.llm.maxRetries] - Max retry attempts for transient errors
19
- */
20
- constructor(config) {
21
- if (!config || !config.llm) {
22
- throw new Error('LLMClient requires a config object with an "llm" property');
23
- }
24
-
25
- const { provider, endpoint, model, apiKey, timeout, maxRetries } = config.llm;
26
-
27
- if (!provider) {
28
- throw new Error('LLMClient requires "provider" in config.llm');
29
- }
30
- if (!model) {
31
- throw new Error('LLMClient requires "model" in config.llm');
32
- }
33
-
34
- this.provider = provider.toLowerCase();
35
- this.endpoint = endpoint;
36
- this.model = model;
37
- this.apiKey = apiKey;
38
- this.timeout = timeout || DEFAULT_TIMEOUT;
39
- this.maxRetries = maxRetries != null ? maxRetries : MAX_RETRIES;
40
- }
41
-
42
- /**
43
- * Resolve the full URL for the chat completions endpoint based on provider.
44
- * @returns {string}
45
- */
46
- _resolveUrl() {
47
- switch (this.provider) {
48
- case 'lmstudio':
49
- return `${this.endpoint || 'http://localhost:1234/v1'}/chat/completions`;
50
-
51
- case 'openai':
52
- return this.endpoint
53
- ? `${this.endpoint}/chat/completions`
54
- : 'https://api.openai.com/v1/chat/completions';
55
-
56
- case 'azure':
57
- if (!this.endpoint) {
58
- throw new Error('Azure OpenAI requires an "endpoint" in config.llm');
59
- }
60
- return `${this.endpoint}/openai/deployments/${this.model}/chat/completions?api-version=2024-02-01`;
61
-
62
- case 'anthropic':
63
- return this.endpoint
64
- ? `${this.endpoint}/v1/messages`
65
- : 'https://api.anthropic.com/v1/messages';
66
-
67
- case 'ollama':
68
- return this.endpoint
69
- ? `${this.endpoint}/api/chat`
70
- : 'http://localhost:11434/api/chat';
71
-
72
- case 'openrouter':
73
- return this.endpoint
74
- ? `${this.endpoint}/chat/completions`
75
- : 'https://openrouter.ai/api/v1/chat/completions';
76
-
77
- default:
78
- throw new Error(`Unsupported LLM provider: "${this.provider}"`);
79
- }
80
- }
81
-
82
- /**
83
- * Build request headers based on provider.
84
- * @returns {object}
85
- */
86
- _buildHeaders() {
87
- const headers = { 'Content-Type': 'application/json' };
88
-
89
- switch (this.provider) {
90
- case 'anthropic':
91
- if (this.apiKey) {
92
- headers['x-api-key'] = this.apiKey;
93
- }
94
- headers['anthropic-version'] = '2023-06-01';
95
- break;
96
-
97
- case 'azure':
98
- if (this.apiKey) {
99
- headers['api-key'] = this.apiKey;
100
- }
101
- break;
102
-
103
- default:
104
- if (this.apiKey) {
105
- headers['Authorization'] = `Bearer ${this.apiKey}`;
106
- }
107
- break;
108
- }
109
-
110
- return headers;
111
- }
112
-
113
- /**
114
- * Convert OpenAI-format messages to Anthropic format.
115
- * @param {Array<{role: string, content: string}>} messages
116
- * @returns {{ system: string|undefined, messages: Array<{role: string, content: string}> }}
117
- */
118
- _toAnthropicFormat(messages) {
119
- let system;
120
- const filtered = [];
121
-
122
- for (const msg of messages) {
123
- if (msg.role === 'system') {
124
- system = system ? `${system}\n${msg.content}` : msg.content;
125
- } else {
126
- filtered.push({
127
- role: msg.role === 'assistant' ? 'assistant' : 'user',
128
- content: msg.content,
129
- });
130
- }
131
- }
132
-
133
- return { system, messages: filtered };
134
- }
135
-
136
- /**
137
- * Build the request body based on provider.
138
- * @param {Array<{role: string, content: string}>} messages
139
- * @param {object} options
140
- * @returns {object}
141
- */
142
- _buildBody(messages, options = {}) {
143
- const { temperature, maxTokens, topP, stop, stream } = options;
144
-
145
- if (this.provider === 'anthropic') {
146
- const { system, messages: anthropicMessages } = this._toAnthropicFormat(messages);
147
- const body = {
148
- model: this.model,
149
- messages: anthropicMessages,
150
- max_tokens: maxTokens || 4096,
151
- };
152
- if (system) body.system = system;
153
- if (temperature != null) body.temperature = temperature;
154
- if (topP != null) body.top_p = topP;
155
- if (stop) body.stop_sequences = Array.isArray(stop) ? stop : [stop];
156
- if (stream) body.stream = true;
157
- return body;
158
- }
159
-
160
- if (this.provider === 'ollama') {
161
- const body = {
162
- model: this.model,
163
- messages,
164
- stream: !!stream,
165
- };
166
- if (temperature != null) body.options = { ...body.options, temperature };
167
- if (topP != null) body.options = { ...body.options, top_p: topP };
168
- return body;
169
- }
170
-
171
- // OpenAI-compatible (openai, lmstudio, azure, openrouter)
172
- const body = {
173
- model: this.model,
174
- messages,
175
- };
176
- if (temperature != null) body.temperature = temperature;
177
- if (maxTokens != null) body.max_tokens = maxTokens;
178
- if (topP != null) body.top_p = topP;
179
- if (stop) body.stop = stop;
180
- if (stream) body.stream = true;
181
- return body;
182
- }
183
-
184
- /**
185
- * Extract assistant response text from a provider's response.
186
- * @param {object} data - Response body
187
- * @returns {string}
188
- */
189
- _extractResponse(data) {
190
- if (this.provider === 'anthropic') {
191
- if (data.content && data.content.length > 0) {
192
- return data.content
193
- .filter((block) => block.type === 'text')
194
- .map((block) => block.text)
195
- .join('');
196
- }
197
- return '';
198
- }
199
-
200
- if (this.provider === 'ollama') {
201
- return data.message ? data.message.content : '';
202
- }
203
-
204
- // OpenAI-compatible
205
- if (data.choices && data.choices.length > 0) {
206
- return data.choices[0].message.content || '';
207
- }
208
- return '';
209
- }
210
-
211
- /**
212
- * Sleep for the given number of milliseconds.
213
- * @param {number} ms
214
- * @returns {Promise<void>}
215
- */
216
- _sleep(ms) {
217
- return new Promise((resolve) => setTimeout(resolve, ms));
218
- }
219
-
220
- /**
221
- * Execute an HTTP request with retry logic for transient errors.
222
- * @param {object} axiosConfig
223
- * @returns {Promise<object>}
224
- */
225
- async _requestWithRetry(axiosConfig) {
226
- let lastError;
227
-
228
- for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
229
- try {
230
- const response = await axios(axiosConfig);
231
- return response;
232
- } catch (err) {
233
- lastError = err;
234
- const status = err.response ? err.response.status : null;
235
-
236
- if (status && RETRY_STATUS_CODES.includes(status) && attempt < this.maxRetries) {
237
- const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
238
- await this._sleep(delay);
239
- continue;
240
- }
241
-
242
- throw this._wrapError(err);
243
- }
244
- }
245
-
246
- throw this._wrapError(lastError);
247
- }
248
-
249
- /**
250
- * Wrap an axios error into a more descriptive error.
251
- * @param {Error} err
252
- * @returns {Error}
253
- */
254
- _wrapError(err) {
255
- if (err.response) {
256
- const status = err.response.status;
257
- const data = err.response.data;
258
- const detail =
259
- typeof data === 'string'
260
- ? data
261
- : data && data.error
262
- ? typeof data.error === 'string'
263
- ? data.error
264
- : data.error.message || JSON.stringify(data.error)
265
- : JSON.stringify(data);
266
- return new Error(`LLM request failed (${status}): ${detail}`);
267
- }
268
- if (err.code === 'ECONNABORTED') {
269
- return new Error(`LLM request timed out after ${this.timeout}ms`);
270
- }
271
- return new Error(`LLM request failed: ${err.message}`);
272
- }
273
-
274
- /**
275
- * Send messages to the LLM and return the assistant's response text.
276
- * @param {Array<{role: string, content: string}>} messages
277
- * @param {object} [options]
278
- * @param {number} [options.temperature]
279
- * @param {number} [options.maxTokens]
280
- * @param {number} [options.topP]
281
- * @param {string|string[]} [options.stop]
282
- * @returns {Promise<string>}
283
- */
284
- async chat(messages, options = {}) {
285
- const url = this._resolveUrl();
286
- const headers = this._buildHeaders();
287
- const body = this._buildBody(messages, { ...options, stream: false });
288
-
289
- const response = await this._requestWithRetry({
290
- method: 'post',
291
- url,
292
- headers,
293
- data: body,
294
- timeout: this.timeout,
295
- });
296
-
297
- return this._extractResponse(response.data);
298
- }
299
-
300
- /**
301
- * Send messages to the LLM and return an async generator yielding text chunks.
302
- * @param {Array<{role: string, content: string}>} messages
303
- * @param {object} [options]
304
- * @param {number} [options.temperature]
305
- * @param {number} [options.maxTokens]
306
- * @param {number} [options.topP]
307
- * @param {string|string[]} [options.stop]
308
- * @returns {AsyncGenerator<string>}
309
- */
310
- async *streamChat(messages, options = {}) {
311
- const url = this._resolveUrl();
312
- const headers = this._buildHeaders();
313
- const body = this._buildBody(messages, { ...options, stream: true });
314
-
315
- let response;
316
- try {
317
- response = await axios({
318
- method: 'post',
319
- url,
320
- headers,
321
- data: body,
322
- timeout: this.timeout,
323
- responseType: 'stream',
324
- });
325
- } catch (err) {
326
- throw this._wrapError(err);
327
- }
328
-
329
- const stream = response.data;
330
- let buffer = '';
331
-
332
- for await (const chunk of stream) {
333
- buffer += chunk.toString();
334
- const lines = buffer.split('\n');
335
- buffer = lines.pop() || '';
336
-
337
- for (const line of lines) {
338
- const trimmed = line.trim();
339
- if (!trimmed) continue;
340
-
341
- if (this.provider === 'ollama') {
342
- // Ollama streams JSON objects, one per line
343
- try {
344
- const parsed = JSON.parse(trimmed);
345
- if (parsed.message && parsed.message.content) {
346
- yield parsed.message.content;
347
- }
348
- if (parsed.done) return;
349
- } catch {
350
- // Incomplete JSON, skip
351
- }
352
- continue;
353
- }
354
-
355
- // SSE format: data: {...}
356
- if (!trimmed.startsWith('data:')) continue;
357
- const payload = trimmed.slice(5).trim();
358
- if (payload === '[DONE]') return;
359
-
360
- try {
361
- const parsed = JSON.parse(payload);
362
-
363
- if (this.provider === 'anthropic') {
364
- // Anthropic streaming events
365
- if (parsed.type === 'content_block_delta' && parsed.delta && parsed.delta.text) {
366
- yield parsed.delta.text;
367
- }
368
- } else {
369
- // OpenAI-compatible streaming
370
- if (parsed.choices && parsed.choices.length > 0) {
371
- const delta = parsed.choices[0].delta;
372
- if (delta && delta.content) {
373
- yield delta.content;
374
- }
375
- }
376
- }
377
- } catch {
378
- // Malformed JSON line, skip
379
- }
380
- }
381
- }
382
-
383
- // Process remaining buffer
384
- if (buffer.trim()) {
385
- const trimmed = buffer.trim();
386
- if (this.provider === 'ollama') {
387
- try {
388
- const parsed = JSON.parse(trimmed);
389
- if (parsed.message && parsed.message.content) {
390
- yield parsed.message.content;
391
- }
392
- } catch {
393
- // Incomplete JSON
394
- }
395
- } else if (trimmed.startsWith('data:')) {
396
- const payload = trimmed.slice(5).trim();
397
- if (payload !== '[DONE]') {
398
- try {
399
- const parsed = JSON.parse(payload);
400
- if (this.provider === 'anthropic') {
401
- if (parsed.type === 'content_block_delta' && parsed.delta && parsed.delta.text) {
402
- yield parsed.delta.text;
403
- }
404
- } else if (parsed.choices && parsed.choices.length > 0) {
405
- const delta = parsed.choices[0].delta;
406
- if (delta && delta.content) {
407
- yield delta.content;
408
- }
409
- }
410
- } catch {
411
- // Malformed JSON
412
- }
413
- }
414
- }
415
- }
416
- }
417
- }
418
-
419
- module.exports = LLMClient;
@@ -1,17 +0,0 @@
1
- {
2
- "name": "@sonar-autofix/llm-client",
3
- "version": "0.0.2",
4
- "description": "LLM client for the Sonar Autofix tool",
5
- "main": "index.js",
6
- "scripts": {
7
- "test": "echo \"Error: no test specified\" && exit 1"
8
- },
9
- "keywords": [
10
- "sonar",
11
- "autofix",
12
- "github",
13
- "llm"
14
- ],
15
- "author": "Sonar Autofix Team",
16
- "license": "MIT"
17
- }