From c992ac22a11dcb5f9c1456e7ab34af444c3b2db2 Mon Sep 17 00:00:00 2001 From: Chinonso Johnson Date: Thu, 29 Oct 2026 01:25:05 +0100 Subject: [PATCH] Fall back to the schema in the prompt when Anthropic can't compile it The profile import's schema has 32 nullable fields, more than Anthropic's structured outputs can compile. The Messages API answers with a 400 ("The compiled grammar is too large"), which Dossier shows as "The AI provider ran into a problem", so importing a resume with Anthropic always failed. On that 400, send the request again without output_config.format, with the schema spelled out in the system prompt (withJsonSchema). The answer is still validated against the schema as before. Other 400s fail as they did. Fixes #24 Co-Authored-By: Claude Opus 5.5 --- src/lib/ai/providers.test.ts | 20 ++++++++++++++++ src/lib/ai/providers/anthropic.ts | 39 +++++++++++++++++++++---------- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/lib/ai/providers.test.ts b/src/lib/ai/providers.test.ts index 462ebf91..0b4166c2 100644 --- a/src/lib/ai/providers.test.ts +++ b/src/lib/ai/providers.test.ts @@ -200,6 +200,26 @@ describe("AnthropicProvider", () => { expect(requests[0].body).toMatchObject({ output_config: { format: { type: "json_schema" } } }); }); + it("puts the schema in the prompt instead when structured outputs can't compile it", async () => { + const requests = stubFetch(({ body }) => + (body as { output_config?: unknown }).output_config + ? json({ type: "error", error: { type: "invalid_request_error", message: "The compiled grammar is too large, which would cause performance issues." } }, { status: 400 }) + : anthropicMessage('{"name":"Jane","years":9}'), + ); + const provider = new AnthropicProvider("https://api.anthropic.com", "sk-ant", "claude-sonnet-5", 60_000); + expect(await provider.complete({ ...ask, schema: Person })).toEqual({ name: "Jane", years: 9 }); + expect(requests).toHaveLength(2); + expect(requests[1].body).not.toHaveProperty("output_config"); + expect((requests[1].body as { system: string }).system).toMatch(/^Be brief\.\n\nReply with only a JSON object[\s\S]*"years"/); + }); + + it("doesn't retry other refused requests", async () => { + const requests = stubFetch(() => json({ type: "error", error: { type: "invalid_request_error", message: "messages: field required" } }, { status: 400 })); + const provider = new AnthropicProvider("https://api.anthropic.com", "sk-ant", "claude-sonnet-5", 60_000); + expect(await failure(provider.complete({ ...ask, schema: Person }))).toMatchObject({ kind: "provider" }); + expect(requests).toHaveLength(1); + }); + it("says when the model declines", async () => { stubFetch(() => anthropicMessage("", "refusal")); const provider = new AnthropicProvider("https://api.anthropic.com", "sk-ant", "claude-sonnet-5", 60_000); diff --git a/src/lib/ai/providers/anthropic.ts b/src/lib/ai/providers/anthropic.ts index 8e45d13c..0a97ffac 100644 --- a/src/lib/ai/providers/anthropic.ts +++ b/src/lib/ai/providers/anthropic.ts @@ -4,7 +4,7 @@ import Anthropic from "@anthropic-ai/sdk"; import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod"; import { AiError } from "../errors"; -import { BaseProvider, type ChatAnswer, type ChatCall, type Effort } from "../provider"; +import { BaseProvider, type ChatAnswer, type ChatCall, type Effort, withJsonSchema } from "../provider"; import { keyGuardedFetch } from "./guarded-fetch"; /** Offered when the model list can't be fetched, e.g. before a key is saved. */ @@ -21,6 +21,9 @@ const THINKING_TOKENS = 12_000; // models, such as Claude Haiku 4.5, refuse the parameter. const effortLevels = new Map>(); +/** The 400 a schema too complex for structured outputs gets. */ +const GRAMMAR_TOO_LARGE = /compiled grammar is too large/i; + /** Claude through the Messages API with the user's own key. */ export class AnthropicProvider extends BaseProvider { readonly kind = "ANTHROPIC" as const; @@ -44,17 +47,29 @@ export class AnthropicProvider extends BaseProvider { protected async chat(call: ChatCall): Promise { if (!this.client) throw AiError.config("Add your Anthropic API key in Settings → AI provider first."); const effort = call.effort && (await this.effortLevels(this.client, call.model, call.signal)).has(call.effort) ? call.effort : undefined; - const outputConfig = { ...(call.json ? { format: zodOutputFormat(call.json.schema) } : {}), ...(effort ? { effort } : {}) }; - const message = await this.client.messages.create( - { - model: call.model, - max_tokens: call.maxTokens + THINKING_TOKENS, - system: call.system, - messages: call.messages, - ...(Object.keys(outputConfig).length > 0 ? { output_config: outputConfig } : {}), - }, - { signal: call.signal }, - ); + const send = (structured: boolean) => { + const outputConfig = { ...(structured && call.json ? { format: zodOutputFormat(call.json.schema) } : {}), ...(effort ? { effort } : {}) }; + return this.client!.messages.create( + { + model: call.model, + max_tokens: call.maxTokens + THINKING_TOKENS, + system: !structured && call.json ? withJsonSchema(call.system, call.json.schema) : call.system, + messages: call.messages, + ...(Object.keys(outputConfig).length > 0 ? { output_config: outputConfig } : {}), + }, + { signal: call.signal }, + ); + }; + let message: Anthropic.Message; + try { + message = await send(true); + } catch (error) { + // Structured outputs refuse schemas with many nullable fields (the + // profile import has 32) with a 400; the schema then goes in the prompt + // instead, and the answer is still validated against it (#24). + if (!call.json || !(error instanceof Anthropic.BadRequestError) || !GRAMMAR_TOO_LARGE.test(error.message)) throw error; + message = await send(false); + } if (message.stop_reason === "refusal") { throw new AiError("provider", "The model declined to answer this one. Try rewording it, or try again.", "refusal"); }