openstock/lib/ai-provider.ts

219 lines
6.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AI Provider abstraction for OpenStock.
*
* Supports multiple LLM backends via the AI_PROVIDER environment variable:
* - "gemini" (default) Google Gemini REST API
* - "minimax" MiniMax (OpenAI-compatible)
* - "siray" Siray.ai (OpenAI-compatible)
* - "deepseek" DeepSeek (OpenAI-compatible)
*
* Each provider returns a plain-text string from the model.
*/
export type AIProviderName = "gemini" | "minimax" | "siray" | "deepseek";
export interface AIProviderConfig {
name: AIProviderName;
apiKey: string;
baseUrl: string;
model: string;
}
/**
* Resolve the provider configuration from environment variables.
*/
export function getProviderConfig(
provider?: AIProviderName
): AIProviderConfig {
const name =
provider ||
(process.env.AI_PROVIDER as AIProviderName) ||
"gemini";
switch (name) {
case "minimax":
return {
name: "minimax",
apiKey: process.env.MINIMAX_API_KEY || "",
baseUrl:
process.env.MINIMAX_BASE_URL || "https://api.minimax.io/v1",
model: process.env.MINIMAX_MODEL || "MiniMax-M2.7",
};
case "siray":
return {
name: "siray",
apiKey: process.env.SIRAY_API_KEY || "",
baseUrl: "https://api.siray.ai/v1",
model: "siray-1.0-ultra",
};
case "deepseek":
return {
name: "deepseek",
apiKey: process.env.DEEPSEEK_API_KEY || "",
baseUrl:
process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com/v1",
model: process.env.DEEPSEEK_MODEL || "deepseek-chat",
};
case "gemini":
default:
return {
name: "gemini",
apiKey: process.env.GEMINI_API_KEY || "",
baseUrl:
"https://generativelanguage.googleapis.com/v1beta/models",
model: process.env.GEMINI_MODEL || "gemini-2.5-flash-lite",
};
}
}
/**
* Get the fallback provider: if the primary is Gemini use MiniMax,
* otherwise fall back to Gemini.
*/
export function getFallbackProviderName(
primary: AIProviderName
): AIProviderName {
if (primary === "gemini") {
// Prefer MiniMax, then DeepSeek, then Siray as fallbacks
if (process.env.MINIMAX_API_KEY) return "minimax";
if (process.env.DEEPSEEK_API_KEY) return "deepseek";
if (process.env.SIRAY_API_KEY) return "siray";
return "deepseek"; // caller will see missing-key error
}
// For non-Gemini primaries, fall back to Gemini
if (process.env.GEMINI_API_KEY) return "gemini";
// If no Gemini key either, try DeepSeek
if (process.env.DEEPSEEK_API_KEY) return "deepseek";
return "gemini"; // caller will see missing-key error
}
export function hasConfiguredAIProvider(
provider?: AIProviderName
): boolean {
const primaryName =
provider ||
(process.env.AI_PROVIDER as AIProviderName) ||
"gemini";
const primaryConfig = getProviderConfig(primaryName);
if (primaryConfig.apiKey) {
return true;
}
const fallbackConfig = getProviderConfig(
getFallbackProviderName(primaryName)
);
return Boolean(fallbackConfig.apiKey);
}
// ── Provider call implementations ──────────────────────────────────
async function callGemini(
prompt: string,
config: AIProviderConfig
): Promise<string> {
if (!config.apiKey) throw new Error("GEMINI_API_KEY is not set");
const url = `${config.baseUrl}/${config.model}:generateContent?key=${config.apiKey}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ role: "user", parts: [{ text: prompt }] }],
}),
});
if (!res.ok) {
throw new Error(`Gemini API error: ${res.status} ${res.statusText}`);
}
const data = await res.json();
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text;
if (!text) throw new Error("Gemini returned empty response");
return text;
}
async function callOpenAICompatible(
prompt: string,
config: AIProviderConfig
): Promise<string> {
if (!config.apiKey) {
throw new Error(
`${config.name.toUpperCase()}_API_KEY is not set`
);
}
const url = `${config.baseUrl}/chat/completions`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
messages: [{ role: "user", content: prompt }],
temperature: 0.7,
}),
});
if (!res.ok) {
throw new Error(
`${config.name} API error: ${res.status} ${res.statusText}`
);
}
const data = await res.json();
const text = data?.choices?.[0]?.message?.content;
if (!text) {
throw new Error(`${config.name} returned empty response`);
}
return text;
}
// ── Public API ─────────────────────────────────────────────────────
/**
* Call the configured (or specified) AI provider and return the model
* response as a plain string.
*/
export async function callAIProvider(
prompt: string,
provider?: AIProviderName
): Promise<string> {
const config = getProviderConfig(provider);
if (config.name === "gemini") {
return callGemini(prompt, config);
}
// MiniMax, Siray, and DeepSeek all use OpenAI-compatible endpoints
return callOpenAICompatible(prompt, config);
}
/**
* Call the AI provider with automatic fallback.
* Tries the primary provider first; on failure switches to the fallback.
*/
export async function callAIProviderWithFallback(
prompt: string
): Promise<string> {
const primaryName =
(process.env.AI_PROVIDER as AIProviderName) || "gemini";
const fallbackName = getFallbackProviderName(primaryName);
try {
return await callAIProvider(prompt, primaryName);
} catch (primaryError) {
console.error(
`⚠️ ${primaryName} failed, switching to ${fallbackName} fallback`,
primaryError
);
return await callAIProvider(prompt, fallbackName);
}
}