diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index 722728c3a031..a902dc022a39 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -263,7 +263,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)) ) - return new QuotaExceededError(details) + return new QuotaExceededError({ ...details, retryAfterMs: input.retryAfterMs }) if ( input.status === 401 || input.status === 403 || diff --git a/packages/ai/src/schema/errors.ts b/packages/ai/src/schema/errors.ts index 76a8e4846146..d890f4132254 100644 --- a/packages/ai/src/schema/errors.ts +++ b/packages/ai/src/schema/errors.ts @@ -83,7 +83,10 @@ export class RateLimitError extends Schema.TaggedError("AI.Error export class QuotaExceededError extends Schema.TaggedError("AI.Error.QuotaExceeded")( "QuotaExceeded", - ReasonFields, + { + ...ReasonFields, + retryAfterMs: Schema.optional(Schema.Number), + }, ) {} export class ContentPolicyError extends Schema.TaggedError("AI.Error.ContentPolicy")( diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 4592c8cf50ca..83c4b0cb4c9a 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -685,6 +685,7 @@ export type SessionLogOutput = readonly message: string readonly status?: number | undefined readonly response?: { readonly body: string } | undefined + readonly retryAfterMs?: number | undefined } } } @@ -852,6 +853,7 @@ export type SessionLogOutput = readonly message: string readonly status?: number | undefined readonly response?: { readonly body: string } | undefined + readonly retryAfterMs?: number | undefined } readonly finish?: "content-filter" | undefined readonly rawFinish?: string | undefined @@ -1040,6 +1042,7 @@ export type SessionLogOutput = readonly message: string readonly status?: number | undefined readonly response?: { readonly body: string } | undefined + readonly retryAfterMs?: number | undefined } readonly content?: | readonly [ @@ -1087,6 +1090,7 @@ export type SessionLogOutput = readonly message: string readonly status?: number | undefined readonly response?: { readonly body: string } | undefined + readonly retryAfterMs?: number | undefined } } } @@ -1148,6 +1152,7 @@ export type SessionLogOutput = readonly message: string readonly status?: number | undefined readonly response?: { readonly body: string } | undefined + readonly retryAfterMs?: number | undefined } readonly inputID?: SessionMessage.ID | undefined readonly cost?: Money.USD | undefined diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index be9351b6cca4..218f7a3dbdc5 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -108,7 +108,13 @@ export type ToolTextContent = { type: "text"; text: string } export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null } -export type SessionStructuredError = { type: string; message: string; status?: number; response?: { body: string } } +export type SessionStructuredError = { + type: string + message: string + status?: number + response?: { body: string } + retryAfterMs?: number +} export type SessionActive = { type: "running" } diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index d8eb0f683afa..fee2e88b7d43 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -25,6 +25,9 @@ const tokenSharingMessages = { "This ChatGPT connection isn't authorized for this request. Reconnect to ChatGPT or choose another connection.", } +const FREE_USAGE_LIMIT_MESSAGE = + "Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models." + export function toSessionError(cause: unknown): SessionError.Error { if (cause instanceof AIError) { switch (cause.reason._tag) { @@ -32,8 +35,17 @@ export function toSessionError(cause: unknown): SessionError.Error { return providerError("provider.rate-limit", cause.reason) case "Authentication": return providerError("provider.auth", cause.reason) - case "QuotaExceeded": - return providerError("provider.quota", cause.reason) + case "QuotaExceeded": { + if (!cause.reason.body?.includes("FreeUsageLimitError")) return providerError("provider.quota", cause.reason) + const retryAfterMs = cause.reason.retryAfterMs + return { + ...providerError("provider.free-limit", cause.reason), + message: FREE_USAGE_LIMIT_MESSAGE, + ...(retryAfterMs !== undefined && Number.isFinite(retryAfterMs) && retryAfterMs >= 0 + ? { retryAfterMs: Math.ceil(retryAfterMs) } + : {}), + } + } case "ContentPolicy": return providerError("provider.content-filter", cause.reason) case "Transport": diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 19728224936c..c5e603c3327b 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -17,6 +17,7 @@ import { ToolFailure, HttpContext, } from "@opencode/ai" +import { RequestExecutor } from "@opencode/ai/route" import { Permission } from "@opencode/core/permission" import { ID } from "@opencode/core/model" import { ModelResolver } from "@opencode/core/model-resolver" @@ -71,6 +72,55 @@ describe("toSessionError", () => { expect(toSessionError(llm(new UnknownProviderError({ message: "unknown" }))).type).toBe("provider.unknown") }) + test("separates the free usage limit from paid rate limits, Go quota, and other providers' 429s", () => { + const fail = (url: string, body: unknown, headers?: Record) => + RequestExecutor.httpFailure({ + message: "Rate limit exceeded. Please try again later.", + url, + status: 429, + responseHeaders: headers, + responseBody: JSON.stringify(body), + }) + + const freeBody = { + type: "error", + error: { type: "FreeUsageLimitError", message: "Rate limit exceeded. Please try again later." }, + } + const free = fail("https://opencode.ai/inference/openai/v1/chat/completions", freeBody, { "retry-after": "3600" }) + expect(SessionRunnerRetry.isRetryable(free)).toBe(false) + expect(toSessionError(free)).toEqual({ + type: "provider.free-limit", + message: "Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models.", + status: 429, + response: { body: JSON.stringify(freeBody) }, + retryAfterMs: 3_600_000, + }) + + const paid = fail( + "https://opencode.ai/inference/openai/v1/chat/completions", + { error: { type: "server_error", message: "Upstream request failed: Rate limit exceeded." } }, + { "retry-after": "45" }, + ) + expect(SessionRunnerRetry.isRetryable(paid)).toBe(true) + expect(toSessionError(paid).type).toBe("provider.rate-limit") + + const go = fail( + "https://opencode.ai/inference/go/openai/v1/chat/completions", + { type: "error", error: { type: "GoUsageLimitError", message: "Subscription quota exceeded." } }, + { "retry-after": "120" }, + ) + expect(SessionRunnerRetry.isRetryable(go)).toBe(false) + expect(toSessionError(go).type).toBe("provider.quota") + + const other = fail( + "https://api.openai.com/v1/chat/completions", + { error: { type: "rate_limit_error", message: "Too many requests" } }, + { "retry-after": "10" }, + ) + expect(SessionRunnerRetry.isRetryable(other)).toBe(true) + expect(toSessionError(other).type).toBe("provider.rate-limit") + }) + test("preserves the permission rejection type without exposing internal fields", () => { const blocked = new Permission.BlockedError({ rules: [], permission: "external_directory", resources: [] }) expect(toSessionError(blocked)).toEqual({ diff --git a/packages/schema/src/session-error.ts b/packages/schema/src/session-error.ts index e9bb70570fe3..b1a29226ac49 100644 --- a/packages/schema/src/session-error.ts +++ b/packages/schema/src/session-error.ts @@ -1,7 +1,7 @@ export * as SessionError from "./session-error.js" import { Schema } from "effect" -import { optional } from "./schema.js" +import { NonNegativeInt, optional } from "./schema.js" export interface Error extends Schema.Schema.Type {} export const Error = Schema.Struct({ @@ -9,4 +9,6 @@ export const Error = Schema.Struct({ message: Schema.String, status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional), response: Schema.Struct({ body: Schema.String }).pipe(optional), + /** How long the provider asked to wait before trying again, counted from when the failure was recorded. */ + retryAfterMs: NonNegativeInt.pipe(optional), }).annotate({ identifier: "Session.StructuredError" }) diff --git a/packages/schema/test/session-error.test.ts b/packages/schema/test/session-error.test.ts index 9eeac44dab18..dfd1dfb55ef9 100644 --- a/packages/schema/test/session-error.test.ts +++ b/packages/schema/test/session-error.test.ts @@ -13,6 +13,7 @@ describe("SessionError", () => { { type: "provider.rate-limit", message: "Slow down" }, { type: "provider.rate-limit", message: "Slow down", status: 429, response: { body: '{"error":{}}' } }, { type: "provider.auth", message: "Authentication failed" }, + { type: "provider.free-limit", message: "Free usage limit reached", status: 429, retryAfterMs: 3_600_000 }, { type: "provider.future-condition", message: "A future provider failure" }, { type: "unknown", message: "Unexpected" }, ] @@ -29,7 +30,7 @@ describe("SessionError", () => { Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.timeout", message: "Timeout", - retryAfterMs: 2_500, + retryable: true, }), ).toEqual({ type: "provider.timeout", message: "Timeout" }) })