From 27310e4f9ad06a59e38946bcc2536be9e6370e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 21:42:22 +0200 Subject: [PATCH] refactor(ai): consolidate providers behind AIProvider interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C1 (v1.4.15) — provider abstraction review. Adds `MockAIProvider` (`src/lib/ai/mock-client.ts`) so contract tests can exercise the route-level interface without burning network calls on every CI run. The mock satisfies `AIProvider`, lets tests queue a list of canned responses, and records every `CompletionParams` invocation for later assertion. Adds a contract test file (`provider-contract.test.ts`) that walks every concrete provider — Codex, OpenAI, Anthropic, Local, Mock — and asserts each one returns a `CompletionResult` with the four contract-required fields (`content`, `tokensUsed`, `model`, `providerType`). Network is stubbed per provider; the test exists to catch interface drift, not to retest each provider's wire format. The route at `/api/insights/generate` already imports the abstract `AIProvider` via `resolveProvider()`, so the consumer side needs no change — this commit ratchets the contract with explicit tests so future provider additions (medical-grounding, multi-provider redundancy in v1.4.16) cannot silently regress the interface. Tests: +7 (132 → 139 ai-domain). 972 / 972 unit pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ai/__tests__/provider-contract.test.ts | 208 ++++++++++++++++++ src/lib/ai/mock-client.ts | 103 +++++++++ 2 files changed, 311 insertions(+) create mode 100644 src/lib/ai/__tests__/provider-contract.test.ts create mode 100644 src/lib/ai/mock-client.ts diff --git a/src/lib/ai/__tests__/provider-contract.test.ts b/src/lib/ai/__tests__/provider-contract.test.ts new file mode 100644 index 000000000..1113622bd --- /dev/null +++ b/src/lib/ai/__tests__/provider-contract.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MockAIProvider } from "../mock-client"; +import { OpenAIClient } from "../openai-client"; +import { AnthropicClient } from "../anthropic-client"; +import { LocalOpenAICompatibleClient } from "../local-client"; +import { CodexClient } from "../codex-client"; +import type { AIProvider, CompletionResult } from "../types"; + +/** + * Provider-contract tests: every concrete AIProvider implementation must + * satisfy the same shape — return a `CompletionResult` with the four + * required fields populated. This is the "consolidated behind one + * interface" guarantee from Phase C1 acceptance criterion #1. + * + * Each test stubs the provider's network dependency and asserts the + * shape on a happy path. Error paths live in each provider's dedicated + * test file (codex-client.test.ts, openai-client.test.ts, …). + */ + +function assertCompletionResult(result: CompletionResult): void { + expect(typeof result.content).toBe("string"); + expect(result.content.length).toBeGreaterThan(0); + expect(["codex", "admin-key", "anthropic", "local"]).toContain( + result.providerType, + ); + expect(typeof result.model).toBe("string"); + expect( + result.tokensUsed === null || typeof result.tokensUsed === "number", + ).toBe(true); +} + +describe("AIProvider contract", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("MockAIProvider — default response satisfies CompletionResult shape", async () => { + const provider: AIProvider = new MockAIProvider(); + const result = await provider.generateCompletion({ + systemPrompt: "sys", + userPrompt: "user", + }); + assertCompletionResult(result); + expect(result.providerType).toBe("local"); + expect(result.model).toBe("mock-model"); + }); + + it("MockAIProvider — records calls and walks the response queue", async () => { + const mock = new MockAIProvider({ + responses: ['{"a":1}', '{"a":2}'], + tokensUsed: [10, 20], + providerType: "codex", + }); + const r1 = await mock.generateCompletion({ + systemPrompt: "s", + userPrompt: "u1", + }); + const r2 = await mock.generateCompletion({ + systemPrompt: "s", + userPrompt: "u2", + }); + const r3 = await mock.generateCompletion({ + systemPrompt: "s", + userPrompt: "u3", + }); + expect(r1.content).toBe('{"a":1}'); + expect(r1.tokensUsed).toBe(10); + expect(r2.content).toBe('{"a":2}'); + expect(r2.tokensUsed).toBe(20); + // Last entry repeats once the queue is exhausted. + expect(r3.content).toBe('{"a":2}'); + expect(mock.callCount).toBe(3); + expect(mock.calls.map((c) => c.userPrompt)).toEqual(["u1", "u2", "u3"]); + }); + + it("MockAIProvider — rejectWith causes generateCompletion to throw", async () => { + const mock = new MockAIProvider({ rejectWith: new Error("boom") }); + await expect( + mock.generateCompletion({ systemPrompt: "s", userPrompt: "u" }), + ).rejects.toThrow("boom"); + }); + + it("OpenAIClient — happy path returns CompletionResult shape", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: '{"summary":"ok"}' } }], + usage: { total_tokens: 17 }, + }), + }), + ); + + const provider: AIProvider = new OpenAIClient({ + apiKey: "sk-test", + model: "gpt-4o-mini", + baseUrl: "https://api.openai.com/v1", + }); + const result = await provider.generateCompletion({ + systemPrompt: "sys", + userPrompt: "user", + }); + assertCompletionResult(result); + expect(result.providerType).toBe("admin-key"); + expect(result.model).toBe("gpt-4o-mini"); + expect(result.tokensUsed).toBe(17); + }); + + it("AnthropicClient — happy path returns CompletionResult shape", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + content: [{ type: "text", text: '{"summary":"ok"}' }], + usage: { input_tokens: 5, output_tokens: 9 }, + }), + }), + ); + const provider: AIProvider = new AnthropicClient({ + apiKey: "sk-ant-test", + model: "claude-3-5-sonnet-latest", + }); + const result = await provider.generateCompletion({ + systemPrompt: "sys", + userPrompt: "user", + }); + assertCompletionResult(result); + expect(result.providerType).toBe("anthropic"); + expect(result.tokensUsed).toBe(14); + }); + + it("LocalOpenAICompatibleClient — happy path returns CompletionResult shape", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + choices: [{ message: { content: '{"summary":"ok"}' } }], + usage: { total_tokens: 11 }, + }), + }), + ); + const provider: AIProvider = new LocalOpenAICompatibleClient({ + apiKey: null, + model: "llama3:8b", + baseUrl: "http://localhost:11434/v1", + }); + const result = await provider.generateCompletion({ + systemPrompt: "sys", + userPrompt: "user", + }); + assertCompletionResult(result); + expect(result.providerType).toBe("local"); + }); + + it("CodexClient — happy path returns CompletionResult shape", async () => { + const encoder = new TextEncoder(); + const events = [ + `event: response.output_item.done\ndata: ${JSON.stringify({ + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: '{"summary":"ok"}' }], + }, + })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response: { id: "resp1", usage: { total_tokens: 23 } }, + })}\n\n`, + ]; + const stream = new ReadableStream({ + start(controller) { + for (const ev of events) controller.enqueue(encoder.encode(ev)); + controller.close(); + }, + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ), + ); + + const provider: AIProvider = new CodexClient({ + accessToken: "test-token", + accountId: "acct-test", + onTokenRefresh: vi + .fn() + .mockResolvedValue({ accessToken: "x", accountId: "acct-test" }), + }); + const result = await provider.generateCompletion({ + systemPrompt: "sys", + userPrompt: "user", + }); + assertCompletionResult(result); + expect(result.providerType).toBe("codex"); + expect(result.tokensUsed).toBe(23); + }); +}); diff --git a/src/lib/ai/mock-client.ts b/src/lib/ai/mock-client.ts new file mode 100644 index 000000000..889701832 --- /dev/null +++ b/src/lib/ai/mock-client.ts @@ -0,0 +1,103 @@ +import type { AIProvider, CompletionParams, CompletionResult } from "./types"; + +/** + * Deterministic mock AI provider for tests. + * + * Phase C1 (v1.4.15): provider abstraction review. The route layer at + * `/api/insights/generate` only depends on the `AIProvider` interface. + * Real providers (Codex / OpenAI / Anthropic / Local) talk to upstream + * services that cannot be exercised cheaply in CI; this implementation + * returns canned `CompletionResult`s so tests covering the interface + * contract (parsing, retry-once, citation enforcement, schema gating) + * never hit the network. + * + * The mock is exported from `src/lib/ai/` so any test can import it, + * but it is NOT registered in `resolveProvider()` — the production + * path must never reach it. + */ +export interface MockAIProviderOptions { + /** Provider tag returned in `CompletionResult.providerType`. Defaults to "local". */ + providerType?: "codex" | "admin-key" | "anthropic" | "local"; + /** Slug returned in `CompletionResult.model`. Defaults to "mock-model". */ + model?: string; + /** + * Either a single canned response that is returned on every call, + * or an array consumed in order — when the array is exhausted the + * last element is repeated. Each element is the JSON-serialised body + * of the assistant message (i.e. what `result.content` will hold). + */ + responses?: string | string[]; + /** + * If set, every call rejects with this error instead of returning a + * response. Useful for fallback / retry tests. + */ + rejectWith?: Error; + /** + * Optional `tokensUsed` figure stamped onto the result (single value + * or per-call array, same semantics as `responses`). + */ + tokensUsed?: number | number[] | null; +} + +const DEFAULT_RESPONSE = JSON.stringify({ + summary: "mock", + classification: "gut", + findings: [], + correlations: [], + recommendations: [], + dataQuality: { coverage: "mock", gaps: [], confidence: "mittel" }, + disclaimer: "mock disclaimer", +}); + +export class MockAIProvider implements AIProvider { + readonly type: "codex" | "admin-key" | "anthropic" | "local"; + readonly calls: CompletionParams[] = []; + private readonly responses: string[]; + private readonly tokens: Array; + private readonly model: string; + private readonly rejectWith: Error | undefined; + private callIdx = 0; + + constructor(opts: MockAIProviderOptions = {}) { + this.type = opts.providerType ?? "local"; + this.model = opts.model ?? "mock-model"; + this.rejectWith = opts.rejectWith; + if (Array.isArray(opts.responses)) { + this.responses = opts.responses.length > 0 ? opts.responses : [DEFAULT_RESPONSE]; + } else if (typeof opts.responses === "string") { + this.responses = [opts.responses]; + } else { + this.responses = [DEFAULT_RESPONSE]; + } + if (Array.isArray(opts.tokensUsed)) { + this.tokens = opts.tokensUsed; + } else if (opts.tokensUsed === undefined) { + this.tokens = [42]; + } else { + this.tokens = [opts.tokensUsed]; + } + } + + async generateCompletion( + params: CompletionParams, + ): Promise { + this.calls.push(params); + if (this.rejectWith) { + throw this.rejectWith; + } + const idx = Math.min(this.callIdx, this.responses.length - 1); + const tokIdx = Math.min(this.callIdx, this.tokens.length - 1); + this.callIdx++; + return { + content: this.responses[idx], + tokensUsed: this.tokens[tokIdx] ?? null, + model: this.model, + providerType: this.type, + }; + } + + /** Number of times `generateCompletion` was invoked. */ + get callCount(): number { + return this.calls.length; + } +}