mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user