Revisione/server/utils/openrouter.ts

119 lines
4.3 KiB
TypeScript

import { aiLimiter } from "./limiter";
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;
let credit402Retries = 0;
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 {
console.log(`[openrouter] queued — active: ${aiLimiter.active}, queued: ${aiLimiter.queued}`);
const res = await aiLimiter.run(() => $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 } : {}),
},
signal: AbortSignal.timeout(600_000),
}
));
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)";
const orErrCode = body?.error?.code ?? body?.error?.type ?? "(unknown)";
console.error(`[openrouter] ✗ ${elapsed}s — status: ${status ?? "unknown"} | or-code: ${orErrCode} | error: ${err?.message}`);
// 402 = insufficient credits — wait 60s and retry up to 5 times
if (status === 402) {
credit402Retries++;
if (credit402Retries > 5) {
console.error(`[openrouter] 402 retries exhausted (${credit402Retries - 1} attempts), giving up`);
throw err;
}
console.warn(`[openrouter] insufficient credits (attempt ${credit402Retries}/5) — waiting 60s before retry…`);
await sleep(60_000);
attempt--; // dont 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;
}