Merge origin/main into fix/briefing-locale

This commit is contained in:
Marc-André Bombeck committed 2026-09-02 10:51:56 +02:00
commit a55ecc5a48
9 files changed
+226 -63

No files matched your search

+2
View File
@@ -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;
+97 -15
View File
@@ -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",
+35
View File
@@ -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(
+12 -17
View File
@@ -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;
+26
View File
@@ -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 -9
View File
@@ -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;
}
/**
+7 -22
View File
@@ -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);
}
/**