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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.6 committed 2026-04-06 00:42:46 +02:00
1 parent f18ded4860
commit 88db398aca
2 files changed
+145

No files matched your search

@@ -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",
);
});
});
+57
View File
@@ -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<CompletionResult> {
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",
};
}
}