drivecore / src /lib /ai-config.server.ts
gpt-engineer-app[bot]
Changes
0b4f921
Raw
History Blame Contribute Delete
5.62 kB
// Resolve the AI endpoint + auth at call time so operators can point the app
// at Lovable AI Gateway, a self-hosted Ollama/vLLM, or any OpenAI-compatible
// server without code changes.
//
// Env vars:
// AI_BASE_URL e.g. "http://localhost:11434/v1" (Ollama) or
// "http://127.0.0.1:8000/v1" (vLLM). Defaults to Lovable AI.
// AI_API_KEY Bearer token. Falls back to LOVABLE_API_KEY. Optional for
// local servers that don't require auth.
// AI_MODEL Optional default model override (e.g. "llama3.1:70b").
const DEFAULT_BASE_URL = "https://ai.gateway.lovable.dev/v1";
export function getAIConfig() {
const baseUrl = (process.env.AI_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
const apiKey = process.env.AI_API_KEY ?? process.env.LOVABLE_API_KEY ?? "";
const defaultModel = process.env.AI_MODEL;
const isLovable = baseUrl === DEFAULT_BASE_URL;
if (isLovable && !apiKey) {
throw new Error("LOVABLE_API_KEY not configured (or set AI_BASE_URL + AI_API_KEY for a self-hosted model).");
}
return { baseUrl, apiKey, defaultModel, isLovable };
}
export function aiChatCompletionsUrl(): string {
return `${getAIConfig().baseUrl}/chat/completions`;
}
export function aiAuthHeaders(): Record<string, string> {
const { apiKey } = getAIConfig();
const h: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) h.Authorization = `Bearer ${apiKey}`;
return h;
}
export function resolveModel(requested: string): string {
const { defaultModel, isLovable } = getAIConfig();
// AI_MODEL override only applies to self-hosted endpoints — the Lovable
// gateway has a fixed allow-list and would 400 on names like "qwen3".
if (defaultModel && !isLovable) return defaultModel;
return requested;
}
function isTransientAiStatus(status: number) {
return [408, 425, 429, 500, 502, 503, 504].includes(status);
}
function extractFetchDetail(error: unknown) {
const e = error as {
message?: string;
cause?: { message?: string; code?: string };
};
return e?.cause?.message || e?.cause?.code || e?.message || "Unknown network error";
}
export async function fetchAIWithFallback(
body: string,
requestedModel: string,
label: string,
userId?: string | null,
) {
const config = getAIConfig();
const model = resolveModel(requestedModel);
const endpoints = [config.baseUrl];
if (config.baseUrl !== DEFAULT_BASE_URL && process.env.LOVABLE_API_KEY) {
endpoints.push(DEFAULT_BASE_URL);
}
let lastError: unknown;
let lastStatusResponse: Response | null = null;
let totalAttempts = 0;
let usedFallbackFinal = false;
let endpointFinal = `${config.baseUrl}/chat/completions`;
const startedAt = Date.now();
const finalize = async (
response: Response | null,
error: unknown,
) => {
const duration = Date.now() - startedAt;
const status = response?.status ?? null;
const ok = !!response?.ok;
let errorText: string | null = null;
if (!ok) {
if (error) errorText = extractFetchDetail(error);
else if (response) {
try {
const cloned = response.clone();
errorText = (await cloned.text()).slice(0, 500);
} catch {
errorText = `HTTP ${response.status}`;
}
}
}
void userId;
console.log(`[ai:${label}] model=${model} endpoint=${endpointFinal} status=${status} ok=${ok} attempts=${totalAttempts} duration=${duration}ms${errorText ? ` error=${errorText}` : ""}${usedFallbackFinal ? " (fallback)" : ""}`);
};
for (const baseUrl of endpoints) {
const usingFallback = baseUrl !== config.baseUrl;
const headers = {
"Content-Type": "application/json",
...(baseUrl === DEFAULT_BASE_URL
? { Authorization: `Bearer ${process.env.LOVABLE_API_KEY ?? ""}` }
: aiAuthHeaders()),
};
for (let attempt = 0; attempt < 3; attempt++) {
totalAttempts++;
usedFallbackFinal = usingFallback;
endpointFinal = `${baseUrl}/chat/completions`;
try {
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
...JSON.parse(body),
// On the Lovable gateway, ignore AI_MODEL override (e.g. "qwen3")
// because the gateway only accepts its own allow-list.
model: baseUrl === DEFAULT_BASE_URL ? requestedModel : model,
}),
});
if (!response.ok && isTransientAiStatus(response.status) && attempt < 2) {
console.error(`[${label}] AI request returned ${response.status} on attempt ${attempt + 1}${usingFallback ? " (fallback gateway)" : ""}`);
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
continue;
}
if (!response.ok) {
lastStatusResponse = response;
}
await finalize(response, null);
return response;
} catch (error) {
lastError = error;
console.error(
`[${label}] AI fetch attempt ${attempt + 1} failed${usingFallback ? " (fallback gateway)" : ""}:`,
extractFetchDetail(error),
);
if (attempt < 2) {
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
}
}
}
}
if (lastStatusResponse) {
await finalize(lastStatusResponse, null);
return lastStatusResponse;
}
await finalize(null, lastError);
const detail = extractFetchDetail(lastError);
throw new Error(`Could not reach the analysis service: ${detail}. Try again in a moment.`);
}