mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
a445b89112
commit
de5ef21051
2 changed files with 42 additions and 21 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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" } };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue