Skip to content
Merged
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
11 changes: 10 additions & 1 deletion src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { parseCLIArguments } from "./args.js";
import { runConvertCommand } from "./convert.js";
import { runStatusCommand } from "./status.js";
import { runSdpubCommand } from "./sdpub.js";
import { LLMPaymentRequiredError } from "../llm/index.js";
import { formatError } from "../utils/node-error.js";

export async function main(): Promise<void> {
Expand Down Expand Up @@ -29,7 +30,15 @@ export async function main(): Promise<void> {
return;
}
} catch (error) {
process.stderr.write(`${formatError(error)}\n`);
process.stderr.write(`${formatCLIError(error)}\n`);
process.exitCode = 1;
}
}

function formatCLIError(error: unknown): string {
if (error instanceof LLMPaymentRequiredError) {
return "LLM payment required. Check your provider billing status or account balance.";
}

return formatError(error);
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { Language } from "./common/language.js";
export { LLMPaymentRequiredError } from "./llm/index.js";
export {
type DigestProgressEvent,
type SerialDiscoveryItem,
Expand Down
20 changes: 20 additions & 0 deletions src/llm/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createHash } from "../utils/hash.js";
import { formatError } from "../utils/node-error.js";
import { LLMCache } from "./cache.js";
import { LLMContext, type LLMContextRequestInput } from "./context.js";
import { LLMPaymentRequiredError } from "./errors.js";
import { createRequestLog } from "./request-log.js";
import { getScopeDefaults, resolveSamplingSetting } from "./sampling.js";
import type {
Expand Down Expand Up @@ -312,6 +313,17 @@ export class LLM<S extends string> {
} catch (error) {
lastError = error;

if (isPaymentRequiredError(error)) {
const paymentError = new LLMPaymentRequiredError(undefined, {
cause: error,
});

await requestLog.append(
`[[Error]]:\n${formatError(paymentError)}\n\n`,
);
throw paymentError;
}

if (!isRetryableError(error)) {
await requestLog.append(`[[Error]]:\n${formatError(error)}\n\n`);
throw error;
Expand Down Expand Up @@ -526,6 +538,10 @@ function capitalize(value: string): string {

function isRetryableError(error: unknown): boolean {
if (APICallError.isInstance(error)) {
if (isPaymentRequiredError(error)) {
return false;
}

if (error.isRetryable) {
return true;
}
Expand All @@ -536,6 +552,10 @@ function isRetryableError(error: unknown): boolean {
return !isAbortLikeError(error) && isRetryableTransportError(error);
}

function isPaymentRequiredError(error: unknown): boolean {
return APICallError.isInstance(error) && error.statusCode === 402;
}

function isRetryableStatusCode(statusCode: number | undefined): boolean {
return (
typeof statusCode === "number" &&
Expand Down
12 changes: 12 additions & 0 deletions src/llm/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export class LLMPaymentRequiredError extends Error {
public readonly isRetryable = false;
public readonly statusCode = 402;

public constructor(
message = "LLM payment required.",
options?: ErrorOptions,
) {
super(message, options);
this.name = "LLMPaymentRequiredError";
}
}
1 change: 1 addition & 0 deletions src/llm/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { LLM } from "./client.js";
export { LLMContext } from "./context.js";
export { LLMPaymentRequiredError } from "./errors.js";
export {
getScopeDefaults,
resolveSamplingSetting,
Expand Down
22 changes: 22 additions & 0 deletions test/cli/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ vi.mock("../../src/cli/sdpub.js", () => ({
}));

import { main } from "../../src/cli/main.js";
import { LLMPaymentRequiredError } from "../../src/llm/index.js";

describe("cli/main", () => {
const originalExitCode = process.exitCode;
Expand Down Expand Up @@ -284,4 +285,25 @@ describe("cli/main", () => {
expect(stderrChunks).toStrictEqual(["convert failed: tls reset\n"]);
expect(process.exitCode).toBe(1);
});

it("writes a stable payment required message for LLM billing failures", async () => {
mainMockState.argsResult = {
args: {
help: false,
verbose: false,
},
help: false,
kind: "convert",
};
mainMockState.runError = new LLMPaymentRequiredError("provider message", {
cause: new Error("raw provider error"),
});

await main();

expect(stderrChunks).toStrictEqual([
"LLM payment required. Check your provider billing status or account balance.\n",
]);
expect(process.exitCode).toBe(1);
});
});
48 changes: 48 additions & 0 deletions test/llm/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ vi.mock("ai", () => ({

import { SpineDigestScope } from "../../src/common/llm-scope.js";
import { LLM } from "../../src/llm/client.js";
import { LLMPaymentRequiredError } from "../../src/llm/errors.js";
import { withTempDir } from "../helpers/temp.js";

const RETRYABLE_TRANSPORT_CODES = [
Expand Down Expand Up @@ -584,6 +585,53 @@ describe("llm/client", () => {
expect(aiMockState.generateTextCalls).toHaveLength(1);
});

it.each([false, true])(
"wraps HTTP 402 payment errors without retrying when isRetryable is %s",
async (isRetryable) => {
const { APICallError } = await import("ai");
const MockAPICallError = APICallError as unknown as {
new (
message: string,
options?: {
cause?: unknown;
isRetryable?: boolean;
statusCode?: number;
},
): Error;
};

aiMockState.generateTextError = new MockAPICallError("Payment required", {
isRetryable,
statusCode: 402,
});

const llm = new LLM({
dataDirPath: process.cwd(),
model: {
modelId: "test-model",
provider: "test-provider",
} as never,
retryIntervalSeconds: 0,
});

const request = llm.request([
{
content: "hello",
role: "user",
},
]);

await expect(request).rejects.toMatchObject({
cause: aiMockState.generateTextError,
isRetryable: false,
message: "LLM payment required.",
statusCode: 402,
});
await expect(request).rejects.toBeInstanceOf(LLMPaymentRequiredError);
expect(aiMockState.generateTextCalls).toHaveLength(1);
},
);

it("retries terminated transport errors for streamText", async () => {
aiMockState.streamTextError = new TypeError("terminated");

Expand Down
Loading