feat: add MiniMax as alternative LLM provider with provider abstraction
Introduce a configurable AI provider system (lib/ai-provider.ts) that supports Gemini (default), MiniMax, and Siray.ai with automatic fallback. The AI_PROVIDER env var selects the primary provider, and on failure the system falls back to a secondary provider automatically. - MiniMax M2.7 via OpenAI-compatible API (api.minimax.io/v1) - Provider abstraction replaces hardcoded Gemini + Siray fallback - 24 unit tests + 3 integration tests (vitest)
This commit is contained in:
parent
08304b51a4
commit
be46d25807
22
README.md
22
README.md
|
|
@ -253,8 +253,17 @@ BETTER_AUTH_URL=http://localhost:3000
|
|||
NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key
|
||||
FINNHUB_BASE_URL=https://finnhub.io/api/v1
|
||||
|
||||
# Inngest AI (Gemini)
|
||||
# AI Provider (optional, default: "gemini")
|
||||
# Supported: "gemini", "minimax", "siray"
|
||||
# AI_PROVIDER=gemini
|
||||
|
||||
# Gemini
|
||||
GEMINI_API_KEY=your_gemini_api_key
|
||||
|
||||
# MiniMax (optional, used when AI_PROVIDER=minimax or as fallback)
|
||||
# Get your key at https://platform.minimaxi.com
|
||||
# MINIMAX_API_KEY=your_minimax_api_key
|
||||
|
||||
# Inngest Signing Key (required for Vercel deployment)
|
||||
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
|
||||
INNGEST_SIGNING_KEY=your_inngest_signing_key
|
||||
|
|
@ -281,8 +290,17 @@ BETTER_AUTH_URL=http://localhost:3000
|
|||
NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key
|
||||
FINNHUB_BASE_URL=https://finnhub.io/api/v1
|
||||
|
||||
# Inngest AI (Gemini)
|
||||
# AI Provider (optional, default: "gemini")
|
||||
# Supported: "gemini", "minimax", "siray"
|
||||
# AI_PROVIDER=gemini
|
||||
|
||||
# Gemini
|
||||
GEMINI_API_KEY=your_gemini_api_key
|
||||
|
||||
# MiniMax (optional, used when AI_PROVIDER=minimax or as fallback)
|
||||
# Get your key at https://platform.minimaxi.com
|
||||
# MINIMAX_API_KEY=your_minimax_api_key
|
||||
|
||||
# Inngest Signing Key (required for Vercel deployment)
|
||||
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
|
||||
INNGEST_SIGNING_KEY=your_inngest_signing_key
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { callAIProvider } from "@/lib/ai-provider";
|
||||
|
||||
/**
|
||||
* Integration tests for AI providers.
|
||||
* These tests call real APIs and require valid API keys in environment.
|
||||
* Run with: npx vitest run __tests__/ai-provider.integration.test.ts
|
||||
*/
|
||||
|
||||
describe.skipIf(!process.env.MINIMAX_API_KEY)(
|
||||
"MiniMax integration",
|
||||
() => {
|
||||
it("generates a response from MiniMax M2.7", async () => {
|
||||
const result = await callAIProvider(
|
||||
"Reply with exactly: MINIMAX_OK",
|
||||
"minimax"
|
||||
);
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
}, 30_000);
|
||||
|
||||
it("handles a longer prompt", async () => {
|
||||
const result = await callAIProvider(
|
||||
"Summarize in one sentence: The stock market is a platform for buying and selling shares of publicly traded companies.",
|
||||
"minimax"
|
||||
);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.length).toBeGreaterThan(10);
|
||||
}, 30_000);
|
||||
|
||||
it("generates HTML content for email personalization", async () => {
|
||||
const result = await callAIProvider(
|
||||
'Generate a single HTML <p> tag with a brief personalized welcome message for a tech investor. Return only the HTML, no markdown.',
|
||||
"minimax"
|
||||
);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
}, 30_000);
|
||||
}
|
||||
);
|
||||
|
||||
describe.skipIf(!process.env.GEMINI_API_KEY)(
|
||||
"Gemini integration",
|
||||
() => {
|
||||
it("generates a response from Gemini", async () => {
|
||||
const result = await callAIProvider(
|
||||
"Reply with exactly: GEMINI_OK",
|
||||
"gemini"
|
||||
);
|
||||
expect(result).toBeTruthy();
|
||||
expect(typeof result).toBe("string");
|
||||
}, 30_000);
|
||||
}
|
||||
);
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
getProviderConfig,
|
||||
getFallbackProviderName,
|
||||
callAIProvider,
|
||||
callAIProviderWithFallback,
|
||||
type AIProviderName,
|
||||
} from "@/lib/ai-provider";
|
||||
|
||||
// ── getProviderConfig ──────────────────────────────────────────────
|
||||
|
||||
describe("getProviderConfig", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("defaults to gemini when no env var is set", () => {
|
||||
delete process.env.AI_PROVIDER;
|
||||
const config = getProviderConfig();
|
||||
expect(config.name).toBe("gemini");
|
||||
expect(config.baseUrl).toContain("generativelanguage.googleapis.com");
|
||||
expect(config.model).toBe("gemini-2.5-flash-lite");
|
||||
});
|
||||
|
||||
it("returns minimax config when provider is minimax", () => {
|
||||
process.env.MINIMAX_API_KEY = "test-key";
|
||||
const config = getProviderConfig("minimax");
|
||||
expect(config.name).toBe("minimax");
|
||||
expect(config.baseUrl).toBe("https://api.minimax.io/v1");
|
||||
expect(config.model).toBe("MiniMax-M2.7");
|
||||
expect(config.apiKey).toBe("test-key");
|
||||
});
|
||||
|
||||
it("respects MINIMAX_MODEL env var", () => {
|
||||
process.env.MINIMAX_MODEL = "MiniMax-M2.5-highspeed";
|
||||
const config = getProviderConfig("minimax");
|
||||
expect(config.model).toBe("MiniMax-M2.5-highspeed");
|
||||
});
|
||||
|
||||
it("respects MINIMAX_BASE_URL env var", () => {
|
||||
process.env.MINIMAX_BASE_URL = "https://custom.minimax.example/v1";
|
||||
const config = getProviderConfig("minimax");
|
||||
expect(config.baseUrl).toBe("https://custom.minimax.example/v1");
|
||||
});
|
||||
|
||||
it("returns siray config when provider is siray", () => {
|
||||
process.env.SIRAY_API_KEY = "siray-key";
|
||||
const config = getProviderConfig("siray");
|
||||
expect(config.name).toBe("siray");
|
||||
expect(config.baseUrl).toBe("https://api.siray.ai/v1");
|
||||
expect(config.model).toBe("siray-1.0-ultra");
|
||||
expect(config.apiKey).toBe("siray-key");
|
||||
});
|
||||
|
||||
it("reads AI_PROVIDER from env when no argument is given", () => {
|
||||
process.env.AI_PROVIDER = "minimax";
|
||||
process.env.MINIMAX_API_KEY = "k";
|
||||
const config = getProviderConfig();
|
||||
expect(config.name).toBe("minimax");
|
||||
});
|
||||
|
||||
it("respects GEMINI_MODEL env var", () => {
|
||||
process.env.GEMINI_MODEL = "gemini-2.0-flash";
|
||||
const config = getProviderConfig("gemini");
|
||||
expect(config.model).toBe("gemini-2.0-flash");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getFallbackProviderName ────────────────────────────────────────
|
||||
|
||||
describe("getFallbackProviderName", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("returns minimax when primary is gemini and MINIMAX_API_KEY is set", () => {
|
||||
process.env.MINIMAX_API_KEY = "k";
|
||||
expect(getFallbackProviderName("gemini")).toBe("minimax");
|
||||
});
|
||||
|
||||
it("returns siray when primary is gemini and only SIRAY_API_KEY is set", () => {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
process.env.SIRAY_API_KEY = "s";
|
||||
expect(getFallbackProviderName("gemini")).toBe("siray");
|
||||
});
|
||||
|
||||
it("returns gemini when primary is minimax", () => {
|
||||
expect(getFallbackProviderName("minimax")).toBe("gemini");
|
||||
});
|
||||
|
||||
it("returns gemini when primary is siray", () => {
|
||||
expect(getFallbackProviderName("siray")).toBe("gemini");
|
||||
});
|
||||
});
|
||||
|
||||
// ── callAIProvider ─────────────────────────────────────────────────
|
||||
|
||||
describe("callAIProvider", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("throws when GEMINI_API_KEY is missing for gemini provider", async () => {
|
||||
delete process.env.GEMINI_API_KEY;
|
||||
await expect(callAIProvider("hello", "gemini")).rejects.toThrow(
|
||||
"GEMINI_API_KEY is not set"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when MINIMAX_API_KEY is missing for minimax provider", async () => {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
await expect(callAIProvider("hello", "minimax")).rejects.toThrow(
|
||||
"MINIMAX_API_KEY is not set"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when SIRAY_API_KEY is missing for siray provider", async () => {
|
||||
delete process.env.SIRAY_API_KEY;
|
||||
await expect(callAIProvider("hello", "siray")).rejects.toThrow(
|
||||
"SIRAY_API_KEY is not set"
|
||||
);
|
||||
});
|
||||
|
||||
it("calls Gemini API with correct format", async () => {
|
||||
process.env.GEMINI_API_KEY = "test-gemini-key";
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
candidates: [
|
||||
{ content: { parts: [{ text: "Hello from Gemini" }] } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const result = await callAIProvider("test prompt", "gemini");
|
||||
expect(result).toBe("Hello from Gemini");
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain("generativelanguage.googleapis.com");
|
||||
expect(url).toContain("key=test-gemini-key");
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.contents[0].parts[0].text).toBe("test prompt");
|
||||
});
|
||||
|
||||
it("calls MiniMax API with OpenAI-compatible format", async () => {
|
||||
process.env.MINIMAX_API_KEY = "test-minimax-key";
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "Hello from MiniMax" } }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const result = await callAIProvider("test prompt", "minimax");
|
||||
expect(result).toBe("Hello from MiniMax");
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe("https://api.minimax.io/v1/chat/completions");
|
||||
expect(options.headers["Authorization"]).toBe(
|
||||
"Bearer test-minimax-key"
|
||||
);
|
||||
const body = JSON.parse(options.body);
|
||||
expect(body.model).toBe("MiniMax-M2.7");
|
||||
expect(body.messages[0].content).toBe("test prompt");
|
||||
expect(body.temperature).toBe(0.7);
|
||||
});
|
||||
|
||||
it("calls Siray API with OpenAI-compatible format", async () => {
|
||||
process.env.SIRAY_API_KEY = "test-siray-key";
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "Hello from Siray" } }],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const result = await callAIProvider("test prompt", "siray");
|
||||
expect(result).toBe("Hello from Siray");
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe("https://api.siray.ai/v1/chat/completions");
|
||||
expect(options.headers["Authorization"]).toBe("Bearer test-siray-key");
|
||||
});
|
||||
|
||||
it("throws on API error response", async () => {
|
||||
process.env.GEMINI_API_KEY = "k";
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: "Too Many Requests",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(callAIProvider("hello", "gemini")).rejects.toThrow(
|
||||
"Gemini API error: 429"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on empty Gemini response", async () => {
|
||||
process.env.GEMINI_API_KEY = "k";
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ candidates: [] }),
|
||||
})
|
||||
);
|
||||
|
||||
await expect(callAIProvider("hello", "gemini")).rejects.toThrow(
|
||||
"Gemini returned empty response"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on empty MiniMax response", async () => {
|
||||
process.env.MINIMAX_API_KEY = "k";
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ choices: [{ message: {} }] }),
|
||||
})
|
||||
);
|
||||
|
||||
await expect(callAIProvider("hello", "minimax")).rejects.toThrow(
|
||||
"minimax returned empty response"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── callAIProviderWithFallback ─────────────────────────────────────
|
||||
|
||||
describe("callAIProviderWithFallback", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it("returns primary provider result on success", async () => {
|
||||
process.env.AI_PROVIDER = "minimax";
|
||||
process.env.MINIMAX_API_KEY = "k";
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "MiniMax response" } }],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await callAIProviderWithFallback("test");
|
||||
expect(result).toBe("MiniMax response");
|
||||
});
|
||||
|
||||
it("falls back to secondary provider on primary failure", async () => {
|
||||
process.env.AI_PROVIDER = "minimax";
|
||||
process.env.MINIMAX_API_KEY = "k";
|
||||
process.env.GEMINI_API_KEY = "g";
|
||||
|
||||
let callCount = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation((url: string) => {
|
||||
callCount++;
|
||||
if (url.includes("minimax")) {
|
||||
return Promise.resolve({ ok: false, status: 500, statusText: "Error" });
|
||||
}
|
||||
// Gemini fallback
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
candidates: [
|
||||
{ content: { parts: [{ text: "Gemini fallback" }] } },
|
||||
],
|
||||
}),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const result = await callAIProviderWithFallback("test");
|
||||
expect(result).toBe("Gemini fallback");
|
||||
expect(callCount).toBe(2);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses gemini as default primary and minimax as fallback", async () => {
|
||||
delete process.env.AI_PROVIDER;
|
||||
process.env.GEMINI_API_KEY = "g";
|
||||
process.env.MINIMAX_API_KEY = "m";
|
||||
|
||||
let callCount = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation((url: string) => {
|
||||
callCount++;
|
||||
if (url.includes("googleapis")) {
|
||||
return Promise.resolve({ ok: false, status: 500, statusText: "Error" });
|
||||
}
|
||||
// MiniMax fallback
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "MiniMax fallback" } }],
|
||||
}),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const result = await callAIProviderWithFallback("test");
|
||||
expect(result).toBe("MiniMax fallback");
|
||||
expect(callCount).toBe(2);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("throws when both primary and fallback fail", async () => {
|
||||
process.env.AI_PROVIDER = "minimax";
|
||||
process.env.MINIMAX_API_KEY = "k";
|
||||
process.env.GEMINI_API_KEY = "g";
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
})
|
||||
);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await expect(callAIProviderWithFallback("test")).rejects.toThrow();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* 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)
|
||||
*
|
||||
* Each provider returns a plain-text string from the model.
|
||||
*/
|
||||
|
||||
export type AIProviderName = "gemini" | "minimax" | "siray";
|
||||
|
||||
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 "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 as fallback when a key is available, else Siray
|
||||
if (process.env.MINIMAX_API_KEY) return "minimax";
|
||||
if (process.env.SIRAY_API_KEY) return "siray";
|
||||
return "minimax"; // caller will see missing-key error
|
||||
}
|
||||
return "gemini";
|
||||
}
|
||||
|
||||
// ── 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 and Siray both 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { getAllUsersForNewsEmail } from "@/lib/actions/user.actions";
|
|||
import { getWatchlistSymbolsByEmail } from "@/lib/actions/watchlist.actions";
|
||||
import { getNews } from "@/lib/actions/finnhub.actions";
|
||||
import { getFormattedTodayDate } from "@/lib/utils";
|
||||
import { callAIProviderWithFallback } from "@/lib/ai-provider";
|
||||
|
||||
export const sendSignUpEmail = inngest.createFunction(
|
||||
{ id: 'sign-up-email' },
|
||||
|
|
@ -20,60 +21,20 @@ export const sendSignUpEmail = inngest.createFunction(
|
|||
const prompt = PERSONALIZED_WELCOME_EMAIL_PROMPT.replace('{{userProfile}}', userProfile)
|
||||
|
||||
|
||||
let aiResponse;
|
||||
try {
|
||||
aiResponse = await step.ai.infer('generate-welcome-intro', {
|
||||
model: step.ai.models.gemini({ model: 'gemini-2.5-flash-lite' }),
|
||||
body: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: prompt }
|
||||
]
|
||||
}]
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("⚠️ Gemini API failed, switching to Siray.ai fallback", error);
|
||||
|
||||
// Fallback Step
|
||||
aiResponse = await step.run('generate-welcome-intro-fallback', async () => {
|
||||
const SIRAY_API_KEY = process.env.SIRAY_API_KEY;
|
||||
if (!SIRAY_API_KEY) throw new Error("Siray API Key missing");
|
||||
|
||||
// Simulated OpenAI-compatible call
|
||||
const res = await fetch('https://api.siray.ai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${SIRAY_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'siray-1.0-ultra', // Hypothetical model
|
||||
messages: [{ role: 'user', content: prompt }]
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Siray API Error: ${res.statusText}`);
|
||||
|
||||
const data = await res.json();
|
||||
// Map to Gemini format for compatibility downstream
|
||||
return {
|
||||
candidates: [{
|
||||
content: { parts: [{ text: data.choices[0].message.content }] }
|
||||
}]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const introText = await step.run('generate-welcome-intro', async () => {
|
||||
try {
|
||||
return await callAIProviderWithFallback(prompt);
|
||||
} catch (error) {
|
||||
console.error("⚠️ All AI providers failed for welcome email", error);
|
||||
return 'Thanks for joining Openstock. You now have the tools to track markets and make smarter moves.';
|
||||
}
|
||||
});
|
||||
|
||||
await step.run('send-welcome-email', async () => {
|
||||
try {
|
||||
const part = aiResponse.candidates?.[0]?.content?.parts?.[0];
|
||||
const introText = (part && 'text' in part ? part.text : null) || 'Thanks for joining Openstock. You now have the tools to track markets and make smarter moves.'
|
||||
|
||||
const { data: { email, name } } = event;
|
||||
// introText is already a plain string from the AI provider
|
||||
|
||||
console.log(`📧 Attempting to send welcome email to: ${email}`);
|
||||
const result = await sendWelcomeEmail({ email, name, intro: introText });
|
||||
|
|
@ -115,43 +76,14 @@ export const sendWeeklyNewsSummary = inngest.createFunction(
|
|||
.replace('Daily', 'Weekly');
|
||||
|
||||
|
||||
let aiResponse;
|
||||
try {
|
||||
aiResponse = await step.ai.infer('generate-news-summary', {
|
||||
model: step.ai.models.gemini({ model: 'gemini-2.5-flash-lite' }),
|
||||
body: { contents: [{ role: 'user', parts: [{ text: prompt }] }] }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("⚠️ Gemini API failed (Weekly News), switching to Siray.ai fallback", error);
|
||||
aiResponse = await step.run('generate-news-summary-fallback', async () => {
|
||||
const SIRAY_API_KEY = process.env.SIRAY_API_KEY;
|
||||
if (!SIRAY_API_KEY) return { candidates: [{ content: { parts: [{ text: "Market is moving. Log in to see more." }] } }] };
|
||||
|
||||
const res = await fetch('https://api.siray.ai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${SIRAY_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'siray-1.0-ultra',
|
||||
messages: [{ role: 'user', content: prompt }]
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error("Siray API Error");
|
||||
const data = await res.json();
|
||||
return {
|
||||
candidates: [{
|
||||
content: { parts: [{ text: data.choices[0].message.content }] }
|
||||
}]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const part = aiResponse.candidates?.[0]?.content?.parts?.[0];
|
||||
const summaryText = (part && 'text' in part ? part.text : null) || 'Market is moving. Log in to see more.';
|
||||
const summaryText = await step.run('generate-news-summary', async () => {
|
||||
try {
|
||||
return await callAIProviderWithFallback(prompt);
|
||||
} catch (error) {
|
||||
console.error("⚠️ All AI providers failed for news summary", error);
|
||||
return 'Market is moving. Log in to see more.';
|
||||
}
|
||||
});
|
||||
|
||||
// Step 3: Send Broadcast via Kit
|
||||
await step.run('send-kit-broadcast', async () => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,7 +7,9 @@
|
|||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test:db": "node scripts/test-db.mjs"
|
||||
"test:db": "node scripts/test-db.mjs",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
|
|
@ -52,6 +54,7 @@
|
|||
"eslint-config-next": "15.5.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue