diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 221e53ec0..984b75e13 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -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, diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts index 1ce27f76c..cf29f225b 100644 --- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -164,10 +164,11 @@ function chatReq(body: Record): 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)", () => { diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index a0589fec3..f04d9c7f1 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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; diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index bddd9313a..134fac3cc 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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); diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index 901456274..954406251 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -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 { 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) { diff --git a/src/lib/ai/__tests__/anthropic-client.test.ts b/src/lib/ai/__tests__/anthropic-client.test.ts index da32e8c77..e31fa0d85 100644 --- a/src/lib/ai/__tests__/anthropic-client.test.ts +++ b/src/lib/ai/__tests__/anthropic-client.test.ts @@ -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); + }); }); diff --git a/src/lib/ai/__tests__/citation-coverage-logging.test.ts b/src/lib/ai/__tests__/citation-coverage-logging.test.ts index 2e37958e9..1dd249802 100644 --- a/src/lib/ai/__tests__/citation-coverage-logging.test.ts +++ b/src/lib/ai/__tests__/citation-coverage-logging.test.ts @@ -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(); }); diff --git a/src/lib/ai/__tests__/citation-enforcement.test.ts b/src/lib/ai/__tests__/citation-enforcement.test.ts index 42cc4189c..684436283 100644 --- a/src/lib/ai/__tests__/citation-enforcement.test.ts +++ b/src/lib/ai/__tests__/citation-enforcement.test.ts @@ -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); }); diff --git a/src/lib/ai/__tests__/codex-client.test.ts b/src/lib/ai/__tests__/codex-client.test.ts index df0c1b06a..a73f06e9d 100644 --- a/src/lib/ai/__tests__/codex-client.test.ts +++ b/src/lib/ai/__tests__/codex-client.test.ts @@ -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); + }); }); diff --git a/src/lib/ai/__tests__/codex-slug-fallback.test.ts b/src/lib/ai/__tests__/codex-slug-fallback.test.ts index 57ecdb773..6d1c0cb9f 100644 --- a/src/lib/ai/__tests__/codex-slug-fallback.test.ts +++ b/src/lib/ai/__tests__/codex-slug-fallback.test.ts @@ -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. diff --git a/src/lib/ai/__tests__/confidence-wrapper.test.ts b/src/lib/ai/__tests__/confidence-wrapper.test.ts index c4711335e..81068d00a 100644 --- a/src/lib/ai/__tests__/confidence-wrapper.test.ts +++ b/src/lib/ai/__tests__/confidence-wrapper.test.ts @@ -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). diff --git a/src/lib/ai/__tests__/generate-insight.test.ts b/src/lib/ai/__tests__/generate-insight.test.ts index 4a77e0fe1..392739fe5 100644 --- a/src/lib/ai/__tests__/generate-insight.test.ts +++ b/src/lib/ai/__tests__/generate-insight.test.ts @@ -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); diff --git a/src/lib/ai/__tests__/insight-generator-prompt.test.ts b/src/lib/ai/__tests__/insight-generator-prompt.test.ts index bcc95b21c..0f4a049f9 100644 --- a/src/lib/ai/__tests__/insight-generator-prompt.test.ts +++ b/src/lib/ai/__tests__/insight-generator-prompt.test.ts @@ -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"); diff --git a/src/lib/ai/__tests__/local-client.test.ts b/src/lib/ai/__tests__/local-client.test.ts index ba6c38ffb..8e6c7b341 100644 --- a/src/lib/ai/__tests__/local-client.test.ts +++ b/src/lib/ai/__tests__/local-client.test.ts @@ -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"); + }); }); diff --git a/src/lib/ai/__tests__/openai-client.test.ts b/src/lib/ai/__tests__/openai-client.test.ts index ac7fa1dd9..25f867a98 100644 --- a/src/lib/ai/__tests__/openai-client.test.ts +++ b/src/lib/ai/__tests__/openai-client.test.ts @@ -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" }, + }); + }); }); diff --git a/src/lib/ai/__tests__/provider-contract.test.ts b/src/lib/ai/__tests__/provider-contract.test.ts index 470b1e63c..7b9190b4b 100644 --- a/src/lib/ai/__tests__/provider-contract.test.ts +++ b/src/lib/ai/__tests__/provider-contract.test.ts @@ -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); diff --git a/src/lib/ai/__tests__/provider-runner.test.ts b/src/lib/ai/__tests__/provider-runner.test.ts index 0b40adc17..1283867ed 100644 --- a/src/lib/ai/__tests__/provider-runner.test.ts +++ b/src/lib/ai/__tests__/provider-runner.test.ts @@ -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) { diff --git a/src/lib/ai/__tests__/rationale-wrapper.test.ts b/src/lib/ai/__tests__/rationale-wrapper.test.ts index fba454dd6..ecf9d78fd 100644 --- a/src/lib/ai/__tests__/rationale-wrapper.test.ts +++ b/src/lib/ai/__tests__/rationale-wrapper.test.ts @@ -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; 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; expect(meta).toMatchObject({ ai_rationale_total_recommendations: 2, diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index 9f99bdb81..afb25ef37 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -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; + } + | { + 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 = {}; + try { + input = JSON.parse(tc.arguments) as Record; + } 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; + }>; + 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, }; } } diff --git a/src/lib/ai/coach/self-context-questions.ts b/src/lib/ai/coach/self-context-questions.ts index eb1c44809..da93def96 100644 --- a/src/lib/ai/coach/self-context-questions.ts +++ b/src/lib/ai/coach/self-context-questions.ts @@ -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 }); } diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 4ee0e7818..2be9fcf8c 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -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", }; } } diff --git a/src/lib/ai/generate-insight.ts b/src/lib/ai/generate-insight.ts index 4b8fb939c..c861b790d 100644 --- a/src/lib/ai/generate-insight.ts +++ b/src/lib/ai/generate-insight.ts @@ -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); diff --git a/src/lib/ai/local-client.ts b/src/lib/ai/local-client.ts index 2843a050f..477d9bebc 100644 --- a/src/lib/ai/local-client.ts +++ b/src/lib/ai/local-client.ts @@ -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 + diff --git a/src/lib/ai/mock-client.ts b/src/lib/ai/mock-client.ts index 2f705ccd0..6cb7a2b65 100644 --- a/src/lib/ai/mock-client.ts +++ b/src/lib/ai/mock-client.ts @@ -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; + 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, }; } diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index bd1f67880..d55a58c81 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -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 { 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), }; } } diff --git a/src/lib/ai/openai-wire.ts b/src/lib/ai/openai-wire.ts new file mode 100644 index 000000000..a8d49f138 --- /dev/null +++ b/src/lib/ai/openai-wire.ts @@ -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 }; diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index 936ad4a11..50612402d 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -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; } /** - * 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; +} + +/** + * 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 }; } diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index 664094870..6e724fcd9 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -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 () => { diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index b302e7f9f..abd855e04 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -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( diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index cbe32d023..cb1015677 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -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, diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts index bcfd1b74f..050e16f50 100644 --- a/src/lib/labs/ocr-extract.ts +++ b/src/lib/labs/ocr-extract.ts @@ -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); }