Merge AI client message-array refactor into release/v1.20.0

Move the AI client layer from a single system/user string pair to a
structured system + messages[] shape, with optional tools/toolChoice and
a result carrying toolCalls/finishReason/cachedInputTokens. Per-provider
prompt-caching is wired (Anthropic cache_control, OpenAI/local auto
prefix); tool plumbing is in place for the Coach to use. Behaviour-
preserving: no streaming change, snapshot-once retained.
This commit is contained in:
Marc-André Bombeck committed 2026-06-22 07:17:48 +02:00
commit b07fbcb6fa
31 files changed
+1556 -485

No files matched your search

+9 -6
View File
@@ -6,6 +6,7 @@ import {
AITestConfigError,
type AITestOverride,
} from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
@@ -65,12 +66,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
try {
const result = await provider.generateCompletion({
systemPrompt: "You are a connection-test responder.",
userPrompt: 'Reply with the JSON object {"ok": true} and nothing else.',
temperature: 0,
maxTokens: 32,
});
const result = await provider.generateCompletion(
singleUserTurn({
system: "You are a connection-test responder.",
user: 'Reply with the JSON object {"ok": true} and nothing else.',
temperature: 0,
maxTokens: 32,
}),
);
return apiSuccess({
ok: true,
@@ -164,10 +164,11 @@ function chatReq(body: Record<string, unknown>): Request {
function lastUserPrompt(): string {
const calls = runRawCompletionWithFallback.mock.calls as unknown as Array<
[{ params: { userPrompt: string } }]
[{ params: { messages: Array<{ role: string; content: string }> } }]
>;
const last = calls[calls.length - 1];
return last[0].params.userPrompt;
const userTurn = last[0].params.messages.find((m) => m.role === "user");
return typeof userTurn?.content === "string" ? userTurn.content : "";
}
describe("coach chat — snapshot sent once per conversation (C4)", () => {
+9 -5
View File
@@ -44,7 +44,7 @@ import {
} from "@/lib/ai/provider-runner";
import { resolveProviderChain, resolveProvider } from "@/lib/ai/provider";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import type { CompletionResult } from "@/lib/ai/types";
import { singleUserTurn, type CompletionResult } from "@/lib/ai/types";
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
@@ -494,12 +494,16 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
const fallback = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt,
// v1.20.0 — the Coach still builds one assembled user turn (the
// transcript-flattening preserves the once-per-conversation snapshot
// trick + grounding exactly), so it ships as a single user message. The
// stable persona/grounding rides `system` and is now cache-eligible.
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
},
}),
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
+13 -15
View File
@@ -52,7 +52,11 @@ import {
type ComparisonBaseline,
} from "@/lib/dashboard-layout";
import type { MeasurementType } from "@/generated/prisma/client";
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
import {
insightResultSchema,
singleUserTurn,
type InsightResult,
} from "@/lib/ai/types";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import {
AllProvidersFailedError,
@@ -662,7 +666,7 @@ export const POST = apiHandler((request: NextRequest) =>
const fallback = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
params: singleUserTurn({
// The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8
// — emit a top-level `dailyBriefing` block when the snapshot has
// analysable signal — plus the trendAnnotations and
@@ -674,14 +678,11 @@ export const POST = apiHandler((request: NextRequest) =>
// tolerates the strict shape via `insightResultSchema.safeParse`'s
// soft fallback to `parsed` and `passthrough()` on the strict
// schema, so switching the prompt does not break legacy callers.
systemPrompt: buildSystemPromptWithReferences(
locale,
referenceMetrics,
),
userPrompt,
system: buildSystemPromptWithReferences(locale, referenceMetrics),
user: userPrompt,
temperature: 0.3,
maxTokens: 1500,
},
}),
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
@@ -809,15 +810,12 @@ export const POST = apiHandler((request: NextRequest) =>
const retry = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt: buildSystemPromptWithReferences(
locale,
referenceMetrics,
),
userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
params: singleUserTurn({
system: buildSystemPromptWithReferences(locale, referenceMetrics),
user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
temperature: 0.3,
maxTokens: 1500,
},
}),
});
const parsedRetry = JSON.parse(retry.result.content);
const validatedRetry = insightResultSchema.safeParse(parsedRetry);
+5 -4
View File
@@ -57,6 +57,7 @@ import {
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import {
@@ -185,16 +186,16 @@ async function handleExtract(request: NextRequest): Promise<Response> {
completion = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt,
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
// Low temperature because we want a deterministic field
// extractor, not a creative rephrase.
temperature: 0.1,
// The reply body is a single small JSON object; 600 is well
// over the empirical max we have seen on a 2k-char input.
maxTokens: 600,
},
}),
});
} catch (err) {
if (err instanceof AllProvidersFailedError) {
+106 -32
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AnthropicClient } from "../anthropic-client";
import { singleUserTurn } from "../types";
describe("AnthropicClient", () => {
beforeEach(() => {
@@ -22,12 +23,14 @@ describe("AnthropicClient", () => {
model: "claude-3-5-sonnet-latest",
});
const result = await client.generateCompletion({
systemPrompt: "You are a doctor.",
userPrompt: "Analyze this data.",
temperature: 0.4,
maxTokens: 800,
});
const result = await client.generateCompletion(
singleUserTurn({
system: "You are a doctor.",
user: "Analyze this data.",
temperature: 0.4,
maxTokens: 800,
}),
);
expect(result.content).toBe('{"summary":"ok"}');
expect(result.tokensUsed).toBe(42);
@@ -45,10 +48,16 @@ describe("AnthropicClient", () => {
expect(body.model).toBe("claude-3-5-sonnet-latest");
expect(body.max_tokens).toBe(800);
expect(body.temperature).toBe(0.4);
// System prompt is a separate top-level field, NOT a message.
expect(body.system).toBe("You are a doctor.");
// v1.20.0 — system is a top-level cache-marked text block (NOT a message).
expect(body.system).toEqual([
{
type: "text",
text: "You are a doctor.",
cache_control: { type: "ephemeral" },
},
]);
expect(body.messages).toEqual([expect.objectContaining({ role: "user" })]);
// User message should be wrapped to coerce JSON output.
// User message carries the original text plus the JSON-coercion instruction.
expect(body.messages[0].content).toContain("Analyze this data.");
expect(body.messages[0].content.toLowerCase()).toContain("json");
});
@@ -70,11 +79,9 @@ describe("AnthropicClient", () => {
model: "claude-3-5-sonnet-latest",
});
const result = await client.generateCompletion({
systemPrompt: "s",
userPrompt: "u",
responseFormat: "json",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
);
// The returned content is the complete object (the `{` re-prepended).
expect(result.content).toBe('{"summary":"ok"}');
@@ -101,10 +108,9 @@ describe("AnthropicClient", () => {
model: "claude-3-5-sonnet-latest",
});
const result = await client.generateCompletion({
systemPrompt: "s",
userPrompt: "u",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "s", user: "u" }),
);
expect(result.content).toBe("Just some prose.");
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
@@ -129,7 +135,7 @@ describe("AnthropicClient", () => {
baseUrl: "https://api.anthropic.example/v1/",
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
expect(mockFetch.mock.calls[0][0]).toBe(
"https://api.anthropic.example/v1/messages",
@@ -156,10 +162,9 @@ describe("AnthropicClient", () => {
let caught: unknown;
try {
await client.generateCompletion({
systemPrompt: "s",
userPrompt: "u",
});
await client.generateCompletion(
singleUserTurn({ system: "s", user: "u" }),
);
} catch (e) {
caught = e;
}
@@ -195,13 +200,15 @@ describe("AnthropicClient", () => {
model: "claude-sonnet-4-6",
});
await client.generateCompletion({
systemPrompt: "Transcribe this report.",
userPrompt: "Extract the readings.",
responseFormat: "json",
images: [{ mediaType: "image/jpeg", dataBase64: "aW1n" }],
documents: [{ mediaType: "application/pdf", dataBase64: "cGRm" }],
});
await client.generateCompletion(
singleUserTurn({
system: "Transcribe this report.",
user: "Extract the readings.",
responseFormat: "json",
images: [{ mediaType: "image/jpeg", dataBase64: "aW1n" }],
documents: [{ mediaType: "application/pdf", dataBase64: "cGRm" }],
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
// First message is the user turn; its content is now a typed array.
@@ -240,7 +247,7 @@ describe("AnthropicClient", () => {
model: "claude-sonnet-4-6",
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(typeof body.messages[0].content).toBe("string");
});
@@ -260,7 +267,74 @@ describe("AnthropicClient", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
client.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow("Anthropic returned empty content");
});
it("maps tools, drops the JSON prefill, parses tool_use + cache reads", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
content: [
{
type: "tool_use",
id: "toolu_1",
name: "fetch_glucose",
input: { window: "last30days" },
},
],
stop_reason: "tool_use",
usage: {
input_tokens: 50,
output_tokens: 8,
cache_read_input_tokens: 40,
},
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new AnthropicClient({
apiKey: "sk-ant-x",
model: "claude-sonnet-4-6",
});
const result = await client.generateCompletion(
singleUserTurn({
system: "s",
user: "u",
// Even with responseFormat json, tools must win and drop the prefill.
responseFormat: "json",
tools: [
{
name: "fetch_glucose",
description: "Fetch glucose readings",
parameters: { type: "object", properties: {} },
},
],
toolChoice: "auto",
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.tools).toEqual([
{
name: "fetch_glucose",
description: "Fetch glucose readings",
input_schema: { type: "object", properties: {} },
},
]);
expect(body.tool_choice).toEqual({ type: "auto" });
// No `{`-prefill assistant turn when tools are present.
expect(body.messages).toHaveLength(1);
expect(result.toolCalls).toEqual([
{
id: "toolu_1",
name: "fetch_glucose",
arguments: JSON.stringify({ window: "last30days" }),
},
]);
expect(result.finishReason).toBe("tool_calls");
expect(result.cachedInputTokens).toBe(40);
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
import { singleUserTurn } from "../types";
import {
detectsNormativeClaim,
computeCitationCoverage,
@@ -276,10 +277,10 @@ describe("generateInsight() — citation-coverage annotation", () => {
warnings: [],
}),
});
await generateInsight(provider, {
systemPrompt: "system",
userPrompt: "user",
});
await generateInsight(
provider,
singleUserTurn({ system: "system", user: "user" }),
);
const meta = findCoverageCall(annotateMock.mock.calls);
expect(meta).toMatchObject({
ai_total_recommendations: 1,
@@ -306,10 +307,10 @@ describe("generateInsight() — citation-coverage annotation", () => {
warnings: [],
}),
});
await generateInsight(provider, {
systemPrompt: "system",
userPrompt: "user",
});
await generateInsight(
provider,
singleUserTurn({ system: "system", user: "user" }),
);
const meta = findCoverageCall(annotateMock.mock.calls);
expect(meta).toMatchObject({
ai_normative_recommendations: 1,
@@ -324,7 +325,7 @@ describe("generateInsight() — citation-coverage annotation", () => {
responses: ["not json", "still not json"],
});
await expect(
generateInsight(provider, { systemPrompt: "s", userPrompt: "u" }),
generateInsight(provider, singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow();
expect(annotateMock).not.toHaveBeenCalled();
});
@@ -8,6 +8,7 @@ import {
} from "../schema";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
import { singleUserTurn } from "../types";
/**
* Phase C1 — citation-from-data hard requirement.
@@ -247,10 +248,10 @@ describe("citation-from-data — wrapper end-to-end", () => {
const provider = new MockAIProvider({
responses: [JSON.stringify(orphan), JSON.stringify(baseValid)],
});
const outcome = await generateInsight(provider, {
systemPrompt: "system",
userPrompt: "user",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "system", user: "user" }),
);
expect(outcome.retried).toBe(true);
expect(outcome.parsed.recommendations[0].id).toBe("rec-1");
});
@@ -284,10 +285,10 @@ describe("citation-from-data — wrapper end-to-end", () => {
const provider = new MockAIProvider({
responses: [JSON.stringify(orphan), JSON.stringify(droppedRecs)],
});
const outcome = await generateInsight(provider, {
systemPrompt: "system",
userPrompt: "user",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "system", user: "user" }),
);
expect(outcome.retried).toBe(true);
expect(outcome.parsed.recommendations).toHaveLength(0);
});
+97 -23
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { CodexClient } from "../codex-client";
import { singleUserTurn } from "../types";
/**
* Build a `Response` whose `.body` streams a list of SSE event strings.
@@ -57,10 +58,9 @@ describe("CodexClient", () => {
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
const result = await client.generateCompletion({
systemPrompt: "You are a doctor.",
userPrompt: "Analyze this.",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "You are a doctor.", user: "Analyze this." }),
);
expect(result.content).toBe('{"summary":"ok"}');
expect(result.providerType).toBe("codex");
@@ -103,10 +103,9 @@ describe("CodexClient", () => {
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
const result = await client.generateCompletion({
systemPrompt: "test",
userPrompt: "test",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "test", user: "test" }),
);
expect(result.content).toBe("Hello world");
});
@@ -135,10 +134,9 @@ describe("CodexClient", () => {
},
});
const result = await client.generateCompletion({
systemPrompt: "test",
userPrompt: "test",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "test", user: "test" }),
);
expect(onRefresh).toHaveBeenCalledOnce();
expect(result.content).toBe('{"test":true}');
@@ -167,7 +165,9 @@ describe("CodexClient", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "test", userPrompt: "test" }),
client.generateCompletion(
singleUserTurn({ system: "test", user: "test" }),
),
).rejects.toThrow("Codex request failed after token refresh (401)");
});
@@ -192,7 +192,9 @@ describe("CodexClient", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "test", userPrompt: "test" }),
client.generateCompletion(
singleUserTurn({ system: "test", user: "test" }),
),
).rejects.toThrow("Codex request failed (500)");
expect(onRefresh).not.toHaveBeenCalled();
});
@@ -213,11 +215,13 @@ describe("CodexClient", () => {
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
await client.generateCompletion({
systemPrompt: "Transcribe.",
userPrompt: "Read this report.",
images: [{ mediaType: "image/png", dataBase64: "QUJD" }],
});
await client.generateCompletion(
singleUserTurn({
system: "Transcribe.",
user: "Read this report.",
images: [{ mediaType: "image/png", dataBase64: "QUJD" }],
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
const content = body.input[0].content;
@@ -248,14 +252,84 @@ describe("CodexClient", () => {
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
await client.generateCompletion({
systemPrompt: "Answer.",
userPrompt: "Plain text only.",
});
await client.generateCompletion(
singleUserTurn({ system: "Answer.", user: "Plain text only." }),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.input[0].content).toEqual([
{ type: "input_text", text: "Plain text only." },
]);
});
it("maps tool defs and parses function_call items + cached tokens", async () => {
const functionCallEvent = `event: response.output_item.done\ndata: ${JSON.stringify(
{
type: "response.output_item.done",
item: {
type: "function_call",
call_id: "fc_1",
name: "fetch_glucose",
arguments: '{"window":"last30days"}',
},
},
)}\n\n`;
const completedWithCache = `event: response.completed\ndata: ${JSON.stringify(
{
type: "response.completed",
response: {
id: "resp1",
usage: {
total_tokens: 30,
input_tokens_details: { cached_tokens: 20 },
},
},
},
)}\n\n`;
const mockFetch = vi
.fn()
.mockResolvedValue(sseResponse([functionCallEvent, completedWithCache]));
vi.stubGlobal("fetch", mockFetch);
const client = new CodexClient({
accessToken: "test-token",
accountId: "acct-test",
onTokenRefresh: vi
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
const result = await client.generateCompletion(
singleUserTurn({
system: "s",
user: "u",
tools: [
{
name: "fetch_glucose",
description: "Fetch glucose readings",
parameters: { type: "object", properties: {} },
},
],
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.tools).toEqual([
{
type: "function",
name: "fetch_glucose",
description: "Fetch glucose readings",
parameters: { type: "object", properties: {} },
},
]);
expect(result.toolCalls).toEqual([
{
id: "fc_1",
name: "fetch_glucose",
arguments: '{"window":"last30days"}',
},
]);
expect(result.finishReason).toBe("tool_calls");
expect(result.cachedInputTokens).toBe(20);
});
});
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { CodexClient, __test } from "../codex-client";
import { singleUserTurn } from "../types";
import {
clearCodexSlugCache,
getCachedCodexSlug,
@@ -94,7 +95,7 @@ describe("Codex slug fallback chain", () => {
onTokenRefresh,
slugChain: ["gpt-5.3-codex", "gpt-5-codex", "gpt-4o"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
expect(mockFetch).toHaveBeenCalledTimes(1);
const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
@@ -121,7 +122,7 @@ describe("Codex slug fallback chain", () => {
onTokenRefresh,
slugChain: ["gpt-5", "gpt-5.3-codex", "gpt-4o"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(JSON.parse(mockFetch.mock.calls[0][1].body).model).toBe("gpt-5");
@@ -155,7 +156,7 @@ describe("Codex slug fallback chain", () => {
onTokenRefresh,
slugChain: ["nonexistent-slug", "gpt-5.3-codex"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(client.getLastDiagnostics()?.workingSlug).toBe("gpt-5.3-codex");
@@ -174,7 +175,7 @@ describe("Codex slug fallback chain", () => {
onTokenRefresh,
slugChain: ["dead-slug", "gpt-5.3-codex"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
expect(mockFetch).toHaveBeenCalledTimes(2);
});
@@ -193,7 +194,7 @@ describe("Codex slug fallback chain", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
client.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow("Codex request failed (500)");
expect(mockFetch).toHaveBeenCalledTimes(1); // no walk
@@ -213,7 +214,7 @@ describe("Codex slug fallback chain", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
client.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow("Codex request failed (429)");
expect(mockFetch).toHaveBeenCalledTimes(1);
@@ -235,7 +236,7 @@ describe("Codex slug fallback chain", () => {
slugChain: ["gpt-5.3-codex", "gpt-4o"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
// Both calls hit the SAME slug — auth retry didn't walk to gpt-4o.
expect(JSON.parse(mockFetch.mock.calls[0][1].body).model).toBe(
@@ -263,7 +264,9 @@ describe("Codex slug fallback chain", () => {
let caught: unknown;
try {
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(
singleUserTurn({ system: "s", user: "u" }),
);
} catch (e) {
caught = e;
}
@@ -291,7 +294,7 @@ describe("Codex slug fallback chain", () => {
onTokenRefresh,
slugChain: ["gpt-5.3-codex", "gpt-5-codex", "gpt-4o"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(JSON.parse(mockFetch.mock.calls[0][1].body).model).toBe("gpt-4o");
@@ -314,7 +317,7 @@ describe("Codex slug fallback chain", () => {
onTokenRefresh,
slugChain: ["gpt-5.3-codex", "gpt-5-codex"],
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
// After walking to "gpt-5.3-codex" (the next entry after the
// cached one), the cache is updated to the new working slug.
+25 -24
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
import { singleUserTurn } from "../types";
/**
* v1.4.16 phase B5d — wrapper post-validation override.
@@ -55,10 +56,10 @@ describe("generateInsight() — confidence override", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(validResponseWithModelConfidence),
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "user",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "user" }),
);
const rec = outcome.parsed.recommendations[0];
// Model claimed 99; wrapper must have replaced it.
// n=12, recencyDays=0 (default), ratio=null (default) →
@@ -83,10 +84,10 @@ describe("generateInsight() — confidence override", () => {
// strip the undefined to emulate the model not emitting the key
const stringified = JSON.stringify(noConfidence);
const provider = new MockAIProvider({ responses: stringified });
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "u",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "u" }),
);
expect(outcome.parsed.recommendations[0].confidence).toBe(66);
});
@@ -111,10 +112,10 @@ describe("generateInsight() — confidence override", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(fourteenN),
});
const outcome = await generateInsight(provider, {
systemPrompt: "s",
userPrompt: "u",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "s", user: "u" }),
);
// n=14, recencyDays=0, ratio=null → 10 + 10*log10(14) + 30 + 15 ≈ 66.46
expect(outcome.parsed.recommendations[0].confidence).toBe(66);
});
@@ -133,10 +134,10 @@ describe("generateInsight() — confidence override", () => {
],
};
const provider = new MockAIProvider({ responses: JSON.stringify(tinyN) });
const outcome = await generateInsight(provider, {
systemPrompt: "s",
userPrompt: "u",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "s", user: "u" }),
);
expect(outcome.parsed.recommendations[0].confidence).toBeLessThanOrEqual(
15,
);
@@ -158,10 +159,10 @@ describe("generateInsight() — confidence override", () => {
],
};
const provider = new MockAIProvider({ responses: JSON.stringify(noN) });
const outcome = await generateInsight(provider, {
systemPrompt: "s",
userPrompt: "u",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "s", user: "u" }),
);
// n=0 → hard-cap floor 10
expect(outcome.parsed.recommendations[0].confidence).toBe(10);
});
@@ -189,10 +190,10 @@ describe("generateInsight() — confidence override", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(twoRecs),
});
const outcome = await generateInsight(provider, {
systemPrompt: "s",
userPrompt: "u",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "s", user: "u" }),
);
// Model claimed (99, 1); both must be discarded and replaced with
// the deterministic value (n=12 → 66, n=5 → 62 from the saturating
// formula 10 + 10*log10(5) + 30 + 15 ≈ 61.99).
+35 -19
View File
@@ -6,6 +6,22 @@ import {
} from "../schema";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
import { singleUserTurn, type CompletionParams } from "../types";
/** Read the last user message's text out of a recorded CompletionParams. */
function lastUserText(params: CompletionParams): string {
for (let i = params.messages.length - 1; i >= 0; i -= 1) {
const m = params.messages[i];
if (m.role !== "user") continue;
return typeof m.content === "string"
? m.content
: m.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("");
}
return "";
}
const validResponse = {
summary: "Your blood pressure is trending high.",
@@ -143,10 +159,10 @@ describe("generateInsight wrapper", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(validResponse),
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "user",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "user" }),
);
expect(outcome.attempts).toBe(1);
expect(outcome.retried).toBe(false);
expect(outcome.parsed.summary).toMatch(/blood pressure/);
@@ -157,16 +173,16 @@ describe("generateInsight wrapper", () => {
const provider = new MockAIProvider({
responses: ["definitely not json", JSON.stringify(validResponse)],
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "Original user prompt.",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "Original user prompt." }),
);
expect(outcome.attempts).toBe(2);
expect(outcome.retried).toBe(true);
expect(provider.callCount).toBe(2);
// The retry call must include the original user prompt PLUS the
// corrective suffix referencing the schema.
const retryUserPrompt = provider.calls[1].userPrompt;
const retryUserPrompt = lastUserText(provider.calls[1]);
expect(retryUserPrompt).toContain("Original user prompt.");
expect(retryUserPrompt).toContain("did not satisfy");
expect(retryUserPrompt).toContain("metricSource");
@@ -178,10 +194,10 @@ describe("generateInsight wrapper", () => {
});
let caught: unknown;
try {
await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "user",
});
await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "user" }),
);
} catch (e) {
caught = e;
}
@@ -220,13 +236,13 @@ describe("generateInsight wrapper", () => {
JSON.stringify(validResponse),
],
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "u",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "u" }),
);
expect(outcome.attempts).toBe(2);
expect(outcome.retried).toBe(true);
expect(provider.calls[1].userPrompt).toContain(
expect(lastUserText(provider.calls[1])).toContain(
"metricSources not in citations",
);
});
@@ -238,7 +254,7 @@ describe("generateInsight wrapper", () => {
}),
});
await expect(
generateInsight(provider, { systemPrompt: "s", userPrompt: "u" }),
generateInsight(provider, singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow("OpenAI request failed (500)");
// Provider only called once — wrapper does not retry on provider errors.
expect(provider.callCount).toBe(1);
@@ -8,6 +8,7 @@ import {
import { aiInsightResponseSchema, findUncitedRecommendations } from "../schema";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
import { singleUserTurn } from "../types";
/**
* Phase C1 — scope-hardened system prompt assertions.
@@ -130,13 +131,15 @@ describe("out-of-scope refusal payloads", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(OUT_OF_SCOPE_REFUSAL_EN),
});
const outcome = await generateInsight(provider, {
systemPrompt: getStrictInsightsSystemPrompt("en"),
// Deliberately out-of-scope user prompt — model is instructed to
// return the refusal rather than fabricate health metrics.
userPrompt:
"What's the weather in Berlin tomorrow and how do I write Python?",
});
const outcome = await generateInsight(
provider,
singleUserTurn({
system: getStrictInsightsSystemPrompt("en"),
// Deliberately out-of-scope user prompt — model is instructed to
// return the refusal rather than fabricate health metrics.
user: "What's the weather in Berlin tomorrow and how do I write Python?",
}),
);
expect(outcome.attempts).toBe(1);
expect(outcome.parsed.recommendations).toEqual([]);
expect(outcome.parsed.summary).toContain("only summarise");
+50 -14
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { LocalOpenAICompatibleClient } from "../local-client";
import { singleUserTurn } from "../types";
describe("LocalOpenAICompatibleClient", () => {
beforeEach(() => {
@@ -31,10 +32,9 @@ describe("LocalOpenAICompatibleClient", () => {
baseUrl: "http://localhost:11434/v1",
});
const result = await client.generateCompletion({
systemPrompt: "system",
userPrompt: "user-input",
});
const result = await client.generateCompletion(
singleUserTurn({ system: "system", user: "user-input" }),
);
expect(result.content).toBe('{"summary":"local"}');
expect(result.tokensUsed).toBe(17);
@@ -76,12 +76,14 @@ describe("LocalOpenAICompatibleClient", () => {
baseUrl: "http://localhost:11434/v1",
});
await client.generateCompletion({
systemPrompt: "s",
userPrompt: "u",
responseFormat: "json",
seed: 99,
});
await client.generateCompletion(
singleUserTurn({
system: "s",
user: "u",
responseFormat: "json",
seed: 99,
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.format).toBe("json");
@@ -105,7 +107,7 @@ describe("LocalOpenAICompatibleClient", () => {
baseUrl: "http://localhost:11434/v1",
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
const init = mockFetch.mock.calls[0][1];
expect(init.headers.Authorization).toBeUndefined();
@@ -128,7 +130,7 @@ describe("LocalOpenAICompatibleClient", () => {
baseUrl: "http://192.168.1.20:1234/v1",
});
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
const init = mockFetch.mock.calls[0][1];
expect(init.headers.Authorization).toBe("Bearer lm-studio-secret");
@@ -155,7 +157,9 @@ describe("LocalOpenAICompatibleClient", () => {
let caught: unknown;
try {
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
await client.generateCompletion(
singleUserTurn({ system: "s", user: "u" }),
);
} catch (e) {
caught = e;
}
@@ -192,7 +196,39 @@ describe("LocalOpenAICompatibleClient", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
client.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow("Local AI returned empty content");
});
it("degrades tools silently — never forwards a `tools` field", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "{}" } }],
usage: { total_tokens: 1 },
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new LocalOpenAICompatibleClient({
apiKey: null,
model: "llama3",
baseUrl: "http://localhost:11434/v1",
});
expect(client.supportsTools).toBe(false);
await client.generateCompletion(
singleUserTurn({
system: "s",
user: "u",
tools: [{ name: "t", description: "d", parameters: {} }],
toolChoice: "auto",
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body).not.toHaveProperty("tools");
expect(body).not.toHaveProperty("tool_choice");
});
});
+126 -14
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { OpenAIClient } from "../openai-client";
import { singleUserTurn } from "../types";
describe("OpenAIClient", () => {
beforeEach(() => {
@@ -23,10 +24,12 @@ describe("OpenAIClient", () => {
baseUrl: "https://api.openai.com/v1",
});
const result = await client.generateCompletion({
systemPrompt: "You are a doctor.",
userPrompt: "Analyze this data.",
});
const result = await client.generateCompletion(
singleUserTurn({
system: "You are a doctor.",
user: "Analyze this data.",
}),
);
expect(result.content).toBe('{"summary":"test"}');
expect(result.tokensUsed).toBe(42);
@@ -60,11 +63,9 @@ describe("OpenAIClient", () => {
baseUrl: "https://api.openai.com/v1",
});
await client.generateCompletion({
systemPrompt: "test",
userPrompt: "test",
seed: 1234,
});
await client.generateCompletion(
singleUserTurn({ system: "test", user: "test", seed: 1234 }),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.seed).toBe(1234);
@@ -87,7 +88,9 @@ describe("OpenAIClient", () => {
});
await expect(
client.generateCompletion({ systemPrompt: "test", userPrompt: "test" }),
client.generateCompletion(
singleUserTurn({ system: "test", user: "test" }),
),
).rejects.toThrow("OpenAI request failed (429)");
});
@@ -108,13 +111,122 @@ describe("OpenAIClient", () => {
baseUrl: "https://openrouter.ai/api/v1",
});
await client.generateCompletion({
systemPrompt: "test",
userPrompt: "test",
});
await client.generateCompletion(
singleUserTurn({ system: "test", user: "test" }),
);
expect(mockFetch.mock.calls[0][0]).toBe(
"https://openrouter.ai/api/v1/chat/completions",
);
});
it("maps tool defs onto the function wire and parses tool_calls", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [
{
message: {
content: null,
tool_calls: [
{
id: "call_1",
function: {
name: "fetch_glucose",
arguments: '{"window":"last30days"}',
},
},
],
},
finish_reason: "tool_calls",
},
],
usage: {
total_tokens: 12,
prompt_tokens_details: { cached_tokens: 8 },
},
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new OpenAIClient({
apiKey: "sk-test",
model: "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
const result = await client.generateCompletion(
singleUserTurn({
system: "s",
user: "u",
tools: [
{
name: "fetch_glucose",
description: "Fetch glucose readings",
parameters: { type: "object", properties: {} },
},
],
toolChoice: "auto",
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.tools).toEqual([
{
type: "function",
function: {
name: "fetch_glucose",
description: "Fetch glucose readings",
parameters: { type: "object", properties: {} },
},
},
]);
expect(body.tool_choice).toBe("auto");
expect(result.toolCalls).toEqual([
{
id: "call_1",
name: "fetch_glucose",
arguments: '{"window":"last30days"}',
},
]);
expect(result.finishReason).toBe("tool_calls");
expect(result.cachedInputTokens).toBe(8);
});
it("folds image parts into the multimodal content array", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '{"rows":[]}' } }],
usage: { total_tokens: 3 },
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new OpenAIClient({
apiKey: "sk-test",
model: "gpt-4o",
baseUrl: "https://api.openai.com/v1",
});
await client.generateCompletion(
singleUserTurn({
system: "s",
user: "transcribe",
images: [{ mediaType: "image/png", dataBase64: "AAAA" }],
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
const userTurn = body.messages[1];
expect(userTurn.role).toBe("user");
// Text-first, then image (matches the pre-refactor OpenAI vision wire).
expect(userTurn.content[0]).toEqual({ type: "text", text: "transcribe" });
expect(userTurn.content[1]).toEqual({
type: "image_url",
image_url: { url: "data:image/png;base64,AAAA" },
});
});
});
+35 -35
View File
@@ -4,7 +4,11 @@ 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";
import {
singleUserTurn,
type AIProvider,
type CompletionResult,
} from "../types";
/**
* Provider-contract tests: every concrete AIProvider implementation must
@@ -44,10 +48,9 @@ describe("AIProvider contract", () => {
it("MockAIProvider — default response satisfies CompletionResult shape", async () => {
const provider: AIProvider = new MockAIProvider();
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
const result = await provider.generateCompletion(
singleUserTurn({ system: "sys", user: "user" }),
);
assertCompletionResult(result);
expect(result.providerType).toBe("local");
expect(result.model).toBe("mock-model");
@@ -59,18 +62,15 @@ describe("AIProvider contract", () => {
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",
});
const r1 = await mock.generateCompletion(
singleUserTurn({ system: "s", user: "u1" }),
);
const r2 = await mock.generateCompletion(
singleUserTurn({ system: "s", user: "u2" }),
);
const r3 = await mock.generateCompletion(
singleUserTurn({ system: "s", user: "u3" }),
);
expect(r1.content).toBe('{"a":1}');
expect(r1.tokensUsed).toBe(10);
expect(r2.content).toBe('{"a":2}');
@@ -78,13 +78,17 @@ describe("AIProvider contract", () => {
// 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"]);
expect(mock.calls.map((c) => c.messages[0].content)).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" }),
mock.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
).rejects.toThrow("boom");
});
@@ -106,10 +110,9 @@ describe("AIProvider contract", () => {
model: "gpt-4o-mini",
baseUrl: "https://api.openai.com/v1",
});
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
const result = await provider.generateCompletion(
singleUserTurn({ system: "sys", user: "user" }),
);
assertCompletionResult(result);
expect(result.providerType).toBe("admin-key");
expect(result.model).toBe("gpt-4o-mini");
@@ -132,10 +135,9 @@ describe("AIProvider contract", () => {
apiKey: "sk-ant-test",
model: "claude-3-5-sonnet-latest",
});
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
const result = await provider.generateCompletion(
singleUserTurn({ system: "sys", user: "user" }),
);
assertCompletionResult(result);
expect(result.providerType).toBe("anthropic");
expect(result.tokensUsed).toBe(14);
@@ -158,10 +160,9 @@ describe("AIProvider contract", () => {
model: "llama3:8b",
baseUrl: "http://localhost:11434/v1",
});
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
const result = await provider.generateCompletion(
singleUserTurn({ system: "sys", user: "user" }),
);
assertCompletionResult(result);
expect(result.providerType).toBe("local");
});
@@ -205,10 +206,9 @@ describe("AIProvider contract", () => {
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
const result = await provider.generateCompletion({
systemPrompt: "sys",
userPrompt: "user",
});
const result = await provider.generateCompletion(
singleUserTurn({ system: "sys", user: "user" }),
);
assertCompletionResult(result);
expect(result.providerType).toBe("codex");
expect(result.tokensUsed).toBe(23);
+28 -23
View File
@@ -1,5 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AIProvider, CompletionParams, CompletionResult } from "../types";
import {
singleUserTurn,
type AIProvider,
type CompletionParams,
type CompletionResult,
} from "../types";
// v1.11.0 W1 — the runner now defaults to the Postgres-backed
// provider-health ledger. These pure chain tests do not stand up a DB,
@@ -139,7 +144,7 @@ describe("runWithFallback — happy path", () => {
const result = await runWithFallback({
userId: "u1",
providers: [{ providerType: "codex", instance: codex }],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(result.parsed.summary).toBe("ok");
expect(result.raw.providerType).toBe("codex");
@@ -155,7 +160,7 @@ describe("runWithFallback — happy path", () => {
const result = await runWithFallback({
userId: "u1",
providers: [{ providerType: "openai", instance: openai }],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(result.workingProvider.providerType).toBe("openai");
expect(result.raw.providerType).toBe("admin-key");
@@ -178,7 +183,7 @@ describe("runWithFallback — primary fails 401, secondary succeeds", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(result.parsed.summary).toBe("ok");
expect(result.workingProvider.providerType).toBe("openai");
@@ -209,7 +214,7 @@ describe("runWithFallback — all-fail cascade", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
} catch (e) {
caught = e;
@@ -250,7 +255,7 @@ describe("runWithFallback — non-hard errors bubble unchanged", () => {
{ providerType: "codex", instance: breaking },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
} catch (e) {
caught = e;
@@ -281,7 +286,7 @@ describe("runWithFallback — last-working cache", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(getLastWorkingProvider("u-cache")).toBe("openai");
@@ -294,7 +299,7 @@ describe("runWithFallback — last-working cache", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
// codex was called exactly once (first call's failure); the cache
@@ -324,7 +329,7 @@ describe("runWithFallback — last-working cache", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(getLastWorkingProvider("u-ttl")).toBe("openai");
@@ -340,7 +345,7 @@ describe("runWithFallback — last-working cache", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(getLastWorkingProvider("u-ttl")).toBe("codex");
});
@@ -353,7 +358,7 @@ describe("runWithFallback — last-working cache", () => {
await runWithFallback({
userId: "u-clear",
providers: [{ providerType: "openai", instance: ok }],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(getLastWorkingProvider("u-clear")).toBe("openai");
clearLastWorkingProviderCache();
@@ -377,7 +382,7 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(result.result.content).toBe('{ "legacy": true }');
expect(result.workingProvider.providerType).toBe("admin-openai");
@@ -404,7 +409,7 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
{ providerType: "codex", instance: codex },
{ providerType: "openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
expect(result.result.content).toBe('{ "ok": true }');
expect(result.workingProvider.providerType).toBe("openai");
@@ -432,7 +437,7 @@ describe("runRawCompletionWithFallback — legacy route shim", () => {
{ providerType: "codex", instance: a },
{ providerType: "admin-openai", instance: b },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
} catch (e) {
caught = e;
@@ -449,7 +454,7 @@ describe("runWithFallback — empty input", () => {
await runWithFallback({
userId: "u-empty",
providers: [],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
});
} catch (e) {
caught = e;
@@ -483,7 +488,7 @@ describe("provider-health ledger — auth-failure negative cache", () => {
await runRawCompletionWithFallback({
userId: "u-neg",
providers,
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
expect((await ledger.getSkipHints("u-neg")).get("codex")?.reason).toBe(
@@ -500,7 +505,7 @@ describe("provider-health ledger — auth-failure negative cache", () => {
await runRawCompletionWithFallback({
userId: "u-neg",
providers,
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
expect(codex.callCount).toBe(codexCallsBefore); // not re-burned
@@ -519,7 +524,7 @@ describe("provider-health ledger — auth-failure negative cache", () => {
await runRawCompletionWithFallback({
userId: "u-clear-neg",
providers: [{ providerType: "codex", instance: codex }],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
expect((await ledger.getSkipHints("u-clear-neg")).size).toBe(0);
@@ -559,7 +564,7 @@ describe("provider-health ledger — local model as guaranteed floor", () => {
{ providerType: "anthropic", instance: dead("anthropic") },
{ providerType: "local", instance: local },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
@@ -583,7 +588,7 @@ describe("provider-health ledger — local model as guaranteed floor", () => {
await runRawCompletionWithFallback({
userId: "u-local-hard",
providers: [{ providerType: "local", instance: local }],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
} catch (e) {
@@ -597,7 +602,7 @@ describe("provider-health ledger — local model as guaranteed floor", () => {
const result = await runRawCompletionWithFallback({
userId: "u-local-hard",
providers: [{ providerType: "local", instance: local }],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
expect(result.workingProvider.providerType).toBe("local");
@@ -623,7 +628,7 @@ describe("AllProvidersFailedError — primaryCredentialExpired", () => {
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
} catch (e) {
@@ -651,7 +656,7 @@ describe("AllProvidersFailedError — primaryCredentialExpired", () => {
{ providerType: "codex", instance: codex },
{ providerType: "admin-openai", instance: openai },
],
params: { systemPrompt: "s", userPrompt: "u" },
params: singleUserTurn({ system: "s", user: "u" }),
ledger,
});
} catch (e) {
+18 -17
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { generateInsight } from "../generate-insight";
import { MockAIProvider } from "../mock-client";
import { singleUserTurn } from "../types";
const annotateMock = vi.fn();
@@ -75,13 +76,13 @@ describe("generateInsight() — corrective retry mentions rationale", () => {
const provider = new MockAIProvider({
responses: [JSON.stringify(broken), JSON.stringify(validResponse)],
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "Original user prompt.",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "Original user prompt." }),
);
expect(outcome.attempts).toBe(2);
expect(outcome.retried).toBe(true);
const retryUserPrompt = provider.calls[1].userPrompt;
const retryUserPrompt = provider.calls[1].messages[0].content as string;
expect(retryUserPrompt).toContain("Original user prompt.");
expect(retryUserPrompt).toContain("rationale");
expect(retryUserPrompt).toContain("dataWindow");
@@ -108,10 +109,10 @@ describe("generateInsight() — corrective retry mentions rationale", () => {
const provider = new MockAIProvider({
responses: [JSON.stringify(broken), JSON.stringify(validResponse)],
});
const outcome = await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "user",
});
const outcome = await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "user" }),
);
expect(outcome.attempts).toBe(2);
expect(outcome.retried).toBe(true);
});
@@ -126,10 +127,10 @@ describe("generateInsight() — rationale-coverage annotation", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(validResponse),
});
await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "user",
});
await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "user" }),
);
expect(annotateMock).toHaveBeenCalled();
const meta = annotateMock.mock.calls[0][0]?.meta as Record<string, unknown>;
expect(meta).toMatchObject({
@@ -157,10 +158,10 @@ describe("generateInsight() — rationale-coverage annotation", () => {
const provider = new MockAIProvider({
responses: JSON.stringify(twoRecs),
});
await generateInsight(provider, {
systemPrompt: "sys",
userPrompt: "user",
});
await generateInsight(
provider,
singleUserTurn({ system: "sys", user: "user" }),
);
const meta = annotateMock.mock.calls[0][0]?.meta as Record<string, unknown>;
expect(meta).toMatchObject({
ai_rationale_total_recommendations: 2,
+231 -72
View File
@@ -1,5 +1,13 @@
import { safeFetch } from "@/lib/safe-fetch";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import type {
AIProvider,
AiContentPart,
AiMessage,
AiToolCall,
AiToolDef,
CompletionParams,
CompletionResult,
} from "./types";
/**
* v1.18.9 — Anthropic Messages API content blocks. The text-only path sends a
@@ -7,6 +15,10 @@ import type { AIProvider, CompletionParams, CompletionResult } from "./types";
* uploaded image / PDF is framed strictly as DATA to transcribe by the system
* prompt — never as instructions (prompt-injection backstop is the human
* review screen downstream).
*
* v1.20.0 — adds the `tool_use` (assistant requests a tool) and `tool_result`
* (a `role:"user"` turn answering one) blocks so a multi-round tool loop (F1)
* round-trips natively.
*/
type AnthropicContentBlock =
| { type: "text"; text: string }
@@ -17,45 +29,22 @@ type AnthropicContentBlock =
| {
type: "document";
source: { type: "base64"; media_type: "application/pdf"; data: string };
}
| {
type: "tool_use";
id: string;
name: string;
input: Record<string, unknown>;
}
| {
type: "tool_result";
tool_use_id: string;
content: string;
};
/**
* Build the user-turn content for a vision request: the image / document
* blocks first (so the model reads the report before the instruction), then
* the JSON-wrapped instruction text. Returns null when there is nothing to
* attach, so the caller keeps the bare-string text path unchanged.
*/
function buildVisionContent(
params: CompletionParams,
text: string,
): AnthropicContentBlock[] | null {
const images = params.images ?? [];
const documents = params.documents ?? [];
if (images.length === 0 && documents.length === 0) return null;
const blocks: AnthropicContentBlock[] = [];
for (const img of images) {
blocks.push({
type: "image",
source: {
type: "base64",
media_type: img.mediaType,
data: img.dataBase64,
},
});
}
for (const doc of documents) {
blocks.push({
type: "document",
source: {
type: "base64",
media_type: "application/pdf",
data: doc.dataBase64,
},
});
}
blocks.push({ type: "text", text });
return blocks;
interface AnthropicWireMessage {
role: "user" | "assistant";
content: string | AnthropicContentBlock[];
}
interface AnthropicClientConfig {
@@ -67,13 +56,99 @@ interface AnthropicClientConfig {
const DEFAULT_BASE_URL = "https://api.anthropic.com/v1";
const ANTHROPIC_VERSION = "2023-06-01";
const JSON_INSTRUCTION =
"\n\nRespond only with valid JSON matching the requested schema. Do not include any prose, markdown fences, or explanation outside the JSON object.";
/**
* Wrap the user prompt so Claude reliably emits valid JSON.
* Anthropic's Messages API has no `response_format: json_object` knob; the
* documented best-practice is an explicit instruction in the user message.
* Map an `AiContentPart[]` body into Anthropic content blocks. The media blocks
* (image / document) are emitted FIRST and the text blocks LAST so the model
* reads the report before the instruction — matching the pre-refactor
* `buildVisionContent` ordering exactly.
*/
function wrapForJson(userPrompt: string): string {
return `${userPrompt}\n\nRespond only with valid JSON matching the requested schema. Do not include any prose, markdown fences, or explanation outside the JSON object.`;
function mapParts(parts: AiContentPart[]): AnthropicContentBlock[] {
const media: AnthropicContentBlock[] = [];
const text: AnthropicContentBlock[] = [];
for (const part of parts) {
if (part.type === "text") {
text.push({ type: "text", text: part.text });
} else if (part.type === "image") {
media.push({
type: "image",
source: {
type: "base64",
media_type: part.mediaType,
data: part.dataBase64,
},
});
} else if (part.type === "document") {
media.push({
type: "document",
source: {
type: "base64",
media_type: "application/pdf",
data: part.dataBase64,
},
});
}
}
return [...media, ...text];
}
/**
* Map one `AiMessage` to its Anthropic wire turn. A `role:"tool"` turn becomes
* a `role:"user"` turn carrying a `tool_result` block (Anthropic's convention).
* An assistant turn with `toolCalls` emits `tool_use` blocks alongside any text.
*/
function mapMessage(m: AiMessage): AnthropicWireMessage {
if (m.role === "tool") {
return {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: m.toolCallId ?? "",
content: typeof m.content === "string" ? m.content : "",
},
],
};
}
const hasToolCalls =
m.role === "assistant" && !!m.toolCalls && m.toolCalls.length > 0;
// Plain-string content with no tool calls stays a bare string on the wire —
// byte-identical to the pre-refactor text-only path. Only build a block array
// when there are content parts or tool_use blocks to carry.
if (typeof m.content === "string" && !hasToolCalls) {
return { role: m.role, content: m.content };
}
const blocks: AnthropicContentBlock[] =
typeof m.content === "string"
? m.content.length > 0
? [{ type: "text", text: m.content }]
: []
: mapParts(m.content);
if (hasToolCalls) {
for (const tc of m.toolCalls!) {
let input: Record<string, unknown> = {};
try {
input = JSON.parse(tc.arguments) as Record<string, unknown>;
} catch {
input = {};
}
blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input });
}
}
return { role: m.role, content: blocks };
}
/** Map tool defs into the Anthropic `tools` array. */
function buildAnthropicTools(tools: AiToolDef[]) {
return tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.parameters,
}));
}
export class AnthropicClient implements AIProvider {
@@ -93,14 +168,71 @@ export class AnthropicClient implements AIProvider {
);
const url = `${baseUrl}/messages`;
// v1.18.9 — fold vision inputs (Lab-OCR) into the user turn when present.
// The text-only path keeps a bare-string content; the vision path sends a
// typed content array (image / document blocks + the instruction text).
// The `{`-prefill JSON trick works after either shape.
const wrappedText = wrapForJson(params.userPrompt);
const visionContent = buildVisionContent(params, wrappedText);
const userContent: AnthropicContentBlock[] | string =
visionContent ?? wrappedText;
const hasTools = !!params.tools && params.tools.length > 0;
// v1.20.0 — the `{`-prefill JSON trick injects an assistant turn that is
// incompatible with a `tool_use` response in the same call, so tools and
// the prefill are mutually exclusive: when tools are present we drop the
// prefill (the tool flow is non-JSON-prefill).
const usePrefill = params.responseFormat === "json" && !hasTools;
const wireMessages: AnthropicWireMessage[] =
params.messages.map(mapMessage);
// JSON-reliability instruction. The pre-refactor client appended this to
// the user prompt UNCONDITIONALLY (not gated on `responseFormat`), so every
// Anthropic caller — including the Coach prose path — saw it. Preserve that
// exactly by appending to the LAST user turn's text. Tools imply the
// non-JSON tool flow, so skip the instruction when tools are present.
if (!hasTools) {
for (let i = wireMessages.length - 1; i >= 0; i -= 1) {
if (wireMessages[i].role !== "user") continue;
const turn = wireMessages[i];
if (typeof turn.content === "string") {
turn.content = `${turn.content}${JSON_INSTRUCTION}`;
} else {
// Merge into the trailing text block when there is one (vision turns
// end with the instruction text) so the wire stays a single text
// block — byte-identical to the pre-refactor `wrapForJson` output.
const last = turn.content[turn.content.length - 1];
if (last && last.type === "text") {
last.text = `${last.text}${JSON_INSTRUCTION}`;
} else {
turn.content = [
...turn.content,
{ type: "text", text: JSON_INSTRUCTION },
];
}
}
break;
}
}
if (usePrefill) {
wireMessages.push({ role: "assistant", content: "{" });
}
// v1.20.0 — prompt-cache the stable system prefix. Anthropic needs an
// explicit `cache_control` marker; the system block carries the large
// brand-free reference grounding + persona, so marking it lets repeated
// calls (status batch, briefing) read it from cache. Surface
// `cache_read_input_tokens` for observability.
const system = [
{
type: "text" as const,
text: params.system,
cache_control: { type: "ephemeral" as const },
},
];
const tools = hasTools
? buildAnthropicTools(params.tools as AiToolDef[])
: undefined;
const toolChoice =
hasTools && params.toolChoice
? params.toolChoice === "none"
? { type: "none" as const }
: { type: "auto" as const }
: undefined;
const res = await safeFetch(
url,
@@ -114,26 +246,14 @@ export class AnthropicClient implements AIProvider {
// NOTE: Anthropic's Messages API has no `seed` parameter, so
// `params.seed` is intentionally not forwarded here — output on this
// provider is non-deterministic regardless of the pinned seed.
//
// v1.18.7 — JSON-reliability prefill. For the structured surfaces
// (`responseFormat: "json"`) we seed the assistant turn with a bare
// `{`. Anthropic continues from that token, so the first emitted
// character is already inside a JSON object — this drops the
// first-pass "prose preamble before the JSON" failure the
// fence-stripping net otherwise has to catch. We re-prepend the `{`
// to the returned text below so the caller sees a complete object.
body: JSON.stringify({
model: this.config.model,
max_tokens: params.maxTokens ?? 1000,
temperature: params.temperature ?? 0.3,
system: params.systemPrompt,
messages:
params.responseFormat === "json"
? [
{ role: "user", content: userContent },
{ role: "assistant", content: "{" },
]
: [{ role: "user", content: userContent }],
system,
messages: wireMessages,
...(tools ? { tools } : {}),
...(toolChoice ? { tool_choice: toolChoice } : {}),
}),
},
// 60 s ceiling — see openai-client.ts for the rationale.
@@ -164,14 +284,40 @@ export class AnthropicClient implements AIProvider {
}
const json = (await res.json()) as {
content?: Array<{ type: string; text?: string }>;
usage?: { input_tokens?: number; output_tokens?: number };
content?: Array<{
type: string;
text?: string;
id?: string;
name?: string;
input?: Record<string, unknown>;
}>;
stop_reason?: string;
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
};
};
const textBlock = json.content?.find((c) => c.type === "text");
const rawText = textBlock?.text;
if (!rawText) {
// v1.20.0 — surface tool_use blocks for F1's loop.
const toolUseBlocks = (json.content ?? []).filter(
(c) => c.type === "tool_use",
);
const toolCalls: AiToolCall[] | undefined =
toolUseBlocks.length > 0
? toolUseBlocks.map((b) => ({
id: b.id ?? "",
name: b.name ?? "",
arguments: JSON.stringify(b.input ?? {}),
}))
: undefined;
// A tool_use-only reply carries no text — that is valid (F1). Only an empty
// reply with neither text NOR a tool call is an error.
if (!rawText && !toolCalls) {
throw new Error("Anthropic returned empty content");
}
@@ -180,19 +326,32 @@ export class AnthropicClient implements AIProvider {
// it so the caller parses a complete object. Guard against a model that
// (rarely) echoes the prefill itself.
const content =
params.responseFormat === "json" && !rawText.trimStart().startsWith("{")
usePrefill && rawText && !rawText.trimStart().startsWith("{")
? `{${rawText}`
: rawText;
: (rawText ?? "");
const inputTokens = json.usage?.input_tokens ?? 0;
const outputTokens = json.usage?.output_tokens ?? 0;
const tokensUsed = inputTokens + outputTokens || null;
const cachedInputTokens = json.usage?.cache_read_input_tokens ?? null;
const finishReason: CompletionResult["finishReason"] =
json.stop_reason === "tool_use"
? "tool_calls"
: json.stop_reason === "max_tokens"
? "length"
: json.stop_reason === "end_turn"
? "stop"
: undefined;
return {
content,
tokensUsed,
cachedInputTokens,
model: this.config.model,
providerType: "anthropic",
...(toolCalls ? { toolCalls } : {}),
finishReason,
};
}
}
+9 -6
View File
@@ -22,6 +22,7 @@
* Server-only — reads `@/lib/db` through the provider resolver.
*/
import { hasAnyConfiguredProvider, resolveProvider } from "@/lib/ai/provider";
import { singleUserTurn } from "@/lib/ai/types";
import {
buildDateKey,
getDailyTokenSpend,
@@ -151,12 +152,14 @@ export async function deriveClarifyingQuestions(
resolveLocale(locale),
snapshotJson,
);
const result = await provider.generateCompletion({
systemPrompt,
userPrompt,
temperature: 0.4,
maxTokens: QUESTIONS_MAX_TOKENS,
});
const result = await provider.generateCompletion(
singleUserTurn({
system: systemPrompt,
user: userPrompt,
temperature: 0.4,
maxTokens: QUESTIONS_MAX_TOKENS,
}),
);
if (typeof result.tokensUsed === "number" && result.tokensUsed > 0) {
await recordSpend({ userId, tokens: result.tokensUsed, dateKey });
}
+145 -25
View File
@@ -1,6 +1,14 @@
import { randomUUID } from "node:crypto";
import { safeFetch } from "@/lib/safe-fetch";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import type {
AIProvider,
AiContentPart,
AiMessage,
AiToolCall,
AiToolDef,
CompletionParams,
CompletionResult,
} from "./types";
import {
getCachedCodexSlug,
invalidateCachedCodexSlug,
@@ -108,6 +116,100 @@ function isSlugRejection(status: number, bodyExcerpt: string): boolean {
const ORIGINATOR = "healthlog";
const USER_AGENT = "HealthLog/1.0 (+https://github.com/MBombeck/HealthLog)";
/** A Responses-API input item. */
type CodexInputItem =
| {
type: "message";
role: "user" | "assistant";
content: Array<
| { type: "input_text"; text: string }
| { type: "input_image"; image_url: string; detail: "high" }
>;
}
| {
type: "function_call";
call_id: string;
name: string;
arguments: string;
}
| {
type: "function_call_output";
call_id: string;
output: string;
};
/** Map an `AiContentPart[]` body into Responses input content blocks. */
function mapCodexParts(
parts: AiContentPart[],
): Array<
| { type: "input_text"; text: string }
| { type: "input_image"; image_url: string; detail: "high" }
> {
const out: Array<
| { type: "input_text"; text: string }
| { type: "input_image"; image_url: string; detail: "high" }
> = [];
for (const part of parts) {
if (part.type === "text") {
out.push({ type: "input_text", text: part.text });
} else if (part.type === "image") {
out.push({
type: "input_image",
image_url: `data:${part.mediaType};base64,${part.dataBase64}`,
detail: "high",
});
}
// `document` (PDF) parts are not representable here (image-only wire).
}
return out;
}
/**
* Build the Responses-API `input` array from the message turns. Tool-call /
* tool-result turns map to the `function_call` / `function_call_output`
* top-level items the Responses API expects; everything else is a `message`.
* A text-only single user turn is byte-identical to the pre-refactor wire.
*/
function buildCodexInput(messages: AiMessage[]): CodexInputItem[] {
const items: CodexInputItem[] = [];
for (const m of messages) {
if (m.role === "tool") {
items.push({
type: "function_call_output",
call_id: m.toolCallId ?? "",
output: typeof m.content === "string" ? m.content : "",
});
continue;
}
const content =
typeof m.content === "string"
? [{ type: "input_text" as const, text: m.content }]
: mapCodexParts(m.content);
items.push({ type: "message", role: m.role, content });
if (m.role === "assistant" && m.toolCalls) {
for (const tc of m.toolCalls) {
items.push({
type: "function_call",
call_id: tc.id,
name: tc.name,
arguments: tc.arguments,
});
}
}
}
return items;
}
/** Map tool defs into the Responses `function` tool shape. */
function buildCodexTools(tools: AiToolDef[]) {
return tools.map((t) => ({
type: "function" as const,
name: t.name,
description: t.description,
parameters: t.parameters,
}));
}
interface CodexClientConfig {
accessToken: string;
accountId: string;
@@ -317,18 +419,16 @@ export class CodexClient implements AIProvider {
// untouched. The image is UNTRUSTED data to transcribe, framed as such by
// the system prompt. Documents (PDF) are NOT folded in — `input_image` is
// image-only; the OCR route gates PDFs to Anthropic.
const images = params.images ?? [];
const content: Array<
| { type: "input_text"; text: string }
| { type: "input_image"; image_url: string; detail: "high" }
> = [{ type: "input_text", text: params.userPrompt }];
for (const img of images) {
content.push({
type: "input_image",
image_url: `data:${img.mediaType};base64,${img.dataBase64}`,
detail: "high",
});
}
//
// v1.20.0 — `input` is now built from the full message array (multi-turn +
// tool-result turns), with `tools` mapped to the Responses `function` shape
// (the wire already declared `tools`/`tool_choice` — now they carry defs).
const input = buildCodexInput(params.messages);
const tools =
params.tools && params.tools.length > 0
? buildCodexTools(params.tools)
: [];
const toolChoice = params.toolChoice ?? "auto";
return safeFetch(
CODEX_ENDPOINT,
@@ -348,16 +448,10 @@ export class CodexClient implements AIProvider {
},
body: JSON.stringify({
model: slug,
instructions: params.systemPrompt,
input: [
{
type: "message",
role: "user",
content,
},
],
tools: [],
tool_choice: "auto",
instructions: params.system,
input,
tools,
tool_choice: toolChoice,
parallel_tool_calls: false,
// Reasoning is required-but-nullable on the wire. We don't ask
// for reasoning summaries — emit `null` so the JSON has the
@@ -407,7 +501,9 @@ export class CodexClient implements AIProvider {
let assembledText = "";
let deltaText = "";
let tokensUsed: number | null = null;
let cachedInputTokens: number | null = null;
let serverModel: string | null = null;
const toolCalls: AiToolCall[] = [];
// The server reports the actual routed model in the OpenAI-Model
// response header — useful when safety-routing kicks in.
@@ -439,9 +535,16 @@ export class CodexClient implements AIProvider {
type?: string;
role?: string;
content?: Array<{ type?: string; text?: string }>;
id?: string;
call_id?: string;
name?: string;
arguments?: string;
};
response?: {
usage?: { total_tokens?: number };
usage?: {
total_tokens?: number;
input_tokens_details?: { cached_tokens?: number };
};
error?: { code?: string; message?: string };
incomplete_details?: { reason?: string };
};
@@ -466,11 +569,23 @@ export class CodexClient implements AIProvider {
.map((c) => c.text!)
.join("");
if (text) assembledText += text;
} else if (item?.type === "function_call") {
// v1.20.0 — the model asked to call a tool. Surface it for
// F1's loop; the id is the call_id the function_call_output
// turn must echo back.
toolCalls.push({
id: item.call_id ?? item.id ?? "",
name: item.name ?? "",
arguments: item.arguments ?? "",
});
}
break;
}
case "response.completed": {
tokensUsed = parsed.response?.usage?.total_tokens ?? null;
cachedInputTokens =
parsed.response?.usage?.input_tokens_details?.cached_tokens ??
null;
break;
}
case "response.failed": {
@@ -509,15 +624,20 @@ export class CodexClient implements AIProvider {
}
const content = assembledText || deltaText;
if (!content) {
// A function-call-only reply carries no text — valid (F1). Only an empty
// reply with neither text NOR a tool call is an error.
if (!content && toolCalls.length === 0) {
throw new Error("Codex returned empty content");
}
return {
content,
tokensUsed,
cachedInputTokens,
model: serverModel ?? requestedSlug,
providerType: "codex",
...(toolCalls.length > 0 ? { toolCalls } : {}),
finishReason: toolCalls.length > 0 ? "tool_calls" : "stop",
};
}
}
+10 -5
View File
@@ -1,5 +1,10 @@
import type { z } from "zod/v4";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import {
appendToLastUserMessage,
type AIProvider,
type CompletionParams,
type CompletionResult,
} from "./types";
import {
aiInsightResponseSchema,
findRecommendationsMissingRationale,
@@ -248,10 +253,10 @@ export async function generateInsight(
first.reason ?? "schema mismatch",
first.details ?? "",
);
const retryParams: CompletionParams = {
...params,
userPrompt: `${params.userPrompt}\n\n${correction}`,
};
const retryParams: CompletionParams = appendToLastUserMessage(
params,
`\n\n${correction}`,
);
const second = await tryOnce(provider, retryParams);
if (second.ok && second.parsed) {
annotateCitationCoverage(second.parsed);
+52 -25
View File
@@ -1,6 +1,12 @@
import { safeFetch } from "@/lib/safe-fetch";
import { isLocalAiHostAllowed } from "./local-host-allowlist";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import type {
AIProvider,
AiMessage,
CompletionParams,
CompletionResult,
} from "./types";
import { buildOpenAIMessages } from "./openai-wire";
interface LocalClientConfig {
apiKey?: string | null;
@@ -8,15 +14,50 @@ interface LocalClientConfig {
baseUrl: string;
}
const STRICT_JSON_PREFIX =
"Return strict JSON only, no markdown, no commentary.\n\n";
/**
* Prepend the strict-JSON instruction to the FIRST user turn's text. Local
* model templates respect an in-message instruction better than the
* `response_format` flag (which many reject). Mirrors the pre-refactor
* behaviour, which prepended to the single user prompt.
*/
function withStrictJsonPrefix(messages: AiMessage[]): AiMessage[] {
const idx = messages.findIndex((m) => m.role === "user");
if (idx === -1) return messages;
const out = [...messages];
const target = out[idx];
if (typeof target.content === "string") {
out[idx] = { ...target, content: `${STRICT_JSON_PREFIX}${target.content}` };
} else {
// Vision parts: prepend a leading text part with the instruction.
out[idx] = {
...target,
content: [{ type: "text", text: STRICT_JSON_PREFIX }, ...target.content],
};
}
return out;
}
/**
* Talks to OpenAI-compatible local servers (Ollama, LocalAI, LM Studio,
* vLLM, …). Same wire format as OpenAI but without
* `response_format: { type: "json_object" }` — many local models reject the
* field outright. Instead we prepend a strict-JSON instruction to the user
* message, which is what most local model templates respect.
*
* v1.20.0 — tool-calling DEGRADES silently here: most local servers reject an
* unknown `tools` field, so the client never forwards it (capability flag
* below). F1's tool loop must therefore tolerate a provider that never returns
* `toolCalls` and answer from base context. Prompt-caching, where the local
* server supports it (vLLM / Ollama prefix cache), is transparent — no flag,
* no harm.
*/
export class LocalOpenAICompatibleClient implements AIProvider {
readonly type = "local" as const;
/** Local servers commonly reject an unknown `tools` field — never send it. */
readonly supportsTools = false;
private config: LocalClientConfig;
constructor(config: LocalClientConfig) {
@@ -38,26 +79,15 @@ export class LocalOpenAICompatibleClient implements AIProvider {
headers.Authorization = `Bearer ${trimmedKey}`;
}
const userPrompt = `Return strict JSON only, no markdown, no commentary.\n\n${params.userPrompt}`;
// v1.18.9 — fold vision inputs (Lab-OCR) into the user turn when present,
// using the OpenAI-compatible `image_url` content array that llava /
// llama-vision models accept over the same endpoint. PDFs are not handled
// here (the OCR route gates PDF to Anthropic); only `params.images` is
// consumed. The image is framed as untrusted DATA by the system prompt.
const images = params.images ?? [];
const userContent =
images.length > 0
? [
{ type: "text" as const, text: userPrompt },
...images.map((img) => ({
type: "image_url" as const,
image_url: {
url: `data:${img.mediaType};base64,${img.dataBase64}`,
},
})),
]
: userPrompt;
// System turn first, then the conversation turns (with the strict-JSON
// instruction folded into the first user turn). Vision parts (Lab-OCR)
// become the OpenAI-compatible `image_url` content array that llava /
// llama-vision models accept over the same endpoint. The image is framed
// as untrusted DATA by the system prompt.
const messages = buildOpenAIMessages(
params.system,
withStrictJsonPrefix(params.messages),
);
// safeFetch defaults: no redirect-follow (the local endpoint is the
// most exploitable on this surface — a user-controlled baseUrl that
@@ -81,10 +111,7 @@ export class LocalOpenAICompatibleClient implements AIProvider {
headers,
body: JSON.stringify({
model: this.config.model,
messages: [
{ role: "system", content: params.systemPrompt },
{ role: "user", content: userContent },
],
messages,
temperature: params.temperature ?? 0.3,
max_tokens: params.maxTokens ?? 1000,
// Deterministic seed for reproducible reference output. Ollama +
+43 -1
View File
@@ -1,4 +1,9 @@
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import type {
AIProvider,
AiToolCall,
CompletionParams,
CompletionResult,
} from "./types";
/**
* Deterministic mock AI provider for tests.
@@ -37,6 +42,23 @@ export interface MockAIProviderOptions {
* 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
@@ -50,9 +72,14 @@ const DEFAULT_RESPONSE = JSON.stringify({
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;
@@ -61,6 +88,15 @@ export class MockAIProvider implements AIProvider {
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];
@@ -87,12 +123,18 @@ export class MockAIProvider implements AIProvider {
}
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,
};
}
+36 -27
View File
@@ -1,5 +1,13 @@
import { safeFetch } from "@/lib/safe-fetch";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import {
buildOpenAIMessages,
buildOpenAITools,
mapFinishReason,
parseCachedTokens,
parseOpenAIToolCalls,
type OpenAIResponseJson,
} from "./openai-wire";
interface OpenAIClientConfig {
apiKey: string;
@@ -31,25 +39,20 @@ export class OpenAIClient implements AIProvider {
): Promise<CompletionResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
// v1.18.9 — fold vision inputs (Lab-OCR) into the user turn when present.
// The text-only path keeps the bare-string content; the vision path sends
// the multimodal `image_url` content array `gpt-4o`-class models accept
// (a data-URL per image). PDFs are NOT handled here (the OCR route gates
// PDF uploads to Anthropic); only `params.images` is consumed. The image
// is framed as untrusted DATA by the system prompt.
const images = params.images ?? [];
const userContent =
images.length > 0
? [
{ type: "text" as const, text: params.userPrompt },
...images.map((img) => ({
type: "image_url" as const,
image_url: {
url: `data:${img.mediaType};base64,${img.dataBase64}`,
},
})),
]
: params.userPrompt;
// System turn first (the stable cache prefix — OpenAI prefix-caches a
// byte-identical ≥1024-token prefix automatically, no API flag), then the
// conversation turns mapped 1:1. Vision parts (Lab-OCR) become the
// multimodal `image_url` content array `gpt-4o`-class models accept. The
// image is framed as untrusted DATA by the system prompt.
const messages = buildOpenAIMessages(params.system, params.messages);
// v1.20.0 — tool plumbing. The defs map onto the OpenAI `function` tool
// wire; F1 supplies real defs and consumes the parsed `toolCalls`. No F4
// call site sets `tools`, so the text-only body is unchanged.
const tools =
params.tools && params.tools.length > 0
? buildOpenAITools(params.tools)
: undefined;
const res = await safeFetch(
url,
@@ -61,10 +64,7 @@ export class OpenAIClient implements AIProvider {
},
body: JSON.stringify({
model: this.config.model,
messages: [
{ role: "system", content: params.systemPrompt },
{ role: "user", content: userContent },
],
messages,
temperature: params.temperature ?? 0.3,
max_tokens: params.maxTokens ?? 1000,
response_format: { type: "json_object" },
@@ -72,6 +72,8 @@ export class OpenAIClient implements AIProvider {
// (undefined → dropped by JSON.stringify) when the caller does
// not pin one (e.g. the seedless daily-briefing re-roll).
...(params.seed !== undefined ? { seed: params.seed } : {}),
...(tools ? { tools } : {}),
...(params.toolChoice ? { tool_choice: params.toolChoice } : {}),
}),
},
// 60 s ceiling so a tar-pit upstream cannot pin a worker
@@ -105,18 +107,25 @@ export class OpenAIClient implements AIProvider {
throw err;
}
const json = await res.json();
const content = json.choices?.[0]?.message?.content;
const json = (await res.json()) as OpenAIResponseJson;
const choice = json.choices?.[0];
const content = choice?.message?.content;
const toolCalls = parseOpenAIToolCalls(choice);
if (!content) {
// A reply with tool calls and no prose is valid (F1 tool loop); only an
// empty reply with neither content NOR tool calls is an error.
if (!content && !toolCalls) {
throw new Error("OpenAI returned empty content");
}
return {
content,
content: content ?? "",
tokensUsed: json.usage?.total_tokens ?? null,
cachedInputTokens: parseCachedTokens(json),
model: this.config.model,
providerType: this.type,
...(toolCalls ? { toolCalls } : {}),
finishReason: mapFinishReason(choice?.finish_reason),
};
}
}
+157
View File
@@ -0,0 +1,157 @@
import type {
AiContentPart,
AiMessage,
AiToolCall,
AiToolDef,
CompletionResult,
} from "./types";
/**
* Shared OpenAI Chat-Completions wire mapping. Both the hosted OpenAI client
* and the local OpenAI-compatible client (Ollama / LM Studio / vLLM) speak this
* shape, so the message → wire translation and the response parsing live here
* once. The two clients differ only in how they treat `responseFormat` and the
* endpoint/auth, not in the message structure.
*/
type OpenAIChatContent =
| string
| Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }
>;
interface OpenAIChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: OpenAIChatContent | null;
tool_calls?: Array<{
id: string;
type: "function";
function: { name: string; arguments: string };
}>;
tool_call_id?: string;
}
/**
* Map a single `AiMessage` content into the Chat-Completions content shape.
* A plain string stays a string (byte-identical to the text-only wire we shipped
* before the refactor); parts become the multimodal array. PDF document parts
* are dropped — the Chat-Completions wire has no document block (the OCR route
* gates PDFs to Anthropic).
*/
function mapContent(content: string | AiContentPart[]): OpenAIChatContent {
if (typeof content === "string") return content;
const out: Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }
> = [];
for (const part of content) {
if (part.type === "text") {
out.push({ type: "text", text: part.text });
} else if (part.type === "image") {
out.push({
type: "image_url",
image_url: { url: `data:${part.mediaType};base64,${part.dataBase64}` },
});
}
// `document` parts are not representable on this wire; drop them.
}
return out;
}
/**
* Build the full Chat-Completions `messages[]` array: the system turn first,
* then every conversation turn mapped 1:1. Assistant `toolCalls` and the
* `role:"tool"` result turns map to the OpenAI tool wire so a multi-round
* tool loop (F1) round-trips.
*/
export function buildOpenAIMessages(
system: string,
messages: AiMessage[],
): OpenAIChatMessage[] {
const out: OpenAIChatMessage[] = [{ role: "system", content: system }];
for (const m of messages) {
if (m.role === "tool") {
out.push({
role: "tool",
tool_call_id: m.toolCallId,
content:
typeof m.content === "string" ? m.content : mapContent(m.content),
});
continue;
}
const mapped: OpenAIChatMessage = {
role: m.role,
content: mapContent(m.content),
};
if (m.role === "assistant" && m.toolCalls && m.toolCalls.length > 0) {
mapped.tool_calls = m.toolCalls.map((tc) => ({
id: tc.id,
type: "function",
function: { name: tc.name, arguments: tc.arguments },
}));
}
out.push(mapped);
}
return out;
}
/** Map tool defs into the OpenAI `tools` array. */
export function buildOpenAITools(tools: AiToolDef[]) {
return tools.map((t) => ({
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}));
}
interface OpenAIChoice {
message?: {
content?: string | null;
tool_calls?: Array<{
id?: string;
function?: { name?: string; arguments?: string };
}>;
};
finish_reason?: string;
}
interface OpenAIResponseJson {
choices?: OpenAIChoice[];
usage?: {
total_tokens?: number;
prompt_tokens_details?: { cached_tokens?: number };
};
}
/** Map the OpenAI `finish_reason` onto the provider-neutral finishReason. */
export function mapFinishReason(
reason: string | undefined,
): CompletionResult["finishReason"] {
if (reason === "tool_calls") return "tool_calls";
if (reason === "length") return "length";
if (reason === "stop") return "stop";
return undefined;
}
/** Pull tool calls off an OpenAI choice into the provider-neutral shape. */
export function parseOpenAIToolCalls(
choice: OpenAIChoice | undefined,
): AiToolCall[] | undefined {
const calls = choice?.message?.tool_calls;
if (!calls || calls.length === 0) return undefined;
return calls.map((c) => ({
id: c.id ?? "",
name: c.function?.name ?? "",
arguments: c.function?.arguments ?? "",
}));
}
export function parseCachedTokens(json: OpenAIResponseJson): number | null {
return json.usage?.prompt_tokens_details?.cached_tokens ?? null;
}
export type { OpenAIChoice, OpenAIResponseJson };
+225 -21
View File
@@ -111,12 +111,21 @@ export type ProviderType =
export interface AIProvider {
type: ProviderType;
/**
* v1.20.0 — per-provider tool-calling capability flag. Defaults to true
* (Anthropic / OpenAI / Codex / mock support tools natively). The local
* OpenAI-compatible client sets it `false` because most local servers reject
* an unknown `tools` field; F1's tool loop must treat a provider with
* `supportsTools === false` (or an empty `toolCalls` result) as "no tools
* available" and answer from base context.
*/
supportsTools?: boolean;
generateCompletion(params: CompletionParams): Promise<CompletionResult>;
}
/**
* v1.18.9 — a single vision input folded into the provider request. Used by
* the Lab-OCR extraction path: a photo of a paper report, or a PDF page.
* v1.18.9 — a single vision input folded into a message's content parts. Used
* by the Lab-OCR extraction path: a photo of a paper report, or a PDF page.
* `dataBase64` is the raw file bytes, base64-encoded (no `data:` prefix —
* each client wraps it in the wire shape it needs). The blob is NEVER threaded
* into an `annotate()` meta or any log field (it is health data).
@@ -129,9 +138,10 @@ export interface CompletionImage {
}
/**
* v1.18.9 — a PDF document folded into the provider request. Only Anthropic
* accepts a native `document` block over the Messages API we use; the other
* clients ignore it (the OCR route gates PDFs to Anthropic providers).
* v1.18.9 — a PDF document folded into a message's content parts. Only
* Anthropic accepts a native `document` block over the Messages API we use;
* the other clients ignore it (the OCR route gates PDFs to Anthropic
* providers).
*/
export interface CompletionDocument {
mediaType: "application/pdf";
@@ -139,25 +149,94 @@ export interface CompletionDocument {
dataBase64: string;
}
// ─── Message-array request shape (v1.20.0) ─────────────────
//
// The provider contract takes a structured `{ system, messages[] }` request
// instead of a single `{ systemPrompt, userPrompt }` string pair. This unlocks
// three things in one refactor: provider prompt-caching (Anthropic
// `cache_control` on the stable system prefix, OpenAI/local automatic prefix
// cache), tool-calling (the typed surface F1 builds on), and clean multi-turn.
//
// The provider contract is still request → complete-string today: the Coach
// fake-tokenises a fully-assembled reply downstream and Codex buffers its SSE
// internally, so there is no real token-streaming dependency here.
/** A plain-text content part. */
export interface AiTextPart {
type: "text";
text: string;
}
/** An image content part (Lab-OCR vision). */
export interface AiImagePart {
type: "image";
mediaType: CompletionImage["mediaType"];
/** Raw image bytes, base64-encoded (no data-URL prefix). */
dataBase64: string;
}
/** A PDF document content part (Anthropic-only; other clients drop it). */
export interface AiDocPart {
type: "document";
mediaType: "application/pdf";
/** Raw PDF bytes, base64-encoded (no data-URL prefix). */
dataBase64: string;
}
export type AiContentPart = AiTextPart | AiImagePart | AiDocPart;
/**
* v1.20.0 — a tool definition handed to the model. `parameters` is a JSON
* Schema object; each client maps it into the provider's tool shape. F4 plumbs
* the param + per-provider wire mapping; F1 supplies real definitions and the
* call→result→answer loop.
*/
export interface AiToolDef {
name: string;
description: string;
/** JSON-schema object describing the tool's arguments. */
parameters: Record<string, unknown>;
}
/**
* v1.20.0 — a tool the model asked to call. `arguments` is the raw JSON string
* the model produced (parsed by F1's loop, never trusted blindly).
*/
export interface AiToolCall {
id: string;
name: string;
arguments: string;
}
/**
* v1.20.0 — one conversation turn. The common case is a plain-string
* `content`; vision turns and tool-result turns use `parts[]`. `toolCalls` are
* set on assistant turns that requested tools (F1); `toolCallId` ties a
* `role:"tool"` turn back to the call it answers (F1).
*/
export interface AiMessage {
role: "user" | "assistant" | "tool";
content: string | AiContentPart[];
/** Set on assistant turns that called tools (F1 fills these). */
toolCalls?: AiToolCall[];
/** Set on `role:"tool"` turns — the id of the call this answers. */
toolCallId?: string;
/**
* Anthropic prompt-cache hint — marks a stable message-prefix boundary.
* A no-op on every other provider (their prefix caching is automatic).
*/
cacheBreakpoint?: boolean;
}
export interface CompletionParams {
systemPrompt: string;
userPrompt: string;
/**
* Stable system prompt. Carries the brand-free reference grounding, persona
* and instructions. Eligible for the provider cache prefix on every client;
* keep per-request volatile data (timestamps, interpolated dates) OUT of it
* so the cached prefix stays byte-identical across calls.
*/
system: string;
/** The conversation turns. The 80% case is one user turn (see {@link singleUserTurn}). */
messages: AiMessage[];
temperature?: number;
maxTokens?: number;
/**
* v1.18.9 — optional vision inputs (Lab-OCR). Additive: every text-only
* caller (Coach, status cards, insights, narratives) leaves these unset, so
* the existing wire shape and streaming path are untouched. A client that
* cannot read images/documents ignores the field. Treat the contents as
* UNTRUSTED data to transcribe — never as instructions.
*/
images?: CompletionImage[];
/**
* v1.18.9 — optional PDF document input (Lab-OCR). Anthropic-only; other
* clients ignore it. Mutually informative with `images` — the OCR route
* sends one or the other depending on the upload type and provider.
*/
documents?: CompletionDocument[];
/**
* Optional deterministic seed. Threaded onto the OpenAI and local
* (Ollama / OpenAI-compatible) request bodies for reproducible output on
@@ -174,13 +253,138 @@ export interface CompletionParams {
* The OpenAI client already pins `response_format: json_object`
* unconditionally and is unaffected. The Coach (prose, NOT JSON) leaves
* this unset, so its streaming path is untouched.
*
* Mutually exclusive with `tools` on Anthropic: the `{`-prefill injects an
* assistant turn that collides with a `tool_use` response, so the Anthropic
* client drops the prefill whenever `tools` are present.
*/
responseFormat?: "json";
/**
* v1.20.0 — tool definitions offered to the model. F4 plumbs the param +
* per-provider wire mapping; no F4 call site sets it. Local providers may
* ignore tools entirely — F1's loop must tolerate an empty `toolCalls`.
*/
tools?: AiToolDef[];
/**
* v1.20.0 — `"auto"` lets the model decide; `"none"` forbids tool calls.
* Mapped per provider; omitted from the wire when unset.
*/
toolChoice?: "auto" | "none";
}
export interface CompletionResult {
content: string;
tokensUsed: number | null;
/**
* v1.20.0 — prompt-cache observability. The count of input tokens served
* from the provider's cache, where the provider reports it (Anthropic
* `cache_read_input_tokens`, OpenAI/Codex `cached_tokens`). Null / absent
* when the provider does not report it or the prefix did not hit.
*/
cachedInputTokens?: number | null;
model: string;
providerType: ProviderType;
/**
* v1.20.0 — populated when the model asked to call tools (F1 consumes).
* Absent on a plain completion.
*/
toolCalls?: AiToolCall[];
/**
* v1.20.0 — why the model stopped. Lets F1's loop branch only on
* `"tool_calls"`. Absent when the provider does not surface it.
*/
finishReason?: "stop" | "tool_calls" | "length";
}
/**
* Ergonomic builder for the common single-user-turn completion. Folds an
* optional vision payload (`images` / `documents`) into the user message's
* content parts so the Lab-OCR path keeps a one-call shape.
*/
export function singleUserTurn(p: {
system: string;
user: string;
temperature?: number;
maxTokens?: number;
seed?: number;
responseFormat?: "json";
images?: CompletionImage[];
documents?: CompletionDocument[];
tools?: AiToolDef[];
toolChoice?: "auto" | "none";
}): CompletionParams {
const images = p.images ?? [];
const documents = p.documents ?? [];
// Parts order is text-first, then media — the OpenAI / local / Codex wires
// emit it in this order. The Anthropic client re-orders to media-first when
// it maps the parts (it reads the report before the instruction), preserving
// each provider's pre-refactor vision wire byte-for-byte.
const content: string | AiContentPart[] =
images.length === 0 && documents.length === 0
? p.user
: [
{ type: "text", text: p.user },
...images.map(
(img): AiImagePart => ({
type: "image",
mediaType: img.mediaType,
dataBase64: img.dataBase64,
}),
),
...documents.map(
(doc): AiDocPart => ({
type: "document",
mediaType: doc.mediaType,
dataBase64: doc.dataBase64,
}),
),
];
return {
system: p.system,
messages: [{ role: "user", content }],
temperature: p.temperature,
maxTokens: p.maxTokens,
seed: p.seed,
responseFormat: p.responseFormat,
tools: p.tools,
toolChoice: p.toolChoice,
};
}
/**
* Return a copy of `params` whose LAST user message has `suffix` appended to
* its text. Preserves the wire shape of the corrective-retry paths (insight
* schema retry, OCR retry, briefing grounding correction): they used to append
* to the single `userPrompt` string, and this keeps the model seeing one user
* turn with the appended text rather than an extra back-to-back user turn.
*
* Throws if there is no user message to append to — every retry path always
* has one, so a throw is a programming error, not a runtime case.
*/
export function appendToLastUserMessage(
params: CompletionParams,
suffix: string,
): CompletionParams {
const messages = [...params.messages];
let idx = -1;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i].role === "user") {
idx = i;
break;
}
}
if (idx === -1) {
throw new Error("appendToLastUserMessage: no user message to append to");
}
const target = messages[idx];
if (typeof target.content === "string") {
messages[idx] = { ...target, content: `${target.content}${suffix}` };
} else {
// Vision / parts content — append the suffix as a trailing text part.
messages[idx] = {
...target,
content: [...target.content, { type: "text", text: suffix }],
};
}
return { ...params, messages };
}
@@ -104,9 +104,11 @@ describe("comprehensive JSON-retry", () => {
expect(outcome).toEqual({ status: "generated", providerType: "openai" });
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(2);
// The retry call appends the correction to the user prompt.
// The retry call appends the correction to the user message.
const retryParams = runRawCompletionWithFallback.mock.calls[1][0].params;
expect(retryParams.userPrompt).toContain("did not satisfy the required");
expect(retryParams.messages[0].content).toContain(
"did not satisfy the required",
);
});
it("fails with invalid-json when both attempts miss", async () => {
+17 -16
View File
@@ -47,6 +47,7 @@ import {
} from "@/lib/ai/prompts/insight-system-prompt";
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
import { buildRetryCorrectionMessage } from "@/lib/ai/generate-insight";
import { singleUserTurn } from "@/lib/ai/types";
import {
buildAboutMeInsightBlock,
getSelfContextTextForUser,
@@ -212,15 +213,15 @@ async function rerollBriefingParagraph(args: {
const fallback = await runRawCompletionWithFallback({
userId: args.userId,
providers: args.chain,
params: {
systemPrompt: args.systemPrompt,
userPrompt: args.userPrompt,
params: singleUserTurn({
system: args.systemPrompt,
user: args.userPrompt,
// Seedless on purpose: the seed would pin the same phrasing, which is
// exactly what we are varying. Higher temperature for prose variety.
temperature: BRIEFING_REROLL_TEMPERATURE,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
responseFormat: "json",
},
}),
});
result = fallback.result;
providerType = fallback.workingProvider.providerType;
@@ -933,16 +934,16 @@ export async function generateComprehensiveInsight(
const fallback = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt,
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
// v1.18.7 — structured surface: opt the non-OpenAI chains into their
// strongest JSON mode (Ollama `format`, Anthropic `{` prefill) so a
// first-pass JSON miss is rarer; stripJsonFences stays the net.
responseFormat: "json",
},
}),
});
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
@@ -972,16 +973,16 @@ export async function generateComprehensiveInsight(
const retry = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt: `${userPrompt}\n\n${buildRetryCorrectionMessage(
params: singleUserTurn({
system: systemPrompt,
user: `${userPrompt}\n\n${buildRetryCorrectionMessage(
"Response was not valid JSON",
"The previous reply could not be parsed as a JSON object.",
)}`,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
responseFormat: "json",
},
}),
});
result = retry.result;
workingProviderType = retry.workingProvider.providerType;
@@ -1014,13 +1015,13 @@ export async function generateComprehensiveInsight(
const retry = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
params: singleUserTurn({
system: systemPrompt,
user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
responseFormat: "json",
},
}),
});
const retryInsights = parseComprehensiveResult(retry.result.content);
const retryUngrounded = findUngroundedBriefingNumbers(
+5 -4
View File
@@ -11,6 +11,7 @@ import {
} from "@/lib/ai/consent-guard";
import { annotate } from "@/lib/logging/context";
import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
import { singleUserTurn } from "@/lib/ai/types";
import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout";
/**
@@ -172,9 +173,9 @@ export async function runStatusCompletion(
runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt,
userPrompt,
params: singleUserTurn({
system: systemPrompt,
user: userPrompt,
temperature: args.temperature ?? AI_BUDGETS.status.temperature,
maxTokens: args.maxTokens ?? AI_BUDGETS.status.maxTokens,
// v1.18.7 — status/reference output is reproducible: pin the
@@ -183,7 +184,7 @@ export async function runStatusCompletion(
// Status cards are JSON by default; the narrative opts out via
// `"text"`.
responseFormat: args.responseFormat === "text" ? undefined : "json",
},
}),
}),
STATUS_PROVIDER_TIMEOUT_MS,
null,
+21 -14
View File
@@ -16,7 +16,12 @@
* an extracted field as a command, and the human review screen is the hard
* backstop — nothing commits without per-row confirmation.
*/
import type { AIProvider, CompletionParams } from "@/lib/ai/types";
import {
appendToLastUserMessage,
singleUserTurn,
type AIProvider,
type CompletionParams,
} from "@/lib/ai/types";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
@@ -213,31 +218,33 @@ export async function runOcrExtraction(
// prompt and images/documents stay UNSET, so the text-only provider wire
// (codex `input_text`) carries it unchanged — the image never reaches here.
const params: CompletionParams = isTextMode
? {
systemPrompt: TEXT_SYSTEM_PROMPT,
userPrompt: `Structure the following OCR'd lab-report text into the JSON schema described in the system prompt. Return only the JSON object.\n\nOCR TEXT:\n${args.ocrText}`,
? singleUserTurn({
system: TEXT_SYSTEM_PROMPT,
user: `Structure the following OCR'd lab-report text into the JSON schema described in the system prompt. Return only the JSON object.\n\nOCR TEXT:\n${args.ocrText}`,
temperature: AI_BUDGETS.ocrExtract.temperature,
maxTokens: AI_BUDGETS.ocrExtract.maxTokens,
responseFormat: "json",
}
: {
systemPrompt: SYSTEM_PROMPT,
userPrompt: USER_PROMPT,
})
: singleUserTurn({
system: SYSTEM_PROMPT,
user: USER_PROMPT,
temperature: AI_BUDGETS.ocrExtract.temperature,
maxTokens: AI_BUDGETS.ocrExtract.maxTokens,
responseFormat: "json",
images: args.images,
documents: args.documents,
};
});
let parsed = await extractOnce(args.provider, params);
if (!parsed.success) {
// One corrective retry — re-state the schema requirement in the prompt.
// Reuse the mode's own user prompt (text mode carries the OCR text in it).
const retryParams: CompletionParams = {
...params,
userPrompt: `${params.userPrompt}\n\nYour previous response was not valid JSON matching the schema. Return ONLY the JSON object described in the system prompt, with no prose or markdown.`,
};
// Reuse the mode's own user prompt (text mode carries the OCR text in it);
// appendToLastUserMessage keeps the single user turn, folding the
// correction into its text (or as a trailing text part on the vision path).
const retryParams = appendToLastUserMessage(
params,
"\n\nYour previous response was not valid JSON matching the schema. Return ONLY the JSON object described in the system prompt, with no prose or markdown.",
);
parsed = await extractOnce(args.provider, retryParams);
}