Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/lib/ai/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
39 changes: 27 additions & 12 deletions src/lib/ai/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -21,6 +21,9 @@ const THINKING_TOKENS = 12_000;
// models, such as Claude Haiku 4.5, refuse the parameter.
const effortLevels = new Map<string, ReadonlySet<Effort>>();

/** 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;
Expand All @@ -44,17 +47,29 @@ export class AnthropicProvider extends BaseProvider {
protected async chat(call: ChatCall): Promise<ChatAnswer> {
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");
}
Expand Down