diff --git a/packages/ai/src/api/anthropic-messages.ts b/packages/ai/src/api/anthropic-messages.ts index a6fd502bd..ba25b59a3 100644 --- a/packages/ai/src/api/anthropic-messages.ts +++ b/packages/ai/src/api/anthropic-messages.ts @@ -390,9 +390,10 @@ function getThreadErrorCode(error: InstanceType): 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 }; diff --git a/packages/ai/test/anthropic-message-threads.test.ts b/packages/ai/test/anthropic-message-threads.test.ts index d9bc67f98..569caf298 100644 --- a/packages/ai/test/anthropic-message-threads.test.ts +++ b/packages/ai/test/anthropic-message-threads.test.ts @@ -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" } };