diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a0cf3fa..4c2f3e4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/components/settings/ai/shared.ts b/src/components/settings/ai/shared.ts index 70dea2ac3..2b33eea1f 100644 --- a/src/components/settings/ai/shared.ts +++ b/src/components/settings/ai/shared.ts @@ -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; 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/__tests__/vision-capability.test.ts b/src/lib/ai/__tests__/vision-capability.test.ts index 775d8c385..1eb2c47e1 100644 --- a/src/lib/ai/__tests__/vision-capability.test.ts +++ b/src/lib/ai/__tests__/vision-capability.test.ts @@ -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( 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/ai/vision-capability.ts b/src/lib/ai/vision-capability.ts index ce7c55468..2a2eacae5 100644 --- a/src/lib/ai/vision-capability.ts +++ b/src/lib/ai/vision-capability.ts @@ -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--[-]`, 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; } /** 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); } /**