interface Message { role: "system" | "user" | "assistant"; content: string; } interface AskAIOptions { model?: string; temperature?: number; maxRetries?: number; maxTokens?: number; } export interface AskAIResult { text: string; cost: number; } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } export async function askAI(messages: Message[], options: AskAIOptions = {}): Promise { const config = useRuntimeConfig(); const apiKey = config.openrouterApiKey; if (!apiKey) throw new Error("OPENROUTER_API_KEY is not set"); const model = options.model ?? (config as any).openrouterModel ?? "anthropic/claude-sonnet-4-5"; const maxRetries = options.maxRetries ?? 4; let lastError: any; for (let attempt = 0; attempt <= maxRetries; attempt++) { const label = attempt > 0 ? ` (attempt ${attempt + 1}/${maxRetries + 1})` : ""; const promptPreview = messages[messages.length - 1]?.content?.slice(0, 120).replace(/\n/g, " "); console.log(`[openrouter] → ${model}${label} | prompt: "${promptPreview}…"`); const t0 = Date.now(); try { const res = await $fetch<{ id?: string; choices: { message: { content: string } }[]; usage?: { prompt_tokens?: number; completion_tokens?: number; cost?: number } }>( "https://openrouter.ai/api/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "HTTP-Referer": "https://revisi.one", "X-Title": "Revisi.one", }, body: { model, messages, temperature: options.temperature ?? 0.3, ...(options.maxTokens ? { max_tokens: options.maxTokens } : {}), }, } ); const elapsed = ((Date.now() - t0) / 1000).toFixed(1); const content = res.choices?.[0]?.message?.content; if (!content) { console.error(`[openrouter] ✗ empty response after ${elapsed}s — full response:`, JSON.stringify(res)); throw new Error("Empty response from OpenRouter"); } const usage = res.usage; const tokenInfo = usage ? ` | tokens: ${usage.prompt_tokens ?? "?"}→${usage.completion_tokens ?? "?"}` : ""; console.log(`[openrouter] ✓ ${elapsed}s${tokenInfo} | reply: "${content.slice(0, 120).replace(/\n/g, " ")}…"`); const cost = usage?.cost ?? 0; return { text: content, cost }; } catch (err: any) { lastError = err; const elapsed = ((Date.now() - t0) / 1000).toFixed(1); const status = err?.response?.status ?? err?.statusCode ?? err?.status; const body = err?.data ?? err?.response?._data ?? "(no body)"; console.error(`[openrouter] ✗ ${elapsed}s — status: ${status ?? "unknown"} | error: ${err?.message}`); if (body && body !== "(no body)") { console.error(`[openrouter] response body:`, JSON.stringify(body).slice(0, 400)); } // 402 = insufficient credits — wait 60s and keep retrying indefinitely if (status === 402) { console.warn(`[openrouter] insufficient credits — waiting 60s before retry…`); await sleep(60_000); attempt--; // don't count against maxRetries continue; } // only retry on 429 and 5xx if (status !== 429 && (status < 500 || status > 599)) throw err; if (attempt < maxRetries) { const retryAfterHeader = err?.response?.headers?.get?.("retry-after"); const retryAfterSec = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN; const backoffMs = isNaN(retryAfterSec) ? Math.min(1000 * 2 ** attempt + Math.random() * 500, 30000) : retryAfterSec * 1000; console.warn(`[openrouter] retrying in ${Math.round(backoffMs)}ms…`); await sleep(backoffMs); } } } throw lastError; }