feat(#124): add OpenAI LLM provider
Implement OpenAI provider for GPT-4, GPT-3.5, and other OpenAI models. Implementation includes: - OpenAI SDK integration with API key authentication - Chat completion with streaming support - Embeddings generation - Health checks and model listing - OpenTelemetry tracing - Comprehensive test suite with 97% coverage Follows TDD methodology: - Written tests first (RED phase) - Implemented minimal code to pass tests (GREEN phase) - Code passes typecheck, linter, and all quality gates Test coverage: 97.18% statements, 97.05% lines All 22 tests passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,3 @@
|
|||||||
export * from "./llm-provider.interface";
|
export * from "./llm-provider.interface";
|
||||||
|
export * from "./openai.provider";
|
||||||
|
export * from "./ollama.provider";
|
||||||
|
|||||||
522
apps/api/src/llm/providers/openai.provider.spec.ts
Normal file
522
apps/api/src/llm/providers/openai.provider.spec.ts
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, type Mock } from "vitest";
|
||||||
|
import { OpenAiProvider, type OpenAiProviderConfig } from "./openai.provider";
|
||||||
|
import type { ChatRequestDto, EmbedRequestDto } from "../dto";
|
||||||
|
|
||||||
|
// Mock the openai module
|
||||||
|
vi.mock("openai", () => {
|
||||||
|
return {
|
||||||
|
default: vi.fn().mockImplementation(function (this: unknown) {
|
||||||
|
return {
|
||||||
|
chat: {
|
||||||
|
completions: {
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
embeddings: {
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
list: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OpenAiProvider", () => {
|
||||||
|
let provider: OpenAiProvider;
|
||||||
|
let config: OpenAiProviderConfig;
|
||||||
|
let mockOpenAiInstance: {
|
||||||
|
chat: { completions: { create: Mock } };
|
||||||
|
embeddings: { create: Mock };
|
||||||
|
models: { list: Mock };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Setup test configuration
|
||||||
|
config = {
|
||||||
|
endpoint: "https://api.openai.com/v1",
|
||||||
|
apiKey: "sk-test-1234567890",
|
||||||
|
timeout: 30000,
|
||||||
|
};
|
||||||
|
|
||||||
|
provider = new OpenAiProvider(config);
|
||||||
|
|
||||||
|
// Get the mock instance created by the constructor
|
||||||
|
mockOpenAiInstance = (provider as any).client;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("constructor and initialization", () => {
|
||||||
|
it("should create provider with correct name and type", () => {
|
||||||
|
expect(provider.name).toBe("OpenAI");
|
||||||
|
expect(provider.type).toBe("openai");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize successfully", async () => {
|
||||||
|
await expect(provider.initialize()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should support organization ID in config", () => {
|
||||||
|
const configWithOrg: OpenAiProviderConfig = {
|
||||||
|
endpoint: "https://api.openai.com/v1",
|
||||||
|
apiKey: "sk-test-1234567890",
|
||||||
|
organization: "org-test123",
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerWithOrg = new OpenAiProvider(configWithOrg);
|
||||||
|
const returnedConfig = providerWithOrg.getConfig();
|
||||||
|
|
||||||
|
expect(returnedConfig.organization).toBe("org-test123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkHealth", () => {
|
||||||
|
it("should return healthy status when OpenAI is reachable", async () => {
|
||||||
|
const mockModels = {
|
||||||
|
data: [{ id: "gpt-4" }, { id: "gpt-3.5-turbo" }, { id: "text-embedding-ada-002" }],
|
||||||
|
};
|
||||||
|
mockOpenAiInstance.models.list.mockResolvedValue(mockModels);
|
||||||
|
|
||||||
|
const health = await provider.checkHealth();
|
||||||
|
|
||||||
|
expect(health).toEqual({
|
||||||
|
healthy: true,
|
||||||
|
provider: "openai",
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
models: ["gpt-4", "gpt-3.5-turbo", "text-embedding-ada-002"],
|
||||||
|
});
|
||||||
|
expect(mockOpenAiInstance.models.list).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return unhealthy status when OpenAI is unreachable", async () => {
|
||||||
|
const error = new Error("API key invalid");
|
||||||
|
mockOpenAiInstance.models.list.mockRejectedValue(error);
|
||||||
|
|
||||||
|
const health = await provider.checkHealth();
|
||||||
|
|
||||||
|
expect(health).toEqual({
|
||||||
|
healthy: false,
|
||||||
|
provider: "openai",
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
error: "API key invalid",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle non-Error exceptions", async () => {
|
||||||
|
mockOpenAiInstance.models.list.mockRejectedValue("string error");
|
||||||
|
|
||||||
|
const health = await provider.checkHealth();
|
||||||
|
|
||||||
|
expect(health.healthy).toBe(false);
|
||||||
|
expect(health.error).toBe("string error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listModels", () => {
|
||||||
|
it("should return array of model names", async () => {
|
||||||
|
const mockModels = {
|
||||||
|
data: [{ id: "gpt-4" }, { id: "gpt-3.5-turbo" }, { id: "gpt-4-turbo" }],
|
||||||
|
};
|
||||||
|
mockOpenAiInstance.models.list.mockResolvedValue(mockModels);
|
||||||
|
|
||||||
|
const models = await provider.listModels();
|
||||||
|
|
||||||
|
expect(models).toEqual(["gpt-4", "gpt-3.5-turbo", "gpt-4-turbo"]);
|
||||||
|
expect(mockOpenAiInstance.models.list).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when listing models fails", async () => {
|
||||||
|
const error = new Error("Failed to connect");
|
||||||
|
mockOpenAiInstance.models.list.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(provider.listModels()).rejects.toThrow("Failed to list models");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chat", () => {
|
||||||
|
it("should perform chat completion successfully", async () => {
|
||||||
|
const request: ChatRequestDto = {
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
id: "chatcmpl-123",
|
||||||
|
object: "chat.completion",
|
||||||
|
created: 1677652288,
|
||||||
|
model: "gpt-4",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: "Hello! How can I assist you today?",
|
||||||
|
},
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 10,
|
||||||
|
completion_tokens: 8,
|
||||||
|
total_tokens: 18,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockOpenAiInstance.chat.completions.create.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const response = await provider.chat(request);
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
model: "gpt-4",
|
||||||
|
message: { role: "assistant", content: "Hello! How can I assist you today?" },
|
||||||
|
done: true,
|
||||||
|
promptEvalCount: 10,
|
||||||
|
evalCount: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOpenAiInstance.chat.completions.create).toHaveBeenCalledWith({
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
stream: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include system prompt in messages", async () => {
|
||||||
|
const request: ChatRequestDto = {
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
systemPrompt: "You are a helpful assistant",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockOpenAiInstance.chat.completions.create.mockResolvedValue({
|
||||||
|
model: "gpt-4",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: { role: "assistant", content: "Hi!" },
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { prompt_tokens: 15, completion_tokens: 2, total_tokens: 17 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.chat(request);
|
||||||
|
|
||||||
|
expect(mockOpenAiInstance.chat.completions.create).toHaveBeenCalledWith({
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "You are a helpful assistant" },
|
||||||
|
{ role: "user", content: "Hello" },
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not duplicate system prompt when already in messages", async () => {
|
||||||
|
const request: ChatRequestDto = {
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "Existing system prompt" },
|
||||||
|
{ role: "user", content: "Hello" },
|
||||||
|
],
|
||||||
|
systemPrompt: "New system prompt (should be ignored)",
|
||||||
|
};
|
||||||
|
|
||||||
|
mockOpenAiInstance.chat.completions.create.mockResolvedValue({
|
||||||
|
model: "gpt-4",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: { role: "assistant", content: "Hi!" },
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { prompt_tokens: 15, completion_tokens: 2, total_tokens: 17 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.chat(request);
|
||||||
|
|
||||||
|
expect(mockOpenAiInstance.chat.completions.create).toHaveBeenCalledWith({
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: "Existing system prompt" },
|
||||||
|
{ role: "user", content: "Hello" },
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass temperature and maxTokens as parameters", async () => {
|
||||||
|
const request: ChatRequestDto = {
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
temperature: 0.7,
|
||||||
|
maxTokens: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockOpenAiInstance.chat.completions.create.mockResolvedValue({
|
||||||
|
model: "gpt-4",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: { role: "assistant", content: "Hi!" },
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.chat(request);
|
||||||
|
|
||||||
|
expect(mockOpenAiInstance.chat.completions.create).toHaveBeenCalledWith({
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
stream: false,
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when chat fails", async () => {
|
||||||
|
const request: ChatRequestDto = {
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockOpenAiInstance.chat.completions.create.mockRejectedValue(
|
||||||
|
new Error("Model not available")
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(provider.chat(request)).rejects.toThrow("Chat completion failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chatStream", () => {
|
||||||
|
it("should stream chat completion chunks", async () => {
|
||||||
|
const request: ChatRequestDto = {
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockChunks = [
|
||||||
|
{
|
||||||
|
id: "chatcmpl-123",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created: 1677652288,
|
||||||
|
model: "gpt-4",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
delta: { role: "assistant", content: "Hello" },
|
||||||
|
finish_reason: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chatcmpl-123",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created: 1677652288,
|
||||||
|
model: "gpt-4",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
delta: { content: "!" },
|
||||||
|
finish_reason: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chatcmpl-123",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created: 1677652288,
|
||||||
|
model: "gpt-4",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
delta: {},
|
||||||
|
finish_reason: "stop",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock async generator
|
||||||
|
async function* mockStreamGenerator() {
|
||||||
|
for (const chunk of mockChunks) {
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mockOpenAiInstance.chat.completions.create.mockResolvedValue(mockStreamGenerator());
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of provider.chatStream(request)) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(chunks).toHaveLength(3);
|
||||||
|
expect(chunks[0]).toEqual({
|
||||||
|
model: "gpt-4",
|
||||||
|
message: { role: "assistant", content: "Hello" },
|
||||||
|
done: false,
|
||||||
|
});
|
||||||
|
expect(chunks[1]).toEqual({
|
||||||
|
model: "gpt-4",
|
||||||
|
message: { role: "assistant", content: "!" },
|
||||||
|
done: false,
|
||||||
|
});
|
||||||
|
expect(chunks[2].done).toBe(true);
|
||||||
|
|
||||||
|
expect(mockOpenAiInstance.chat.completions.create).toHaveBeenCalledWith({
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
stream: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass options in streaming mode", async () => {
|
||||||
|
const request: ChatRequestDto = {
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
temperature: 0.5,
|
||||||
|
maxTokens: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function* mockStreamGenerator() {
|
||||||
|
yield {
|
||||||
|
model: "gpt-4",
|
||||||
|
choices: [{ delta: { role: "assistant", content: "Hi" }, finish_reason: "stop" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
mockOpenAiInstance.chat.completions.create.mockResolvedValue(mockStreamGenerator());
|
||||||
|
|
||||||
|
const generator = provider.chatStream(request);
|
||||||
|
await generator.next();
|
||||||
|
|
||||||
|
expect(mockOpenAiInstance.chat.completions.create).toHaveBeenCalledWith({
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
stream: true,
|
||||||
|
temperature: 0.5,
|
||||||
|
max_tokens: 50,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when streaming fails", async () => {
|
||||||
|
const request: ChatRequestDto = {
|
||||||
|
model: "gpt-4",
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockOpenAiInstance.chat.completions.create.mockRejectedValue(new Error("Stream error"));
|
||||||
|
|
||||||
|
const generator = provider.chatStream(request);
|
||||||
|
|
||||||
|
await expect(generator.next()).rejects.toThrow("Streaming failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("embed", () => {
|
||||||
|
it("should generate embeddings successfully", async () => {
|
||||||
|
const request: EmbedRequestDto = {
|
||||||
|
model: "text-embedding-ada-002",
|
||||||
|
input: ["Hello world", "Test embedding"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
object: "list",
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
object: "embedding",
|
||||||
|
index: 0,
|
||||||
|
embedding: [0.1, 0.2, 0.3],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
object: "embedding",
|
||||||
|
index: 1,
|
||||||
|
embedding: [0.4, 0.5, 0.6],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
model: "text-embedding-ada-002",
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: 10,
|
||||||
|
total_tokens: 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockOpenAiInstance.embeddings.create.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const response = await provider.embed(request);
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
model: "text-embedding-ada-002",
|
||||||
|
embeddings: [
|
||||||
|
[0.1, 0.2, 0.3],
|
||||||
|
[0.4, 0.5, 0.6],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOpenAiInstance.embeddings.create).toHaveBeenCalledWith({
|
||||||
|
model: "text-embedding-ada-002",
|
||||||
|
input: ["Hello world", "Test embedding"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle single string input", async () => {
|
||||||
|
const request: EmbedRequestDto = {
|
||||||
|
model: "text-embedding-ada-002",
|
||||||
|
input: ["Single text"],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockOpenAiInstance.embeddings.create.mockResolvedValue({
|
||||||
|
data: [{ embedding: [0.1, 0.2] }],
|
||||||
|
model: "text-embedding-ada-002",
|
||||||
|
usage: { prompt_tokens: 5, total_tokens: 5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await provider.embed(request);
|
||||||
|
|
||||||
|
expect(mockOpenAiInstance.embeddings.create).toHaveBeenCalledWith({
|
||||||
|
model: "text-embedding-ada-002",
|
||||||
|
input: ["Single text"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when embedding fails", async () => {
|
||||||
|
const request: EmbedRequestDto = {
|
||||||
|
model: "text-embedding-ada-002",
|
||||||
|
input: ["Test"],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockOpenAiInstance.embeddings.create.mockRejectedValue(new Error("Embedding error"));
|
||||||
|
|
||||||
|
await expect(provider.embed(request)).rejects.toThrow("Embedding failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getConfig", () => {
|
||||||
|
it("should return copy of configuration", () => {
|
||||||
|
const returnedConfig = provider.getConfig();
|
||||||
|
|
||||||
|
expect(returnedConfig).toEqual(config);
|
||||||
|
expect(returnedConfig).not.toBe(config); // Should be a copy, not reference
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prevent external modification of config", () => {
|
||||||
|
const returnedConfig = provider.getConfig();
|
||||||
|
returnedConfig.apiKey = "sk-modified-key";
|
||||||
|
|
||||||
|
const secondCall = provider.getConfig();
|
||||||
|
expect(secondCall.apiKey).toBe("sk-test-1234567890"); // Original unchanged
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not expose API key in logs", () => {
|
||||||
|
const returnedConfig = provider.getConfig();
|
||||||
|
|
||||||
|
// API key should be present in config
|
||||||
|
expect(returnedConfig.apiKey).toBeDefined();
|
||||||
|
expect(returnedConfig.apiKey.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
351
apps/api/src/llm/providers/openai.provider.ts
Normal file
351
apps/api/src/llm/providers/openai.provider.ts
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import type { ChatCompletionMessageParam } from "openai/resources/chat";
|
||||||
|
import type {
|
||||||
|
LlmProviderInterface,
|
||||||
|
LlmProviderConfig,
|
||||||
|
LlmProviderHealthStatus,
|
||||||
|
} from "./llm-provider.interface";
|
||||||
|
import type { ChatRequestDto, ChatResponseDto, EmbedRequestDto, EmbedResponseDto } from "../dto";
|
||||||
|
import { TraceLlmCall, createLlmSpan } from "../../telemetry";
|
||||||
|
import { SpanStatusCode } from "@opentelemetry/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for OpenAI LLM provider.
|
||||||
|
* Extends base LlmProviderConfig with OpenAI-specific options.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const config: OpenAiProviderConfig = {
|
||||||
|
* endpoint: "https://api.openai.com/v1",
|
||||||
|
* apiKey: "sk-...",
|
||||||
|
* organization: "org-...",
|
||||||
|
* timeout: 30000
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface OpenAiProviderConfig extends LlmProviderConfig {
|
||||||
|
/**
|
||||||
|
* OpenAI API endpoint URL
|
||||||
|
* @default "https://api.openai.com/v1"
|
||||||
|
*/
|
||||||
|
endpoint: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI API key (required)
|
||||||
|
*/
|
||||||
|
apiKey: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional OpenAI organization ID
|
||||||
|
*/
|
||||||
|
organization?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request timeout in milliseconds
|
||||||
|
* @default 30000
|
||||||
|
*/
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI LLM provider implementation.
|
||||||
|
* Provides integration with OpenAI's GPT models (GPT-4, GPT-3.5, etc.).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const provider = new OpenAiProvider({
|
||||||
|
* endpoint: "https://api.openai.com/v1",
|
||||||
|
* apiKey: "sk-...",
|
||||||
|
* timeout: 30000
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* await provider.initialize();
|
||||||
|
*
|
||||||
|
* const response = await provider.chat({
|
||||||
|
* model: "gpt-4",
|
||||||
|
* messages: [{ role: "user", content: "Hello" }]
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class OpenAiProvider implements LlmProviderInterface {
|
||||||
|
readonly name = "OpenAI";
|
||||||
|
readonly type = "openai" as const;
|
||||||
|
|
||||||
|
private readonly logger = new Logger(OpenAiProvider.name);
|
||||||
|
private readonly client: OpenAI;
|
||||||
|
private readonly config: OpenAiProviderConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new OpenAI provider instance.
|
||||||
|
*
|
||||||
|
* @param config - OpenAI provider configuration
|
||||||
|
*/
|
||||||
|
constructor(config: OpenAiProviderConfig) {
|
||||||
|
this.config = {
|
||||||
|
...config,
|
||||||
|
timeout: config.timeout ?? 30000,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.client = new OpenAI({
|
||||||
|
apiKey: this.config.apiKey,
|
||||||
|
organization: this.config.organization,
|
||||||
|
baseURL: this.config.endpoint,
|
||||||
|
timeout: this.config.timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`OpenAI provider initialized with endpoint: ${this.config.endpoint}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the OpenAI provider.
|
||||||
|
* This is a no-op for OpenAI as the client is initialized in the constructor.
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
// OpenAI client is initialized in constructor
|
||||||
|
// No additional setup required
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the OpenAI API is healthy and reachable.
|
||||||
|
*
|
||||||
|
* @returns Health status with available models if healthy
|
||||||
|
*/
|
||||||
|
async checkHealth(): Promise<LlmProviderHealthStatus> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.models.list();
|
||||||
|
const models = response.data.map((m) => m.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
healthy: true,
|
||||||
|
provider: "openai",
|
||||||
|
endpoint: this.config.endpoint,
|
||||||
|
models,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`OpenAI health check failed: ${errorMessage}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
healthy: false,
|
||||||
|
provider: "openai",
|
||||||
|
endpoint: this.config.endpoint,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all available models from the OpenAI API.
|
||||||
|
*
|
||||||
|
* @returns Array of model names
|
||||||
|
* @throws {Error} If the request fails
|
||||||
|
*/
|
||||||
|
async listModels(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.models.list();
|
||||||
|
return response.data.map((m) => m.id);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.error(`Failed to list models: ${errorMessage}`);
|
||||||
|
throw new Error(`Failed to list models: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a synchronous chat completion.
|
||||||
|
*
|
||||||
|
* @param request - Chat request with messages and configuration
|
||||||
|
* @returns Complete chat response
|
||||||
|
* @throws {Error} If the request fails
|
||||||
|
*/
|
||||||
|
@TraceLlmCall({ system: "openai", operation: "chat" })
|
||||||
|
async chat(request: ChatRequestDto): Promise<ChatResponseDto> {
|
||||||
|
try {
|
||||||
|
const messages = this.buildMessages(request);
|
||||||
|
const options = this.buildChatOptions(request);
|
||||||
|
|
||||||
|
const response = await this.client.chat.completions.create({
|
||||||
|
model: request.model,
|
||||||
|
messages,
|
||||||
|
stream: false,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
const choice = response.choices[0];
|
||||||
|
if (!choice) {
|
||||||
|
throw new Error("No completion choice returned from OpenAI");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ChatResponseDto = {
|
||||||
|
model: response.model,
|
||||||
|
message: {
|
||||||
|
role: choice.message.role as "assistant",
|
||||||
|
content: choice.message.content ?? "",
|
||||||
|
},
|
||||||
|
done: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add optional properties only if they exist
|
||||||
|
if (response.usage?.prompt_tokens !== undefined) {
|
||||||
|
result.promptEvalCount = response.usage.prompt_tokens;
|
||||||
|
}
|
||||||
|
if (response.usage?.completion_tokens !== undefined) {
|
||||||
|
result.evalCount = response.usage.completion_tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.error(`Chat completion failed: ${errorMessage}`);
|
||||||
|
throw new Error(`Chat completion failed: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a streaming chat completion.
|
||||||
|
* Yields response chunks as they arrive from the OpenAI API.
|
||||||
|
*
|
||||||
|
* @param request - Chat request with messages and configuration
|
||||||
|
* @yields Chat response chunks
|
||||||
|
* @throws {Error} If the request fails
|
||||||
|
*/
|
||||||
|
async *chatStream(request: ChatRequestDto): AsyncGenerator<ChatResponseDto> {
|
||||||
|
const span = createLlmSpan("openai", "chat.stream", request.model);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages = this.buildMessages(request);
|
||||||
|
const options = this.buildChatOptions(request);
|
||||||
|
|
||||||
|
const stream = await this.client.chat.completions.create({
|
||||||
|
model: request.model,
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
const choice = chunk.choices[0];
|
||||||
|
if (!choice) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDone = choice.finish_reason === "stop" || choice.finish_reason === "length";
|
||||||
|
|
||||||
|
const role = choice.delta.role === "assistant" ? "assistant" : "assistant";
|
||||||
|
|
||||||
|
yield {
|
||||||
|
model: chunk.model,
|
||||||
|
message: {
|
||||||
|
role,
|
||||||
|
content: choice.delta.content ?? "",
|
||||||
|
},
|
||||||
|
done: isDone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
span.setStatus({ code: SpanStatusCode.OK });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.error(`Streaming failed: ${errorMessage}`);
|
||||||
|
|
||||||
|
span.recordException(error instanceof Error ? error : new Error(errorMessage));
|
||||||
|
span.setStatus({
|
||||||
|
code: SpanStatusCode.ERROR,
|
||||||
|
message: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Error(`Streaming failed: ${errorMessage}`);
|
||||||
|
} finally {
|
||||||
|
span.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate embeddings for the given input texts.
|
||||||
|
*
|
||||||
|
* @param request - Embedding request with model and input texts
|
||||||
|
* @returns Embeddings response with vector arrays
|
||||||
|
* @throws {Error} If the request fails
|
||||||
|
*/
|
||||||
|
@TraceLlmCall({ system: "openai", operation: "embed" })
|
||||||
|
async embed(request: EmbedRequestDto): Promise<EmbedResponseDto> {
|
||||||
|
try {
|
||||||
|
const response = await this.client.embeddings.create({
|
||||||
|
model: request.model,
|
||||||
|
input: request.input,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
model: response.model,
|
||||||
|
embeddings: response.data.map((item) => item.embedding),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.error(`Embedding failed: ${errorMessage}`);
|
||||||
|
throw new Error(`Embedding failed: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current provider configuration.
|
||||||
|
* Returns a copy to prevent external modification.
|
||||||
|
*
|
||||||
|
* @returns Provider configuration object
|
||||||
|
*/
|
||||||
|
getConfig(): OpenAiProviderConfig {
|
||||||
|
return { ...this.config };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build message array from chat request.
|
||||||
|
* Prepends system prompt if provided and not already in messages.
|
||||||
|
*
|
||||||
|
* @param request - Chat request
|
||||||
|
* @returns Array of messages for OpenAI
|
||||||
|
*/
|
||||||
|
private buildMessages(request: ChatRequestDto): ChatCompletionMessageParam[] {
|
||||||
|
const messages: ChatCompletionMessageParam[] = [];
|
||||||
|
|
||||||
|
// Add system prompt if provided and not already in messages
|
||||||
|
if (request.systemPrompt && !request.messages.some((m) => m.role === "system")) {
|
||||||
|
messages.push({
|
||||||
|
role: "system",
|
||||||
|
content: request.systemPrompt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all request messages
|
||||||
|
for (const message of request.messages) {
|
||||||
|
messages.push({
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build OpenAI-specific chat options from request.
|
||||||
|
*
|
||||||
|
* @param request - Chat request
|
||||||
|
* @returns OpenAI options object
|
||||||
|
*/
|
||||||
|
private buildChatOptions(request: ChatRequestDto): {
|
||||||
|
temperature?: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
} {
|
||||||
|
const options: { temperature?: number; max_tokens?: number } = {};
|
||||||
|
|
||||||
|
if (request.temperature !== undefined) {
|
||||||
|
options.temperature = request.temperature;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.maxTokens !== undefined) {
|
||||||
|
options.max_tokens = request.maxTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user