fix(ai): only fall back on thread errors for Anthropic message threads

Any 400 or 404 on a thread request was resent without `thread`, and a
successful resend turned threads off for the session. Ordinary errors
took that path too: "prompt is too long" was sent twice, and an error
only the first request hit turned threads off for no reason.

Now a rejected thread request is resent only when the error is about the
thread: the thread error codes, or a 400 without a code whose message
mentions `thread` (an endpoint that doesn't know the field). Every other
error is passed on unchanged and threads stay on.
This commit is contained in:
David Brailovsky 2026-11-02 23:20:49 +01:00
parent a445b89112
commit de5ef21051
2 changed files with 42 additions and 21 deletions

View file

@ -390,9 +390,10 @@ function getThreadErrorCode(error: InstanceType<typeof Anthropic.APIError>): str
}
/**
* Sends a thread request. A rejection (never a started stream) is resent once in full: as a new
* thread when the old one can't be continued, otherwise without `thread`, which also turns threads
* off for the session. Returns the response and the thread request that produced it, if any.
* Sends a thread request. A rejection about the thread (never a started stream) is resent once in
* full: as a new thread when the old one can't be continued, or without `thread` when the endpoint
* can't use threads, which also turns threads off for the session. Other errors are passed on.
* Returns the response and the thread request that produced it, if any.
*/
async function sendThreadRequest(
thread: PreparedThreadRequest,
@ -404,17 +405,18 @@ async function sendThreadRequest(
try {
return { response: await send(thread.body), thread };
} catch (error) {
// Rate limits and auth errors are not about the thread; strict tool errors go to the
// strict tool fallback in the caller.
if (
signal?.aborted ||
!(error instanceof Anthropic.APIError) ||
(error.status !== 400 && error.status !== 404) ||
isStrictToolLimitErrorMessage(error.message)
) {
throw error;
}
if (signal?.aborted || !(error instanceof Anthropic.APIError)) throw error;
const errorCode = getThreadErrorCode(error);
const startNewThread = errorCode !== undefined && NEW_THREAD_ERRORS.has(errorCode);
// The endpoint can't use threads: it can't thread this request, or it doesn't know the
// `thread` field and rejects it without a code ("thread: Extra inputs are not permitted"),
// e.g. with an `anthropic-beta` header that lacks the threads beta.
const threadsUnsupported =
errorCode === "thread_unsupported_request" ||
(errorCode === undefined && error.status === 400 && error.message.includes("thread"));
// Any other error (prompt too long, unknown model, strict tool limits, ...) is not about the
// thread, and resending would only fail again.
if (!startNewThread && !threadsUnsupported) throw error;
// Recorded before the resend, so a failed resend keeps it.
appendAssistantMessageDiagnostic(output, {
type: "anthropic_thread_fallback",
@ -422,12 +424,11 @@ async function sendThreadRequest(
error: { message: error.message },
details: { reason: errorCode ?? String(error.status) },
});
if (errorCode !== undefined && NEW_THREAD_ERRORS.has(errorCode)) {
if (startNewThread) {
const created = createThreadRequest(thread.sessionId, params);
return { response: await send(created.body), thread: created };
}
// The endpoint rejects something about threads for this session. With the threads beta, a
// request without `thread` is a normal request.
// With the threads beta, a request without `thread` is a normal request.
const response = await send(params);
setThreadState(thread.sessionId, "off");
return { response, thread: undefined };

View file

@ -382,14 +382,14 @@ describe("Anthropic message threads", () => {
});
it.each([
["thread_unsupported_request", "thread_unsupported_request"],
["a 400 without error code", undefined],
])("turns threads off for the session after %s on continue", async (_name, errorCode) => {
["thread_unsupported_request", "thread_unsupported_request", "rejected"],
["a 400 without error code about the thread field", undefined, "thread: Extra inputs are not permitted"],
])("turns threads off for the session after %s on continue", async (_name, errorCode, message) => {
const h = harness();
const messages = [user("first")];
await say(h, messages);
messages.push(user("second"));
h.queue.push(() => errorResponse(400, "rejected", errorCode));
h.queue.push(() => errorResponse(400, message, errorCode));
const result = await say(h, messages);
expect(result.stopReason).toBe("stop");
@ -411,7 +411,7 @@ describe("Anthropic message threads", () => {
await say(h, messages);
messages.push(user("second"));
h.queue.push(
() => errorResponse(400, "rejected"),
() => errorResponse(400, "thread: Extra inputs are not permitted"),
() => errorResponse(400, "Real problem"),
);
const failed = await turn(h, messages);
@ -423,6 +423,26 @@ describe("Anthropic message threads", () => {
expect(threadOf(h.requests[3])).toEqual({ type: "create" });
});
it.each([
[400, "prompt is too long: 250000 tokens > 200000 maximum"],
[404, "model: claude-test"],
])("passes a %s that is not about the thread on without resending", async (status, message) => {
const h = harness();
const messages = [user("first")];
await say(h, messages);
messages.push(user("second"));
h.queue.push(() => errorResponse(status, message));
const failed = await turn(h, messages);
expect(failed.stopReason).toBe("error");
expect(failed.errorMessage).toContain(message);
expect(h.requests).toHaveLength(2);
expect(failed.diagnostics).toBeUndefined();
// Threads stay on: after a fix such as compaction, the next request starts a new thread.
await turn(h, messages);
expect(threadOf(h.requests[2])).toEqual({ type: "create" });
});
it("leaves strict tool rejections to the strict tool fallback", async () => {
const model: Model<"anthropic-messages"> = { ...baseModel, compat: { supportsStrictTools: true } };
const strictTool: Tool = { ...tool("lookup"), constrainedSampling: { type: "json_schema", strict: "prefer" } };