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
2 changes: 1 addition & 1 deletion packages/ai/src/provider-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
5 changes: 4 additions & 1 deletion packages/ai/src/schema/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error

export class QuotaExceededError extends Schema.TaggedError<QuotaExceededError>("AI.Error.QuotaExceeded")(
"QuotaExceeded",
ReasonFields,
{
...ReasonFields,
retryAfterMs: Schema.optional(Schema.Number),
},
) {}

export class ContentPolicyError extends Schema.TaggedError<ContentPolicyError>("AI.Error.ContentPolicy")(
Expand Down
5 changes: 5 additions & 0 deletions packages/client/src/effect/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,7 @@ export type SessionLogOutput =
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
readonly retryAfterMs?: number | undefined
}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -1087,6 +1090,7 @@ export type SessionLogOutput =
readonly message: string
readonly status?: number | undefined
readonly response?: { readonly body: string } | undefined
readonly retryAfterMs?: number | undefined
}
}
}
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand Down
16 changes: 14 additions & 2 deletions packages/core/src/session/to-session-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,27 @@ 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) {
case "RateLimit":
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":
Expand Down
50 changes: 50 additions & 0 deletions packages/core/test/session-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, string>) =>
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({
Expand Down
4 changes: 3 additions & 1 deletion packages/schema/src/session-error.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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<typeof Error> {}
export const Error = Schema.Struct({
type: Schema.String,
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" })
3 changes: 2 additions & 1 deletion packages/schema/test/session-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand All @@ -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" })
})
Expand Down
Loading