111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
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<AskAIResult> {
|
|
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;
|
|
}
|