From 0fdcfa6ed3d9499eb3210fc6731613ebb239d3d6 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 31 Jan 2026 14:21:38 -0600 Subject: [PATCH] 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 --- apps/api/src/llm/providers/index.ts | 2 + .../src/llm/providers/openai.provider.spec.ts | 522 ++++++++++++++++++ apps/api/src/llm/providers/openai.provider.ts | 351 ++++++++++++ 3 files changed, 875 insertions(+) create mode 100644 apps/api/src/llm/providers/openai.provider.spec.ts create mode 100644 apps/api/src/llm/providers/openai.provider.ts diff --git a/apps/api/src/llm/providers/index.ts b/apps/api/src/llm/providers/index.ts index 596ec53..84e3dbe 100644 --- a/apps/api/src/llm/providers/index.ts +++ b/apps/api/src/llm/providers/index.ts @@ -1 +1,3 @@ export * from "./llm-provider.interface"; +export * from "./openai.provider"; +export * from "./ollama.provider"; diff --git a/apps/api/src/llm/providers/openai.provider.spec.ts b/apps/api/src/llm/providers/openai.provider.spec.ts new file mode 100644 index 0000000..2098754 --- /dev/null +++ b/apps/api/src/llm/providers/openai.provider.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/src/llm/providers/openai.provider.ts b/apps/api/src/llm/providers/openai.provider.ts new file mode 100644 index 0000000..62eb52c --- /dev/null +++ b/apps/api/src/llm/providers/openai.provider.ts @@ -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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +}