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
15 changes: 14 additions & 1 deletion packages/redrob/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
124 changes: 123 additions & 1 deletion packages/redrob/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,22 @@ export type Err = ReturnType<NamedError["toObject"]>

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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
69 changes: 69 additions & 0 deletions packages/redrob/test/session/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
26 changes: 26 additions & 0 deletions packages/schema/src/session-status-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Info>

Expand Down
12 changes: 12 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/**
Expand Down
Loading