diff --git a/packages/redrob/src/session/processor.ts b/packages/redrob/src/session/processor.ts index ab583f0685..7a43dfaf17 100644 --- a/packages/redrob/src/session/processor.ts +++ b/packages/redrob/src/session/processor.ts @@ -640,7 +640,20 @@ const layer = Layer.effect( sessionID: ctx.assistantMessage.sessionID, error: ctx.assistantMessage.error, }) - yield* status.set(ctx.sessionID, { type: "idle" }) + /* + A refusal the user can act on ends as `blocked` rather than `idle`, so the client can offer the + page that fixes it instead of printing a sentence and leaving them to find it. `idle` otherwise, + which is every failure with nothing to click. + + Published BEFORE the error event would be wrong: a client that renders the error first and then + sees `idle` has already drawn the plain box. The order here -- error, then the terminal status -- + is the existing one, and `blocked` simply replaces `idle` in it. + */ + const block = SessionRetry.blocking(error, input.model.providerID) + yield* status.set( + ctx.sessionID, + block ? { type: "blocked", message: block.message, action: block.action } : { type: "idle" }, + ) }) const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) { diff --git a/packages/redrob/src/session/retry.ts b/packages/redrob/src/session/retry.ts index b687d2cc64..95da7b46a8 100644 --- a/packages/redrob/src/session/retry.ts +++ b/packages/redrob/src/session/retry.ts @@ -9,7 +9,22 @@ export type Err = ReturnType export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" export const GO_UPSELL_URL = "https://code.redrob.ai/go" -export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {}) +/** + * Where a console refusal sends the user. `/limits` shows the tier and its per-minute ceiling; + * `/api-keys` is where a key's monthly cap is raised; `/billing` is where the account is topped up. + * Each card links to the page that fixes ITS refusal, because a card that lands on the wrong page is + * barely better than no card. + */ +export const CONSOLE_LIMITS_URL = "https://console.redrob.ai/limits" +export const CONSOLE_KEYS_URL = "https://console.redrob.ai/api-keys" +export const CONSOLE_BILLING_URL = "https://console.redrob.ai/billing" +export type RetryReason = + | "free_tier_limit" + | "account_rate_limit" + | "console_rate_limit" + | "console_key_budget" + | "console_out_of_credit" + | (string & {}) export type Retryable = { message: string @@ -82,6 +97,107 @@ function exponential(attempt: number, random: number) { return Math.ceil(base + base * RETRY_JITTER_FACTOR * random) } +/** + * A console refusal the user can act on, as the card cowork already draws. + * + * Only for our own provider. `rate_limit_exceeded` and `insufficient_quota` are OpenAI's generic codes, + * so any vendor may send them -- offering a link to the Redrob console for somebody else's rate limit + * would send the user to a page that cannot fix their problem. + * + * Only the RATE refusal is here. A 402 is deliberately not retryable: the console chose that status over + * 429 precisely so clients would stop rather than turn one refusal into six, and a spinner reading + * "Retrying in 4s" over something that will never succeed is worse than a plain message. Those are + * handled by `blocking()` instead. + */ +function consoleRateLimit(error: SessionV1.APIError, provider: string): Retryable | undefined { + if (provider !== "redrob") return undefined + if (consoleErrorCode(error) !== "rate_limit_exceeded") return undefined + + /* + The console's own message already names the tier's per-minute ceiling and how long to wait, so it is + used as-is rather than paraphrased into something less specific. + */ + const message = error.data.message || "Rate limit reached" + return { + message, + action: { + reason: "console_rate_limit", + provider, + title: "Rate limit reached", + message: + "This is your workspace's requests-per-minute ceiling, which rises with your lifetime top-ups. It clears on its own; the console shows the current tier and limit.", + label: "open limits", + link: CONSOLE_LIMITS_URL, + }, + } +} + +/** The machine-readable code from the console's OpenAI-shaped error envelope, if there is one. */ +function consoleErrorCode(error: SessionV1.APIError): string | undefined { + const body = parseJSON(error.data.responseBody) + if (!isRecord(body)) return undefined + const envelope = body["error"] + if (!isRecord(envelope)) return undefined + const code = envelope["code"] + return typeof code === "string" && code.length > 0 ? code : undefined +} + +export type Blocking = { + message: string + action?: Retryable["action"] +} + +/** + * A console refusal that retrying cannot fix, but a person can. + * + * Both of the console's 402s land here. They are NOT routed through `retryable()` on purpose: the console + * answers 402 rather than 429 specifically so that clients stop, and a retry card would both retry and + * claim to be retrying something that will never succeed. + * + * The two are told apart by `code`, which is the only thing that distinguishes them on the + * OpenAI-compatible path -- the console's fuller refusal body, with `reason` and the figures, does not + * survive that envelope. They need different pages: a key over its cap is fixed by raising that key's + * cap, an empty balance by topping up, and sending the user to the wrong one wastes the card. + */ +export function blocking(error: Err, provider: string): Blocking | undefined { + if (provider !== "redrob") return undefined + if (!SessionV1.APIError.isInstance(error)) return undefined + const code = consoleErrorCode(error) + /* The console's own message carries the figures, so it is shown rather than paraphrased. */ + const message = error.data.message || "The request was refused" + + if (code === "api_key_budget_exhausted") { + return { + message, + action: { + reason: "console_key_budget", + provider, + title: "This key is over its monthly budget", + message: + "The cap is per API key and resets at the start of the month. Raise it on the key, or use a key without a cap.", + label: "open api keys", + link: CONSOLE_KEYS_URL, + }, + } + } + + if (code === "insufficient_quota") { + return { + message, + action: { + reason: "console_out_of_credit", + provider, + title: "The workspace is out of credit", + message: "Top up to continue. A balance covers every key on the workspace.", + label: "open billing", + link: CONSOLE_BILLING_URL, + }, + } + } + + return undefined +} + export function retryable(error: Err, provider: string) { // context overflow errors should not be retried if (SessionV1.ContextOverflowError.isInstance(error)) return undefined @@ -96,6 +212,12 @@ export function retryable(error: Err, provider: string) { !matchesRetryableMessage(error.data.responseBody) ) return undefined + /* + Checked before the upstream markers below, because those match on a body substring while this + matches on the console's own error code -- the more specific signal should win. + */ + const rate = consoleRateLimit(error, provider) + if (rate) return rate if (error.data.responseBody?.includes("FreeUsageLimitError")) { return { message: GO_UPSELL_MESSAGE, diff --git a/packages/redrob/test/session/retry.test.ts b/packages/redrob/test/session/retry.test.ts index 702d5956f2..01f7bfd1ac 100644 --- a/packages/redrob/test/session/retry.test.ts +++ b/packages/redrob/test/session/retry.test.ts @@ -519,3 +519,72 @@ describe("session.message-v2.fromError", () => { }) }) }) + +/** + * The console's refusals, and the line between the two kinds. + * + * A 429 is retryable and gets the retry card. A 402 is NOT: the console answers 402 rather than 429 + * specifically so clients stop, so it must never appear in `retryable()` -- a retry card would both retry + * and claim to be retrying something that will never succeed. + * + * Both are gated on the provider being ours. `rate_limit_exceeded` and `insufficient_quota` are OpenAI's + * generic codes, so another vendor sending one must not be handed a link to our console. + */ +describe("session.retry console refusals", () => { + function consoleError(status: number, code: string, message = "refused"): SessionV1.APIError { + return Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ + message, + isRetryable: status === 429, + statusCode: status, + responseBody: JSON.stringify({ error: { message, type: "x", param: null, code } }), + }).toObject(), + ) + } + + test("a rate refusal becomes a retry card pointing at the limits page", () => { + const result = SessionRetry.retryable(consoleError(429, "rate_limit_exceeded", "slow down"), "redrob") + expect(result?.message).toBe("slow down") + expect(result?.action?.reason).toBe("console_rate_limit") + expect(result?.action?.link).toBe(SessionRetry.CONSOLE_LIMITS_URL) + }) + + test("another vendor's rate limit gets no console link", () => { + const result = SessionRetry.retryable(consoleError(429, "rate_limit_exceeded"), "openai") + expect(result?.action).toBeUndefined() + }) + + test("a budget refusal is NOT retryable", () => { + expect(SessionRetry.retryable(consoleError(402, "api_key_budget_exhausted"), "redrob")).toBeUndefined() + }) + + test("a budget refusal blocks, pointing at the keys page where the cap lives", () => { + const result = SessionRetry.blocking(consoleError(402, "api_key_budget_exhausted", "over cap"), "redrob") + expect(result?.message).toBe("over cap") + expect(result?.action?.reason).toBe("console_key_budget") + expect(result?.action?.link).toBe(SessionRetry.CONSOLE_KEYS_URL) + }) + + test("an empty balance blocks, pointing at billing instead", () => { + const result = SessionRetry.blocking(consoleError(402, "insufficient_quota", "no credit"), "redrob") + expect(result?.action?.reason).toBe("console_out_of_credit") + expect(result?.action?.link).toBe(SessionRetry.CONSOLE_BILLING_URL) + }) + + test("the two 402s are told apart, which is the whole point of carrying the code", () => { + const budget = SessionRetry.blocking(consoleError(402, "api_key_budget_exhausted"), "redrob") + const balance = SessionRetry.blocking(consoleError(402, "insufficient_quota"), "redrob") + expect(budget?.action?.link).not.toBe(balance?.action?.link) + }) + + test("another vendor's 402 does not block with our pages", () => { + expect(SessionRetry.blocking(consoleError(402, "insufficient_quota"), "openai")).toBeUndefined() + }) + + test("a 402 with no code is left alone rather than guessed at", () => { + const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ message: "refused", isRetryable: false, statusCode: 402 }).toObject(), + ) + expect(SessionRetry.blocking(error, "redrob")).toBeUndefined() + }) +}) diff --git a/packages/schema/src/session-status-event.ts b/packages/schema/src/session-status-event.ts index f6a3022bcb..47cdec58e1 100644 --- a/packages/schema/src/session-status-event.ts +++ b/packages/schema/src/session-status-event.ts @@ -29,6 +29,32 @@ export const Info = Schema.Union([ Schema.Struct({ type: Schema.Literal("busy"), }), + /** + * The turn stopped and no retry will fix it, but there IS something the user can do. + * + * Distinct from `retry` because that variant means a retry is in flight: a client renders it with a + * spinner and a countdown, which would be a lie here. Distinct from a plain session error because + * those travel as message text and so can carry no link -- the user was told "budget exhausted" and + * left to find the page themselves. + * + * The console's 402s are the case this exists for. Both a key over its monthly cap and an account with + * an empty balance are refusals a person fixes in the console, and the console deliberately answers 402 + * rather than 429 so that clients STOP instead of turning one refusal into six. + */ + Schema.Struct({ + type: Schema.Literal("blocked"), + message: Schema.String, + action: optional( + Schema.Struct({ + reason: Schema.String, + provider: Schema.String, + title: Schema.String, + message: Schema.String, + label: Schema.String, + link: optional(Schema.String), + }), + ), + }), ]).annotate({ identifier: "SessionStatus" }) export type Info = Schema.Schema.Type diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a609f30ae7..ac9249b06d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -693,6 +693,18 @@ export type SessionStatus = | { type: "busy" } + | { + type: "blocked" + message: string + action?: { + reason: string + provider: string + title: string + message: string + label: string + link?: string + } + } export type QuestionOption = { /**