mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge AI client message-array refactor into release/v1.20.0
Move the AI client layer from a single system/user string pair to a structured system + messages[] shape, with optional tools/toolChoice and a result carrying toolCalls/finishReason/cachedInputTokens. Per-provider prompt-caching is wired (Anthropic cache_control, OpenAI/local auto prefix); tool plumbing is in place for the Coach to use. Behaviour- preserving: no streaming change, snapshot-once retained.
This commit is contained in:
commit
b07fbcb6fa
31 files changed
+1556
-485
No files matched your search
@@ -6,6 +6,7 @@ import {
|
||||
AITestConfigError,
|
||||
type AITestOverride,
|
||||
} from "@/lib/ai/provider";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -65,12 +66,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: "You are a connection-test responder.",
|
||||
userPrompt: 'Reply with the JSON object {"ok": true} and nothing else.',
|
||||
temperature: 0,
|
||||
maxTokens: 32,
|
||||
});
|
||||
const result = await provider.generateCompletion(
|
||||
singleUserTurn({
|
||||
system: "You are a connection-test responder.",
|
||||
user: 'Reply with the JSON object {"ok": true} and nothing else.',
|
||||
temperature: 0,
|
||||
maxTokens: 32,
|
||||
}),
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
|
||||
@@ -164,10 +164,11 @@ function chatReq(body: Record<string, unknown>): Request {
|
||||
|
||||
function lastUserPrompt(): string {
|
||||
const calls = runRawCompletionWithFallback.mock.calls as unknown as Array<
|
||||
[{ params: { userPrompt: string } }]
|
||||
[{ params: { messages: Array<{ role: string; content: string }> } }]
|
||||
>;
|
||||
const last = calls[calls.length - 1];
|
||||
return last[0].params.userPrompt;
|
||||
const userTurn = last[0].params.messages.find((m) => m.role === "user");
|
||||
return typeof userTurn?.content === "string" ? userTurn.content : "";
|
||||
}
|
||||
|
||||
describe("coach chat — snapshot sent once per conversation (C4)", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
runRawCompletionWithFallback,
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
|
||||
import {
|
||||
@@ -185,16 +186,16 @@ async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
completion = await runRawCompletionWithFallback({
|
||||
userId,
|
||||
providers: chain,
|
||||
params: {
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
params: singleUserTurn({
|
||||
system: systemPrompt,
|
||||
user: userPrompt,
|
||||
// Low temperature because we want a deterministic field
|
||||
// extractor, not a creative rephrase.
|
||||
temperature: 0.1,
|
||||
// The reply body is a single small JSON object; 600 is well
|
||||
// over the empirical max we have seen on a 2k-char input.
|
||||
maxTokens: 600,
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AllProvidersFailedError) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AnthropicClient } from "../anthropic-client";
|
||||
import { singleUserTurn } from "../types";
|
||||
|
||||
describe("AnthropicClient", () => {
|
||||
beforeEach(() => {
|
||||
@@ -22,12 +23,14 @@ describe("AnthropicClient", () => {
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
|
||||
const result = await client.generateCompletion({
|
||||
systemPrompt: "You are a doctor.",
|
||||
userPrompt: "Analyze this data.",
|
||||
temperature: 0.4,
|
||||
maxTokens: 800,
|
||||
});
|
||||
const result = await client.generateCompletion(
|
||||
singleUserTurn({
|
||||
system: "You are a doctor.",
|
||||
user: "Analyze this data.",
|
||||
temperature: 0.4,
|
||||
maxTokens: 800,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.content).toBe('{"summary":"ok"}');
|
||||
expect(result.tokensUsed).toBe(42);
|
||||
@@ -45,10 +48,16 @@ describe("AnthropicClient", () => {
|
||||
expect(body.model).toBe("claude-3-5-sonnet-latest");
|
||||
expect(body.max_tokens).toBe(800);
|
||||
expect(body.temperature).toBe(0.4);
|
||||
// System prompt is a separate top-level field, NOT a message.
|
||||
expect(body.system).toBe("You are a doctor.");
|
||||
// v1.20.0 — system is a top-level cache-marked text block (NOT a message).
|
||||
expect(body.system).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "You are a doctor.",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
]);
|
||||
expect(body.messages).toEqual([expect.objectContaining({ role: "user" })]);
|
||||
// User message should be wrapped to coerce JSON output.
|
||||
// User message carries the original text plus the JSON-coercion instruction.
|
||||
expect(body.messages[0].content).toContain("Analyze this data.");
|
||||
expect(body.messages[0].content.toLowerCase()).toContain("json");
|
||||
});
|
||||
@@ -70,11 +79,9 @@ describe("AnthropicClient", () => {
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
|
||||
const result = await client.generateCompletion({
|
||||
systemPrompt: "s",
|
||||
userPrompt: "u",
|
||||
responseFormat: "json",
|
||||
});
|
||||
const result = await client.generateCompletion(
|
||||
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
|
||||
);
|
||||
|
||||
// The returned content is the complete object (the `{` re-prepended).
|
||||
expect(result.content).toBe('{"summary":"ok"}');
|
||||
@@ -101,10 +108,9 @@ describe("AnthropicClient", () => {
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
|
||||
const result = await client.generateCompletion({
|
||||
systemPrompt: "s",
|
||||
userPrompt: "u",
|
||||
});
|
||||
const result = await client.generateCompletion(
|
||||
singleUserTurn({ system: "s", user: "u" }),
|
||||
);
|
||||
|
||||
expect(result.content).toBe("Just some prose.");
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
@@ -129,7 +135,7 @@ describe("AnthropicClient", () => {
|
||||
baseUrl: "https://api.anthropic.example/v1/",
|
||||
});
|
||||
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
|
||||
|
||||
expect(mockFetch.mock.calls[0][0]).toBe(
|
||||
"https://api.anthropic.example/v1/messages",
|
||||
@@ -156,10 +162,9 @@ describe("AnthropicClient", () => {
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.generateCompletion({
|
||||
systemPrompt: "s",
|
||||
userPrompt: "u",
|
||||
});
|
||||
await client.generateCompletion(
|
||||
singleUserTurn({ system: "s", user: "u" }),
|
||||
);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
@@ -195,13 +200,15 @@ describe("AnthropicClient", () => {
|
||||
model: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
await client.generateCompletion({
|
||||
systemPrompt: "Transcribe this report.",
|
||||
userPrompt: "Extract the readings.",
|
||||
responseFormat: "json",
|
||||
images: [{ mediaType: "image/jpeg", dataBase64: "aW1n" }],
|
||||
documents: [{ mediaType: "application/pdf", dataBase64: "cGRm" }],
|
||||
});
|
||||
await client.generateCompletion(
|
||||
singleUserTurn({
|
||||
system: "Transcribe this report.",
|
||||
user: "Extract the readings.",
|
||||
responseFormat: "json",
|
||||
images: [{ mediaType: "image/jpeg", dataBase64: "aW1n" }],
|
||||
documents: [{ mediaType: "application/pdf", dataBase64: "cGRm" }],
|
||||
}),
|
||||
);
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
// First message is the user turn; its content is now a typed array.
|
||||
@@ -240,7 +247,7 @@ describe("AnthropicClient", () => {
|
||||
model: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(typeof body.messages[0].content).toBe("string");
|
||||
});
|
||||
@@ -260,7 +267,74 @@ describe("AnthropicClient", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
|
||||
client.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
|
||||
).rejects.toThrow("Anthropic returned empty content");
|
||||
});
|
||||
|
||||
it("maps tools, drops the JSON prefill, parses tool_use + cache reads", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "fetch_glucose",
|
||||
input: { window: "last30days" },
|
||||
},
|
||||
],
|
||||
stop_reason: "tool_use",
|
||||
usage: {
|
||||
input_tokens: 50,
|
||||
output_tokens: 8,
|
||||
cache_read_input_tokens: 40,
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-x",
|
||||
model: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
const result = await client.generateCompletion(
|
||||
singleUserTurn({
|
||||
system: "s",
|
||||
user: "u",
|
||||
// Even with responseFormat json, tools must win and drop the prefill.
|
||||
responseFormat: "json",
|
||||
tools: [
|
||||
{
|
||||
name: "fetch_glucose",
|
||||
description: "Fetch glucose readings",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
toolChoice: "auto",
|
||||
}),
|
||||
);
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.tools).toEqual([
|
||||
{
|
||||
name: "fetch_glucose",
|
||||
description: "Fetch glucose readings",
|
||||
input_schema: { type: "object", properties: {} },
|
||||
},
|
||||
]);
|
||||
expect(body.tool_choice).toEqual({ type: "auto" });
|
||||
// No `{`-prefill assistant turn when tools are present.
|
||||
expect(body.messages).toHaveLength(1);
|
||||
expect(result.toolCalls).toEqual([
|
||||
{
|
||||
id: "toolu_1",
|
||||
name: "fetch_glucose",
|
||||
arguments: JSON.stringify({ window: "last30days" }),
|
||||
},
|
||||
]);
|
||||
expect(result.finishReason).toBe("tool_calls");
|
||||
expect(result.cachedInputTokens).toBe(40);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { generateInsight } from "../generate-insight";
|
||||
import { MockAIProvider } from "../mock-client";
|
||||
import { singleUserTurn } from "../types";
|
||||
import {
|
||||
detectsNormativeClaim,
|
||||
computeCitationCoverage,
|
||||
@@ -276,10 +277,10 @@ describe("generateInsight() — citation-coverage annotation", () => {
|
||||
warnings: [],
|
||||
}),
|
||||
});
|
||||
await generateInsight(provider, {
|
||||
systemPrompt: "system",
|
||||
userPrompt: "user",
|
||||
});
|
||||
await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({ system: "system", user: "user" }),
|
||||
);
|
||||
const meta = findCoverageCall(annotateMock.mock.calls);
|
||||
expect(meta).toMatchObject({
|
||||
ai_total_recommendations: 1,
|
||||
@@ -306,10 +307,10 @@ describe("generateInsight() — citation-coverage annotation", () => {
|
||||
warnings: [],
|
||||
}),
|
||||
});
|
||||
await generateInsight(provider, {
|
||||
systemPrompt: "system",
|
||||
userPrompt: "user",
|
||||
});
|
||||
await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({ system: "system", user: "user" }),
|
||||
);
|
||||
const meta = findCoverageCall(annotateMock.mock.calls);
|
||||
expect(meta).toMatchObject({
|
||||
ai_normative_recommendations: 1,
|
||||
@@ -324,7 +325,7 @@ describe("generateInsight() — citation-coverage annotation", () => {
|
||||
responses: ["not json", "still not json"],
|
||||
});
|
||||
await expect(
|
||||
generateInsight(provider, { systemPrompt: "s", userPrompt: "u" }),
|
||||
generateInsight(provider, singleUserTurn({ system: "s", user: "u" })),
|
||||
).rejects.toThrow();
|
||||
expect(annotateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../schema";
|
||||
import { generateInsight } from "../generate-insight";
|
||||
import { MockAIProvider } from "../mock-client";
|
||||
import { singleUserTurn } from "../types";
|
||||
|
||||
/**
|
||||
* Phase C1 — citation-from-data hard requirement.
|
||||
@@ -247,10 +248,10 @@ describe("citation-from-data — wrapper end-to-end", () => {
|
||||
const provider = new MockAIProvider({
|
||||
responses: [JSON.stringify(orphan), JSON.stringify(baseValid)],
|
||||
});
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: "system",
|
||||
userPrompt: "user",
|
||||
});
|
||||
const outcome = await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({ system: "system", user: "user" }),
|
||||
);
|
||||
expect(outcome.retried).toBe(true);
|
||||
expect(outcome.parsed.recommendations[0].id).toBe("rec-1");
|
||||
});
|
||||
@@ -284,10 +285,10 @@ describe("citation-from-data — wrapper end-to-end", () => {
|
||||
const provider = new MockAIProvider({
|
||||
responses: [JSON.stringify(orphan), JSON.stringify(droppedRecs)],
|
||||
});
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: "system",
|
||||
userPrompt: "user",
|
||||
});
|
||||
const outcome = await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({ system: "system", user: "user" }),
|
||||
);
|
||||
expect(outcome.retried).toBe(true);
|
||||
expect(outcome.parsed.recommendations).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { CodexClient } from "../codex-client";
|
||||
import { singleUserTurn } from "../types";
|
||||
|
||||
/**
|
||||
* Build a `Response` whose `.body` streams a list of SSE event strings.
|
||||
@@ -57,10 +58,9 @@ describe("CodexClient", () => {
|
||||
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
||||
});
|
||||
|
||||
const result = await client.generateCompletion({
|
||||
systemPrompt: "You are a doctor.",
|
||||
userPrompt: "Analyze this.",
|
||||
});
|
||||
const result = await client.generateCompletion(
|
||||
singleUserTurn({ system: "You are a doctor.", user: "Analyze this." }),
|
||||
);
|
||||
|
||||
expect(result.content).toBe('{"summary":"ok"}');
|
||||
expect(result.providerType).toBe("codex");
|
||||
@@ -103,10 +103,9 @@ describe("CodexClient", () => {
|
||||
.fn()
|
||||
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
||||
});
|
||||
const result = await client.generateCompletion({
|
||||
systemPrompt: "test",
|
||||
userPrompt: "test",
|
||||
});
|
||||
const result = await client.generateCompletion(
|
||||
singleUserTurn({ system: "test", user: "test" }),
|
||||
);
|
||||
expect(result.content).toBe("Hello world");
|
||||
});
|
||||
|
||||
@@ -135,10 +134,9 @@ describe("CodexClient", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await client.generateCompletion({
|
||||
systemPrompt: "test",
|
||||
userPrompt: "test",
|
||||
});
|
||||
const result = await client.generateCompletion(
|
||||
singleUserTurn({ system: "test", user: "test" }),
|
||||
);
|
||||
|
||||
expect(onRefresh).toHaveBeenCalledOnce();
|
||||
expect(result.content).toBe('{"test":true}');
|
||||
@@ -167,7 +165,9 @@ describe("CodexClient", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.generateCompletion({ systemPrompt: "test", userPrompt: "test" }),
|
||||
client.generateCompletion(
|
||||
singleUserTurn({ system: "test", user: "test" }),
|
||||
),
|
||||
).rejects.toThrow("Codex request failed after token refresh (401)");
|
||||
});
|
||||
|
||||
@@ -192,7 +192,9 @@ describe("CodexClient", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.generateCompletion({ systemPrompt: "test", userPrompt: "test" }),
|
||||
client.generateCompletion(
|
||||
singleUserTurn({ system: "test", user: "test" }),
|
||||
),
|
||||
).rejects.toThrow("Codex request failed (500)");
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -213,11 +215,13 @@ describe("CodexClient", () => {
|
||||
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
||||
});
|
||||
|
||||
await client.generateCompletion({
|
||||
systemPrompt: "Transcribe.",
|
||||
userPrompt: "Read this report.",
|
||||
images: [{ mediaType: "image/png", dataBase64: "QUJD" }],
|
||||
});
|
||||
await client.generateCompletion(
|
||||
singleUserTurn({
|
||||
system: "Transcribe.",
|
||||
user: "Read this report.",
|
||||
images: [{ mediaType: "image/png", dataBase64: "QUJD" }],
|
||||
}),
|
||||
);
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
const content = body.input[0].content;
|
||||
@@ -248,14 +252,84 @@ describe("CodexClient", () => {
|
||||
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
||||
});
|
||||
|
||||
await client.generateCompletion({
|
||||
systemPrompt: "Answer.",
|
||||
userPrompt: "Plain text only.",
|
||||
});
|
||||
await client.generateCompletion(
|
||||
singleUserTurn({ system: "Answer.", user: "Plain text only." }),
|
||||
);
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.input[0].content).toEqual([
|
||||
{ type: "input_text", text: "Plain text only." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps tool defs and parses function_call items + cached tokens", async () => {
|
||||
const functionCallEvent = `event: response.output_item.done\ndata: ${JSON.stringify(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "fc_1",
|
||||
name: "fetch_glucose",
|
||||
arguments: '{"window":"last30days"}',
|
||||
},
|
||||
},
|
||||
)}\n\n`;
|
||||
const completedWithCache = `event: response.completed\ndata: ${JSON.stringify(
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp1",
|
||||
usage: {
|
||||
total_tokens: 30,
|
||||
input_tokens_details: { cached_tokens: 20 },
|
||||
},
|
||||
},
|
||||
},
|
||||
)}\n\n`;
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(sseResponse([functionCallEvent, completedWithCache]));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new CodexClient({
|
||||
accessToken: "test-token",
|
||||
accountId: "acct-test",
|
||||
onTokenRefresh: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
||||
});
|
||||
|
||||
const result = await client.generateCompletion(
|
||||
singleUserTurn({
|
||||
system: "s",
|
||||
user: "u",
|
||||
tools: [
|
||||
{
|
||||
name: "fetch_glucose",
|
||||
description: "Fetch glucose readings",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.tools).toEqual([
|
||||
{
|
||||
type: "function",
|
||||
name: "fetch_glucose",
|
||||
description: "Fetch glucose readings",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
]);
|
||||
expect(result.toolCalls).toEqual([
|
||||
{
|
||||
id: "fc_1",
|
||||
name: "fetch_glucose",
|
||||
arguments: '{"window":"last30days"}',
|
||||
},
|
||||
]);
|
||||
expect(result.finishReason).toBe("tool_calls");
|
||||
expect(result.cachedInputTokens).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { CodexClient, __test } from "../codex-client";
|
||||
import { singleUserTurn } from "../types";
|
||||
import {
|
||||
clearCodexSlugCache,
|
||||
getCachedCodexSlug,
|
||||
@@ -94,7 +95,7 @@ describe("Codex slug fallback chain", () => {
|
||||
onTokenRefresh,
|
||||
slugChain: ["gpt-5.3-codex", "gpt-5-codex", "gpt-4o"],
|
||||
});
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
@@ -121,7 +122,7 @@ describe("Codex slug fallback chain", () => {
|
||||
onTokenRefresh,
|
||||
slugChain: ["gpt-5", "gpt-5.3-codex", "gpt-4o"],
|
||||
});
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.parse(mockFetch.mock.calls[0][1].body).model).toBe("gpt-5");
|
||||
@@ -155,7 +156,7 @@ describe("Codex slug fallback chain", () => {
|
||||
onTokenRefresh,
|
||||
slugChain: ["nonexistent-slug", "gpt-5.3-codex"],
|
||||
});
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(client.getLastDiagnostics()?.workingSlug).toBe("gpt-5.3-codex");
|
||||
@@ -174,7 +175,7 @@ describe("Codex slug fallback chain", () => {
|
||||
onTokenRefresh,
|
||||
slugChain: ["dead-slug", "gpt-5.3-codex"],
|
||||
});
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -193,7 +194,7 @@ describe("Codex slug fallback chain", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
|
||||
client.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
|
||||
).rejects.toThrow("Codex request failed (500)");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1); // no walk
|
||||
@@ -213,7 +214,7 @@ describe("Codex slug fallback chain", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
|
||||
client.generateCompletion(singleUserTurn({ system: "s", user: "u" })),
|
||||
).rejects.toThrow("Codex request failed (429)");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
@@ -235,7 +236,7 @@ describe("Codex slug fallback chain", () => {
|
||||
slugChain: ["gpt-5.3-codex", "gpt-4o"],
|
||||
});
|
||||
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
|
||||
|
||||
// Both calls hit the SAME slug — auth retry didn't walk to gpt-4o.
|
||||
expect(JSON.parse(mockFetch.mock.calls[0][1].body).model).toBe(
|
||||
@@ -263,7 +264,9 @@ describe("Codex slug fallback chain", () => {
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
await client.generateCompletion(
|
||||
singleUserTurn({ system: "s", user: "u" }),
|
||||
);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
@@ -291,7 +294,7 @@ describe("Codex slug fallback chain", () => {
|
||||
onTokenRefresh,
|
||||
slugChain: ["gpt-5.3-codex", "gpt-5-codex", "gpt-4o"],
|
||||
});
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.parse(mockFetch.mock.calls[0][1].body).model).toBe("gpt-4o");
|
||||
@@ -314,7 +317,7 @@ describe("Codex slug fallback chain", () => {
|
||||
onTokenRefresh,
|
||||
slugChain: ["gpt-5.3-codex", "gpt-5-codex"],
|
||||
});
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
await client.generateCompletion(singleUserTurn({ system: "s", user: "u" }));
|
||||
|
||||
// After walking to "gpt-5.3-codex" (the next entry after the
|
||||
// cached one), the cache is updated to the new working slug.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -6,6 +6,22 @@ import {
|
||||
} from "../schema";
|
||||
import { generateInsight } from "../generate-insight";
|
||||
import { MockAIProvider } from "../mock-client";
|
||||
import { singleUserTurn, type CompletionParams } from "../types";
|
||||
|
||||
/** Read the last user message's text out of a recorded CompletionParams. */
|
||||
function lastUserText(params: CompletionParams): string {
|
||||
for (let i = params.messages.length - 1; i >= 0; i -= 1) {
|
||||
const m = params.messages[i];
|
||||
if (m.role !== "user") continue;
|
||||
return typeof m.content === "string"
|
||||
? m.content
|
||||
: m.content
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const validResponse = {
|
||||
summary: "Your blood pressure is trending high.",
|
||||
@@ -143,10 +159,10 @@ describe("generateInsight wrapper", () => {
|
||||
const provider = new MockAIProvider({
|
||||
responses: JSON.stringify(validResponse),
|
||||
});
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: "sys",
|
||||
userPrompt: "user",
|
||||
});
|
||||
const outcome = await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({ system: "sys", user: "user" }),
|
||||
);
|
||||
expect(outcome.attempts).toBe(1);
|
||||
expect(outcome.retried).toBe(false);
|
||||
expect(outcome.parsed.summary).toMatch(/blood pressure/);
|
||||
@@ -157,16 +173,16 @@ describe("generateInsight wrapper", () => {
|
||||
const provider = new MockAIProvider({
|
||||
responses: ["definitely not json", JSON.stringify(validResponse)],
|
||||
});
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: "sys",
|
||||
userPrompt: "Original user prompt.",
|
||||
});
|
||||
const outcome = await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({ system: "sys", user: "Original user prompt." }),
|
||||
);
|
||||
expect(outcome.attempts).toBe(2);
|
||||
expect(outcome.retried).toBe(true);
|
||||
expect(provider.callCount).toBe(2);
|
||||
// The retry call must include the original user prompt PLUS the
|
||||
// corrective suffix referencing the schema.
|
||||
const retryUserPrompt = provider.calls[1].userPrompt;
|
||||
const retryUserPrompt = lastUserText(provider.calls[1]);
|
||||
expect(retryUserPrompt).toContain("Original user prompt.");
|
||||
expect(retryUserPrompt).toContain("did not satisfy");
|
||||
expect(retryUserPrompt).toContain("metricSource");
|
||||
@@ -178,10 +194,10 @@ describe("generateInsight wrapper", () => {
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await generateInsight(provider, {
|
||||
systemPrompt: "sys",
|
||||
userPrompt: "user",
|
||||
});
|
||||
await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({ system: "sys", user: "user" }),
|
||||
);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
@@ -220,13 +236,13 @@ describe("generateInsight wrapper", () => {
|
||||
JSON.stringify(validResponse),
|
||||
],
|
||||
});
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: "sys",
|
||||
userPrompt: "u",
|
||||
});
|
||||
const outcome = await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({ system: "sys", user: "u" }),
|
||||
);
|
||||
expect(outcome.attempts).toBe(2);
|
||||
expect(outcome.retried).toBe(true);
|
||||
expect(provider.calls[1].userPrompt).toContain(
|
||||
expect(lastUserText(provider.calls[1])).toContain(
|
||||
"metricSources not in citations",
|
||||
);
|
||||
});
|
||||
@@ -238,7 +254,7 @@ describe("generateInsight wrapper", () => {
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
generateInsight(provider, { systemPrompt: "s", userPrompt: "u" }),
|
||||
generateInsight(provider, singleUserTurn({ system: "s", user: "u" })),
|
||||
).rejects.toThrow("OpenAI request failed (500)");
|
||||
// Provider only called once — wrapper does not retry on provider errors.
|
||||
expect(provider.callCount).toBe(1);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { aiInsightResponseSchema, findUncitedRecommendations } from "../schema";
|
||||
import { generateInsight } from "../generate-insight";
|
||||
import { MockAIProvider } from "../mock-client";
|
||||
import { singleUserTurn } from "../types";
|
||||
|
||||
/**
|
||||
* Phase C1 — scope-hardened system prompt assertions.
|
||||
@@ -130,13 +131,15 @@ describe("out-of-scope refusal payloads", () => {
|
||||
const provider = new MockAIProvider({
|
||||
responses: JSON.stringify(OUT_OF_SCOPE_REFUSAL_EN),
|
||||
});
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: getStrictInsightsSystemPrompt("en"),
|
||||
// Deliberately out-of-scope user prompt — model is instructed to
|
||||
// return the refusal rather than fabricate health metrics.
|
||||
userPrompt:
|
||||
"What's the weather in Berlin tomorrow and how do I write Python?",
|
||||
});
|
||||
const outcome = await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({
|
||||
system: getStrictInsightsSystemPrompt("en"),
|
||||
// Deliberately out-of-scope user prompt — model is instructed to
|
||||
// return the refusal rather than fabricate health metrics.
|
||||
user: "What's the weather in Berlin tomorrow and how do I write Python?",
|
||||
}),
|
||||
);
|
||||
expect(outcome.attempts).toBe(1);
|
||||
expect(outcome.parsed.recommendations).toEqual([]);
|
||||
expect(outcome.parsed.summary).toContain("only summarise");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { generateInsight } from "../generate-insight";
|
||||
import { MockAIProvider } from "../mock-client";
|
||||
import { singleUserTurn } from "../types";
|
||||
|
||||
const annotateMock = vi.fn();
|
||||
|
||||
@@ -75,13 +76,13 @@ describe("generateInsight() — corrective retry mentions rationale", () => {
|
||||
const provider = new MockAIProvider({
|
||||
responses: [JSON.stringify(broken), JSON.stringify(validResponse)],
|
||||
});
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: "sys",
|
||||
userPrompt: "Original user prompt.",
|
||||
});
|
||||
const outcome = await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({ system: "sys", user: "Original user prompt." }),
|
||||
);
|
||||
expect(outcome.attempts).toBe(2);
|
||||
expect(outcome.retried).toBe(true);
|
||||
const retryUserPrompt = provider.calls[1].userPrompt;
|
||||
const retryUserPrompt = provider.calls[1].messages[0].content as string;
|
||||
expect(retryUserPrompt).toContain("Original user prompt.");
|
||||
expect(retryUserPrompt).toContain("rationale");
|
||||
expect(retryUserPrompt).toContain("dataWindow");
|
||||
@@ -108,10 +109,10 @@ describe("generateInsight() — corrective retry mentions rationale", () => {
|
||||
const provider = new MockAIProvider({
|
||||
responses: [JSON.stringify(broken), JSON.stringify(validResponse)],
|
||||
});
|
||||
const outcome = await generateInsight(provider, {
|
||||
systemPrompt: "sys",
|
||||
userPrompt: "user",
|
||||
});
|
||||
const outcome = await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({ system: "sys", user: "user" }),
|
||||
);
|
||||
expect(outcome.attempts).toBe(2);
|
||||
expect(outcome.retried).toBe(true);
|
||||
});
|
||||
@@ -126,10 +127,10 @@ describe("generateInsight() — rationale-coverage annotation", () => {
|
||||
const provider = new MockAIProvider({
|
||||
responses: JSON.stringify(validResponse),
|
||||
});
|
||||
await generateInsight(provider, {
|
||||
systemPrompt: "sys",
|
||||
userPrompt: "user",
|
||||
});
|
||||
await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({ system: "sys", user: "user" }),
|
||||
);
|
||||
expect(annotateMock).toHaveBeenCalled();
|
||||
const meta = annotateMock.mock.calls[0][0]?.meta as Record<string, unknown>;
|
||||
expect(meta).toMatchObject({
|
||||
@@ -157,10 +158,10 @@ describe("generateInsight() — rationale-coverage annotation", () => {
|
||||
const provider = new MockAIProvider({
|
||||
responses: JSON.stringify(twoRecs),
|
||||
});
|
||||
await generateInsight(provider, {
|
||||
systemPrompt: "sys",
|
||||
userPrompt: "user",
|
||||
});
|
||||
await generateInsight(
|
||||
provider,
|
||||
singleUserTurn({ system: "sys", user: "user" }),
|
||||
);
|
||||
const meta = annotateMock.mock.calls[0][0]?.meta as Record<string, unknown>;
|
||||
expect(meta).toMatchObject({
|
||||
ai_rationale_total_recommendations: 2,
|
||||
|
||||
+231
-72
@@ -1,5 +1,13 @@
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
import type {
|
||||
AIProvider,
|
||||
AiContentPart,
|
||||
AiMessage,
|
||||
AiToolCall,
|
||||
AiToolDef,
|
||||
CompletionParams,
|
||||
CompletionResult,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* v1.18.9 — Anthropic Messages API content blocks. The text-only path sends a
|
||||
@@ -7,6 +15,10 @@ import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
* uploaded image / PDF is framed strictly as DATA to transcribe by the system
|
||||
* prompt — never as instructions (prompt-injection backstop is the human
|
||||
* review screen downstream).
|
||||
*
|
||||
* v1.20.0 — adds the `tool_use` (assistant requests a tool) and `tool_result`
|
||||
* (a `role:"user"` turn answering one) blocks so a multi-round tool loop (F1)
|
||||
* round-trips natively.
|
||||
*/
|
||||
type AnthropicContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
@@ -17,45 +29,22 @@ type AnthropicContentBlock =
|
||||
| {
|
||||
type: "document";
|
||||
source: { type: "base64"; media_type: "application/pdf"; data: string };
|
||||
}
|
||||
| {
|
||||
type: "tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: "tool_result";
|
||||
tool_use_id: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the user-turn content for a vision request: the image / document
|
||||
* blocks first (so the model reads the report before the instruction), then
|
||||
* the JSON-wrapped instruction text. Returns null when there is nothing to
|
||||
* attach, so the caller keeps the bare-string text path unchanged.
|
||||
*/
|
||||
function buildVisionContent(
|
||||
params: CompletionParams,
|
||||
text: string,
|
||||
): AnthropicContentBlock[] | null {
|
||||
const images = params.images ?? [];
|
||||
const documents = params.documents ?? [];
|
||||
if (images.length === 0 && documents.length === 0) return null;
|
||||
|
||||
const blocks: AnthropicContentBlock[] = [];
|
||||
for (const img of images) {
|
||||
blocks.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: img.mediaType,
|
||||
data: img.dataBase64,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const doc of documents) {
|
||||
blocks.push({
|
||||
type: "document",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "application/pdf",
|
||||
data: doc.dataBase64,
|
||||
},
|
||||
});
|
||||
}
|
||||
blocks.push({ type: "text", text });
|
||||
return blocks;
|
||||
interface AnthropicWireMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string | AnthropicContentBlock[];
|
||||
}
|
||||
|
||||
interface AnthropicClientConfig {
|
||||
@@ -67,13 +56,99 @@ interface AnthropicClientConfig {
|
||||
const DEFAULT_BASE_URL = "https://api.anthropic.com/v1";
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
|
||||
const JSON_INSTRUCTION =
|
||||
"\n\nRespond only with valid JSON matching the requested schema. Do not include any prose, markdown fences, or explanation outside the JSON object.";
|
||||
|
||||
/**
|
||||
* Wrap the user prompt so Claude reliably emits valid JSON.
|
||||
* Anthropic's Messages API has no `response_format: json_object` knob; the
|
||||
* documented best-practice is an explicit instruction in the user message.
|
||||
* Map an `AiContentPart[]` body into Anthropic content blocks. The media blocks
|
||||
* (image / document) are emitted FIRST and the text blocks LAST so the model
|
||||
* reads the report before the instruction — matching the pre-refactor
|
||||
* `buildVisionContent` ordering exactly.
|
||||
*/
|
||||
function wrapForJson(userPrompt: string): string {
|
||||
return `${userPrompt}\n\nRespond only with valid JSON matching the requested schema. Do not include any prose, markdown fences, or explanation outside the JSON object.`;
|
||||
function mapParts(parts: AiContentPart[]): AnthropicContentBlock[] {
|
||||
const media: AnthropicContentBlock[] = [];
|
||||
const text: AnthropicContentBlock[] = [];
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
text.push({ type: "text", text: part.text });
|
||||
} else if (part.type === "image") {
|
||||
media.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: part.mediaType,
|
||||
data: part.dataBase64,
|
||||
},
|
||||
});
|
||||
} else if (part.type === "document") {
|
||||
media.push({
|
||||
type: "document",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "application/pdf",
|
||||
data: part.dataBase64,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...media, ...text];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one `AiMessage` to its Anthropic wire turn. A `role:"tool"` turn becomes
|
||||
* a `role:"user"` turn carrying a `tool_result` block (Anthropic's convention).
|
||||
* An assistant turn with `toolCalls` emits `tool_use` blocks alongside any text.
|
||||
*/
|
||||
function mapMessage(m: AiMessage): AnthropicWireMessage {
|
||||
if (m.role === "tool") {
|
||||
return {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: m.toolCallId ?? "",
|
||||
content: typeof m.content === "string" ? m.content : "",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const hasToolCalls =
|
||||
m.role === "assistant" && !!m.toolCalls && m.toolCalls.length > 0;
|
||||
|
||||
// Plain-string content with no tool calls stays a bare string on the wire —
|
||||
// byte-identical to the pre-refactor text-only path. Only build a block array
|
||||
// when there are content parts or tool_use blocks to carry.
|
||||
if (typeof m.content === "string" && !hasToolCalls) {
|
||||
return { role: m.role, content: m.content };
|
||||
}
|
||||
|
||||
const blocks: AnthropicContentBlock[] =
|
||||
typeof m.content === "string"
|
||||
? m.content.length > 0
|
||||
? [{ type: "text", text: m.content }]
|
||||
: []
|
||||
: mapParts(m.content);
|
||||
if (hasToolCalls) {
|
||||
for (const tc of m.toolCalls!) {
|
||||
let input: Record<string, unknown> = {};
|
||||
try {
|
||||
input = JSON.parse(tc.arguments) as Record<string, unknown>;
|
||||
} catch {
|
||||
input = {};
|
||||
}
|
||||
blocks.push({ type: "tool_use", id: tc.id, name: tc.name, input });
|
||||
}
|
||||
}
|
||||
return { role: m.role, content: blocks };
|
||||
}
|
||||
|
||||
/** Map tool defs into the Anthropic `tools` array. */
|
||||
function buildAnthropicTools(tools: AiToolDef[]) {
|
||||
return tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
input_schema: t.parameters,
|
||||
}));
|
||||
}
|
||||
|
||||
export class AnthropicClient implements AIProvider {
|
||||
@@ -93,14 +168,71 @@ export class AnthropicClient implements AIProvider {
|
||||
);
|
||||
const url = `${baseUrl}/messages`;
|
||||
|
||||
// v1.18.9 — fold vision inputs (Lab-OCR) into the user turn when present.
|
||||
// The text-only path keeps a bare-string content; the vision path sends a
|
||||
// typed content array (image / document blocks + the instruction text).
|
||||
// The `{`-prefill JSON trick works after either shape.
|
||||
const wrappedText = wrapForJson(params.userPrompt);
|
||||
const visionContent = buildVisionContent(params, wrappedText);
|
||||
const userContent: AnthropicContentBlock[] | string =
|
||||
visionContent ?? wrappedText;
|
||||
const hasTools = !!params.tools && params.tools.length > 0;
|
||||
// v1.20.0 — the `{`-prefill JSON trick injects an assistant turn that is
|
||||
// incompatible with a `tool_use` response in the same call, so tools and
|
||||
// the prefill are mutually exclusive: when tools are present we drop the
|
||||
// prefill (the tool flow is non-JSON-prefill).
|
||||
const usePrefill = params.responseFormat === "json" && !hasTools;
|
||||
|
||||
const wireMessages: AnthropicWireMessage[] =
|
||||
params.messages.map(mapMessage);
|
||||
|
||||
// JSON-reliability instruction. The pre-refactor client appended this to
|
||||
// the user prompt UNCONDITIONALLY (not gated on `responseFormat`), so every
|
||||
// Anthropic caller — including the Coach prose path — saw it. Preserve that
|
||||
// exactly by appending to the LAST user turn's text. Tools imply the
|
||||
// non-JSON tool flow, so skip the instruction when tools are present.
|
||||
if (!hasTools) {
|
||||
for (let i = wireMessages.length - 1; i >= 0; i -= 1) {
|
||||
if (wireMessages[i].role !== "user") continue;
|
||||
const turn = wireMessages[i];
|
||||
if (typeof turn.content === "string") {
|
||||
turn.content = `${turn.content}${JSON_INSTRUCTION}`;
|
||||
} else {
|
||||
// Merge into the trailing text block when there is one (vision turns
|
||||
// end with the instruction text) so the wire stays a single text
|
||||
// block — byte-identical to the pre-refactor `wrapForJson` output.
|
||||
const last = turn.content[turn.content.length - 1];
|
||||
if (last && last.type === "text") {
|
||||
last.text = `${last.text}${JSON_INSTRUCTION}`;
|
||||
} else {
|
||||
turn.content = [
|
||||
...turn.content,
|
||||
{ type: "text", text: JSON_INSTRUCTION },
|
||||
];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (usePrefill) {
|
||||
wireMessages.push({ role: "assistant", content: "{" });
|
||||
}
|
||||
|
||||
// v1.20.0 — prompt-cache the stable system prefix. Anthropic needs an
|
||||
// explicit `cache_control` marker; the system block carries the large
|
||||
// brand-free reference grounding + persona, so marking it lets repeated
|
||||
// calls (status batch, briefing) read it from cache. Surface
|
||||
// `cache_read_input_tokens` for observability.
|
||||
const system = [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: params.system,
|
||||
cache_control: { type: "ephemeral" as const },
|
||||
},
|
||||
];
|
||||
|
||||
const tools = hasTools
|
||||
? buildAnthropicTools(params.tools as AiToolDef[])
|
||||
: undefined;
|
||||
const toolChoice =
|
||||
hasTools && params.toolChoice
|
||||
? params.toolChoice === "none"
|
||||
? { type: "none" as const }
|
||||
: { type: "auto" as const }
|
||||
: undefined;
|
||||
|
||||
const res = await safeFetch(
|
||||
url,
|
||||
@@ -114,26 +246,14 @@ export class AnthropicClient implements AIProvider {
|
||||
// NOTE: Anthropic's Messages API has no `seed` parameter, so
|
||||
// `params.seed` is intentionally not forwarded here — output on this
|
||||
// provider is non-deterministic regardless of the pinned seed.
|
||||
//
|
||||
// v1.18.7 — JSON-reliability prefill. For the structured surfaces
|
||||
// (`responseFormat: "json"`) we seed the assistant turn with a bare
|
||||
// `{`. Anthropic continues from that token, so the first emitted
|
||||
// character is already inside a JSON object — this drops the
|
||||
// first-pass "prose preamble before the JSON" failure the
|
||||
// fence-stripping net otherwise has to catch. We re-prepend the `{`
|
||||
// to the returned text below so the caller sees a complete object.
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
temperature: params.temperature ?? 0.3,
|
||||
system: params.systemPrompt,
|
||||
messages:
|
||||
params.responseFormat === "json"
|
||||
? [
|
||||
{ role: "user", content: userContent },
|
||||
{ role: "assistant", content: "{" },
|
||||
]
|
||||
: [{ role: "user", content: userContent }],
|
||||
system,
|
||||
messages: wireMessages,
|
||||
...(tools ? { tools } : {}),
|
||||
...(toolChoice ? { tool_choice: toolChoice } : {}),
|
||||
}),
|
||||
},
|
||||
// 60 s ceiling — see openai-client.ts for the rationale.
|
||||
@@ -164,14 +284,40 @@ export class AnthropicClient implements AIProvider {
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
usage?: { input_tokens?: number; output_tokens?: number };
|
||||
content?: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: Record<string, unknown>;
|
||||
}>;
|
||||
stop_reason?: string;
|
||||
usage?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const textBlock = json.content?.find((c) => c.type === "text");
|
||||
const rawText = textBlock?.text;
|
||||
|
||||
if (!rawText) {
|
||||
// v1.20.0 — surface tool_use blocks for F1's loop.
|
||||
const toolUseBlocks = (json.content ?? []).filter(
|
||||
(c) => c.type === "tool_use",
|
||||
);
|
||||
const toolCalls: AiToolCall[] | undefined =
|
||||
toolUseBlocks.length > 0
|
||||
? toolUseBlocks.map((b) => ({
|
||||
id: b.id ?? "",
|
||||
name: b.name ?? "",
|
||||
arguments: JSON.stringify(b.input ?? {}),
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
// A tool_use-only reply carries no text — that is valid (F1). Only an empty
|
||||
// reply with neither text NOR a tool call is an error.
|
||||
if (!rawText && !toolCalls) {
|
||||
throw new Error("Anthropic returned empty content");
|
||||
}
|
||||
|
||||
@@ -180,19 +326,32 @@ export class AnthropicClient implements AIProvider {
|
||||
// it so the caller parses a complete object. Guard against a model that
|
||||
// (rarely) echoes the prefill itself.
|
||||
const content =
|
||||
params.responseFormat === "json" && !rawText.trimStart().startsWith("{")
|
||||
usePrefill && rawText && !rawText.trimStart().startsWith("{")
|
||||
? `{${rawText}`
|
||||
: rawText;
|
||||
: (rawText ?? "");
|
||||
|
||||
const inputTokens = json.usage?.input_tokens ?? 0;
|
||||
const outputTokens = json.usage?.output_tokens ?? 0;
|
||||
const tokensUsed = inputTokens + outputTokens || null;
|
||||
const cachedInputTokens = json.usage?.cache_read_input_tokens ?? null;
|
||||
|
||||
const finishReason: CompletionResult["finishReason"] =
|
||||
json.stop_reason === "tool_use"
|
||||
? "tool_calls"
|
||||
: json.stop_reason === "max_tokens"
|
||||
? "length"
|
||||
: json.stop_reason === "end_turn"
|
||||
? "stop"
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
content,
|
||||
tokensUsed,
|
||||
cachedInputTokens,
|
||||
model: this.config.model,
|
||||
providerType: "anthropic",
|
||||
...(toolCalls ? { toolCalls } : {}),
|
||||
finishReason,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
* Server-only — reads `@/lib/db` through the provider resolver.
|
||||
*/
|
||||
import { hasAnyConfiguredProvider, resolveProvider } from "@/lib/ai/provider";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import {
|
||||
buildDateKey,
|
||||
getDailyTokenSpend,
|
||||
@@ -151,12 +152,14 @@ export async function deriveClarifyingQuestions(
|
||||
resolveLocale(locale),
|
||||
snapshotJson,
|
||||
);
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
temperature: 0.4,
|
||||
maxTokens: QUESTIONS_MAX_TOKENS,
|
||||
});
|
||||
const result = await provider.generateCompletion(
|
||||
singleUserTurn({
|
||||
system: systemPrompt,
|
||||
user: userPrompt,
|
||||
temperature: 0.4,
|
||||
maxTokens: QUESTIONS_MAX_TOKENS,
|
||||
}),
|
||||
);
|
||||
if (typeof result.tokensUsed === "number" && result.tokensUsed > 0) {
|
||||
await recordSpend({ userId, tokens: result.tokensUsed, dateKey });
|
||||
}
|
||||
|
||||
+145
-25
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { z } from "zod/v4";
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
import {
|
||||
appendToLastUserMessage,
|
||||
type AIProvider,
|
||||
type CompletionParams,
|
||||
type CompletionResult,
|
||||
} from "./types";
|
||||
import {
|
||||
aiInsightResponseSchema,
|
||||
findRecommendationsMissingRationale,
|
||||
@@ -248,10 +253,10 @@ export async function generateInsight(
|
||||
first.reason ?? "schema mismatch",
|
||||
first.details ?? "",
|
||||
);
|
||||
const retryParams: CompletionParams = {
|
||||
...params,
|
||||
userPrompt: `${params.userPrompt}\n\n${correction}`,
|
||||
};
|
||||
const retryParams: CompletionParams = appendToLastUserMessage(
|
||||
params,
|
||||
`\n\n${correction}`,
|
||||
);
|
||||
const second = await tryOnce(provider, retryParams);
|
||||
if (second.ok && second.parsed) {
|
||||
annotateCitationCoverage(second.parsed);
|
||||
|
||||
+52
-25
@@ -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 +
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
import type {
|
||||
AIProvider,
|
||||
AiToolCall,
|
||||
CompletionParams,
|
||||
CompletionResult,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Deterministic mock AI provider for tests.
|
||||
@@ -37,6 +42,23 @@ export interface MockAIProviderOptions {
|
||||
* or per-call array, same semantics as `responses`).
|
||||
*/
|
||||
tokensUsed?: number | number[] | null;
|
||||
/**
|
||||
* v1.20.0 — tool calls echoed on the result (single set replayed every
|
||||
* call, or an array consumed per-call). Lets F1 exercise its tool loop
|
||||
* against the mock without a live provider.
|
||||
*/
|
||||
toolCalls?: AiToolCall[] | AiToolCall[][];
|
||||
/** v1.20.0 — finishReason echoed on the result. Defaults to undefined. */
|
||||
finishReason?: CompletionResult["finishReason"];
|
||||
/** v1.20.0 — cachedInputTokens echoed on the result. Defaults to null. */
|
||||
cachedInputTokens?: number | null;
|
||||
}
|
||||
|
||||
/** Type guard: a per-call array of tool-call sets. */
|
||||
function isToolCallMatrix(
|
||||
v: AiToolCall[] | AiToolCall[][] | undefined,
|
||||
): v is AiToolCall[][] {
|
||||
return Array.isArray(v) && v.length > 0 && Array.isArray(v[0]);
|
||||
}
|
||||
|
||||
// Conforms to v1.4.15 strict `aiInsightResponseSchema`. Tests that need
|
||||
@@ -50,9 +72,14 @@ const DEFAULT_RESPONSE = JSON.stringify({
|
||||
|
||||
export class MockAIProvider implements AIProvider {
|
||||
readonly type: "codex" | "admin-key" | "anthropic" | "local";
|
||||
/** The mock can echo tool calls, so it advertises tool support. */
|
||||
readonly supportsTools = true;
|
||||
readonly calls: CompletionParams[] = [];
|
||||
private readonly responses: string[];
|
||||
private readonly tokens: Array<number | null>;
|
||||
private readonly toolCalls: AiToolCall[][] | undefined;
|
||||
private readonly finishReason: CompletionResult["finishReason"];
|
||||
private readonly cachedInputTokens: number | null;
|
||||
private readonly model: string;
|
||||
private readonly rejectWith: Error | undefined;
|
||||
private callIdx = 0;
|
||||
@@ -61,6 +88,15 @@ export class MockAIProvider implements AIProvider {
|
||||
this.type = opts.providerType ?? "local";
|
||||
this.model = opts.model ?? "mock-model";
|
||||
this.rejectWith = opts.rejectWith;
|
||||
this.finishReason = opts.finishReason;
|
||||
this.cachedInputTokens = opts.cachedInputTokens ?? null;
|
||||
if (isToolCallMatrix(opts.toolCalls)) {
|
||||
this.toolCalls = opts.toolCalls;
|
||||
} else if (Array.isArray(opts.toolCalls)) {
|
||||
this.toolCalls = [opts.toolCalls];
|
||||
} else {
|
||||
this.toolCalls = undefined;
|
||||
}
|
||||
if (Array.isArray(opts.responses)) {
|
||||
this.responses =
|
||||
opts.responses.length > 0 ? opts.responses : [DEFAULT_RESPONSE];
|
||||
@@ -87,12 +123,18 @@ export class MockAIProvider implements AIProvider {
|
||||
}
|
||||
const idx = Math.min(this.callIdx, this.responses.length - 1);
|
||||
const tokIdx = Math.min(this.callIdx, this.tokens.length - 1);
|
||||
const toolCalls = this.toolCalls
|
||||
? this.toolCalls[Math.min(this.callIdx, this.toolCalls.length - 1)]
|
||||
: undefined;
|
||||
this.callIdx++;
|
||||
return {
|
||||
content: this.responses[idx],
|
||||
tokensUsed: this.tokens[tokIdx] ?? null,
|
||||
cachedInputTokens: this.cachedInputTokens,
|
||||
model: this.model,
|
||||
providerType: this.type,
|
||||
...(toolCalls ? { toolCalls } : {}),
|
||||
finishReason: this.finishReason,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+36
-27
@@ -1,5 +1,13 @@
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
import {
|
||||
buildOpenAIMessages,
|
||||
buildOpenAITools,
|
||||
mapFinishReason,
|
||||
parseCachedTokens,
|
||||
parseOpenAIToolCalls,
|
||||
type OpenAIResponseJson,
|
||||
} from "./openai-wire";
|
||||
|
||||
interface OpenAIClientConfig {
|
||||
apiKey: string;
|
||||
@@ -31,25 +39,20 @@ export class OpenAIClient implements AIProvider {
|
||||
): Promise<CompletionResult> {
|
||||
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
|
||||
// v1.18.9 — fold vision inputs (Lab-OCR) into the user turn when present.
|
||||
// The text-only path keeps the bare-string content; the vision path sends
|
||||
// the multimodal `image_url` content array `gpt-4o`-class models accept
|
||||
// (a data-URL per image). PDFs are NOT handled here (the OCR route gates
|
||||
// PDF uploads to Anthropic); only `params.images` is consumed. The image
|
||||
// is framed as untrusted DATA by the system prompt.
|
||||
const images = params.images ?? [];
|
||||
const userContent =
|
||||
images.length > 0
|
||||
? [
|
||||
{ type: "text" as const, text: params.userPrompt },
|
||||
...images.map((img) => ({
|
||||
type: "image_url" as const,
|
||||
image_url: {
|
||||
url: `data:${img.mediaType};base64,${img.dataBase64}`,
|
||||
},
|
||||
})),
|
||||
]
|
||||
: params.userPrompt;
|
||||
// System turn first (the stable cache prefix — OpenAI prefix-caches a
|
||||
// byte-identical ≥1024-token prefix automatically, no API flag), then the
|
||||
// conversation turns mapped 1:1. Vision parts (Lab-OCR) become the
|
||||
// multimodal `image_url` content array `gpt-4o`-class models accept. The
|
||||
// image is framed as untrusted DATA by the system prompt.
|
||||
const messages = buildOpenAIMessages(params.system, params.messages);
|
||||
|
||||
// v1.20.0 — tool plumbing. The defs map onto the OpenAI `function` tool
|
||||
// wire; F1 supplies real defs and consumes the parsed `toolCalls`. No F4
|
||||
// call site sets `tools`, so the text-only body is unchanged.
|
||||
const tools =
|
||||
params.tools && params.tools.length > 0
|
||||
? buildOpenAITools(params.tools)
|
||||
: undefined;
|
||||
|
||||
const res = await safeFetch(
|
||||
url,
|
||||
@@ -61,10 +64,7 @@ export class OpenAIClient implements AIProvider {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
messages: [
|
||||
{ role: "system", content: params.systemPrompt },
|
||||
{ role: "user", content: userContent },
|
||||
],
|
||||
messages,
|
||||
temperature: params.temperature ?? 0.3,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
response_format: { type: "json_object" },
|
||||
@@ -72,6 +72,8 @@ export class OpenAIClient implements AIProvider {
|
||||
// (undefined → dropped by JSON.stringify) when the caller does
|
||||
// not pin one (e.g. the seedless daily-briefing re-roll).
|
||||
...(params.seed !== undefined ? { seed: params.seed } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
...(params.toolChoice ? { tool_choice: params.toolChoice } : {}),
|
||||
}),
|
||||
},
|
||||
// 60 s ceiling so a tar-pit upstream cannot pin a worker
|
||||
@@ -105,18 +107,25 @@ export class OpenAIClient implements AIProvider {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const content = json.choices?.[0]?.message?.content;
|
||||
const json = (await res.json()) as OpenAIResponseJson;
|
||||
const choice = json.choices?.[0];
|
||||
const content = choice?.message?.content;
|
||||
const toolCalls = parseOpenAIToolCalls(choice);
|
||||
|
||||
if (!content) {
|
||||
// A reply with tool calls and no prose is valid (F1 tool loop); only an
|
||||
// empty reply with neither content NOR tool calls is an error.
|
||||
if (!content && !toolCalls) {
|
||||
throw new Error("OpenAI returned empty content");
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
content: content ?? "",
|
||||
tokensUsed: json.usage?.total_tokens ?? null,
|
||||
cachedInputTokens: parseCachedTokens(json),
|
||||
model: this.config.model,
|
||||
providerType: this.type,
|
||||
...(toolCalls ? { toolCalls } : {}),
|
||||
finishReason: mapFinishReason(choice?.finish_reason),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import type {
|
||||
AiContentPart,
|
||||
AiMessage,
|
||||
AiToolCall,
|
||||
AiToolDef,
|
||||
CompletionResult,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Shared OpenAI Chat-Completions wire mapping. Both the hosted OpenAI client
|
||||
* and the local OpenAI-compatible client (Ollama / LM Studio / vLLM) speak this
|
||||
* shape, so the message → wire translation and the response parsing live here
|
||||
* once. The two clients differ only in how they treat `responseFormat` and the
|
||||
* endpoint/auth, not in the message structure.
|
||||
*/
|
||||
|
||||
type OpenAIChatContent =
|
||||
| string
|
||||
| Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image_url"; image_url: { url: string } }
|
||||
>;
|
||||
|
||||
interface OpenAIChatMessage {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: OpenAIChatContent | null;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: "function";
|
||||
function: { name: string; arguments: string };
|
||||
}>;
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a single `AiMessage` content into the Chat-Completions content shape.
|
||||
* A plain string stays a string (byte-identical to the text-only wire we shipped
|
||||
* before the refactor); parts become the multimodal array. PDF document parts
|
||||
* are dropped — the Chat-Completions wire has no document block (the OCR route
|
||||
* gates PDFs to Anthropic).
|
||||
*/
|
||||
function mapContent(content: string | AiContentPart[]): OpenAIChatContent {
|
||||
if (typeof content === "string") return content;
|
||||
const out: Array<
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image_url"; image_url: { url: string } }
|
||||
> = [];
|
||||
for (const part of content) {
|
||||
if (part.type === "text") {
|
||||
out.push({ type: "text", text: part.text });
|
||||
} else if (part.type === "image") {
|
||||
out.push({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${part.mediaType};base64,${part.dataBase64}` },
|
||||
});
|
||||
}
|
||||
// `document` parts are not representable on this wire; drop them.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full Chat-Completions `messages[]` array: the system turn first,
|
||||
* then every conversation turn mapped 1:1. Assistant `toolCalls` and the
|
||||
* `role:"tool"` result turns map to the OpenAI tool wire so a multi-round
|
||||
* tool loop (F1) round-trips.
|
||||
*/
|
||||
export function buildOpenAIMessages(
|
||||
system: string,
|
||||
messages: AiMessage[],
|
||||
): OpenAIChatMessage[] {
|
||||
const out: OpenAIChatMessage[] = [{ role: "system", content: system }];
|
||||
for (const m of messages) {
|
||||
if (m.role === "tool") {
|
||||
out.push({
|
||||
role: "tool",
|
||||
tool_call_id: m.toolCallId,
|
||||
content:
|
||||
typeof m.content === "string" ? m.content : mapContent(m.content),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const mapped: OpenAIChatMessage = {
|
||||
role: m.role,
|
||||
content: mapContent(m.content),
|
||||
};
|
||||
if (m.role === "assistant" && m.toolCalls && m.toolCalls.length > 0) {
|
||||
mapped.tool_calls = m.toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: "function",
|
||||
function: { name: tc.name, arguments: tc.arguments },
|
||||
}));
|
||||
}
|
||||
out.push(mapped);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Map tool defs into the OpenAI `tools` array. */
|
||||
export function buildOpenAITools(tools: AiToolDef[]) {
|
||||
return tools.map((t) => ({
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
interface OpenAIChoice {
|
||||
message?: {
|
||||
content?: string | null;
|
||||
tool_calls?: Array<{
|
||||
id?: string;
|
||||
function?: { name?: string; arguments?: string };
|
||||
}>;
|
||||
};
|
||||
finish_reason?: string;
|
||||
}
|
||||
|
||||
interface OpenAIResponseJson {
|
||||
choices?: OpenAIChoice[];
|
||||
usage?: {
|
||||
total_tokens?: number;
|
||||
prompt_tokens_details?: { cached_tokens?: number };
|
||||
};
|
||||
}
|
||||
|
||||
/** Map the OpenAI `finish_reason` onto the provider-neutral finishReason. */
|
||||
export function mapFinishReason(
|
||||
reason: string | undefined,
|
||||
): CompletionResult["finishReason"] {
|
||||
if (reason === "tool_calls") return "tool_calls";
|
||||
if (reason === "length") return "length";
|
||||
if (reason === "stop") return "stop";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Pull tool calls off an OpenAI choice into the provider-neutral shape. */
|
||||
export function parseOpenAIToolCalls(
|
||||
choice: OpenAIChoice | undefined,
|
||||
): AiToolCall[] | undefined {
|
||||
const calls = choice?.message?.tool_calls;
|
||||
if (!calls || calls.length === 0) return undefined;
|
||||
return calls.map((c) => ({
|
||||
id: c.id ?? "",
|
||||
name: c.function?.name ?? "",
|
||||
arguments: c.function?.arguments ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
export function parseCachedTokens(json: OpenAIResponseJson): number | null {
|
||||
return json.usage?.prompt_tokens_details?.cached_tokens ?? null;
|
||||
}
|
||||
|
||||
export type { OpenAIChoice, OpenAIResponseJson };
|
||||
+225
-21
@@ -111,12 +111,21 @@ export type ProviderType =
|
||||
|
||||
export interface AIProvider {
|
||||
type: ProviderType;
|
||||
/**
|
||||
* v1.20.0 — per-provider tool-calling capability flag. Defaults to true
|
||||
* (Anthropic / OpenAI / Codex / mock support tools natively). The local
|
||||
* OpenAI-compatible client sets it `false` because most local servers reject
|
||||
* an unknown `tools` field; F1's tool loop must treat a provider with
|
||||
* `supportsTools === false` (or an empty `toolCalls` result) as "no tools
|
||||
* available" and answer from base context.
|
||||
*/
|
||||
supportsTools?: boolean;
|
||||
generateCompletion(params: CompletionParams): Promise<CompletionResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.9 — a single vision input folded into the provider request. Used by
|
||||
* the Lab-OCR extraction path: a photo of a paper report, or a PDF page.
|
||||
* v1.18.9 — a single vision input folded into a message's content parts. Used
|
||||
* by the Lab-OCR extraction path: a photo of a paper report, or a PDF page.
|
||||
* `dataBase64` is the raw file bytes, base64-encoded (no `data:` prefix —
|
||||
* each client wraps it in the wire shape it needs). The blob is NEVER threaded
|
||||
* into an `annotate()` meta or any log field (it is health data).
|
||||
@@ -129,9 +138,10 @@ export interface CompletionImage {
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.9 — a PDF document folded into the provider request. Only Anthropic
|
||||
* accepts a native `document` block over the Messages API we use; the other
|
||||
* clients ignore it (the OCR route gates PDFs to Anthropic providers).
|
||||
* v1.18.9 — a PDF document folded into a message's content parts. Only
|
||||
* Anthropic accepts a native `document` block over the Messages API we use;
|
||||
* the other clients ignore it (the OCR route gates PDFs to Anthropic
|
||||
* providers).
|
||||
*/
|
||||
export interface CompletionDocument {
|
||||
mediaType: "application/pdf";
|
||||
@@ -139,25 +149,94 @@ export interface CompletionDocument {
|
||||
dataBase64: string;
|
||||
}
|
||||
|
||||
// ─── Message-array request shape (v1.20.0) ─────────────────
|
||||
//
|
||||
// The provider contract takes a structured `{ system, messages[] }` request
|
||||
// instead of a single `{ systemPrompt, userPrompt }` string pair. This unlocks
|
||||
// three things in one refactor: provider prompt-caching (Anthropic
|
||||
// `cache_control` on the stable system prefix, OpenAI/local automatic prefix
|
||||
// cache), tool-calling (the typed surface F1 builds on), and clean multi-turn.
|
||||
//
|
||||
// The provider contract is still request → complete-string today: the Coach
|
||||
// fake-tokenises a fully-assembled reply downstream and Codex buffers its SSE
|
||||
// internally, so there is no real token-streaming dependency here.
|
||||
|
||||
/** A plain-text content part. */
|
||||
export interface AiTextPart {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
/** An image content part (Lab-OCR vision). */
|
||||
export interface AiImagePart {
|
||||
type: "image";
|
||||
mediaType: CompletionImage["mediaType"];
|
||||
/** Raw image bytes, base64-encoded (no data-URL prefix). */
|
||||
dataBase64: string;
|
||||
}
|
||||
/** A PDF document content part (Anthropic-only; other clients drop it). */
|
||||
export interface AiDocPart {
|
||||
type: "document";
|
||||
mediaType: "application/pdf";
|
||||
/** Raw PDF bytes, base64-encoded (no data-URL prefix). */
|
||||
dataBase64: string;
|
||||
}
|
||||
export type AiContentPart = AiTextPart | AiImagePart | AiDocPart;
|
||||
|
||||
/**
|
||||
* v1.20.0 — a tool definition handed to the model. `parameters` is a JSON
|
||||
* Schema object; each client maps it into the provider's tool shape. F4 plumbs
|
||||
* the param + per-provider wire mapping; F1 supplies real definitions and the
|
||||
* call→result→answer loop.
|
||||
*/
|
||||
export interface AiToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
/** JSON-schema object describing the tool's arguments. */
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.20.0 — a tool the model asked to call. `arguments` is the raw JSON string
|
||||
* the model produced (parsed by F1's loop, never trusted blindly).
|
||||
*/
|
||||
export interface AiToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.20.0 — one conversation turn. The common case is a plain-string
|
||||
* `content`; vision turns and tool-result turns use `parts[]`. `toolCalls` are
|
||||
* set on assistant turns that requested tools (F1); `toolCallId` ties a
|
||||
* `role:"tool"` turn back to the call it answers (F1).
|
||||
*/
|
||||
export interface AiMessage {
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: string | AiContentPart[];
|
||||
/** Set on assistant turns that called tools (F1 fills these). */
|
||||
toolCalls?: AiToolCall[];
|
||||
/** Set on `role:"tool"` turns — the id of the call this answers. */
|
||||
toolCallId?: string;
|
||||
/**
|
||||
* Anthropic prompt-cache hint — marks a stable message-prefix boundary.
|
||||
* A no-op on every other provider (their prefix caching is automatic).
|
||||
*/
|
||||
cacheBreakpoint?: boolean;
|
||||
}
|
||||
|
||||
export interface CompletionParams {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
/**
|
||||
* Stable system prompt. Carries the brand-free reference grounding, persona
|
||||
* and instructions. Eligible for the provider cache prefix on every client;
|
||||
* keep per-request volatile data (timestamps, interpolated dates) OUT of it
|
||||
* so the cached prefix stays byte-identical across calls.
|
||||
*/
|
||||
system: string;
|
||||
/** The conversation turns. The 80% case is one user turn (see {@link singleUserTurn}). */
|
||||
messages: AiMessage[];
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
/**
|
||||
* v1.18.9 — optional vision inputs (Lab-OCR). Additive: every text-only
|
||||
* caller (Coach, status cards, insights, narratives) leaves these unset, so
|
||||
* the existing wire shape and streaming path are untouched. A client that
|
||||
* cannot read images/documents ignores the field. Treat the contents as
|
||||
* UNTRUSTED data to transcribe — never as instructions.
|
||||
*/
|
||||
images?: CompletionImage[];
|
||||
/**
|
||||
* v1.18.9 — optional PDF document input (Lab-OCR). Anthropic-only; other
|
||||
* clients ignore it. Mutually informative with `images` — the OCR route
|
||||
* sends one or the other depending on the upload type and provider.
|
||||
*/
|
||||
documents?: CompletionDocument[];
|
||||
/**
|
||||
* Optional deterministic seed. Threaded onto the OpenAI and local
|
||||
* (Ollama / OpenAI-compatible) request bodies for reproducible output on
|
||||
@@ -174,13 +253,138 @@ export interface CompletionParams {
|
||||
* The OpenAI client already pins `response_format: json_object`
|
||||
* unconditionally and is unaffected. The Coach (prose, NOT JSON) leaves
|
||||
* this unset, so its streaming path is untouched.
|
||||
*
|
||||
* Mutually exclusive with `tools` on Anthropic: the `{`-prefill injects an
|
||||
* assistant turn that collides with a `tool_use` response, so the Anthropic
|
||||
* client drops the prefill whenever `tools` are present.
|
||||
*/
|
||||
responseFormat?: "json";
|
||||
/**
|
||||
* v1.20.0 — tool definitions offered to the model. F4 plumbs the param +
|
||||
* per-provider wire mapping; no F4 call site sets it. Local providers may
|
||||
* ignore tools entirely — F1's loop must tolerate an empty `toolCalls`.
|
||||
*/
|
||||
tools?: AiToolDef[];
|
||||
/**
|
||||
* v1.20.0 — `"auto"` lets the model decide; `"none"` forbids tool calls.
|
||||
* Mapped per provider; omitted from the wire when unset.
|
||||
*/
|
||||
toolChoice?: "auto" | "none";
|
||||
}
|
||||
|
||||
export interface CompletionResult {
|
||||
content: string;
|
||||
tokensUsed: number | null;
|
||||
/**
|
||||
* v1.20.0 — prompt-cache observability. The count of input tokens served
|
||||
* from the provider's cache, where the provider reports it (Anthropic
|
||||
* `cache_read_input_tokens`, OpenAI/Codex `cached_tokens`). Null / absent
|
||||
* when the provider does not report it or the prefix did not hit.
|
||||
*/
|
||||
cachedInputTokens?: number | null;
|
||||
model: string;
|
||||
providerType: ProviderType;
|
||||
/**
|
||||
* v1.20.0 — populated when the model asked to call tools (F1 consumes).
|
||||
* Absent on a plain completion.
|
||||
*/
|
||||
toolCalls?: AiToolCall[];
|
||||
/**
|
||||
* v1.20.0 — why the model stopped. Lets F1's loop branch only on
|
||||
* `"tool_calls"`. Absent when the provider does not surface it.
|
||||
*/
|
||||
finishReason?: "stop" | "tool_calls" | "length";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergonomic builder for the common single-user-turn completion. Folds an
|
||||
* optional vision payload (`images` / `documents`) into the user message's
|
||||
* content parts so the Lab-OCR path keeps a one-call shape.
|
||||
*/
|
||||
export function singleUserTurn(p: {
|
||||
system: string;
|
||||
user: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
seed?: number;
|
||||
responseFormat?: "json";
|
||||
images?: CompletionImage[];
|
||||
documents?: CompletionDocument[];
|
||||
tools?: AiToolDef[];
|
||||
toolChoice?: "auto" | "none";
|
||||
}): CompletionParams {
|
||||
const images = p.images ?? [];
|
||||
const documents = p.documents ?? [];
|
||||
// Parts order is text-first, then media — the OpenAI / local / Codex wires
|
||||
// emit it in this order. The Anthropic client re-orders to media-first when
|
||||
// it maps the parts (it reads the report before the instruction), preserving
|
||||
// each provider's pre-refactor vision wire byte-for-byte.
|
||||
const content: string | AiContentPart[] =
|
||||
images.length === 0 && documents.length === 0
|
||||
? p.user
|
||||
: [
|
||||
{ type: "text", text: p.user },
|
||||
...images.map(
|
||||
(img): AiImagePart => ({
|
||||
type: "image",
|
||||
mediaType: img.mediaType,
|
||||
dataBase64: img.dataBase64,
|
||||
}),
|
||||
),
|
||||
...documents.map(
|
||||
(doc): AiDocPart => ({
|
||||
type: "document",
|
||||
mediaType: doc.mediaType,
|
||||
dataBase64: doc.dataBase64,
|
||||
}),
|
||||
),
|
||||
];
|
||||
return {
|
||||
system: p.system,
|
||||
messages: [{ role: "user", content }],
|
||||
temperature: p.temperature,
|
||||
maxTokens: p.maxTokens,
|
||||
seed: p.seed,
|
||||
responseFormat: p.responseFormat,
|
||||
tools: p.tools,
|
||||
toolChoice: p.toolChoice,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a copy of `params` whose LAST user message has `suffix` appended to
|
||||
* its text. Preserves the wire shape of the corrective-retry paths (insight
|
||||
* schema retry, OCR retry, briefing grounding correction): they used to append
|
||||
* to the single `userPrompt` string, and this keeps the model seeing one user
|
||||
* turn with the appended text rather than an extra back-to-back user turn.
|
||||
*
|
||||
* Throws if there is no user message to append to — every retry path always
|
||||
* has one, so a throw is a programming error, not a runtime case.
|
||||
*/
|
||||
export function appendToLastUserMessage(
|
||||
params: CompletionParams,
|
||||
suffix: string,
|
||||
): CompletionParams {
|
||||
const messages = [...params.messages];
|
||||
let idx = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i].role === "user") {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (idx === -1) {
|
||||
throw new Error("appendToLastUserMessage: no user message to append to");
|
||||
}
|
||||
const target = messages[idx];
|
||||
if (typeof target.content === "string") {
|
||||
messages[idx] = { ...target, content: `${target.content}${suffix}` };
|
||||
} else {
|
||||
// Vision / parts content — append the suffix as a trailing text part.
|
||||
messages[idx] = {
|
||||
...target,
|
||||
content: [...target.content, { type: "text", text: suffix }],
|
||||
};
|
||||
}
|
||||
return { ...params, messages };
|
||||
}
|
||||
@@ -104,9 +104,11 @@ describe("comprehensive JSON-retry", () => {
|
||||
|
||||
expect(outcome).toEqual({ status: "generated", providerType: "openai" });
|
||||
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(2);
|
||||
// The retry call appends the correction to the user prompt.
|
||||
// The retry call appends the correction to the user message.
|
||||
const retryParams = runRawCompletionWithFallback.mock.calls[1][0].params;
|
||||
expect(retryParams.userPrompt).toContain("did not satisfy the required");
|
||||
expect(retryParams.messages[0].content).toContain(
|
||||
"did not satisfy the required",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails with invalid-json when both attempts miss", async () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/lib/ai/consent-guard";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout";
|
||||
|
||||
/**
|
||||
@@ -172,9 +173,9 @@ export async function runStatusCompletion(
|
||||
runRawCompletionWithFallback({
|
||||
userId,
|
||||
providers: chain,
|
||||
params: {
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
params: singleUserTurn({
|
||||
system: systemPrompt,
|
||||
user: userPrompt,
|
||||
temperature: args.temperature ?? AI_BUDGETS.status.temperature,
|
||||
maxTokens: args.maxTokens ?? AI_BUDGETS.status.maxTokens,
|
||||
// v1.18.7 — status/reference output is reproducible: pin the
|
||||
@@ -183,7 +184,7 @@ export async function runStatusCompletion(
|
||||
// Status cards are JSON by default; the narrative opts out via
|
||||
// `"text"`.
|
||||
responseFormat: args.responseFormat === "text" ? undefined : "json",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
STATUS_PROVIDER_TIMEOUT_MS,
|
||||
null,
|
||||
|
||||
+21
-14
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user