You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1122 lines
32 KiB
1122 lines
32 KiB
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
|
|
// Use vi.hoisted to define mocks that work with hoisted vi.mock
|
|
const { mockGenerateContent, MockGoogleGenAI, mockGoogleGenAIInstance } = vi.hoisted(() => {
|
|
const mockGenerateContent = vi.fn();
|
|
const mockGoogleGenAIInstance = {
|
|
models: {
|
|
generateContent: mockGenerateContent,
|
|
},
|
|
};
|
|
// Use a class to properly support `new` keyword
|
|
class MockGoogleGenAI {
|
|
apiKey: string;
|
|
models = mockGoogleGenAIInstance.models;
|
|
|
|
constructor(config: { apiKey: string }) {
|
|
this.apiKey = config.apiKey;
|
|
// Track calls to constructor
|
|
MockGoogleGenAI.lastCalledWith = config;
|
|
MockGoogleGenAI.callCount++;
|
|
}
|
|
|
|
static lastCalledWith: { apiKey: string } | null = null;
|
|
static callCount = 0;
|
|
static reset() {
|
|
MockGoogleGenAI.lastCalledWith = null;
|
|
MockGoogleGenAI.callCount = 0;
|
|
}
|
|
}
|
|
return { mockGenerateContent, MockGoogleGenAI, mockGoogleGenAIInstance };
|
|
});
|
|
|
|
vi.mock("@google/genai", () => ({
|
|
GoogleGenAI: MockGoogleGenAI,
|
|
}));
|
|
|
|
// Mock logger to avoid console noise during tests
|
|
vi.mock("@/utils/logger", () => ({
|
|
logger: {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import { POST } from "../route";
|
|
|
|
// Store original env and fetch
|
|
const originalEnv = { ...process.env };
|
|
const originalFetch = global.fetch;
|
|
|
|
// Mock fetch for OpenAI API
|
|
const mockFetch = vi.fn();
|
|
|
|
// Helper to create mock NextRequest for POST
|
|
function createMockPostRequest(
|
|
body: unknown,
|
|
headers?: Record<string, string>
|
|
): NextRequest {
|
|
return {
|
|
json: vi.fn().mockResolvedValue(body),
|
|
headers: new Headers({
|
|
Authorization: "Bearer login-token",
|
|
...(headers || {}),
|
|
}),
|
|
} as unknown as NextRequest;
|
|
}
|
|
|
|
function createMockPostRequestWithoutLogin(
|
|
body: unknown,
|
|
headers?: Record<string, string>
|
|
): NextRequest {
|
|
return {
|
|
json: vi.fn().mockResolvedValue(body),
|
|
headers: new Headers(headers || {}),
|
|
} as unknown as NextRequest;
|
|
}
|
|
|
|
describe("/api/llm route", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
MockGoogleGenAI.reset();
|
|
// Reset env to original
|
|
process.env = { ...originalEnv };
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = originalEnv;
|
|
global.fetch = originalFetch;
|
|
});
|
|
|
|
describe("Google provider", () => {
|
|
it("should generate text successfully with Google/Gemini", async () => {
|
|
process.env.GEMINI_API_KEY = "test-gemini-key";
|
|
|
|
mockGenerateContent.mockResolvedValueOnce({
|
|
text: "Generated response from Gemini",
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "google",
|
|
model: "gemini-2.5-flash",
|
|
temperature: 0.7,
|
|
maxTokens: 1024,
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
expect(data.text).toBe("Generated response from Gemini");
|
|
expect(mockGenerateContent).toHaveBeenCalledWith({
|
|
model: "gemini-2.5-flash",
|
|
contents: "Test prompt",
|
|
config: {
|
|
temperature: 0.7,
|
|
maxOutputTokens: 1024,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should handle multimodal input (images + prompt)", async () => {
|
|
process.env.GEMINI_API_KEY = "test-gemini-key";
|
|
|
|
mockGenerateContent.mockResolvedValueOnce({
|
|
text: "Description of the image",
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Describe this image",
|
|
images: ["data:image/png;base64,iVBORw0KGgo="],
|
|
provider: "google",
|
|
model: "gemini-2.5-flash",
|
|
temperature: 0.7,
|
|
maxTokens: 1024,
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
expect(data.text).toBe("Description of the image");
|
|
|
|
// Verify multimodal content structure
|
|
expect(mockGenerateContent).toHaveBeenCalledWith({
|
|
model: "gemini-2.5-flash",
|
|
contents: [
|
|
{
|
|
inlineData: {
|
|
mimeType: "image/png",
|
|
data: "iVBORw0KGgo=",
|
|
},
|
|
},
|
|
{ text: "Describe this image" },
|
|
],
|
|
config: {
|
|
temperature: 0.7,
|
|
maxOutputTokens: 1024,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should reject missing prompt", async () => {
|
|
process.env.GEMINI_API_KEY = "test-gemini-key";
|
|
|
|
const request = createMockPostRequest({
|
|
provider: "google",
|
|
model: "gemini-2.5-flash",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe("Prompt is required");
|
|
});
|
|
|
|
it("should reject missing API key (no env var, no header)", async () => {
|
|
delete process.env.GEMINI_API_KEY;
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "google",
|
|
model: "gemini-2.5-flash",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toContain("GEMINI_API_KEY not configured");
|
|
});
|
|
|
|
it("should use X-Gemini-API-Key header over env var", async () => {
|
|
process.env.GEMINI_API_KEY = "env-gemini-key";
|
|
|
|
mockGenerateContent.mockResolvedValueOnce({
|
|
text: "Response with header key",
|
|
});
|
|
|
|
const request = createMockPostRequest(
|
|
{
|
|
prompt: "Test prompt",
|
|
provider: "google",
|
|
model: "gemini-2.5-flash",
|
|
},
|
|
{ "X-Gemini-API-Key": "header-gemini-key" }
|
|
);
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
|
|
// Verify GoogleGenAI was called with header key (takes precedence)
|
|
expect(MockGoogleGenAI.lastCalledWith).toEqual({ apiKey: "header-gemini-key" });
|
|
});
|
|
|
|
it("should return 429 on rate limit errors", async () => {
|
|
process.env.GEMINI_API_KEY = "test-gemini-key";
|
|
|
|
mockGenerateContent.mockRejectedValueOnce(
|
|
new Error("429 Resource exhausted")
|
|
);
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "google",
|
|
model: "gemini-2.5-flash",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(429);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe("Rate limit reached. Please wait and try again.");
|
|
});
|
|
|
|
it("should return 500 on API errors", async () => {
|
|
process.env.GEMINI_API_KEY = "test-gemini-key";
|
|
|
|
mockGenerateContent.mockRejectedValueOnce(
|
|
new Error("Internal server error")
|
|
);
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "google",
|
|
model: "gemini-2.5-flash",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe("Internal server error");
|
|
});
|
|
|
|
it("should handle no text in Google AI response", async () => {
|
|
process.env.GEMINI_API_KEY = "test-gemini-key";
|
|
|
|
mockGenerateContent.mockResolvedValueOnce({
|
|
text: null,
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "google",
|
|
model: "gemini-2.5-flash",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe("No text in Google AI response");
|
|
});
|
|
|
|
it("should handle image without data URL prefix", async () => {
|
|
process.env.GEMINI_API_KEY = "test-gemini-key";
|
|
|
|
mockGenerateContent.mockResolvedValueOnce({
|
|
text: "Image description",
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Describe this",
|
|
images: ["iVBORw0KGgoAAAANSUhEUgAAAAUA"], // raw base64, no prefix
|
|
provider: "google",
|
|
model: "gemini-2.5-flash",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
|
|
// Verify fallback to PNG mime type
|
|
expect(mockGenerateContent).toHaveBeenCalledWith({
|
|
model: "gemini-2.5-flash",
|
|
contents: [
|
|
{
|
|
inlineData: {
|
|
mimeType: "image/png",
|
|
data: "iVBORw0KGgoAAAANSUhEUgAAAAUA",
|
|
},
|
|
},
|
|
{ text: "Describe this" },
|
|
],
|
|
config: {
|
|
temperature: 0.7,
|
|
maxOutputTokens: 1024,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("NewApiWG provider", () => {
|
|
beforeEach(() => {
|
|
global.fetch = mockFetch;
|
|
});
|
|
|
|
it("should use current Doubao Seed model ids for NewApiWG", async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
choices: [{ message: { content: "Doubao response text" } }],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest(
|
|
{
|
|
prompt: "Test prompt",
|
|
provider: "newapiwg",
|
|
model: "doubao-seed-2-0-lite-260428",
|
|
temperature: 0.7,
|
|
maxTokens: 1024,
|
|
},
|
|
{
|
|
Authorization: "Bearer login-token",
|
|
"X-NewApiWG-Key": "test-newapiwg-key",
|
|
"X-NewApiWG-Base-URL": "https://newapi.example/v1",
|
|
}
|
|
);
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
expect(data.text).toBe("Doubao response text");
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://newapi.example/v1/chat/completions",
|
|
expect.objectContaining({
|
|
body: JSON.stringify({
|
|
model: "doubao-seed-2-0-lite-260428",
|
|
messages: [{ role: "user", content: "Test prompt" }],
|
|
temperature: 0.7,
|
|
max_tokens: 1024,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should allow NewApiWG requests with a manual gateway key and no login token", async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
choices: [{ message: { content: "Manual key response text" } }],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequestWithoutLogin(
|
|
{
|
|
prompt: "Test prompt",
|
|
provider: "newapiwg",
|
|
model: "doubao-seed-2-0-lite-260428",
|
|
},
|
|
{
|
|
"X-NewApiWG-Key": "manual-newapi-key",
|
|
"X-NewApiWG-Base-URL": "https://newapi.example/v1",
|
|
}
|
|
);
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://newapi.example/v1/chat/completions",
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: "Bearer manual-newapi-key",
|
|
}),
|
|
})
|
|
);
|
|
const requestHeaders = mockFetch.mock.calls[0]?.[1]?.headers as Record<string, string>;
|
|
expect(requestHeaders["X-User-Token"]).toBeUndefined();
|
|
expect(requestHeaders.token).toBeUndefined();
|
|
});
|
|
|
|
it("should map legacy Doubao selector values to available gateway model ids", async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
choices: [{ message: { content: "Legacy Doubao response text" } }],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest(
|
|
{
|
|
prompt: "Test prompt",
|
|
provider: "newapiwg",
|
|
model: "Doubao-Seed-2.0-lite",
|
|
},
|
|
{
|
|
Authorization: "Bearer login-token",
|
|
"X-NewApiWG-Key": "test-newapiwg-key",
|
|
"X-NewApiWG-Base-URL": "https://newapi.example/v1",
|
|
}
|
|
);
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://newapi.example/v1/chat/completions",
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('"model":"doubao-seed-2-0-lite-260428"'),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("PopiServer provider", () => {
|
|
beforeEach(() => {
|
|
global.fetch = mockFetch;
|
|
process.env.POPISERVER_BASE_URL = "https://wwwtest.popi.art";
|
|
});
|
|
|
|
it("should call llmChat and return the direct response text", async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
status: "0000",
|
|
message: "ok",
|
|
data: {
|
|
choices: [
|
|
{
|
|
message: {
|
|
content: "PopiServer LLM response text",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "请描述这张图",
|
|
provider: "popiserver",
|
|
model: "kimi-k2.6",
|
|
images: ["https://static.popi.art/uploads/input.png"],
|
|
parameters: {
|
|
temperature: 0.7,
|
|
maxTokens: 8192,
|
|
max_tokens: 8192,
|
|
},
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
expect(data.text).toBe("PopiServer LLM response text");
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://wwwtest.popi.art/api_client/anime/task/llmChat",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
maxTokens: 8192,
|
|
max_tokens: 8192,
|
|
messages: [
|
|
{ role: "system", content: "You are a helpful assistant." },
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "请描述这张图" },
|
|
{ type: "image_url", image_url: { url: "https://static.popi.art/uploads/input.png" } },
|
|
],
|
|
},
|
|
],
|
|
model: "",
|
|
stream: false,
|
|
aiModelId: 31,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("OpenAI provider", () => {
|
|
beforeEach(() => {
|
|
global.fetch = mockFetch;
|
|
});
|
|
|
|
it("should generate text successfully with OpenAI", async () => {
|
|
process.env.OPENAI_API_KEY = "test-openai-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
choices: [{ message: { content: "OpenAI response text" } }],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "openai",
|
|
model: "gpt-4.1-mini",
|
|
temperature: 0.7,
|
|
maxTokens: 1024,
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
expect(data.text).toBe("OpenAI response text");
|
|
|
|
// Verify fetch was called with correct parameters
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://api.openai.com/v1/chat/completions",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer test-openai-key",
|
|
},
|
|
body: JSON.stringify({
|
|
model: "gpt-4.1-mini",
|
|
messages: [{ role: "user", content: "Test prompt" }],
|
|
temperature: 0.7,
|
|
max_tokens: 1024,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should handle vision input (images + prompt)", async () => {
|
|
process.env.OPENAI_API_KEY = "test-openai-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
choices: [{ message: { content: "Image description from OpenAI" } }],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Describe this image",
|
|
images: ["data:image/png;base64,iVBORw0KGgo="],
|
|
provider: "openai",
|
|
model: "gpt-4.1-mini",
|
|
temperature: 0.7,
|
|
maxTokens: 1024,
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
expect(data.text).toBe("Image description from OpenAI");
|
|
|
|
// Verify fetch was called with vision content structure
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://api.openai.com/v1/chat/completions",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
model: "gpt-4.1-mini",
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Describe this image" },
|
|
{ type: "image_url", image_url: { url: "data:image/png;base64,iVBORw0KGgo=" } },
|
|
],
|
|
},
|
|
],
|
|
temperature: 0.7,
|
|
max_tokens: 1024,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should include video_url blocks when video media is provided", async () => {
|
|
process.env.OPENAI_API_KEY = "test-openai-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
choices: [{ message: { content: "Video description from OpenAI" } }],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Describe this video",
|
|
videos: ["data:video/mp4;base64,AAAA"],
|
|
provider: "openai",
|
|
model: "gpt-4.1-mini",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://api.openai.com/v1/chat/completions",
|
|
expect.objectContaining({
|
|
body: JSON.stringify({
|
|
model: "gpt-4.1-mini",
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: "Describe this video" },
|
|
{ type: "video_url", video_url: { url: "data:video/mp4;base64,AAAA" } },
|
|
],
|
|
},
|
|
],
|
|
temperature: 0.7,
|
|
max_tokens: 1024,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should reject unknown provider", async () => {
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "unknown-provider",
|
|
model: "some-model",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe("Unknown provider: unknown-provider");
|
|
});
|
|
|
|
it("should reject missing OpenAI API key", async () => {
|
|
delete process.env.OPENAI_API_KEY;
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "openai",
|
|
model: "gpt-4.1-mini",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toContain("OPENAI_API_KEY not configured");
|
|
});
|
|
|
|
it("should use X-OpenAI-API-Key header over env var", async () => {
|
|
process.env.OPENAI_API_KEY = "env-openai-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
choices: [{ message: { content: "Response with header key" } }],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest(
|
|
{
|
|
prompt: "Test prompt",
|
|
provider: "openai",
|
|
model: "gpt-4.1-mini",
|
|
},
|
|
{ "X-OpenAI-API-Key": "header-openai-key" }
|
|
);
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
|
|
// Verify fetch was called with header key (takes precedence)
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://api.openai.com/v1/chat/completions",
|
|
expect.objectContaining({
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer header-openai-key",
|
|
},
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should return 429 on rate limit errors", async () => {
|
|
process.env.OPENAI_API_KEY = "test-openai-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 429,
|
|
json: () =>
|
|
Promise.resolve({
|
|
error: { message: "429 Rate limit exceeded" },
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "openai",
|
|
model: "gpt-4.1-mini",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(429);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe("Rate limit reached. Please wait and try again.");
|
|
});
|
|
|
|
it("should handle OpenAI API error responses", async () => {
|
|
process.env.OPENAI_API_KEY = "test-openai-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
json: () =>
|
|
Promise.resolve({
|
|
error: { message: "Invalid API key" },
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "openai",
|
|
model: "gpt-4.1-mini",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe("Invalid API key");
|
|
});
|
|
|
|
it("should handle OpenAI API error without message", async () => {
|
|
process.env.OPENAI_API_KEY = "test-openai-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 500,
|
|
json: () => Promise.resolve({}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "openai",
|
|
model: "gpt-4.1-mini",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe("OpenAI API error: 500");
|
|
});
|
|
|
|
it("should handle no text in OpenAI response", async () => {
|
|
process.env.OPENAI_API_KEY = "test-openai-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
choices: [{ message: { content: null } }],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "openai",
|
|
model: "gpt-4.1-mini",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe("No text in OpenAI response");
|
|
});
|
|
});
|
|
|
|
describe("Anthropic provider", () => {
|
|
beforeEach(() => {
|
|
global.fetch = mockFetch;
|
|
});
|
|
|
|
it("should generate text successfully with Anthropic", async () => {
|
|
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
content: [{ type: "text", text: "Anthropic response text" }],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4.5",
|
|
temperature: 0.7,
|
|
maxTokens: 1024,
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
expect(data.text).toBe("Anthropic response text");
|
|
|
|
// Verify fetch was called with correct parameters
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://api.anthropic.com/v1/messages",
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-api-key": "test-anthropic-key",
|
|
"anthropic-version": "2023-06-01",
|
|
},
|
|
body: JSON.stringify({
|
|
model: "claude-sonnet-4-5-20250929",
|
|
messages: [{ role: "user", content: [{ type: "text", text: "Test prompt" }] }],
|
|
temperature: 0.7,
|
|
max_tokens: 1024,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should handle multimodal input with Anthropic content block structure", async () => {
|
|
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
content: [{ type: "text", text: "Image description from Claude" }],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Describe this image",
|
|
images: ["data:image/png;base64,iVBORw0KGgo="],
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4.5",
|
|
temperature: 0.7,
|
|
maxTokens: 1024,
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
expect(data.text).toBe("Image description from Claude");
|
|
|
|
// Verify Anthropic content block structure
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://api.anthropic.com/v1/messages",
|
|
expect.objectContaining({
|
|
body: JSON.stringify({
|
|
model: "claude-sonnet-4-5-20250929",
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "image",
|
|
source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" },
|
|
},
|
|
{ type: "text", text: "Describe this image" },
|
|
],
|
|
},
|
|
],
|
|
temperature: 0.7,
|
|
max_tokens: 1024,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should reject missing Anthropic API key", async () => {
|
|
delete process.env.ANTHROPIC_API_KEY;
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4.5",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toContain("ANTHROPIC_API_KEY not configured");
|
|
});
|
|
|
|
it("should use X-Anthropic-API-Key header over env var", async () => {
|
|
process.env.ANTHROPIC_API_KEY = "env-anthropic-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
content: [{ type: "text", text: "Response with header key" }],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest(
|
|
{
|
|
prompt: "Test prompt",
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4.5",
|
|
},
|
|
{ "X-Anthropic-API-Key": "header-anthropic-key" }
|
|
);
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
|
|
// Verify fetch was called with header key (takes precedence)
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://api.anthropic.com/v1/messages",
|
|
expect.objectContaining({
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"x-api-key": "header-anthropic-key",
|
|
"anthropic-version": "2023-06-01",
|
|
},
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should return 429 on rate limit errors", async () => {
|
|
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 429,
|
|
json: () =>
|
|
Promise.resolve({
|
|
error: { message: "429 Rate limit exceeded" },
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4.5",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(429);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe("Rate limit reached. Please wait and try again.");
|
|
});
|
|
|
|
it("should handle Anthropic API error responses", async () => {
|
|
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 401,
|
|
json: () =>
|
|
Promise.resolve({
|
|
error: { message: "Invalid API key" },
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4.5",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe("Invalid API key");
|
|
});
|
|
|
|
it("should handle no text in Anthropic response", async () => {
|
|
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
content: [],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Test prompt",
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4.5",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(data.success).toBe(false);
|
|
expect(data.error).toBe("No text in Anthropic response");
|
|
});
|
|
|
|
it("should handle image without data URL prefix", async () => {
|
|
process.env.ANTHROPIC_API_KEY = "test-anthropic-key";
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
content: [{ type: "text", text: "Image description" }],
|
|
}),
|
|
});
|
|
|
|
const request = createMockPostRequest({
|
|
prompt: "Describe this",
|
|
images: ["iVBORw0KGgoAAAANSUhEUgAAAAUA"],
|
|
provider: "anthropic",
|
|
model: "claude-sonnet-4.5",
|
|
});
|
|
|
|
const response = await POST(request);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.success).toBe(true);
|
|
|
|
// Verify fallback to PNG mime type
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://api.anthropic.com/v1/messages",
|
|
expect.objectContaining({
|
|
body: JSON.stringify({
|
|
model: "claude-sonnet-4-5-20250929",
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{
|
|
type: "image",
|
|
source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgoAAAANSUhEUgAAAAUA" },
|
|
},
|
|
{ type: "text", text: "Describe this" },
|
|
],
|
|
},
|
|
],
|
|
temperature: 0.7,
|
|
max_tokens: 1024,
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|