398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
getProviderConfig,
|
|
getFallbackProviderName,
|
|
hasConfiguredAIProvider,
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("hasConfiguredAIProvider", () => {
|
|
const originalEnv = { ...process.env };
|
|
|
|
afterEach(() => {
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
it("returns true when the primary provider has a key", () => {
|
|
process.env.AI_PROVIDER = "gemini";
|
|
process.env.GEMINI_API_KEY = "g";
|
|
expect(hasConfiguredAIProvider()).toBe(true);
|
|
});
|
|
|
|
it("returns true when only the fallback provider has a key", () => {
|
|
process.env.AI_PROVIDER = "gemini";
|
|
delete process.env.GEMINI_API_KEY;
|
|
process.env.MINIMAX_API_KEY = "m";
|
|
expect(hasConfiguredAIProvider()).toBe(true);
|
|
});
|
|
|
|
it("returns false when neither primary nor fallback has a key", () => {
|
|
process.env.AI_PROVIDER = "minimax";
|
|
delete process.env.MINIMAX_API_KEY;
|
|
delete process.env.GEMINI_API_KEY;
|
|
expect(hasConfiguredAIProvider()).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── 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();
|
|
});
|
|
});
|