mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge origin/main into fix/briefing-locale
This commit is contained in:
commit
a55ecc5a48
9 files changed
+226
-63
No files matched your search
@@ -4,6 +4,25 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anthropic keys work again on current Claude models.** Every JSON surface
|
||||
on the Anthropic provider (the daily briefing, the status cards, lab and
|
||||
document extraction, anything that asks the model for a JSON object) had
|
||||
been failing with an HTTP 400 on the Claude 4.6, 4.7 and 4.8 family, on
|
||||
Opus 5 and Sonnet 5, and on Fable 5 and 5.1. The client had been steering
|
||||
the reply by starting the assistant turn with an opening brace and
|
||||
gluing it back on afterwards. Those models reject an assistant prefill
|
||||
outright, so a self-hoster who brought a key with a current model got a
|
||||
provider failure on each of those calls while the plain chat path kept
|
||||
working. The prefill is gone on every model; the request now carries a
|
||||
plain instruction to reply with a single JSON object and nothing else,
|
||||
and the reply is narrowed to the object body on the way out, so a fenced
|
||||
or prefixed answer still parses. Reported independently by @sweidinger.
|
||||
- **Lab and document OCR no longer refuse a current Claude model.** The
|
||||
vision check knew the Claude 3 and 4 families by name, so a key pinned to
|
||||
`claude-sonnet-5`, `claude-opus-5` or a Fable model was told the provider
|
||||
cannot read images even though it can. Any Claude tier from the 4 family
|
||||
upward, and every Fable model, now counts as vision-capable. The model
|
||||
preset list in Settings also offers `claude-sonnet-5` and `claude-opus-5`.
|
||||
- Dashboard, Today: a self-hoster using the app in German could see the
|
||||
dashboard lead and the Today briefing in English, and they stayed English
|
||||
no matter how often the page was opened. The nightly briefing writer
|
||||
|
||||
@@ -101,6 +101,8 @@ export const OPENAI_MODEL_PRESETS = [
|
||||
export const ANTHROPIC_MODEL_PRESETS = [
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-7",
|
||||
"claude-sonnet-5",
|
||||
"claude-opus-5",
|
||||
"claude-haiku-4-5",
|
||||
"claude-3-5-sonnet-latest",
|
||||
] as const;
|
||||
|
||||
@@ -77,13 +77,14 @@ describe("AnthropicClient", () => {
|
||||
expect(body.messages[0].content.toLowerCase()).toContain("json");
|
||||
});
|
||||
|
||||
it("prefills the assistant turn with `{` and re-prepends it for JSON surfaces", async () => {
|
||||
it("sends no assistant turn for JSON surfaces and instructs the last user turn", async () => {
|
||||
// Current Anthropic models reject an assistant prefill with HTTP 400, so
|
||||
// the JSON contract rides on the instruction alone.
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
// Model continues from the prefilled `{`, so its text omits it.
|
||||
content: [{ type: "text", text: '"summary":"ok"}' }],
|
||||
content: [{ type: "text", text: '{"summary":"ok"}' }],
|
||||
usage: { input_tokens: 5, output_tokens: 5 },
|
||||
}),
|
||||
});
|
||||
@@ -91,23 +92,103 @@ describe("AnthropicClient", () => {
|
||||
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-test",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
model: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
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"}');
|
||||
expect(JSON.parse(result.content)).toEqual({ summary: "ok" });
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.messages).toHaveLength(2);
|
||||
expect(body.messages[1]).toEqual({ role: "assistant", content: "{" });
|
||||
expect(body.messages).toHaveLength(1);
|
||||
expect(
|
||||
body.messages.some((m: { role: string }) => m.role === "assistant"),
|
||||
).toBe(false);
|
||||
const last = body.messages[body.messages.length - 1];
|
||||
expect(last.role).toBe("user");
|
||||
expect(last.content).toMatch(/single JSON object/i);
|
||||
expect(last.content).toMatch(/code fences/i);
|
||||
});
|
||||
|
||||
it("does NOT prefill for the prose (non-JSON) path", async () => {
|
||||
it("parses a plain `{...}` JSON reply", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
content: [{ type: "text", text: '{"summary":"plain"}' }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-test",
|
||||
model: "claude-sonnet-4-6",
|
||||
});
|
||||
const result = await client.generateCompletion(
|
||||
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
|
||||
);
|
||||
expect(result.content).toBe('{"summary":"plain"}');
|
||||
expect(JSON.parse(result.content)).toEqual({ summary: "plain" });
|
||||
});
|
||||
|
||||
it("unwraps a ```json-fenced JSON reply", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
content: [
|
||||
{ type: "text", text: '```json\n{"summary":"fenced"}\n```' },
|
||||
],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-test",
|
||||
model: "claude-sonnet-4-6",
|
||||
});
|
||||
const result = await client.generateCompletion(
|
||||
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
|
||||
);
|
||||
expect(result.content).toBe('{"summary":"fenced"}');
|
||||
expect(JSON.parse(result.content)).toEqual({ summary: "fenced" });
|
||||
});
|
||||
|
||||
it("trims a leading sentence before the JSON object", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: 'Here is the requested JSON:\n{"summary":"prefixed"}',
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-test",
|
||||
model: "claude-sonnet-4-6",
|
||||
});
|
||||
const result = await client.generateCompletion(
|
||||
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
|
||||
);
|
||||
expect(result.content).toBe('{"summary":"prefixed"}');
|
||||
expect(JSON.parse(result.content)).toEqual({ summary: "prefixed" });
|
||||
});
|
||||
|
||||
it("sends a single user turn on the prose (non-JSON) path", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
@@ -204,7 +285,7 @@ describe("AnthropicClient", () => {
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
content: [{ type: "text", text: '"rows":[]}' }],
|
||||
content: [{ type: "text", text: '{"rows":[]}' }],
|
||||
usage: { input_tokens: 100, output_tokens: 10 },
|
||||
}),
|
||||
});
|
||||
@@ -242,8 +323,8 @@ describe("AnthropicClient", () => {
|
||||
});
|
||||
const doc = content.find((b: { type: string }) => b.type === "document");
|
||||
expect(doc.source.media_type).toBe("application/pdf");
|
||||
// The assistant `{`-prefill still rides after the array content.
|
||||
expect(body.messages[1]).toEqual({ role: "assistant", content: "{" });
|
||||
// No assistant turn follows the array content.
|
||||
expect(body.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a bare-string user content when no vision input is present", async () => {
|
||||
@@ -286,7 +367,7 @@ describe("AnthropicClient", () => {
|
||||
).rejects.toThrow("Anthropic returned empty content");
|
||||
});
|
||||
|
||||
it("maps tools, drops the JSON prefill, parses tool_use + cache reads", async () => {
|
||||
it("maps tools, skips the JSON instruction, parses tool_use + cache reads", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
@@ -318,7 +399,7 @@ describe("AnthropicClient", () => {
|
||||
singleUserTurn({
|
||||
system: "s",
|
||||
user: "u",
|
||||
// Even with responseFormat json, tools must win and drop the prefill.
|
||||
// Even with responseFormat json, tools win: no JSON instruction.
|
||||
responseFormat: "json",
|
||||
tools: [
|
||||
{
|
||||
@@ -340,8 +421,9 @@ describe("AnthropicClient", () => {
|
||||
},
|
||||
]);
|
||||
expect(body.tool_choice).toEqual({ type: "auto" });
|
||||
// No `{`-prefill assistant turn when tools are present.
|
||||
// A single user turn, and the JSON instruction stays off the tool path.
|
||||
expect(body.messages).toHaveLength(1);
|
||||
expect(body.messages[0].content).toBe("u");
|
||||
expect(result.toolCalls).toEqual([
|
||||
{
|
||||
id: "toolu_1",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractJsonObject } from "../json-extract";
|
||||
|
||||
describe("extractJsonObject", () => {
|
||||
it("returns a plain object untouched", () => {
|
||||
expect(extractJsonObject('{"summary":"ok"}')).toBe('{"summary":"ok"}');
|
||||
});
|
||||
|
||||
it("unwraps a ```json fence", () => {
|
||||
expect(extractJsonObject('```json\n{"summary":"ok"}\n```')).toBe(
|
||||
'{"summary":"ok"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("unwraps a bare ``` fence", () => {
|
||||
expect(extractJsonObject('```\n{"summary":"ok"}\n```')).toBe(
|
||||
'{"summary":"ok"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("trims a leading sentence and a trailing remark around the object", () => {
|
||||
expect(
|
||||
extractJsonObject('Here is the result:\n{"summary":"ok"}\nDone.'),
|
||||
).toBe('{"summary":"ok"}');
|
||||
});
|
||||
|
||||
it("keeps nested braces inside the outermost span", () => {
|
||||
const nested = '{"a":{"b":[{"c":1}]},"d":"}"}';
|
||||
expect(extractJsonObject(`Sure!\n${nested}`)).toBe(nested);
|
||||
});
|
||||
|
||||
it("returns the trimmed input when there is no object body", () => {
|
||||
expect(extractJsonObject(" plain prose ")).toBe("plain prose");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,19 @@ describe("supportsVisionForConfig", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts current Claude 5-generation and Fable pins for anthropic", () => {
|
||||
for (const model of [
|
||||
"claude-sonnet-5",
|
||||
"claude-opus-5",
|
||||
"claude-haiku-4-5",
|
||||
"claude-fable-5",
|
||||
"claude-fable-5-1",
|
||||
"claude-opus-4-8",
|
||||
]) {
|
||||
expect(supportsVisionForConfig("anthropic", model)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects legacy / text-only anthropic models", () => {
|
||||
expect(supportsVisionForConfig("anthropic", "claude-2.1")).toBe(false);
|
||||
expect(supportsVisionForConfig("anthropic", "claude-instant-1")).toBe(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { extractJsonObject } from "./json-extract";
|
||||
import type {
|
||||
AIProvider,
|
||||
AiContentPart,
|
||||
@@ -56,8 +57,11 @@ interface AnthropicClientConfig {
|
||||
const DEFAULT_BASE_URL = "https://api.anthropic.com/v1";
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
|
||||
// The only JSON steering on this wire. Current Anthropic models reject an
|
||||
// assistant-turn prefill (the old `{` trick) with HTTP 400, so the contract
|
||||
// rides on the instruction and on `extractJsonObject` on the way out.
|
||||
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.";
|
||||
"\n\nReply with a single JSON object matching the requested schema and nothing else: no prose, no markdown code fences, no explanation before or after the object.";
|
||||
|
||||
/**
|
||||
* Map an `AiContentPart[]` body into Anthropic content blocks. The media blocks
|
||||
@@ -169,11 +173,9 @@ export class AnthropicClient implements AIProvider {
|
||||
const url = `${baseUrl}/messages`;
|
||||
|
||||
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;
|
||||
// Tools imply the tool flow, which returns tool_use blocks rather than a
|
||||
// JSON body; the object extraction below stays off that path.
|
||||
const wantsJson = params.responseFormat === "json" && !hasTools;
|
||||
|
||||
const wireMessages: AnthropicWireMessage[] =
|
||||
params.messages.map(mapMessage);
|
||||
@@ -207,10 +209,6 @@ export class AnthropicClient implements AIProvider {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -337,14 +335,11 @@ export class AnthropicClient implements AIProvider {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// When we prefilled the assistant turn with `{`, the model continues
|
||||
// from there and its returned text omits that leading brace — re-prepend
|
||||
// it so the caller parses a complete object. Guard against a model that
|
||||
// (rarely) echoes the prefill itself.
|
||||
// Without a prefill the model returns the whole object, sometimes inside
|
||||
// a ```json fence or behind a short lead-in sentence; narrow to the
|
||||
// object so the caller parses it as-is.
|
||||
const content =
|
||||
usePrefill && rawText && !rawText.trimStart().startsWith("{")
|
||||
? `{${rawText}`
|
||||
: (rawText ?? "");
|
||||
wantsJson && rawText ? extractJsonObject(rawText) : (rawText ?? "");
|
||||
|
||||
const inputTokens = json.usage?.input_tokens ?? 0;
|
||||
const outputTokens = json.usage?.output_tokens ?? 0;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Isolate the JSON object body of a model reply.
|
||||
*
|
||||
* Providers without a native JSON mode (Anthropic, the local endpoints)
|
||||
* are steered by a prompt instruction alone, and a compliant model still
|
||||
* routinely wraps its object in a ```json fence or puts a sentence in
|
||||
* front of it. This helper drops a leading/trailing fence and narrows to
|
||||
* the outermost `{` ... `}` span so `JSON.parse` sees the object.
|
||||
*
|
||||
* It is a no-op on already-clean JSON and on fence-free prose with no
|
||||
* braces: both return the trimmed input, so a prose fallback downstream
|
||||
* still works.
|
||||
*/
|
||||
export function extractJsonObject(raw: string): string {
|
||||
let text = raw.trim();
|
||||
const fenced = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
|
||||
if (fenced) {
|
||||
text = fenced[1].trim();
|
||||
}
|
||||
const first = text.indexOf("{");
|
||||
const last = text.lastIndexOf("}");
|
||||
if (first !== -1 && last !== -1 && last > first) {
|
||||
return text.slice(first, last + 1);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -15,27 +15,33 @@
|
||||
|
||||
/**
|
||||
* Anthropic vision models. The Messages API `image` + `document` blocks are
|
||||
* accepted by the whole Claude 3 family (3 / 3.5 / 3.7) and the Claude 4
|
||||
* family. An older `claude-2*` / `claude-instant*` pin is text-only.
|
||||
* accepted by the whole Claude 3 family (3 / 3.5 / 3.7) and by every family
|
||||
* from Claude 4 upward, Fable included. An older `claude-2*` /
|
||||
* `claude-instant*` pin is text-only.
|
||||
*
|
||||
* The bare `claude-3-opus` / `claude-3-sonnet` / `claude-3-haiku` prefixes are
|
||||
* listed explicitly: a self-hoster pinned to e.g. `claude-3-opus-20240229`
|
||||
* reads images, but the `claude-3-5` prefix does not match it.
|
||||
* reads images, but the `claude-3-5` prefix does not match it. From Claude 4
|
||||
* on the slug is `claude-<tier>-<major>[-<minor>]`, so a rule on the major
|
||||
* version covers `claude-sonnet-4-6`, `claude-opus-5`, `claude-haiku-4-5` and
|
||||
* whatever ships next without this list growing per release.
|
||||
*/
|
||||
function anthropicSupportsVision(model: string): boolean {
|
||||
const m = model.toLowerCase();
|
||||
return (
|
||||
if (
|
||||
m.startsWith("claude-3-opus") ||
|
||||
m.startsWith("claude-3-sonnet") ||
|
||||
m.startsWith("claude-3-haiku") ||
|
||||
m.startsWith("claude-3-5") ||
|
||||
m.startsWith("claude-3.5") ||
|
||||
m.startsWith("claude-3-7") ||
|
||||
m.startsWith("claude-3.7") ||
|
||||
m.startsWith("claude-sonnet-4") ||
|
||||
m.startsWith("claude-opus-4") ||
|
||||
m.startsWith("claude-haiku-4")
|
||||
);
|
||||
m.startsWith("claude-3.7")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (m.startsWith("claude-fable-")) return true;
|
||||
const tiered = m.match(/^claude-(?:sonnet|opus|haiku)-(\d+)/);
|
||||
return tiered !== null && Number(tiered[1]) >= 4;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { extractJsonObject } from "@/lib/ai/json-extract";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import {
|
||||
screenModelOutput,
|
||||
@@ -176,31 +177,15 @@ export function summarizeSeries(
|
||||
*
|
||||
* The Anthropic and local providers have no native JSON mode (only the
|
||||
* OpenAI-family clients send `response_format: json_object`), so a
|
||||
* compliant model still routinely wraps its `{ "summary": … }` reply in a
|
||||
* ```json … ``` fence or prefixes it with a sentence. `JSON.parse` then
|
||||
* compliant model still routinely wraps its `{ "summary": ... }` reply in a
|
||||
* ```json ... ``` fence or prefixes it with a sentence. `JSON.parse` then
|
||||
* throws and the caller would surface the raw fenced string as the
|
||||
* user-facing assessment. This helper removes a leading/trailing fence and
|
||||
* narrows to the first `{` … last `}` span so the parse sees clean JSON.
|
||||
*
|
||||
* It is a no-op on already-clean JSON and on genuinely fence-free prose
|
||||
* with no braces — in both cases the original (trimmed) string is returned,
|
||||
* so the bare-prose fallback below still works.
|
||||
* user-facing assessment. The extraction itself lives in
|
||||
* `src/lib/ai/json-extract.ts`, where the Anthropic client applies it on the
|
||||
* way out; this name stays for the insights callers.
|
||||
*/
|
||||
export function stripJsonFences(content: string): string {
|
||||
let text = content.trim();
|
||||
// Drop a leading ```json / ``` fence and a trailing ``` fence if present.
|
||||
const fenced = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
|
||||
if (fenced) {
|
||||
text = fenced[1].trim();
|
||||
}
|
||||
// Narrow to the first `{` … last `}` so a leading/trailing sentence the
|
||||
// model added around the object does not break the parse.
|
||||
const first = text.indexOf("{");
|
||||
const last = text.lastIndexOf("}");
|
||||
if (first !== -1 && last !== -1 && last > first) {
|
||||
return text.slice(first, last + 1);
|
||||
}
|
||||
return text;
|
||||
return extractJsonObject(content);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user