import { describe, it, expect, beforeEach, vi } from "vitest";
import {
parseAIProviders,
isProviderAvailable,
analyzeImageWithProvider,
getDefaultModelForProvider,
determineProviderAndModel,
} from "../../../src/utils/ai-providers";
import { AIProvider } from "../../../src/types";
const mockLogger = {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
} as any;
global.fetch = vi.fn();
// Mock OpenAI
vi.mock("openai", () => {
return {
default: vi.fn().mockImplementation((config) => ({
models: {
list: vi.fn(),
},
chat: {
completions: {
create: vi.fn(),
},
},
})),
};
});
describe("AI Providers Utility", () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env.PEEPIT_OLLAMA_BASE_URL;
delete process.env.OPENAI_API_KEY;
delete process.env.ANTHROPIC_API_KEY;
(global.fetch as vi.Mock).mockReset();
});
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,
json: () => Promise.resolve({
models: [
{ name: "llava:latest" },
{ name: "llama2:latest" },
],
}),
});
const result = await isProviderAvailable(
{ provider: "ollama", model: "llava" },
mockLogger,
);
expect(result).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
"http://localhost:11434/api/tags",
expect.objectContaining({
signal: expect.any(AbortSignal),
}),
);
});
it("should use PEEPIT_OLLAMA_BASE_URL for Ollama check", async () => {
process.env.PEEPIT_OLLAMA_BASE_URL = "http://custom-ollama:11434";
(global.fetch as vi.Mock).mockResolvedValue({
ok: true,
json: () => Promise.resolve({
models: [
{ name: "llava:latest" },
],
}),
});
await isProviderAvailable(
{ provider: "ollama", model: "llava" },
mockLogger,
);
expect(global.fetch).toHaveBeenCalledWith(
"http://custom-ollama:11434/api/tags",
expect.objectContaining({
signal: expect.any(AbortSignal),
}),
);
});
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,
status: 500,
});
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 { default: OpenAI } = await import("openai");
const mockOpenAI = OpenAI as any;
const mockList = vi.fn().mockResolvedValue({
data: [
{ id: "gpt-4o" },
{ id: "gpt-3.5-turbo" },
],
});
mockOpenAI.mockImplementation(() => ({
models: { list: mockList },
}));
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 false for Anthropic (not yet implemented)", async () => {
process.env.ANTHROPIC_API_KEY = "test-key";
const result = await isProviderAvailable(
{ provider: "anthropic", model: "claude-3" },
mockLogger,
);
expect(result).toBe(false); // Not yet implemented
});
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";
const { default: OpenAI } = await import("openai");
const mockOpenAI = OpenAI as any;
const mockCreate = vi.fn().mockResolvedValue({
choices: [{ message: { content: "OpenAI says hello" } }],
});
mockOpenAI.mockImplementation(() => ({
chat: {
completions: { create: mockCreate },
},
}));
const result = await analyzeImageWithProvider(
{ provider: "openai", model: "gpt-4o" },
"path/img.png",
imageBase64,
question,
mockLogger,
);
expect(result).toBe("OpenAI says hello");
expect(mockCreate).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";
const { default: OpenAI } = await import("openai");
const mockOpenAI = OpenAI as any;
const mockCreate = vi.fn().mockResolvedValue({
choices: [{ message: { content: null } }],
});
mockOpenAI.mockImplementation(() => ({
chat: {
completions: { create: mockCreate },
},
}));
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 use default prompt for empty question with Ollama", async () => {
(global.fetch as vi.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ response: "This image shows a window with text content." }),
});
const result = await analyzeImageWithProvider(
{ provider: "ollama", model: "llava" },
"path/img.png",
imageBase64,
"", // Empty question
mockLogger,
);
expect(result).toBe("This image shows a window with text content.");
const fetchCall = (global.fetch as vi.Mock).mock.calls[0];
const body = JSON.parse(fetchCall[1].body);
expect(body.prompt).toBe("Please describe what you see in this image.");
});
it("should use default prompt for whitespace-only question with OpenAI", async () => {
process.env.OPENAI_API_KEY = "test-key";
const { default: OpenAI } = await import("openai");
const mockOpenAI = OpenAI as any;
const mockCreate = vi.fn().mockResolvedValue({
choices: [{ message: { content: "This image displays a user interface." } }],
});
mockOpenAI.mockImplementation(() => ({
chat: {
completions: { create: mockCreate },
},
}));
const result = await analyzeImageWithProvider(
{ provider: "openai", model: "gpt-4o" },
"path/img.png",
imageBase64,
" ", // Whitespace-only question
mockLogger,
);
expect(result).toBe("This image displays a user interface.");
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
content: expect.arrayContaining([
{ type: "text", text: "Please describe what you see in this image." },
]),
}),
]),
}),
);
});
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 models", () => {
expect(getDefaultModelForProvider("ollama")).toBe("llava:latest");
expect(getDefaultModelForProvider("openai")).toBe("gpt-4o");
expect(getDefaultModelForProvider("anthropic")).toBe(
"claude-3-sonnet-20240229",
);
expect(getDefaultModelForProvider("unknown")).toBe("unknown");
});
});
describe("determineProviderAndModel", () => {
let configuredProviders: AIProvider[];
beforeEach(() => {
configuredProviders = [
{ provider: "ollama", model: "llava:custom" },
{ provider: "openai", model: "gpt-4o-mini" },
];
});
it("should select a specifically requested and available provider", async () => {
process.env.OPENAI_API_KEY = "test-key";
const { default: OpenAI } = await import("openai");
const mockOpenAI = OpenAI as any;
const mockList = vi.fn().mockResolvedValue({
data: [{ id: "gpt-4o-mini" }],
});
mockOpenAI.mockImplementation(() => ({
models: { list: mockList },
}));
const result = await determineProviderAndModel(
{ type: "openai" },
configuredProviders,
mockLogger,
);
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-4o-mini");
});
it("should use a requested model over the configured default", async () => {
process.env.OPENAI_API_KEY = "test-key";
const { default: OpenAI } = await import("openai");
const mockOpenAI = OpenAI as any;
const mockList = vi.fn().mockResolvedValue({
data: [{ id: "gpt-4-turbo" }],
});
mockOpenAI.mockImplementation(() => ({
models: { list: mockList },
}));
const result = await determineProviderAndModel(
{ type: "openai", model: "gpt-4-turbo" },
configuredProviders,
mockLogger,
);
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-4-turbo");
});
it("should throw if requested provider is not configured", async () => {
await expect(
determineProviderAndModel(
{ type: "anthropic" },
configuredProviders,
mockLogger,
),
).rejects.toThrow(
"Provider 'anthropic' is not enabled in server's PEEPIT_AI_PROVIDERS configuration.",
);
});
it("should throw if requested provider is not available", async () => {
// OPENAI_API_KEY is not set
await expect(
determineProviderAndModel(
{ type: "openai" },
configuredProviders,
mockLogger,
),
).rejects.toThrow(
"Provider 'openai' is configured but not currently available.",
);
});
it("should auto-select the first available provider", async () => {
// Mock Ollama as available
(global.fetch as vi.Mock).mockResolvedValue({
ok: true,
json: () => Promise.resolve({
models: [
{ name: "llava:custom" },
{ name: "llama2:latest" },
],
}),
});
// Mock OpenAI as also available
process.env.OPENAI_API_KEY = "test-key";
const { default: OpenAI } = await import("openai");
const mockOpenAI = OpenAI as any;
const mockList = vi.fn().mockResolvedValue({
data: [{ id: "gpt-4o-mini" }],
});
mockOpenAI.mockImplementation(() => ({
models: { list: mockList },
}));
const result = await determineProviderAndModel(
undefined, // auto mode
configuredProviders,
mockLogger,
);
// Should pick the first one in the list: Ollama
expect(result.provider).toBe("ollama");
expect(result.model).toBe("llava:custom");
});
it("should fall back to the next available provider in auto mode", async () => {
// Mock Ollama as NOT available
(global.fetch as vi.Mock).mockResolvedValue({
ok: false,
status: 500,
});
// Mock OpenAI as available
process.env.OPENAI_API_KEY = "test-key";
const { default: OpenAI } = await import("openai");
const mockOpenAI = OpenAI as any;
const mockList = vi.fn().mockResolvedValue({
data: [{ id: "gpt-4o-mini" }],
});
mockOpenAI.mockImplementation(() => ({
models: { list: mockList },
}));
const result = await determineProviderAndModel(
undefined, // auto mode
configuredProviders,
mockLogger,
);
expect(result.provider).toBe("openai");
expect(result.model).toBe("gpt-4o-mini");
});
it("should return null if no providers are available in auto mode", async () => {
// Mock Ollama as NOT available
(global.fetch as vi.Mock).mockResolvedValue({
ok: false,
status: 500,
});
// OPENAI_API_KEY is not set (so OpenAI not available)
const result = await determineProviderAndModel(
undefined, // auto mode
configuredProviders,
mockLogger,
);
expect(result.provider).toBeNull();
expect(result.model).toBe("");
});
});
});