mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Replace the CompletionParams { systemPrompt, userPrompt } string pair with a
structured { system, messages[] } request across all five AI clients. One
refactor unlocks three things: provider prompt-caching (Anthropic cache_control
on the stable system prefix, OpenAI/Codex automatic prefix cache, with
cachedInputTokens surfaced for observability), the tool-calling wire surface,
and clean multi-turn.
Each client maps the new shape to its own wire and degrades unsupported
features silently: the local OpenAI-compatible server never receives a tools
field (most reject unknown keys), and prompt-caching is best-effort everywhere.
CompletionResult gains toolCalls, finishReason and cachedInputTokens; the tool
types and per-provider mapping ship now, the tool-use loop does not.
Vision inputs fold into the user turn's content parts via singleUserTurn, with
each provider's pre-existing block ordering preserved. The corrective-retry
paths append to the last user message rather than a flat string. The Coach
keeps assembling one user turn so its snapshot-once and grounding behaviour is
unchanged. No wire schema visible to API consumers changed.
Behaviour is unchanged across every surface; the full suite stays green.
146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
import type {
|
|
AIProvider,
|
|
AiToolCall,
|
|
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;
|
|
/**
|
|
* v1.20.0 — tool calls echoed on the result (single set replayed every
|
|
* call, or an array consumed per-call). Lets F1 exercise its tool loop
|
|
* against the mock without a live provider.
|
|
*/
|
|
toolCalls?: AiToolCall[] | AiToolCall[][];
|
|
/** v1.20.0 — finishReason echoed on the result. Defaults to undefined. */
|
|
finishReason?: CompletionResult["finishReason"];
|
|
/** v1.20.0 — cachedInputTokens echoed on the result. Defaults to null. */
|
|
cachedInputTokens?: number | null;
|
|
}
|
|
|
|
/** Type guard: a per-call array of tool-call sets. */
|
|
function isToolCallMatrix(
|
|
v: AiToolCall[] | AiToolCall[][] | undefined,
|
|
): v is AiToolCall[][] {
|
|
return Array.isArray(v) && v.length > 0 && Array.isArray(v[0]);
|
|
}
|
|
|
|
// Conforms to v1.4.15 strict `aiInsightResponseSchema`. Tests that need
|
|
// the legacy v1.4.14 rich shape opt in via `responses: [...]`.
|
|
const DEFAULT_RESPONSE = JSON.stringify({
|
|
summary: "mock",
|
|
recommendations: [],
|
|
citations: [],
|
|
warnings: [],
|
|
});
|
|
|
|
export class MockAIProvider implements AIProvider {
|
|
readonly type: "codex" | "admin-key" | "anthropic" | "local";
|
|
/** The mock can echo tool calls, so it advertises tool support. */
|
|
readonly supportsTools = true;
|
|
readonly calls: CompletionParams[] = [];
|
|
private readonly responses: string[];
|
|
private readonly tokens: Array<number | null>;
|
|
private readonly toolCalls: AiToolCall[][] | undefined;
|
|
private readonly finishReason: CompletionResult["finishReason"];
|
|
private readonly cachedInputTokens: 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;
|
|
this.finishReason = opts.finishReason;
|
|
this.cachedInputTokens = opts.cachedInputTokens ?? null;
|
|
if (isToolCallMatrix(opts.toolCalls)) {
|
|
this.toolCalls = opts.toolCalls;
|
|
} else if (Array.isArray(opts.toolCalls)) {
|
|
this.toolCalls = [opts.toolCalls];
|
|
} else {
|
|
this.toolCalls = undefined;
|
|
}
|
|
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);
|
|
const toolCalls = this.toolCalls
|
|
? this.toolCalls[Math.min(this.callIdx, this.toolCalls.length - 1)]
|
|
: undefined;
|
|
this.callIdx++;
|
|
return {
|
|
content: this.responses[idx],
|
|
tokensUsed: this.tokens[tokIdx] ?? null,
|
|
cachedInputTokens: this.cachedInputTokens,
|
|
model: this.model,
|
|
providerType: this.type,
|
|
...(toolCalls ? { toolCalls } : {}),
|
|
finishReason: this.finishReason,
|
|
};
|
|
}
|
|
|
|
/** Number of times `generateCompletion` was invoked. */
|
|
get callCount(): number {
|
|
return this.calls.length;
|
|
}
|
|
}
|