Peekaboo/tests/unit/utils/ai-providers.test.ts
2025-05-25 14:07:21 +02:00

382 lines
12 KiB
TypeScript

import { vi } from "vitest";
import {
parseAIProviders,
isProviderAvailable,
analyzeImageWithProvider,
getDefaultModelForProvider,
} from "../../../src/utils/ai-providers";
import { AIProvider } from "../../../src/types";
import OpenAI from "openai";
const mockLogger = {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
} as any;
global.fetch = vi.fn();
// Centralized mock for OpenAI().chat.completions.create
const mockChatCompletionsCreate = vi.fn();
vi.mock("openai", () => {
const MockedOpenAI = vi.fn().mockImplementation(() => {
return {
chat: {
completions: {
create: mockChatCompletionsCreate,
},
},
};
});
return { default: MockedOpenAI }; // Simulate default export
});
// No need for `let mockOpenAICreate` outside, use mockChatCompletionsCreate directly.
describe("AI Providers Utility", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.PEEKABOO_OLLAMA_BASE_URL;
delete process.env.OPENAI_API_KEY;
delete process.env.ANTHROPIC_API_KEY;
(global.fetch as vi.Mock).mockReset();
mockChatCompletionsCreate.mockReset(); // Reset the shared mock function
});
describe("parseAIProviders", () => {
it("should return empty array for empty or whitespace string", () => {
expect(parseAIProviders("")).toEqual([]);
expect(parseAIProviders(" ")).toEqual([]);
});
it("should parse a single provider string", () => {
expect(parseAIProviders("ollama/llava")).toEqual([
{ provider: "ollama", model: "llava" },
]);
});
it("should parse multiple comma-separated providers", () => {
const expected: AIProvider[] = [
{ provider: "ollama", model: "llava" },
{ provider: "openai", model: "gpt-4o" },
];
expect(parseAIProviders("ollama/llava, openai/gpt-4o")).toEqual(expected);
});
it("should handle extra whitespace", () => {
expect(parseAIProviders(" ollama/llava , openai/gpt-4o ")).toEqual([
{ provider: "ollama", model: "llava" },
{ provider: "openai", model: "gpt-4o" },
]);
});
it("should filter out entries without a model or provider name", () => {
expect(
parseAIProviders("ollama/, /gpt-4o, openai/llama3, incomplete"),
).toEqual([{ provider: "openai", model: "llama3" }]);
});
it("should filter out entries with only provider or only model or no slash or empty parts", () => {
expect(parseAIProviders("ollama/")).toEqual([]);
expect(parseAIProviders("/gpt-4o")).toEqual([]);
expect(parseAIProviders("ollama")).toEqual([]);
expect(parseAIProviders("ollama/,,openai/gpt4")).toEqual([
{ provider: "openai", model: "gpt4" },
]);
});
});
describe("isProviderAvailable", () => {
it("should return true for available Ollama (fetch ok)", async () => {
(global.fetch as vi.Mock).mockResolvedValue({ ok: true });
const result = await isProviderAvailable(
{ provider: "ollama", model: "llava" },
mockLogger,
);
expect(result).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:11434/api/tags",
);
});
it("should use PEEKABOO_OLLAMA_BASE_URL for Ollama check", async () => {
process.env.PEEKABOO_OLLAMA_BASE_URL = "http://custom-ollama:11434";
(global.fetch as vi.Mock).mockResolvedValue({ ok: true });
await isProviderAvailable(
{ provider: "ollama", model: "llava" },
mockLogger,
);
expect(global.fetch).toHaveBeenCalledWith(
"http://custom-ollama:11434/api/tags",
);
});
it("should return false for unavailable Ollama (fetch fails)", async () => {
(global.fetch as vi.Mock).mockRejectedValue(new Error("Network Error"));
const result = await isProviderAvailable(
{ provider: "ollama", model: "llava" },
mockLogger,
);
expect(result).toBe(false);
expect(mockLogger.debug).toHaveBeenCalledWith(
{ error: new Error("Network Error") },
"Ollama not available",
);
});
it("should return false for unavailable Ollama (response not ok)", async () => {
(global.fetch as vi.Mock).mockResolvedValue({ ok: false });
const result = await isProviderAvailable(
{ provider: "ollama", model: "llava" },
mockLogger,
);
expect(result).toBe(false);
});
it("should return true for available OpenAI (API key set)", async () => {
process.env.OPENAI_API_KEY = "test-key";
const result = await isProviderAvailable(
{ provider: "openai", model: "gpt-4o" },
mockLogger,
);
expect(result).toBe(true);
});
it("should return false for unavailable OpenAI (API key not set)", async () => {
const result = await isProviderAvailable(
{ provider: "openai", model: "gpt-4o" },
mockLogger,
);
expect(result).toBe(false);
});
it("should return true for available Anthropic (API key set)", async () => {
process.env.ANTHROPIC_API_KEY = "test-key";
const result = await isProviderAvailable(
{ provider: "anthropic", model: "claude-3" },
mockLogger,
);
expect(result).toBe(true);
});
it("should return false for unavailable Anthropic (API key not set)", async () => {
const result = await isProviderAvailable(
{ provider: "anthropic", model: "claude-3" },
mockLogger,
);
expect(result).toBe(false);
});
it("should return false and log warning for unknown provider", async () => {
const result = await isProviderAvailable(
{ provider: "unknown", model: "test" },
mockLogger,
);
expect(result).toBe(false);
expect(mockLogger.warn).toHaveBeenCalledWith(
{ provider: "unknown" },
"Unknown AI provider",
);
});
it("should handle errors during ollama availability check gracefully (fetch throws)", async () => {
const fetchError = new Error("Unexpected fetch error");
(global.fetch as vi.Mock).mockImplementationOnce(() => {
// Ensure this mock is specific to the ollama check path that uses fetch
if (
(global.fetch as vi.Mock).mock.calls.some((call) =>
call[0].includes("/api/tags"),
)
) {
throw fetchError;
}
// Fallback for other fetches if any, though not expected in this test path
return Promise.resolve({ ok: true, json: async () => ({}) });
});
const result = await isProviderAvailable(
{ provider: "ollama", model: "llava" },
mockLogger,
);
expect(result).toBe(false);
expect(mockLogger.debug).toHaveBeenCalledWith(
{ error: fetchError },
"Ollama not available",
);
expect(mockLogger.error).not.toHaveBeenCalledWith(
expect.objectContaining({ error: fetchError, provider: "ollama" }),
"Error checking provider availability",
);
});
});
describe("analyzeImageWithProvider", () => {
const imageBase64 = "test-base64-image";
const question = "What is this?";
it("should call analyzeWithOllama for ollama provider", async () => {
(global.fetch as vi.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ response: "Ollama says hello" }),
});
const result = await analyzeImageWithProvider(
{ provider: "ollama", model: "llava" },
"path/img.png",
imageBase64,
question,
mockLogger,
);
expect(result).toBe("Ollama says hello");
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:11434/api/generate",
expect.any(Object),
);
expect(
JSON.parse((global.fetch as vi.Mock).mock.calls[0][1].body),
).toEqual(
expect.objectContaining({
model: "llava",
prompt: question,
images: [imageBase64],
}),
);
});
it("should throw Ollama API error if response not ok", async () => {
(global.fetch as vi.Mock).mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => "Internal Server Error",
});
await expect(
analyzeImageWithProvider(
{ provider: "ollama", model: "llava" },
"path/img.png",
imageBase64,
question,
mockLogger,
),
).rejects.toThrow("Ollama API error: 500 - Internal Server Error");
});
it("should call analyzeWithOpenAI for openai provider", async () => {
process.env.OPENAI_API_KEY = "test-key";
mockChatCompletionsCreate.mockResolvedValueOnce({
choices: [{ message: { content: "OpenAI says hello" } }],
});
const result = await analyzeImageWithProvider(
{ provider: "openai", model: "gpt-4o" },
"path/img.png",
imageBase64,
question,
mockLogger,
);
expect(result).toBe("OpenAI says hello");
expect(mockChatCompletionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "gpt-4o",
messages: expect.arrayContaining([
expect.objectContaining({
role: "user",
content: expect.arrayContaining([
{ type: "text", text: question },
{
type: "image_url",
image_url: { url: `data:image/jpeg;base64,${imageBase64}` },
},
]),
}),
]),
}),
);
});
it("should throw error if OpenAI API key is missing for openai provider", async () => {
await expect(
analyzeImageWithProvider(
{ provider: "openai", model: "gpt-4o" },
"path/img.png",
imageBase64,
question,
mockLogger,
),
).rejects.toThrow("OpenAI API key not configured");
});
it("should return default message if OpenAI provides no response content", async () => {
process.env.OPENAI_API_KEY = "test-key";
mockChatCompletionsCreate.mockResolvedValueOnce({
choices: [{ message: { content: null } }],
});
const result = await analyzeImageWithProvider(
{ provider: "openai", model: "gpt-4o" },
"path/img.png",
imageBase64,
question,
mockLogger,
);
expect(result).toBe("No response from OpenAI");
});
it("should return default message if Ollama provides no response content", async () => {
(global.fetch as vi.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ response: null }),
});
const result = await analyzeImageWithProvider(
{ provider: "ollama", model: "llava" },
"path/img.png",
imageBase64,
question,
mockLogger,
);
expect(result).toBe("No response from Ollama");
});
it("should throw error for anthropic provider (not implemented)", async () => {
await expect(
analyzeImageWithProvider(
{ provider: "anthropic", model: "claude-3" },
"path/img.png",
imageBase64,
question,
mockLogger,
),
).rejects.toThrow("Anthropic support not yet implemented");
});
it("should throw error for unsupported provider", async () => {
await expect(
analyzeImageWithProvider(
{ provider: "unknown", model: "test" },
"path/img.png",
imageBase64,
question,
mockLogger,
),
).rejects.toThrow("Unsupported AI provider: unknown");
});
});
describe("getDefaultModelForProvider", () => {
it("should return correct default for ollama", () => {
expect(getDefaultModelForProvider("ollama")).toBe("llava:latest");
expect(getDefaultModelForProvider("Ollama")).toBe("llava:latest");
});
it("should return correct default for openai", () => {
expect(getDefaultModelForProvider("openai")).toBe("gpt-4o");
});
it("should return correct default for anthropic", () => {
expect(getDefaultModelForProvider("anthropic")).toBe(
"claude-3-sonnet-20240229",
);
});
it('should return "unknown" for an unknown provider', () => {
expect(getDefaultModelForProvider("unknown-provider")).toBe("unknown");
});
});
});