mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user