|
1 | 1 | import * as https from 'node:https'; |
2 | 2 |
|
| 3 | +const DEFAULT_TIMEOUT_MS = 120_000; |
| 4 | + |
3 | 5 | export type Provider = 'anthropic' | 'openai' | 'openrouter' | 'fireworks'; |
4 | 6 |
|
5 | 7 | export interface LLMRequest { |
@@ -46,11 +48,19 @@ export async function callLLM(req: LLMRequest): Promise<LLMResponse> { |
46 | 48 | ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' } |
47 | 49 | : { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' }; |
48 | 50 |
|
| 51 | + const price = PRICE_PER_M_TOKENS[req.model]; |
| 52 | + if (!price) { |
| 53 | + const known = Object.keys(PRICE_PER_M_TOKENS).join(', '); |
| 54 | + throw new Error( |
| 55 | + `Unknown model "${req.model}". Budget cap cannot be enforced. ` + |
| 56 | + `Add a price entry to PRICE_PER_M_TOKENS in src/optimizer/llm.ts or pick one of: ${known}.`, |
| 57 | + ); |
| 58 | + } |
| 59 | + |
49 | 60 | const raw = await postJson(cfg.host, cfg.path, headers, body); |
50 | 61 | const parsed = JSON.parse(raw); |
51 | 62 | const text = extractText(req.provider, parsed); |
52 | 63 | const usage = extractUsage(req.provider, parsed); |
53 | | - const price = PRICE_PER_M_TOKENS[req.model] ?? { input: 0, output: 0 }; |
54 | 64 | const costUsd = (usage.input / 1_000_000) * price.input + (usage.output / 1_000_000) * price.output; |
55 | 65 |
|
56 | 66 | return { text, inputTokens: usage.input, outputTokens: usage.output, costUsd }; |
@@ -99,20 +109,39 @@ function extractUsage(provider: Provider, body: unknown): { input: number; outpu |
99 | 109 | } |
100 | 110 |
|
101 | 111 | function postJson(host: string, path: string, headers: Record<string, string>, body: string): Promise<string> { |
| 112 | + const timeoutMs = parseInt(process.env.SKILL_OPTIMIZER_TIMEOUT_MS ?? '', 10) || DEFAULT_TIMEOUT_MS; |
102 | 113 | return new Promise((resolve, reject) => { |
| 114 | + let timer: NodeJS.Timeout | null = null; |
| 115 | + let settled = false; |
| 116 | + const settle = (fn: () => void) => { |
| 117 | + if (settled) return; |
| 118 | + settled = true; |
| 119 | + if (timer) { |
| 120 | + clearTimeout(timer); |
| 121 | + timer = null; |
| 122 | + } |
| 123 | + fn(); |
| 124 | + }; |
103 | 125 | const req = https.request( |
104 | 126 | { host, path, method: 'POST', headers: { ...headers, 'content-length': Buffer.byteLength(body).toString() } }, |
105 | 127 | (res) => { |
106 | 128 | const chunks: Buffer[] = []; |
107 | 129 | res.on('data', (c) => chunks.push(c)); |
108 | 130 | res.on('end', () => { |
109 | 131 | const text = Buffer.concat(chunks).toString('utf8'); |
110 | | - if ((res.statusCode ?? 500) >= 400) reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 500)}`)); |
111 | | - else resolve(text); |
| 132 | + if ((res.statusCode ?? 500) >= 400) { |
| 133 | + settle(() => reject(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 500)}`))); |
| 134 | + } else { |
| 135 | + settle(() => resolve(text)); |
| 136 | + } |
112 | 137 | }); |
| 138 | + res.on('error', (err) => settle(() => reject(err))); |
113 | 139 | }, |
114 | 140 | ); |
115 | | - req.on('error', reject); |
| 141 | + req.on('error', (err) => settle(() => reject(err))); |
| 142 | + timer = setTimeout(() => { |
| 143 | + req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)); |
| 144 | + }, timeoutMs); |
116 | 145 | req.write(body); |
117 | 146 | req.end(); |
118 | 147 | }); |
|
0 commit comments