qa360 2.1.5 → 2.1.7
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/dist/commands/ai.d.ts +2 -0
- package/dist/commands/ai.js +106 -1
- package/dist/commands/init.js +19 -3
- package/dist/core/ai/anthropic-provider.js +50 -38
- package/dist/core/ai/deepseek-provider.js +46 -34
- package/dist/core/ai/ollama-provider.js +41 -29
- package/dist/core/ai/openai-provider.js +46 -34
- package/dist/core/pack-v2/loader.d.ts +1 -0
- package/dist/core/pack-v2/loader.js +46 -13
- package/package.json +1 -1
package/dist/commands/ai.d.ts
CHANGED
package/dist/commands/ai.js
CHANGED
|
@@ -236,6 +236,11 @@ export async function aiBenchmarkCommand(options = {}) {
|
|
|
236
236
|
export async function aiGenerateCommand(prompt, options = {}) {
|
|
237
237
|
let spinner;
|
|
238
238
|
try {
|
|
239
|
+
// Use pack generation if --pack flag is set
|
|
240
|
+
if (options.pack) {
|
|
241
|
+
await generatePackCommand(prompt, options);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
239
244
|
const systemPrompt = buildSystemPrompt(options.type || 'api');
|
|
240
245
|
const provider = options.provider
|
|
241
246
|
? await createLLMProvider({ preferred: options.provider })
|
|
@@ -289,10 +294,61 @@ export async function aiGenerateCommand(prompt, options = {}) {
|
|
|
289
294
|
console.error(chalk.red('\nAnthropic Error:'), error.message);
|
|
290
295
|
}
|
|
291
296
|
else {
|
|
292
|
-
|
|
297
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
298
|
+
console.error(chalk.red('Error:'), errMsg);
|
|
293
299
|
}
|
|
294
300
|
}
|
|
295
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Generate a QA360 pack YAML using AI
|
|
304
|
+
*/
|
|
305
|
+
async function generatePackCommand(prompt, options = {}) {
|
|
306
|
+
const spinner = ora('Generating QA360 pack...').start();
|
|
307
|
+
try {
|
|
308
|
+
const systemPrompt = buildPackSystemPrompt();
|
|
309
|
+
const provider = options.provider
|
|
310
|
+
? await createLLMProvider({ preferred: options.provider })
|
|
311
|
+
: await createBest();
|
|
312
|
+
const providerType = provider.getProviderType();
|
|
313
|
+
const enhancedPrompt = `Generate a QA360 v2 pack YAML for: ${prompt}
|
|
314
|
+
|
|
315
|
+
Important: Return ONLY valid YAML starting with "version: 2". No code blocks, no explanations, just the YAML.`;
|
|
316
|
+
const response = await provider.generate({
|
|
317
|
+
prompt: enhancedPrompt,
|
|
318
|
+
systemPrompt,
|
|
319
|
+
maxTokens: 4096,
|
|
320
|
+
temperature: 0.3, // Lower temperature for more structured output
|
|
321
|
+
});
|
|
322
|
+
spinner.succeed(`Generated pack using ${chalk.cyan(providerType)}!\n`);
|
|
323
|
+
// Extract YAML from response (remove code blocks if present)
|
|
324
|
+
let yamlContent = response.content;
|
|
325
|
+
// Remove markdown code blocks
|
|
326
|
+
yamlContent = yamlContent.replace(/```yaml\n?/g, '').replace(/```\n?/g, '');
|
|
327
|
+
// Remove leading/trailing whitespace
|
|
328
|
+
yamlContent = yamlContent.trim();
|
|
329
|
+
// Determine output path
|
|
330
|
+
const outputPath = options.output || './qa360-pack.yml';
|
|
331
|
+
// Save the pack
|
|
332
|
+
const { writeFileSync } = await import('fs');
|
|
333
|
+
writeFileSync(outputPath, yamlContent, 'utf-8');
|
|
334
|
+
console.log(chalk.bold('Generated QA360 Pack:\n'));
|
|
335
|
+
console.log(chalk.gray('─'.repeat(70)));
|
|
336
|
+
console.log(yamlContent);
|
|
337
|
+
console.log(chalk.gray('─'.repeat(70)));
|
|
338
|
+
console.log(chalk.green(`\n✓ Pack saved to: ${outputPath}`));
|
|
339
|
+
console.log(chalk.gray(`\n[INFO] Tokens: ${response.usage.totalTokens} (${response.usage.promptTokens} in + ${response.usage.completionTokens} out)`));
|
|
340
|
+
console.log(chalk.gray(`[INFO] Provider: ${providerType} (${response.model})\n`));
|
|
341
|
+
// Show usage instructions
|
|
342
|
+
console.log(chalk.yellow('Next steps:'));
|
|
343
|
+
console.log(chalk.gray(` 1. Review the pack: cat ${outputPath}`));
|
|
344
|
+
console.log(chalk.gray(` 2. Run tests: qa360 run ${outputPath}`));
|
|
345
|
+
console.log(chalk.gray(` 3. Or validate: qa360 pack validate ${outputPath}\n`));
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
spinner.fail('Pack generation failed');
|
|
349
|
+
console.error(chalk.red('Error:'), error instanceof Error ? error.message : String(error));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
296
352
|
/**
|
|
297
353
|
* Show AI configuration
|
|
298
354
|
*/
|
|
@@ -436,6 +492,53 @@ For accessibility tests:
|
|
|
436
492
|
};
|
|
437
493
|
return typePrompts[type] || typePrompts.api;
|
|
438
494
|
}
|
|
495
|
+
/**
|
|
496
|
+
* Build system prompt for QA360 pack YAML generation
|
|
497
|
+
*/
|
|
498
|
+
function buildPackSystemPrompt() {
|
|
499
|
+
return `You are a QA360 pack YAML generator. Generate VALID QA360 v2 pack YAML files.
|
|
500
|
+
|
|
501
|
+
QA360 Pack v2 Structure:
|
|
502
|
+
\`\`\`yaml
|
|
503
|
+
version: 2
|
|
504
|
+
name: test-pack-name
|
|
505
|
+
description: Test pack description
|
|
506
|
+
gates:
|
|
507
|
+
gate-name:
|
|
508
|
+
adapter: playwright-api|playwright-ui|k6-perf
|
|
509
|
+
enabled: true
|
|
510
|
+
config:
|
|
511
|
+
baseUrl: "https://example.com"
|
|
512
|
+
# API adapter: use "smoke" array with "METHOD path -> status" format
|
|
513
|
+
smoke:
|
|
514
|
+
- "GET /api/health -> 200"
|
|
515
|
+
- "POST /api/users -> 201"
|
|
516
|
+
# UI adapter: use "scenario" array or "pages" array
|
|
517
|
+
scenario:
|
|
518
|
+
- action: navigate
|
|
519
|
+
url: /login
|
|
520
|
+
- action: fill
|
|
521
|
+
selector: "#email"
|
|
522
|
+
value: "test@example.com"
|
|
523
|
+
- action: click
|
|
524
|
+
selector: "button[type='submit']"
|
|
525
|
+
options:
|
|
526
|
+
timeout: 10000
|
|
527
|
+
budgets:
|
|
528
|
+
a11y_min: 70
|
|
529
|
+
perf_p95_ms: 3000
|
|
530
|
+
\`\`\`
|
|
531
|
+
|
|
532
|
+
Rules:
|
|
533
|
+
1. ALWAYS start with "version: 2"
|
|
534
|
+
2. Use "adapter:" not "type:"
|
|
535
|
+
3. For API tests: use adapter: playwright-api with smoke: array
|
|
536
|
+
4. For UI tests: use adapter: playwright-ui with scenario: array or pages: array
|
|
537
|
+
5. Smoke format: "METHOD path -> status" (e.g., "GET /users -> 200")
|
|
538
|
+
6. Scenario actions: navigate, fill, click, waitForSelector, assertText, assertVisible
|
|
539
|
+
7. Return ONLY the YAML, no markdown code blocks, no explanations
|
|
540
|
+
8. Make sure YAML is valid and properly indented`;
|
|
541
|
+
}
|
|
439
542
|
/**
|
|
440
543
|
* Create AI commands
|
|
441
544
|
*/
|
|
@@ -486,6 +589,8 @@ export function createAICommands() {
|
|
|
486
589
|
.option('-P, --provider <provider>', 'Use specific provider (deepseek, ollama, openai, anthropic)')
|
|
487
590
|
.option('-t, --type <type>', 'Test type (api, ui, perf, a11y)', 'api')
|
|
488
591
|
.option('--json', 'Output as JSON')
|
|
592
|
+
.option('--pack', 'Generate a QA360 pack YAML (instead of test code)')
|
|
593
|
+
.option('-o, --output <path>', 'Output file path (for --pack: ./qa360-pack.yml)')
|
|
489
594
|
.action(async (prompt, options) => {
|
|
490
595
|
try {
|
|
491
596
|
await aiGenerateCommand(prompt, options);
|
package/dist/commands/init.js
CHANGED
|
@@ -530,9 +530,25 @@ export async function initCommand(options = {}) {
|
|
|
530
530
|
try {
|
|
531
531
|
console.log(chalk.bold.cyan('\n🚀 QA360 Pack Generator (v2)\n'));
|
|
532
532
|
// Determine output file
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
533
|
+
let outputFile;
|
|
534
|
+
if (options.output) {
|
|
535
|
+
const resolved = resolve(options.output);
|
|
536
|
+
// Check if it's a directory or if it doesn't have an extension
|
|
537
|
+
if (options.output.endsWith('/') || !options.output.includes('.')) {
|
|
538
|
+
// It's a directory path, create the file inside it
|
|
539
|
+
mkdirSync(resolved, { recursive: true });
|
|
540
|
+
outputFile = join(resolved, 'qa360.yml');
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
// It's a file path, ensure parent directory exists
|
|
544
|
+
const dir = resolve(resolved, '..');
|
|
545
|
+
mkdirSync(dir, { recursive: true });
|
|
546
|
+
outputFile = resolved;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
outputFile = join(process.cwd(), 'qa360.yml');
|
|
551
|
+
}
|
|
536
552
|
// Check if file exists
|
|
537
553
|
if (existsSync(outputFile) && !options.force) {
|
|
538
554
|
const overwrite = await inquirer.prompt([
|
|
@@ -36,6 +36,9 @@ export class AnthropicProvider {
|
|
|
36
36
|
}
|
|
37
37
|
// Try a minimal API call
|
|
38
38
|
try {
|
|
39
|
+
// Use timeout with AbortController for compatibility
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
39
42
|
const response = await fetch(`${this.baseURL}/v1/messages`, {
|
|
40
43
|
method: 'POST',
|
|
41
44
|
headers: {
|
|
@@ -43,13 +46,14 @@ export class AnthropicProvider {
|
|
|
43
46
|
'anthropic-version': this.version,
|
|
44
47
|
'content-type': 'application/json',
|
|
45
48
|
},
|
|
46
|
-
signal:
|
|
49
|
+
signal: controller.signal,
|
|
47
50
|
body: JSON.stringify({
|
|
48
51
|
model: this.defaultModel,
|
|
49
52
|
max_tokens: 1,
|
|
50
53
|
messages: [{ role: 'user', content: 'Hi' }]
|
|
51
54
|
})
|
|
52
55
|
});
|
|
56
|
+
clearTimeout(timeoutId);
|
|
53
57
|
// Anthropic may return 400 for malformed request but still be available
|
|
54
58
|
// We consider it available if we get any response (not a network error)
|
|
55
59
|
return response.status !== 401 && response.status !== 403;
|
|
@@ -65,46 +69,54 @@ export class AnthropicProvider {
|
|
|
65
69
|
});
|
|
66
70
|
}
|
|
67
71
|
const messages = this.buildMessages(request);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
status: response.status,
|
|
88
|
-
response: error
|
|
72
|
+
// Use timeout with AbortController for compatibility
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch(`${this.baseURL}/v1/messages`, {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: {
|
|
79
|
+
'x-api-key': this.apiKey,
|
|
80
|
+
'anthropic-version': this.version,
|
|
81
|
+
'content-type': 'application/json',
|
|
82
|
+
},
|
|
83
|
+
signal: controller.signal,
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
model: this.defaultModel,
|
|
86
|
+
messages,
|
|
87
|
+
system: request.systemPrompt,
|
|
88
|
+
temperature: request.temperature ?? 0.7,
|
|
89
|
+
max_tokens: request.maxTokens ?? 4096,
|
|
90
|
+
})
|
|
89
91
|
});
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const error = await response.text().catch(() => 'Unknown error');
|
|
94
|
+
throw new AnthropicError(`Anthropic request failed: ${response.status} ${error}`, {
|
|
95
|
+
status: response.status,
|
|
96
|
+
response: error
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const data = await response.json();
|
|
100
|
+
if (data.error) {
|
|
101
|
+
throw new AnthropicError(`Anthropic API error: ${data.error.message}`, {
|
|
102
|
+
type: data.error.type
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const content = this.extractContent(data.content);
|
|
106
|
+
return {
|
|
107
|
+
content,
|
|
108
|
+
usage: {
|
|
109
|
+
promptTokens: data.usage?.input_tokens || 0,
|
|
110
|
+
completionTokens: data.usage?.output_tokens || 0,
|
|
111
|
+
totalTokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),
|
|
112
|
+
},
|
|
113
|
+
model: data.model,
|
|
114
|
+
finishReason: data.stop_reason === 'end_turn' ? 'stop' : 'length'
|
|
115
|
+
};
|
|
90
116
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
throw new AnthropicError(`Anthropic API error: ${data.error.message}`, {
|
|
94
|
-
type: data.error.type
|
|
95
|
-
});
|
|
117
|
+
finally {
|
|
118
|
+
clearTimeout(timeoutId);
|
|
96
119
|
}
|
|
97
|
-
const content = this.extractContent(data.content);
|
|
98
|
-
return {
|
|
99
|
-
content,
|
|
100
|
-
usage: {
|
|
101
|
-
promptTokens: data.usage?.input_tokens || 0,
|
|
102
|
-
completionTokens: data.usage?.output_tokens || 0,
|
|
103
|
-
totalTokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),
|
|
104
|
-
},
|
|
105
|
-
model: data.model,
|
|
106
|
-
finishReason: data.stop_reason === 'end_turn' ? 'stop' : 'length'
|
|
107
|
-
};
|
|
108
120
|
}
|
|
109
121
|
async *stream(request) {
|
|
110
122
|
if (!this.apiKey) {
|
|
@@ -48,13 +48,17 @@ export class DeepSeekProvider {
|
|
|
48
48
|
}
|
|
49
49
|
// Try a minimal API call
|
|
50
50
|
try {
|
|
51
|
+
// Use timeout with AbortController for compatibility
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
51
54
|
const response = await fetch(`${this.baseURL}/v1/models`, {
|
|
52
55
|
method: 'GET',
|
|
53
56
|
headers: {
|
|
54
57
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
55
58
|
},
|
|
56
|
-
signal:
|
|
59
|
+
signal: controller.signal,
|
|
57
60
|
});
|
|
61
|
+
clearTimeout(timeoutId);
|
|
58
62
|
return response.ok;
|
|
59
63
|
}
|
|
60
64
|
catch {
|
|
@@ -69,42 +73,50 @@ export class DeepSeekProvider {
|
|
|
69
73
|
});
|
|
70
74
|
}
|
|
71
75
|
const messages = this.buildMessages(request);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
status: response.status,
|
|
90
|
-
response: errorText
|
|
76
|
+
// Use timeout with AbortController for compatibility
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch(`${this.baseURL}/v1/chat/completions`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: {
|
|
83
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
84
|
+
'Content-Type': 'application/json',
|
|
85
|
+
},
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
model: this.defaultModel,
|
|
89
|
+
messages,
|
|
90
|
+
temperature: request.temperature ?? 0.7,
|
|
91
|
+
max_tokens: request.maxTokens ?? 4096,
|
|
92
|
+
})
|
|
91
93
|
});
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const errorText = await response.text().catch(() => 'Unknown error');
|
|
96
|
+
throw new DeepSeekError(`DeepSeek request failed: ${response.status} ${errorText}`, {
|
|
97
|
+
status: response.status,
|
|
98
|
+
response: errorText
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const data = await response.json();
|
|
102
|
+
const choice = data.choices?.[0];
|
|
103
|
+
if (!choice) {
|
|
104
|
+
throw new DeepSeekError('No choices returned from DeepSeek');
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
content: choice.message.content,
|
|
108
|
+
usage: {
|
|
109
|
+
promptTokens: data.usage?.prompt_tokens || 0,
|
|
110
|
+
completionTokens: data.usage?.completion_tokens || 0,
|
|
111
|
+
totalTokens: data.usage?.total_tokens || 0,
|
|
112
|
+
},
|
|
113
|
+
model: data.model,
|
|
114
|
+
finishReason: choice.finish_reason === 'stop' ? 'stop' : 'length'
|
|
115
|
+
};
|
|
92
116
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (!choice) {
|
|
96
|
-
throw new DeepSeekError('No choices returned from DeepSeek');
|
|
117
|
+
finally {
|
|
118
|
+
clearTimeout(timeoutId);
|
|
97
119
|
}
|
|
98
|
-
return {
|
|
99
|
-
content: choice.message.content,
|
|
100
|
-
usage: {
|
|
101
|
-
promptTokens: data.usage?.prompt_tokens || 0,
|
|
102
|
-
completionTokens: data.usage?.completion_tokens || 0,
|
|
103
|
-
totalTokens: data.usage?.total_tokens || 0,
|
|
104
|
-
},
|
|
105
|
-
model: data.model,
|
|
106
|
-
finishReason: choice.finish_reason === 'stop' ? 'stop' : 'length'
|
|
107
|
-
};
|
|
108
120
|
}
|
|
109
121
|
async *stream(request) {
|
|
110
122
|
if (!this.apiKey) {
|
|
@@ -32,9 +32,13 @@ export class OllamaProvider {
|
|
|
32
32
|
}
|
|
33
33
|
async isAvailable() {
|
|
34
34
|
try {
|
|
35
|
+
// Use timeout with AbortSignal - wrap for compatibility
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
35
38
|
const response = await fetch(`${this.baseUrl}/api/tags`, {
|
|
36
|
-
signal:
|
|
39
|
+
signal: controller.signal,
|
|
37
40
|
});
|
|
41
|
+
clearTimeout(timeoutId);
|
|
38
42
|
return response.ok;
|
|
39
43
|
}
|
|
40
44
|
catch {
|
|
@@ -43,37 +47,45 @@ export class OllamaProvider {
|
|
|
43
47
|
}
|
|
44
48
|
async generate(request) {
|
|
45
49
|
const fullPrompt = this.buildFullPrompt(request);
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
// Use timeout with AbortController for compatibility
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetch(`${this.baseUrl}/api/generate`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
model: this.defaultModel,
|
|
60
|
+
prompt: fullPrompt,
|
|
61
|
+
stream: false,
|
|
62
|
+
options: {
|
|
63
|
+
temperature: request.temperature ?? 0.7,
|
|
64
|
+
num_predict: request.maxTokens ?? 4096,
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
});
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new OllamaError(`Ollama request failed: ${response.status} ${response.statusText}`, { response: await response.text().catch(() => 'Unknown error') });
|
|
70
|
+
}
|
|
71
|
+
const data = await response.json();
|
|
72
|
+
if (data.error) {
|
|
73
|
+
throw new OllamaError(data.error);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
content: data.response,
|
|
77
|
+
usage: {
|
|
78
|
+
promptTokens: data.prompt_eval_count || 0,
|
|
79
|
+
completionTokens: data.eval_count || 0,
|
|
80
|
+
totalTokens: (data.prompt_eval_count || 0) + (data.eval_count || 0),
|
|
81
|
+
},
|
|
51
82
|
model: this.defaultModel,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
options: {
|
|
55
|
-
temperature: request.temperature ?? 0.7,
|
|
56
|
-
num_predict: request.maxTokens ?? 4096,
|
|
57
|
-
}
|
|
58
|
-
})
|
|
59
|
-
});
|
|
60
|
-
if (!response.ok) {
|
|
61
|
-
throw new OllamaError(`Ollama request failed: ${response.status} ${response.statusText}`, { response: await response.text().catch(() => 'Unknown error') });
|
|
83
|
+
finishReason: data.done_reason || 'stop',
|
|
84
|
+
};
|
|
62
85
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
throw new OllamaError(data.error);
|
|
86
|
+
finally {
|
|
87
|
+
clearTimeout(timeoutId);
|
|
66
88
|
}
|
|
67
|
-
return {
|
|
68
|
-
content: data.response,
|
|
69
|
-
usage: {
|
|
70
|
-
promptTokens: data.prompt_eval_count || 0,
|
|
71
|
-
completionTokens: data.eval_count || 0,
|
|
72
|
-
totalTokens: (data.prompt_eval_count || 0) + (data.eval_count || 0),
|
|
73
|
-
},
|
|
74
|
-
model: this.defaultModel,
|
|
75
|
-
finishReason: data.done_reason || 'stop',
|
|
76
|
-
};
|
|
77
89
|
}
|
|
78
90
|
async *stream(request) {
|
|
79
91
|
const fullPrompt = this.buildFullPrompt(request);
|
|
@@ -36,13 +36,17 @@ export class OpenAIProvider {
|
|
|
36
36
|
}
|
|
37
37
|
// Try a minimal API call
|
|
38
38
|
try {
|
|
39
|
+
// Use timeout with AbortController for compatibility
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
39
42
|
const response = await fetch(`${this.baseURL}/models`, {
|
|
40
43
|
method: 'GET',
|
|
41
44
|
headers: {
|
|
42
45
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
43
46
|
},
|
|
44
|
-
signal:
|
|
47
|
+
signal: controller.signal,
|
|
45
48
|
});
|
|
49
|
+
clearTimeout(timeoutId);
|
|
46
50
|
return response.ok;
|
|
47
51
|
}
|
|
48
52
|
catch {
|
|
@@ -56,42 +60,50 @@ export class OpenAIProvider {
|
|
|
56
60
|
});
|
|
57
61
|
}
|
|
58
62
|
const messages = this.buildMessages(request);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
status: response.status,
|
|
77
|
-
response: error
|
|
63
|
+
// Use timeout with AbortController for compatibility
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`${this.baseURL}/chat/completions`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: {
|
|
70
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
},
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
model: this.defaultModel,
|
|
76
|
+
messages,
|
|
77
|
+
temperature: request.temperature ?? 0.7,
|
|
78
|
+
max_tokens: request.maxTokens ?? 4096,
|
|
79
|
+
})
|
|
78
80
|
});
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const error = await response.text().catch(() => 'Unknown error');
|
|
83
|
+
throw new OpenAIError(`OpenAI request failed: ${response.status} ${error}`, {
|
|
84
|
+
status: response.status,
|
|
85
|
+
response: error
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const data = await response.json();
|
|
89
|
+
const choice = data.choices?.[0];
|
|
90
|
+
if (!choice) {
|
|
91
|
+
throw new OpenAIError('No choices returned from OpenAI');
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
content: choice.message.content,
|
|
95
|
+
usage: {
|
|
96
|
+
promptTokens: data.usage?.prompt_tokens || 0,
|
|
97
|
+
completionTokens: data.usage?.completion_tokens || 0,
|
|
98
|
+
totalTokens: data.usage?.total_tokens || 0,
|
|
99
|
+
},
|
|
100
|
+
model: data.model,
|
|
101
|
+
finishReason: choice.finish_reason === 'stop' ? 'stop' : 'length'
|
|
102
|
+
};
|
|
79
103
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (!choice) {
|
|
83
|
-
throw new OpenAIError('No choices returned from OpenAI');
|
|
104
|
+
finally {
|
|
105
|
+
clearTimeout(timeoutId);
|
|
84
106
|
}
|
|
85
|
-
return {
|
|
86
|
-
content: choice.message.content,
|
|
87
|
-
usage: {
|
|
88
|
-
promptTokens: data.usage?.prompt_tokens || 0,
|
|
89
|
-
completionTokens: data.usage?.completion_tokens || 0,
|
|
90
|
-
totalTokens: data.usage?.total_tokens || 0,
|
|
91
|
-
},
|
|
92
|
-
model: data.model,
|
|
93
|
-
finishReason: choice.finish_reason === 'stop' ? 'stop' : 'length'
|
|
94
|
-
};
|
|
95
107
|
}
|
|
96
108
|
async *stream(request) {
|
|
97
109
|
if (!this.apiKey) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* QA360 Pack v2 Loader
|
|
3
3
|
*
|
|
4
4
|
* Loads and parses pack.yml v2 files from the filesystem.
|
|
5
|
+
* Supports environment variable substitution with ${VAR} and ${VAR:-default} syntax.
|
|
5
6
|
*/
|
|
6
7
|
import { PackConfigV2, PackValidationResultV2, ValidationError } from '../types/pack-v2.js';
|
|
7
8
|
export interface PackLoadResult {
|
|
@@ -2,12 +2,41 @@
|
|
|
2
2
|
* QA360 Pack v2 Loader
|
|
3
3
|
*
|
|
4
4
|
* Loads and parses pack.yml v2 files from the filesystem.
|
|
5
|
+
* Supports environment variable substitution with ${VAR} and ${VAR:-default} syntax.
|
|
5
6
|
*/
|
|
6
7
|
import { readFileSync, existsSync } from 'fs';
|
|
7
8
|
import { resolve } from 'path';
|
|
8
9
|
import * as yaml from 'js-yaml';
|
|
9
10
|
import { PackValidatorV2 } from './validator.js';
|
|
10
11
|
import { PackMigrator } from './migrator.js';
|
|
12
|
+
/**
|
|
13
|
+
* Substitute environment variables in a string
|
|
14
|
+
* Supports ${VAR} and ${VAR:-default} syntax
|
|
15
|
+
*/
|
|
16
|
+
function substituteEnvVars(str) {
|
|
17
|
+
return str.replace(/\$\{([^}:]+)(:-([^}]*))?\}/g, (match, varName, _, defaultValue) => {
|
|
18
|
+
return process.env[varName] ?? defaultValue ?? match;
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Recursively substitute environment variables in an object
|
|
23
|
+
*/
|
|
24
|
+
function substituteEnvVarsRecursive(obj) {
|
|
25
|
+
if (typeof obj === 'string') {
|
|
26
|
+
return substituteEnvVars(obj);
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(obj)) {
|
|
29
|
+
return obj.map(substituteEnvVarsRecursive);
|
|
30
|
+
}
|
|
31
|
+
if (obj !== null && typeof obj === 'object') {
|
|
32
|
+
const result = {};
|
|
33
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
34
|
+
result[key] = substituteEnvVarsRecursive(value);
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
return obj;
|
|
39
|
+
}
|
|
11
40
|
export class PackLoaderV2 {
|
|
12
41
|
cache = new Map();
|
|
13
42
|
/**
|
|
@@ -30,16 +59,18 @@ export class PackLoaderV2 {
|
|
|
30
59
|
const content = readFileSync(resolvedPath, 'utf-8');
|
|
31
60
|
// Parse YAML
|
|
32
61
|
const parsed = yaml.load(content);
|
|
62
|
+
// Substitute environment variables
|
|
63
|
+
const substituted = substituteEnvVarsRecursive(parsed);
|
|
33
64
|
// Detect version
|
|
34
65
|
const migrator = new PackMigrator();
|
|
35
|
-
const detectedVersion = migrator.detectVersion(
|
|
66
|
+
const detectedVersion = migrator.detectVersion(substituted);
|
|
36
67
|
// Set format
|
|
37
68
|
const format = detectedVersion === 1 ? 'v1' : detectedVersion === 2 ? 'v2' : 'unknown';
|
|
38
69
|
// For v1 without migration
|
|
39
70
|
if (detectedVersion === 1 && options?.migrate === false) {
|
|
40
71
|
return {
|
|
41
72
|
success: true,
|
|
42
|
-
pack:
|
|
73
|
+
pack: substituted,
|
|
43
74
|
format: 'v1',
|
|
44
75
|
migrated: false,
|
|
45
76
|
sourcePath: resolvedPath
|
|
@@ -51,10 +82,10 @@ export class PackLoaderV2 {
|
|
|
51
82
|
let migrationWarnings = [];
|
|
52
83
|
if (detectedVersion === 1) {
|
|
53
84
|
// Check for legacy format before migrating
|
|
54
|
-
if (migrator.isLegacyFormat(
|
|
85
|
+
if (migrator.isLegacyFormat(substituted)) {
|
|
55
86
|
return {
|
|
56
87
|
success: false,
|
|
57
|
-
error: new Error(`Legacy pack format detected (version ${
|
|
88
|
+
error: new Error(`Legacy pack format detected (version ${substituted.version || '0.9.x'}). ` +
|
|
58
89
|
`This format is no longer supported.\n\n` +
|
|
59
90
|
`To fix this:\n` +
|
|
60
91
|
` 1. Delete the old pack file: ${resolvedPath}\n` +
|
|
@@ -65,7 +96,7 @@ export class PackLoaderV2 {
|
|
|
65
96
|
};
|
|
66
97
|
}
|
|
67
98
|
// Migrate v1 to v2
|
|
68
|
-
const migrationResult = migrator.migrate(
|
|
99
|
+
const migrationResult = migrator.migrate(substituted);
|
|
69
100
|
if (!migrationResult.success) {
|
|
70
101
|
return {
|
|
71
102
|
success: false,
|
|
@@ -80,14 +111,14 @@ export class PackLoaderV2 {
|
|
|
80
111
|
migrationWarnings = migrationResult.warnings;
|
|
81
112
|
}
|
|
82
113
|
else if (detectedVersion === 2) {
|
|
83
|
-
pack =
|
|
114
|
+
pack = substituted;
|
|
84
115
|
}
|
|
85
116
|
else {
|
|
86
117
|
// Check for legacy format and provide helpful error
|
|
87
|
-
if (migrator.isLegacyFormat(
|
|
118
|
+
if (migrator.isLegacyFormat(substituted)) {
|
|
88
119
|
return {
|
|
89
120
|
success: false,
|
|
90
|
-
error: new Error(`Legacy pack format detected (version ${
|
|
121
|
+
error: new Error(`Legacy pack format detected (version ${substituted.version || '0.9.x'}). ` +
|
|
91
122
|
`This format is no longer supported.\n\n` +
|
|
92
123
|
`To fix this:\n` +
|
|
93
124
|
` 1. Delete the old pack file: ${resolvedPath}\n` +
|
|
@@ -99,7 +130,7 @@ export class PackLoaderV2 {
|
|
|
99
130
|
}
|
|
100
131
|
return {
|
|
101
132
|
success: false,
|
|
102
|
-
error: new Error(`Unknown pack format. Version: ${
|
|
133
|
+
error: new Error(`Unknown pack format. Version: ${substituted.version}`),
|
|
103
134
|
sourcePath: resolvedPath,
|
|
104
135
|
format: 'unknown'
|
|
105
136
|
};
|
|
@@ -189,13 +220,15 @@ export class PackLoaderV2 {
|
|
|
189
220
|
async loadFromString(yamlContent, options) {
|
|
190
221
|
try {
|
|
191
222
|
const parsed = yaml.load(yamlContent);
|
|
223
|
+
// Substitute environment variables
|
|
224
|
+
const substituted = substituteEnvVarsRecursive(parsed);
|
|
192
225
|
// Detect version and migrate if needed
|
|
193
226
|
const migrator = new PackMigrator();
|
|
194
|
-
const detectedVersion = migrator.detectVersion(
|
|
227
|
+
const detectedVersion = migrator.detectVersion(substituted);
|
|
195
228
|
let pack;
|
|
196
229
|
let migrated = false;
|
|
197
230
|
if (detectedVersion === 1) {
|
|
198
|
-
const migrationResult = migrator.migrate(
|
|
231
|
+
const migrationResult = migrator.migrate(substituted);
|
|
199
232
|
if (!migrationResult.success) {
|
|
200
233
|
return {
|
|
201
234
|
success: false,
|
|
@@ -206,12 +239,12 @@ export class PackLoaderV2 {
|
|
|
206
239
|
migrated = true;
|
|
207
240
|
}
|
|
208
241
|
else if (detectedVersion === 2) {
|
|
209
|
-
pack =
|
|
242
|
+
pack = substituted;
|
|
210
243
|
}
|
|
211
244
|
else {
|
|
212
245
|
return {
|
|
213
246
|
success: false,
|
|
214
|
-
error: new Error(`Unknown pack format. Version: ${
|
|
247
|
+
error: new Error(`Unknown pack format. Version: ${substituted.version}`)
|
|
215
248
|
};
|
|
216
249
|
}
|
|
217
250
|
// Validate if requested
|