mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-10 07:11:25 +03:00
feat(core): typed free-limit error with reset time (#57146)
This commit is contained in:
committed by
GitHub
Unverified
parent
db1fbe849c
commit
a9fa57b738
@@ -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 ||
|
||||
|
||||
@@ -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")(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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<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({
|
||||
|
||||
@@ -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<typeof Error> {}
|
||||
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" })
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user