From dc4f6cbb9d4786324badaf473de18a56db4af3ad Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 31 Jan 2026 11:38:38 -0600 Subject: [PATCH] feat(#122): create LLM provider interface Implemented abstract LLM provider interface to enable multi-provider support. Key components: - LlmProviderInterface: Abstract contract for all LLM providers - LlmProviderConfig: Base configuration interface - LlmProviderHealthStatus: Standardized health check response - LlmProviderType: Type discriminator for runtime checks Methods defined: - initialize(): Async provider setup - checkHealth(): Health status verification - listModels(): Available model enumeration - chat(): Synchronous completion - chatStream(): Streaming completion (async generator) - embed(): Embedding generation - getConfig(): Configuration access All methods fully documented with JSDoc. 13 tests written and passing. Type checking verified. Fixes #122 Co-Authored-By: Claude Sonnet 4.5 --- apps/api/src/llm/providers/index.ts | 1 + .../providers/llm-provider.interface.spec.ts | 227 ++++++++++++++++++ .../llm/providers/llm-provider.interface.ts | 160 ++++++++++++ .../scratchpads/122-llm-provider-interface.md | 81 +++++++ 4 files changed, 469 insertions(+) create mode 100644 apps/api/src/llm/providers/index.ts create mode 100644 apps/api/src/llm/providers/llm-provider.interface.spec.ts create mode 100644 apps/api/src/llm/providers/llm-provider.interface.ts create mode 100644 docs/scratchpads/122-llm-provider-interface.md diff --git a/apps/api/src/llm/providers/index.ts b/apps/api/src/llm/providers/index.ts new file mode 100644 index 0000000..596ec53 --- /dev/null +++ b/apps/api/src/llm/providers/index.ts @@ -0,0 +1 @@ +export * from "./llm-provider.interface"; diff --git a/apps/api/src/llm/providers/llm-provider.interface.spec.ts b/apps/api/src/llm/providers/llm-provider.interface.spec.ts new file mode 100644 index 0000000..4ce1826 --- /dev/null +++ b/apps/api/src/llm/providers/llm-provider.interface.spec.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { + LlmProviderInterface, + LlmProviderConfig, + LlmProviderHealthStatus, +} from "./llm-provider.interface"; +import type { ChatRequestDto, ChatResponseDto, EmbedRequestDto, EmbedResponseDto } from "../dto"; + +/** + * Mock provider implementation for testing the interface contract + */ +class MockLlmProvider implements LlmProviderInterface { + readonly name = "mock"; + readonly type = "ollama" as const; + private initialized = false; + + constructor(private config: LlmProviderConfig) {} + + async initialize(): Promise { + this.initialized = true; + } + + async checkHealth(): Promise { + return { + healthy: this.initialized, + provider: this.name, + endpoint: this.config.endpoint, + }; + } + + async listModels(): Promise { + if (!this.initialized) throw new Error("Provider not initialized"); + return ["mock-model-1", "mock-model-2"]; + } + + async chat(request: ChatRequestDto): Promise { + if (!this.initialized) throw new Error("Provider not initialized"); + return { + model: request.model, + message: { role: "assistant", content: "Mock response" }, + done: true, + }; + } + + async *chatStream(request: ChatRequestDto): AsyncGenerator { + if (!this.initialized) throw new Error("Provider not initialized"); + yield { + model: request.model, + message: { role: "assistant", content: "Mock " }, + done: false, + }; + yield { + model: request.model, + message: { role: "assistant", content: "stream" }, + done: true, + }; + } + + async embed(request: EmbedRequestDto): Promise { + if (!this.initialized) throw new Error("Provider not initialized"); + return { + model: request.model, + embeddings: request.input.map(() => [0.1, 0.2, 0.3]), + }; + } + + getConfig(): LlmProviderConfig { + return { ...this.config }; + } +} + +describe("LlmProviderInterface", () => { + let provider: LlmProviderInterface; + + beforeEach(() => { + provider = new MockLlmProvider({ + endpoint: "http://localhost:8000", + timeout: 30000, + }); + }); + + describe("initialization", () => { + it("should initialize successfully", async () => { + await expect(provider.initialize()).resolves.toBeUndefined(); + }); + + it("should have name and type properties", () => { + expect(provider.name).toBeDefined(); + expect(provider.type).toBeDefined(); + expect(typeof provider.name).toBe("string"); + expect(typeof provider.type).toBe("string"); + }); + }); + + describe("checkHealth", () => { + it("should return health status", async () => { + await provider.initialize(); + const health = await provider.checkHealth(); + + expect(health).toHaveProperty("healthy"); + expect(health).toHaveProperty("provider"); + expect(health.healthy).toBe(true); + expect(health.provider).toBe("mock"); + }); + + it("should include endpoint in health status", async () => { + await provider.initialize(); + const health = await provider.checkHealth(); + + expect(health.endpoint).toBe("http://localhost:8000"); + }); + }); + + describe("listModels", () => { + it("should return array of model names", async () => { + await provider.initialize(); + const models = await provider.listModels(); + + expect(Array.isArray(models)).toBe(true); + expect(models.length).toBeGreaterThan(0); + models.forEach((model) => expect(typeof model).toBe("string")); + }); + + it("should throw if not initialized", async () => { + await expect(provider.listModels()).rejects.toThrow("not initialized"); + }); + }); + + describe("chat", () => { + it("should return chat response", async () => { + await provider.initialize(); + const request: ChatRequestDto = { + model: "test-model", + messages: [{ role: "user", content: "Hello" }], + }; + + const response = await provider.chat(request); + + expect(response).toHaveProperty("model"); + expect(response).toHaveProperty("message"); + expect(response).toHaveProperty("done"); + expect(response.message.role).toBe("assistant"); + expect(typeof response.message.content).toBe("string"); + }); + + it("should throw if not initialized", async () => { + const request: ChatRequestDto = { + model: "test-model", + messages: [{ role: "user", content: "Hello" }], + }; + + await expect(provider.chat(request)).rejects.toThrow("not initialized"); + }); + }); + + describe("chatStream", () => { + it("should yield chat response chunks", async () => { + await provider.initialize(); + const request: ChatRequestDto = { + model: "test-model", + messages: [{ role: "user", content: "Hello" }], + }; + + const chunks: ChatResponseDto[] = []; + for await (const chunk of provider.chatStream(request)) { + chunks.push(chunk); + } + + expect(chunks.length).toBeGreaterThan(0); + chunks.forEach((chunk) => { + expect(chunk).toHaveProperty("model"); + expect(chunk).toHaveProperty("message"); + expect(chunk).toHaveProperty("done"); + }); + expect(chunks[chunks.length - 1].done).toBe(true); + }); + }); + + describe("embed", () => { + it("should return embeddings", async () => { + await provider.initialize(); + const request: EmbedRequestDto = { + model: "test-model", + input: ["text1", "text2"], + }; + + const response = await provider.embed(request); + + expect(response).toHaveProperty("model"); + expect(response).toHaveProperty("embeddings"); + expect(Array.isArray(response.embeddings)).toBe(true); + expect(response.embeddings.length).toBe(request.input.length); + response.embeddings.forEach((embedding) => { + expect(Array.isArray(embedding)).toBe(true); + expect(embedding.length).toBeGreaterThan(0); + }); + }); + + it("should throw if not initialized", async () => { + const request: EmbedRequestDto = { + model: "test-model", + input: ["text1"], + }; + + await expect(provider.embed(request)).rejects.toThrow("not initialized"); + }); + }); + + describe("getConfig", () => { + it("should return provider configuration", () => { + const config = provider.getConfig(); + + expect(config).toHaveProperty("endpoint"); + expect(config).toHaveProperty("timeout"); + expect(config.endpoint).toBe("http://localhost:8000"); + expect(config.timeout).toBe(30000); + }); + + it("should return a copy of config, not reference", () => { + const config1 = provider.getConfig(); + const config2 = provider.getConfig(); + + expect(config1).not.toBe(config2); + expect(config1).toEqual(config2); + }); + }); +}); diff --git a/apps/api/src/llm/providers/llm-provider.interface.ts b/apps/api/src/llm/providers/llm-provider.interface.ts new file mode 100644 index 0000000..29930df --- /dev/null +++ b/apps/api/src/llm/providers/llm-provider.interface.ts @@ -0,0 +1,160 @@ +import type { ChatRequestDto, ChatResponseDto, EmbedRequestDto, EmbedResponseDto } from "../dto"; + +/** + * Base configuration for all LLM providers. + * Provider-specific implementations can extend this interface. + */ +export interface LlmProviderConfig { + /** + * Provider endpoint URL (e.g., "http://localhost:11434" for Ollama) + */ + endpoint: string; + + /** + * Request timeout in milliseconds + * @default 30000 + */ + timeout?: number; + + /** + * Additional provider-specific configuration + */ + [key: string]: unknown; +} + +/** + * Health status returned by provider health checks + */ +export interface LlmProviderHealthStatus { + /** + * Whether the provider is healthy and ready to accept requests + */ + healthy: boolean; + + /** + * Provider name (e.g., "ollama", "claude", "openai") + */ + provider: string; + + /** + * Provider endpoint being checked + */ + endpoint?: string; + + /** + * Error message if unhealthy + */ + error?: string; + + /** + * Available models (optional, for providers that support listing) + */ + models?: string[]; + + /** + * Additional metadata about the health check + */ + metadata?: Record; +} + +/** + * Provider type discriminator for runtime type checking + */ +export type LlmProviderType = "ollama" | "claude" | "openai"; + +/** + * Abstract interface that all LLM providers must implement. + * Supports multiple LLM backends (Ollama, Claude, OpenAI, etc.) + * + * @example + * ```typescript + * class OllamaProvider implements LlmProviderInterface { + * readonly name = "ollama"; + * readonly type = "ollama"; + * + * constructor(config: OllamaProviderConfig) { + * // Initialize provider + * } + * + * async initialize(): Promise { + * // Setup provider connection + * } + * + * async chat(request: ChatRequestDto): Promise { + * // Implement chat completion + * } + * + * // ... implement other methods + * } + * ``` + */ +export interface LlmProviderInterface { + /** + * Human-readable provider name (e.g., "Ollama", "Claude", "OpenAI") + */ + readonly name: string; + + /** + * Provider type discriminator for runtime type checking + */ + readonly type: LlmProviderType; + + /** + * Initialize the provider connection and resources. + * Called once during provider instantiation. + * + * @throws {Error} If initialization fails + */ + initialize(): Promise; + + /** + * Check if the provider is healthy and ready to accept requests. + * + * @returns Health status with provider details + */ + checkHealth(): Promise; + + /** + * List all available models from this provider. + * + * @returns Array of model names + * @throws {Error} If provider is not initialized or request fails + */ + listModels(): Promise; + + /** + * Perform a synchronous chat completion. + * + * @param request - Chat request with messages and configuration + * @returns Complete chat response + * @throws {Error} If provider is not initialized or request fails + */ + chat(request: ChatRequestDto): Promise; + + /** + * Perform a streaming chat completion. + * Yields response chunks as they arrive from the provider. + * + * @param request - Chat request with messages and configuration + * @yields Chat response chunks + * @throws {Error} If provider is not initialized or request fails + */ + chatStream(request: ChatRequestDto): AsyncGenerator; + + /** + * 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 provider is not initialized or request fails + */ + embed(request: EmbedRequestDto): Promise; + + /** + * Get the current provider configuration. + * Should return a copy to prevent external modification. + * + * @returns Provider configuration object + */ + getConfig(): LlmProviderConfig; +} diff --git a/docs/scratchpads/122-llm-provider-interface.md b/docs/scratchpads/122-llm-provider-interface.md new file mode 100644 index 0000000..73d26fe --- /dev/null +++ b/docs/scratchpads/122-llm-provider-interface.md @@ -0,0 +1,81 @@ +# Issue #122: Create LLM Provider Interface + +## Objective + +Define the abstract contract that all LLM providers (Ollama, Claude, OpenAI) must implement to enable multi-provider support. + +## Approach + +### Current State + +- `LlmService` is hardcoded to Ollama +- Direct coupling to `ollama` npm package +- Methods: `chat()`, `chatStream()`, `embed()`, `listModels()`, `checkHealth()` + +### Target Architecture + +``` +LlmProviderInterface (abstract) + ├── OllamaProvider (implements) + ├── ClaudeProvider (implements) + └── OpenAIProvider (implements) + +LlmManagerService + └── manages provider instances + └── routes requests to appropriate provider +``` + +### Interface Methods (from current LlmService) + +1. `chat(request)` - Synchronous chat completion +2. `chatStream(request)` - Streaming chat completion (async generator) +3. `embed(request)` - Generate embeddings +4. `listModels()` - List available models +5. `checkHealth()` - Health check + +### Provider Configuration + +Each provider needs different config: + +- Ollama: `{ host, timeout }` +- Claude: `{ apiKey, baseUrl?, timeout? }` +- OpenAI: `{ apiKey, baseUrl?, organization?, timeout? }` + +Need generic config interface that providers can extend. + +## Progress + +- [x] Write interface tests (TDD - RED) +- [x] Create base types and DTOs +- [x] Implement LlmProviderInterface +- [x] Implement LlmProviderConfig interface +- [x] Add JSDoc documentation +- [x] Run tests (TDD - GREEN) - All 13 tests passed +- [x] Type checking passed +- [x] Refactor if needed (TDD - REFACTOR) - No refactoring needed + +## Testing + +Created a mock provider implementation to test interface contract. + +**Test Results:** + +``` +✓ src/llm/providers/llm-provider.interface.spec.ts (13 tests) 7ms + - initialization (2 tests) + - checkHealth (2 tests) + - listModels (2 tests) + - chat (2 tests) + - chatStream (1 test) + - embed (2 tests) + - getConfig (2 tests) +``` + +**Type Check:** ✅ Passed + +## Notes + +- Interface should be provider-agnostic +- Use existing DTOs from current LlmService +- Consider async initialization for providers +- Health check should return standardized status