Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ AUTH_TRUST_HOST=true

OPENAI_API_KEY=your-openai-api-key-here

# Local OpenAI-compatible server (LM Studio, llama.cpp, vLLM, etc.)
OPENAI_COMPAT_BASE_URL=http://127.0.0.1:1234
OPENAI_COMPAT_API_KEY=

# DeepSeek API Key - get from https://platform.deepseek.com
DEEPSEEK_API_KEY=your-deepseek-api-key-here

Expand Down
12 changes: 12 additions & 0 deletions __tests__/aiSettings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
vi.mock("@/lib/db", () => ({ default: {} }));

import { AiProvider } from "@/models/ai.model";
import { getDefaultModelForProvider } from "@/lib/scraper/automation-run/aiSettings";

describe("getDefaultModelForProvider", () => {
it("requires an explicit model for openai-compatible", () => {
expect(() =>
getDefaultModelForProvider(AiProvider.OPENAI_COMPATIBLE),
).toThrow("Select a model for the OpenAI-compatible provider");
});
});
35 changes: 35 additions & 0 deletions __tests__/api-key-resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,38 @@ describe("resolveApiKey", () => {
expect(mockFindUnique).not.toHaveBeenCalled();
});
});

describe("resolveApiKey – openai-compatible-key", () => {
const OLD_ENV = process.env;

beforeEach(() => {
vi.clearAllMocks();
process.env = { ...OLD_ENV };
delete process.env.OPENAI_COMPAT_API_KEY;
});

afterAll(() => {
process.env = OLD_ENV;
});

it("returns a stored key", async () => {
mockFindUnique.mockResolvedValue({
id: "k-compat",
encryptedKey: "cipher",
iv: "saltiv",
});

const result = await resolveApiKey("user-1", "openai-compatible-key");

expect(result).toBe("decrypted(cipher,saltiv)");
});

it("falls back to OPENAI_COMPAT_API_KEY", async () => {
mockFindUnique.mockResolvedValue(null);
process.env.OPENAI_COMPAT_API_KEY = "env-compat-key";

const result = await resolveApiKey("user-1", "openai-compatible-key");

expect(result).toBe("env-compat-key");
});
});
57 changes: 57 additions & 0 deletions __tests__/openai-compatible-models-route.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("@/lib/api-key-resolver", () => ({ resolveApiKey: vi.fn() }));
vi.mock("@/lib/telemetry", () => ({ log: { error: vi.fn() } }));
vi.mock("next/server", () => ({
NextResponse: {
json: (data: unknown, init?: { status?: number }) => ({
status: init?.status ?? 200,
json: async () => data,
}),
},
}));

import { GET } from "@/app/api/ai/openai-compatible/models/route";
import { auth } from "@/auth";
import { resolveApiKey } from "@/lib/api-key-resolver";

describe("GET /api/ai/openai-compatible/models", () => {
beforeEach(() => {
vi.clearAllMocks();
(auth as any).mockResolvedValue({ user: { id: "user-1" } });
});

it("returns 500 when no base URL is configured", async () => {
(resolveApiKey as any).mockResolvedValue(undefined);

const response = await GET();
const data = await response.json();

expect(response.status).toBe(500);
expect(data.error).toContain("base URL not configured");
});

it("lists models from the local /v1/models endpoint", async () => {
(resolveApiKey as any).mockImplementation(
async (_userId: string | undefined, provider: string) => {
if (provider === "openai-compatible") return "http://127.0.0.1:1234/";
if (provider === "openai-compatible-key") return "sk-local";
return undefined;
},
);
const mockModels = { data: [{ id: "local-model" }] };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockModels,
});

const response = await GET();
const data = await response.json();

expect(response.status).toBe(200);
expect(data).toEqual(mockModels);
expect(global.fetch).toHaveBeenCalledWith(
"http://127.0.0.1:1234/v1/models",
{ headers: { Authorization: "Bearer sk-local" } },
);
});
});
32 changes: 32 additions & 0 deletions __tests__/provider-registry-server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,35 @@ describe("PROVIDER_FACTORIES – openrouter", () => {
);
});
});

describe("PROVIDER_VERIFIERS – openai-compatible", () => {
it("verifies a base URL without an API key", async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });

const result = await PROVIDER_VERIFIERS["openai-compatible"](
"http://127.0.0.1:1234",
);

expect(result).toEqual({ success: true });
expect(global.fetch).toHaveBeenCalledWith(
"http://127.0.0.1:1234/v1/models",
expect.objectContaining({ headers: {} }),
);
});

it("sends a Bearer token when given { baseURL, apiKey }", async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });

await PROVIDER_VERIFIERS["openai-compatible"]({
baseURL: "http://127.0.0.1:1234/",
apiKey: "sk-local",
});

expect(global.fetch).toHaveBeenCalledWith(
"http://127.0.0.1:1234/v1/models",
expect.objectContaining({
headers: { Authorization: "Bearer sk-local" },
}),
);
});
});
23 changes: 20 additions & 3 deletions __tests__/provider-registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ describe("AI_PROVIDERS", () => {
expect(AI_PROVIDERS).toContain("openrouter");
});

it("contains exactly 5 providers", () => {
expect(AI_PROVIDERS).toHaveLength(5);
it("contains exactly 6 providers", () => {
expect(AI_PROVIDERS).toHaveLength(6);
});

it("contains ollama, openai, deepseek, openrouter, gemini", () => {
it("contains ollama, openai, openai-compatible, deepseek, openrouter, gemini", () => {
expect(AI_PROVIDERS).toEqual(
expect.arrayContaining([
"ollama",
"openai",
"openai-compatible",
"deepseek",
"openrouter",
"gemini",
Expand Down Expand Up @@ -134,3 +135,19 @@ describe("PROVIDER_REGISTRY – openrouter entry", () => {
});
});
});

describe("PROVIDER_REGISTRY – openai-compatible entry", () => {
const entry = PROVIDER_REGISTRY["openai-compatible"];

it("uses a non-sensitive base URL", () => {
expect(entry.credentialType).toBe("base-url");
expect(entry.keyConfig.sensitive).toBe(false);
expect(entry.modelsEndpoint).toBe("openai-compatible/models");
});

it("parses OpenAI-style model lists", () => {
expect(
entry.parseModelsResponse!({ data: [{ id: "local-model" }] }),
).toEqual(["local-model"]);
});
});
44 changes: 44 additions & 0 deletions __tests__/providers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,18 @@ vi.mock("@/lib/ai/provider-registry.server", () => ({
ollama: vi.fn(),
gemini: vi.fn(),
},
openaiCompatibleRoot: (url: string) =>
url.replace(/\/+$/, "").replace(/\/v1$/i, ""),
}));

vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(),
}));

import { getModel } from "@/lib/ai/providers";
import { resolveApiKey } from "@/lib/api-key-resolver";
import { PROVIDER_FACTORIES } from "@/lib/ai/provider-registry.server";
import { createOpenAI } from "@ai-sdk/openai";

describe("getModel – openrouter", () => {
const mockModelInstance = { modelId: "openai/gpt-4o" };
Expand Down Expand Up @@ -87,3 +94,40 @@ describe("getModel – provider validation", () => {
).resolves.toBeDefined();
});
});

describe("getModel – openai-compatible", () => {
const mockModelInstance = { modelId: "local-model" };

beforeEach(() => {
vi.clearAllMocks();
const mockChat = vi.fn().mockReturnValue(mockModelInstance);
(createOpenAI as any).mockReturnValue({ chat: mockChat });
(resolveApiKey as any).mockImplementation(
async (_userId: string | undefined, provider: string) => {
if (provider === "openai-compatible") return "http://127.0.0.1:1234";
if (provider === "openai-compatible-key") return "sk-local";
return undefined;
},
);
});

it("builds a Chat Completions client from base URL and optional API key", async () => {
const result = await getModel("openai-compatible", "local-model", "user-1");

expect(createOpenAI).toHaveBeenCalledWith({
baseURL: "http://127.0.0.1:1234/v1",
apiKey: "sk-local",
name: "openai-compatible",
});
expect((createOpenAI as any).mock.results[0].value.chat).toHaveBeenCalledWith(
"local-model",
);
expect(result).toBe(mockModelInstance);
});

it("throws when no model is selected", async () => {
await expect(getModel("openai-compatible", "", "user-1")).rejects.toThrow(
"Select a model for the OpenAI-compatible provider",
);
});
});
20 changes: 20 additions & 0 deletions __tests__/verify-api-key-route.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ vi.mock("@/lib/ai/provider-registry.server", () => ({
get openai() {
return mockVerifier;
},
get "openai-compatible"() {
return mockVerifier;
},
},
}));

Expand Down Expand Up @@ -106,4 +109,21 @@ describe("POST /api/settings/api-keys/verify", () => {
expect(data.error).toBe("Invalid API key");
expect(data.reason).toBeUndefined();
});

it("passes openai-compatible credentials as { baseURL, apiKey }", async () => {
mockVerifier.mockResolvedValue({ success: true });

await POST(
req({
provider: "openai-compatible",
key: "http://127.0.0.1:1234",
apiKey: "sk-local",
}),
);

expect(mockVerifier).toHaveBeenCalledWith({
baseURL: "http://127.0.0.1:1234",
apiKey: "sk-local",
});
});
});
50 changes: 50 additions & 0 deletions src/app/api/ai/openai-compatible/models/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { auth } from "@/auth";
import { NextResponse } from "next/server";
import { resolveApiKey } from "@/lib/api-key-resolver";
import { openaiCompatibleRoot } from "@/lib/ai/provider-registry.server";
import { log } from "@/lib/telemetry";

export async function GET() {
try {
const session = await auth();
const userId = session?.user?.id;

const baseURL = await resolveApiKey(userId, "openai-compatible");
if (!baseURL) {
return NextResponse.json(
{ error: "OpenAI-compatible base URL not configured" },
{ status: 500 },
);
}

const apiKey = await resolveApiKey(userId, "openai-compatible-key");
const headers: Record<string, string> = {};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}

const response = await fetch(
`${openaiCompatibleRoot(baseURL)}/v1/models`,
{ headers },
);

if (!response.ok) {
return NextResponse.json(
{ error: `Failed to fetch models: ${response.status}` },
{ status: response.status },
);
}

const data = await response.json();
return NextResponse.json(data);
} catch (error) {
log.error("Error fetching models", {
provider: "openai-compatible",
error: String(error),
});
return NextResponse.json(
{ error: "Failed to fetch models from OpenAI-compatible endpoint" },
{ status: 502 },
);
}
}
7 changes: 5 additions & 2 deletions src/app/api/settings/api-keys/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: false, error: "Not authenticated" }, { status: 401 });
}

const { provider, key } = await req.json();
const body = await req.json();
const { provider, key, apiKey } = body;

if (!provider || !key) {
return NextResponse.json(
Expand All @@ -26,7 +27,9 @@ export async function POST(req: NextRequest) {
}

try {
const result = await verifier(key);
const verifyKey =
provider === "openai-compatible" ? { baseURL: key, apiKey } : key;
const result = await verifier(verifyKey);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Verification failed";
Expand Down
Loading