From 88db398acaa6ca3851d07ed3413ece780c3cd833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 6 Apr 2026 00:19:12 +0200 Subject: [PATCH] feat(ai): add OpenAI client with custom base URL support TDD implementation of OpenAIClient that wraps the OpenAI chat completions API with configurable base URL (supports OpenRouter and other compatible APIs). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/ai/__tests__/openai-client.test.ts | 88 ++++++++++++++++++++++ src/lib/ai/openai-client.ts | 57 ++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 src/lib/ai/__tests__/openai-client.test.ts create mode 100644 src/lib/ai/openai-client.ts diff --git a/src/lib/ai/__tests__/openai-client.test.ts b/src/lib/ai/__tests__/openai-client.test.ts new file mode 100644 index 000000000..e20c32d12 --- /dev/null +++ b/src/lib/ai/__tests__/openai-client.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { OpenAIClient } from "../openai-client"; + +describe("OpenAIClient", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("sends correct request format", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: '{"summary":"test"}' } }], + usage: { total_tokens: 42 }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const client = new OpenAIClient({ + apiKey: "sk-test-key", + model: "gpt-4o-mini", + baseUrl: "https://api.openai.com/v1", + }); + + const result = await client.generateCompletion({ + systemPrompt: "You are a doctor.", + userPrompt: "Analyze this data.", + }); + + expect(result.content).toBe('{"summary":"test"}'); + expect(result.tokensUsed).toBe(42); + expect(result.model).toBe("gpt-4o-mini"); + expect(result.providerType).toBe("admin-key"); + + const call = mockFetch.mock.calls[0]; + expect(call[0]).toBe("https://api.openai.com/v1/chat/completions"); + const body = JSON.parse(call[1].body); + expect(body.model).toBe("gpt-4o-mini"); + expect(body.messages).toHaveLength(2); + expect(body.response_format).toEqual({ type: "json_object" }); + }); + + it("throws on non-ok response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 429, + text: () => Promise.resolve("rate limited"), + }), + ); + + const client = new OpenAIClient({ + apiKey: "sk-test", + model: "gpt-4o-mini", + baseUrl: "https://api.openai.com/v1", + }); + + await expect( + client.generateCompletion({ systemPrompt: "test", userPrompt: "test" }), + ).rejects.toThrow("OpenAI request failed (429)"); + }); + + it("uses custom base URL for OpenRouter", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: '{"test":true}' } }], + usage: { total_tokens: 10 }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const client = new OpenAIClient({ + apiKey: "sk-or-test", + model: "openai/gpt-4o-mini", + baseUrl: "https://openrouter.ai/api/v1", + }); + + await client.generateCompletion({ systemPrompt: "test", userPrompt: "test" }); + + expect(mockFetch.mock.calls[0][0]).toBe( + "https://openrouter.ai/api/v1/chat/completions", + ); + }); +}); diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts new file mode 100644 index 000000000..7d498384d --- /dev/null +++ b/src/lib/ai/openai-client.ts @@ -0,0 +1,57 @@ +import type { AIProvider, CompletionParams, CompletionResult } from "./types"; + +interface OpenAIClientConfig { + apiKey: string; + model: string; + baseUrl: string; +} + +export class OpenAIClient implements AIProvider { + readonly type = "admin-key" as const; + private config: OpenAIClientConfig; + + constructor(config: OpenAIClientConfig) { + this.config = config; + } + + async generateCompletion(params: CompletionParams): Promise { + const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify({ + model: this.config.model, + messages: [ + { role: "system", content: params.systemPrompt }, + { role: "user", content: params.userPrompt }, + ], + temperature: params.temperature ?? 0.3, + max_tokens: params.maxTokens ?? 1000, + response_format: { type: "json_object" }, + }), + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`OpenAI request failed (${res.status}): ${body}`); + } + + const json = await res.json(); + const content = json.choices?.[0]?.message?.content; + + if (!content) { + throw new Error("OpenAI returned empty content"); + } + + return { + content, + tokensUsed: json.usage?.total_tokens ?? null, + model: this.config.model, + providerType: "admin-key", + }; + } +}