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"); }