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
8 changes: 5 additions & 3 deletions packages/ai/src/route/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,20 @@ const headerDetails = (headers: Headers.Headers) =>
const normalizedHeaders = (headers: Headers.Headers) =>
Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))

const clampRetryAfterMs = (millis: number) => Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, Math.ceil(millis)))

const retryAfterMs = (headers: Record<string, string>) => {
const millis = Number(headers["retry-after-ms"])
if (Number.isFinite(millis)) return Math.max(0, millis)
if (Number.isFinite(millis)) return clampRetryAfterMs(millis)

const value = headers["retry-after"]
if (!value) return undefined

const seconds = Number(value)
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
if (Number.isFinite(seconds)) return clampRetryAfterMs(seconds * 1000)

const date = Date.parse(value)
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
if (!Number.isNaN(date)) return clampRetryAfterMs(date - Date.now())
return undefined
}

Expand Down
9 changes: 5 additions & 4 deletions packages/ai/src/schema/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Schema } from "effect"
import { NonNegativeInt } from "@opencode/schema"
import { Tool } from "@opencode/schema/tool"
import { ModelID, ProviderID, RouteID } from "./ids.js"

Expand All @@ -12,7 +13,7 @@ export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
}) {}

export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("AI.HttpRateLimitDetails")({
retryAfterMs: Schema.optional(Schema.Number),
retryAfterMs: Schema.optional(NonNegativeInt),
limit: Schema.optional(Schema.Record(Schema.String, Schema.String)),
remaining: Schema.optional(Schema.Record(Schema.String, Schema.String)),
reset: Schema.optional(Schema.Record(Schema.String, Schema.String)),
Expand Down Expand Up @@ -77,15 +78,15 @@ export class AuthenticationError extends Schema.TaggedError<AuthenticationError>

export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error.RateLimit")("RateLimit", {
...ReasonFields,
retryAfterMs: Schema.optional(Schema.Number),
retryAfterMs: Schema.optional(NonNegativeInt),
rateLimit: Schema.optional(HttpRateLimitDetails),
}) {}

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

Expand All @@ -98,7 +99,7 @@ export class ProviderInternalError extends Schema.TaggedError<ProviderInternalEr
"ProviderInternal",
{
...ReasonFields,
retryAfterMs: Schema.optional(Schema.Number),
retryAfterMs: Schema.optional(NonNegativeInt),
},
) {}

Expand Down
8 changes: 8 additions & 0 deletions packages/ai/test/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ describe("RequestExecutor", () => {
)
expect(RequestExecutor.httpFailure({ message: "No response", url: request.url }).reason.http).toBeUndefined()
expect(RequestExecutor.httpFailure({ message: "No URL", status: 500 }).reason.http).toBeUndefined()
expect(
RequestExecutor.httpFailure({
message: "Huge retry-after",
url: request.url,
status: 429,
responseHeaders: { "Retry-After": "1e20" },
}).reason,
).toMatchObject({ _tag: "RateLimit", retryAfterMs: Number.MAX_SAFE_INTEGER })
}),
)

Expand Down
9 changes: 3 additions & 6 deletions packages/core/src/session/to-session-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,12 @@ export function toSessionError(cause: unknown): SessionError.Error {
return providerError("provider.rate-limit", cause.reason)
case "Authentication":
return providerError("provider.auth", cause.reason)
case "QuotaExceeded": {
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 Expand Up @@ -108,10 +103,12 @@ export function contentFilterError(summary: string, reason: FinishReasonDetails)

function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
const status = reason.http?.status
const retryAfterMs = "retryAfterMs" in reason ? reason.retryAfterMs : undefined
return {
type,
message: Object.entries(tokenSharingMessages).find(([code]) => reason.body?.includes(code))?.[1] ?? reason.message,
...(status === undefined ? {} : { status }),
...(reason.body === undefined ? {} : { response: { body: reason.body } }),
...(retryAfterMs === undefined ? {} : { retryAfterMs }),
}
}
19 changes: 14 additions & 5 deletions packages/core/test/session-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,23 @@ describe("toSessionError", () => {
expect(toSessionError(llm(new RateLimitError({ message: "rate", retryAfterMs: 123 })))).toEqual({
type: "provider.rate-limit",
message: "rate",
retryAfterMs: 123,
})
expect(toSessionError(llm(new AuthenticationError({ message: "auth" }))).type).toBe("provider.auth")
expect(toSessionError(llm(new QuotaExceededError({ message: "quota" }))).type).toBe("provider.quota")
expect(toSessionError(llm(new QuotaExceededError({ message: "quota", retryAfterMs: 456 })))).toEqual({
type: "provider.quota",
message: "quota",
retryAfterMs: 456,
})
expect(toSessionError(llm(new ContentPolicyError({ message: "blocked" }))).type).toBe("provider.content-filter")
expect(
toSessionError(llm(new TransportError({ message: "transport", transport: "http", operation: "request" }))).type,
).toBe("provider.transport")
expect(toSessionError(llm(new ProviderInternalError({ message: "internal" }))).type).toBe("provider.internal")
expect(toSessionError(llm(new ProviderInternalError({ message: "internal", retryAfterMs: 789 })))).toEqual({
type: "provider.internal",
message: "internal",
retryAfterMs: 789,
})
expect(toSessionError(llm(new InvalidProviderOutputError({ message: "output" }))).type).toBe(
"provider.invalid-output",
)
Expand Down Expand Up @@ -102,23 +111,23 @@ describe("toSessionError", () => {
{ "retry-after": "45" },
)
expect(SessionRunnerRetry.isRetryable(paid)).toBe(true)
expect(toSessionError(paid).type).toBe("provider.rate-limit")
expect(toSessionError(paid)).toMatchObject({ type: "provider.rate-limit", retryAfterMs: 45_000 })

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")
expect(toSessionError(go)).toMatchObject({ type: "provider.quota", retryAfterMs: 120_000 })

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")
expect(toSessionError(other)).toMatchObject({ type: "provider.rate-limit", retryAfterMs: 10_000 })
})

test("preserves the permission rejection type without exposing internal fields", () => {
Expand Down
Loading