diff --git a/CHANGELOG.md b/CHANGELOG.md index f34b1ac0d..a8564e9de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [Unreleased] + +### 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. + ## [1.38.1] — 2026-09-01 You can push readings in from your own hardware again, and a lab reading diff --git a/src/lib/ai/__tests__/anthropic-client.test.ts b/src/lib/ai/__tests__/anthropic-client.test.ts index 63bfa5e73..e5761064f 100644 --- a/src/lib/ai/__tests__/anthropic-client.test.ts +++ b/src/lib/ai/__tests__/anthropic-client.test.ts @@ -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", diff --git a/src/lib/ai/__tests__/json-extract.test.ts b/src/lib/ai/__tests__/json-extract.test.ts new file mode 100644 index 000000000..8c1788c60 --- /dev/null +++ b/src/lib/ai/__tests__/json-extract.test.ts @@ -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"); + }); +}); diff --git a/src/lib/ai/anthropic-client.ts b/src/lib/ai/anthropic-client.ts index f2bea274d..c26903f90 100644 --- a/src/lib/ai/anthropic-client.ts +++ b/src/lib/ai/anthropic-client.ts @@ -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; diff --git a/src/lib/ai/json-extract.ts b/src/lib/ai/json-extract.ts new file mode 100644 index 000000000..2c85ae7aa --- /dev/null +++ b/src/lib/ai/json-extract.ts @@ -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; +} diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index eab2ccf40..0dc0316ff 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -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); } /**