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
|
NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key
|
||||||
FINNHUB_BASE_URL=https://finnhub.io/api/v1
|
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
|
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)
|
# Inngest Signing Key (required for Vercel deployment)
|
||||||
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
|
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
|
||||||
INNGEST_SIGNING_KEY=your_inngest_signing_key
|
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
|
NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key
|
||||||
FINNHUB_BASE_URL=https://finnhub.io/api/v1
|
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
|
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)
|
# Inngest Signing Key (required for Vercel deployment)
|
||||||
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
|
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
|
||||||
INNGEST_SIGNING_KEY=your_inngest_signing_key
|
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 { getWatchlistSymbolsByEmail } from "@/lib/actions/watchlist.actions";
|
||||||
import { getNews } from "@/lib/actions/finnhub.actions";
|
import { getNews } from "@/lib/actions/finnhub.actions";
|
||||||
import { getFormattedTodayDate } from "@/lib/utils";
|
import { getFormattedTodayDate } from "@/lib/utils";
|
||||||
|
import { callAIProviderWithFallback } from "@/lib/ai-provider";
|
||||||
|
|
||||||
export const sendSignUpEmail = inngest.createFunction(
|
export const sendSignUpEmail = inngest.createFunction(
|
||||||
{ id: 'sign-up-email' },
|
{ id: 'sign-up-email' },
|
||||||
|
|
@ -20,60 +21,20 @@ export const sendSignUpEmail = inngest.createFunction(
|
||||||
const prompt = PERSONALIZED_WELCOME_EMAIL_PROMPT.replace('{{userProfile}}', userProfile)
|
const prompt = PERSONALIZED_WELCOME_EMAIL_PROMPT.replace('{{userProfile}}', userProfile)
|
||||||
|
|
||||||
|
|
||||||
let aiResponse;
|
const introText = await step.run('generate-welcome-intro', async () => {
|
||||||
try {
|
try {
|
||||||
aiResponse = await step.ai.infer('generate-welcome-intro', {
|
return await callAIProviderWithFallback(prompt);
|
||||||
model: step.ai.models.gemini({ model: 'gemini-2.5-flash-lite' }),
|
|
||||||
body: {
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
parts: [
|
|
||||||
{ text: prompt }
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("⚠️ Gemini API failed, switching to Siray.ai fallback", 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.';
|
||||||
// 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 }] }
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await step.run('send-welcome-email', async () => {
|
await step.run('send-welcome-email', async () => {
|
||||||
try {
|
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;
|
const { data: { email, name } } = event;
|
||||||
|
// introText is already a plain string from the AI provider
|
||||||
|
|
||||||
console.log(`📧 Attempting to send welcome email to: ${email}`);
|
console.log(`📧 Attempting to send welcome email to: ${email}`);
|
||||||
const result = await sendWelcomeEmail({ email, name, intro: introText });
|
const result = await sendWelcomeEmail({ email, name, intro: introText });
|
||||||
|
|
@ -115,43 +76,14 @@ export const sendWeeklyNewsSummary = inngest.createFunction(
|
||||||
.replace('Daily', 'Weekly');
|
.replace('Daily', 'Weekly');
|
||||||
|
|
||||||
|
|
||||||
let aiResponse;
|
const summaryText = await step.run('generate-news-summary', async () => {
|
||||||
try {
|
try {
|
||||||
aiResponse = await step.ai.infer('generate-news-summary', {
|
return await callAIProviderWithFallback(prompt);
|
||||||
model: step.ai.models.gemini({ model: 'gemini-2.5-flash-lite' }),
|
|
||||||
body: { contents: [{ role: 'user', parts: [{ text: prompt }] }] }
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("⚠️ Gemini API failed (Weekly News), switching to Siray.ai fallback", error);
|
console.error("⚠️ All AI providers failed for news summary", error);
|
||||||
aiResponse = await step.run('generate-news-summary-fallback', async () => {
|
return 'Market is moving. Log in to see more.';
|
||||||
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.';
|
|
||||||
|
|
||||||
// Step 3: Send Broadcast via Kit
|
// Step 3: Send Broadcast via Kit
|
||||||
await step.run('send-kit-broadcast', async () => {
|
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",
|
"build": "next build --turbopack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"test:db": "node scripts/test-db.mjs"
|
"test:db": "node scripts/test-db.mjs",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
|
@ -52,6 +54,7 @@
|
||||||
"eslint-config-next": "15.5.4",
|
"eslint-config-next": "15.5.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"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