mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +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