Skip to content
Open
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
57 changes: 57 additions & 0 deletions core/llm/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,60 @@ describe("BaseLLM", () => {
});
});
});

describe("BaseLLM error annotation", () => {
class ErrorLLM extends BaseLLM {
static providerName = "openai";
// parseError is private; these tests are about what it leaves on the error
// for llm/utils/retry.ts to read.
parse(resp: unknown): Promise<Error> {
return (this as any).parseError(resp);
}
}

const llm = () => new ErrorLLM({ model: "dummy-model" });

const response = (init: { status: number; headers?: Record<string, string> }) => ({
status: init.status,
statusText: "Too Many Requests",
url: "https://api.test-api-dummy.com/v1/chat/completions",
headers: new Headers(init.headers ?? {}),
text: async () => "rate limited",
});

it("puts the provider's retry-after where the backoff reads it", async () => {
// calculateDelay() in llm/utils/retry.ts looks for error.headers["retry-after"]
// to wait exactly as long as the provider asked. Nothing was setting it, so
// every rate limit fell through to exponential backoff -- a guess, when the
// provider had already said the answer.
const error = (await llm().parse(
response({ status: 429, headers: { "Retry-After": "17" } }),
)) as Error & { status?: number; headers?: Record<string, string> };

expect(error.status).toBe(429);
expect(error.headers?.["retry-after"]).toBe("17");
});

it("lower-cases the names the reader looks for", async () => {
const error = (await llm().parse(
response({
status: 429,
headers: { "X-RateLimit-Reset": "1789621158", "X-RateLimit-Remaining": "0" },
}),
)) as Error & { headers?: Record<string, string> };

expect(error.headers?.["x-ratelimit-reset"]).toBe("1789621158");
expect(error.headers?.["x-ratelimit-remaining"]).toBe("0");
});

it("leaves headers unset when the response carried none", async () => {
// An empty object would read as "the provider answered with no limits";
// absent says it never answered at all.
const error = (await llm().parse(response({ status: 500 }))) as Error & {
headers?: Record<string, string>;
};

expect(error.headers).toBeUndefined();
expect(error.status).toBe(500);
});
});
32 changes: 32 additions & 0 deletions core/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,39 @@ export abstract class BaseLLM implements ILLM {
}
}

/**
* Copies the response's status and headers onto the error.
*
* `calculateDelay` in llm/utils/retry.ts reads `error.headers["retry-after"]`
* and friends to back off for as long as the provider asked. Nothing was ever
* putting them there, so that path could not fire and every rate limit fell
* through to exponential backoff -- a guess, when the provider had already
* said the answer.
*
* Header names are lower-cased because that is how `Headers` yields them and
* how the reader spells the ones it looks for first.
*/
private annotateError(error: Error, resp: any): Error {
const annotated = error as Error & {
status?: number;
headers?: Record<string, string>;
};
annotated.status = resp?.status;
const headers: Record<string, string> = {};
resp?.headers?.forEach?.((value: string, name: string) => {
headers[name.toLowerCase()] = value;
});
if (Object.keys(headers).length > 0) {
annotated.headers = headers;
}
return annotated;
}

private async parseError(resp: any): Promise<Error> {
return this.annotateError(await this.buildError(resp), resp);
}

private async buildError(resp: any): Promise<Error> {
let text = await resp.text();

if (resp.status === 404 && !resp.url.includes("/v1")) {
Expand Down
Loading