refactor(ai): consolidate providers behind AIProvider interface

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 21:42:22 +02:00
1 parent f5c6505e37
commit 27310e4f9a
2 files changed
+311

No files matched your search

@@ -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<Uint8Array>({
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);
});
});
+103
View File
@@ -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<number | null>;
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<CompletionResult> {
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;
}
}