Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/ai/src/provider-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)

if (codes.includes("freeusagelimiterror")) return new QuotaExceededError({ ...details, classification: "free-tier" })
if (
clientScoped &&
(codes.includes("context_length_exceeded") ||
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 @@ -55,7 +55,10 @@ export class RateLimitError extends Schema.TaggedError<RateLimitError>("AI.Error

export class QuotaExceededError extends Schema.TaggedError<QuotaExceededError>("AI.Error.QuotaExceeded")(
"QuotaExceeded",
ReasonFields,
{
...ReasonFields,
classification: Schema.optional(Schema.Literal("free-tier")),
},
) {}

export class ContentPolicyError extends Schema.TaggedError<ContentPolicyError>("AI.Error.ContentPolicy")(
Expand Down
19 changes: 19 additions & 0 deletions packages/ai/test/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@ const expectAIError = (error: unknown) => {
const largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}`

describe("RequestExecutor", () => {
it.effect("preserves free-tier exhaustion on HTTP 429 responses", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expect(error.reason).toMatchObject({
_tag: "QuotaExceeded",
classification: "free-tier",
http: { status: 429, headers: { "retry-after": "60" } },
})
}).pipe(
Effect.provide(
fixedResponse(JSON.stringify({ error: { type: "FreeUsageLimitError", message: "Rate limit exceeded" } }), {
status: 429,
headers: { "content-type": "application/json", "retry-after": "60" },
}),
),
),
)

it.effect("preserves externally captured HTTP errors without inventing response context", () =>
Effect.sync(() => {
const cause = new Error("upstream request failed")
Expand Down
13 changes: 13 additions & 0 deletions packages/ai/test/provider-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ import { isContextOverflow } from "../src/index.js"
import { classifyProviderFailure } from "../src/provider-error.js"

describe("provider error classification", () => {
test("distinguishes free-tier exhaustion from transient rate limits", () => {
const rawBody = JSON.stringify({ error: { type: "FreeUsageLimitError", message: "Rate limit exceeded" } })
const reason = classifyProviderFailure({ message: "Rate limit exceeded", status: 429, rawBody })
expect(reason).toMatchObject({ _tag: "QuotaExceeded", classification: "free-tier", body: rawBody })
expect(reason.message).toBe("Rate limit exceeded")
expect(classifyProviderFailure({ message: rawBody })).toMatchObject({
_tag: "QuotaExceeded",
classification: "free-tier",
})
expect(classifyProviderFailure({ message: "Rate limit exceeded", status: 429 })._tag).toBe("RateLimit")
expect(classifyProviderFailure({ message: "FreeUsageLimitError", status: 429 })._tag).toBe("RateLimit")
})

test("classifies provider token limit messages as context overflow", () => {
const messages = [
"tokens in request more than max tokens allowed",
Expand Down
131 changes: 131 additions & 0 deletions packages/app/e2e/session/free-usage-limit.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { expect, test, type Page } from "@playwright/test"
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"

test.use({ serviceWorkers: "block" })

const title = "Free limit reached"
const preference = "opencode.global.dat:go-upsell"
const message = "Free usage exceeded, subscribe to Go: https://opencode.ai/go"

async function fail(
page: Page,
timeline: Awaited<ReturnType<typeof setupTimeline>>,
sequence: number,
input: { sessionID?: string; type?: string } = {},
) {
const error = { type: input.type ?? "provider.free-tier-limit", message: `${message} (${sequence})` }
const id = input.sessionID ?? sessionID
// Execution failures are server-wide events and deliberately have no location.
await timeline.send({
id: `evt_free_limit_${sequence}`,
created: Date.now(),
type: "session.execution.failed",
durable: { aggregateID: id, seq: sequence, version: 1 },
data: { sessionID: id, error },
})
await expect
.poll(() =>
page.evaluate(
(message) =>
Object.keys(localStorage)
.filter((key) => key.includes("notification"))
.some((key) =>
JSON.parse(localStorage.getItem(key) ?? "{}").list?.some(
(notification: { error?: { message?: string } }) => notification.error?.message === message,
),
),
error.message,
),
)
.toBe(true)
}

test("free limit opens only for the current session and continues to Go connection", async ({
page,
context,
}, info) => {
const other = "ses_free_limit_other"
const timeline = await setupTimeline(page, { sessions: [session(), session({ id: other })], reducedMotion: true })
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
const dialog = page.getByRole("dialog", { name: title })

await fail(page, timeline, 1, { sessionID: other })
await expect(dialog).toHaveCount(0)
await fail(page, timeline, 2, { type: "provider.error" })
await expect(dialog).toHaveCount(0)
await fail(page, timeline, 3)
await expect(dialog).toBeVisible()
await expect(dialog).toContainText(
"Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
)
await expect(dialog.getByRole("button", { name: "Subscribe", exact: true })).toBeEnabled()
await page.screenshot({ path: info.outputPath("free-limit-go.png") })
await info.attach("free-limit-go", { path: info.outputPath("free-limit-go.png"), contentType: "image/png" })

await fail(page, timeline, 4)
await expect(page.getByRole("dialog")).toHaveCount(1)
await context.route("https://opencode.ai/go", (route) => route.fulfill({ body: "Go signup destination" }))
const popup = context.waitForEvent("page")
await dialog.getByRole("button", { name: "Subscribe", exact: true }).click()
const signup = await popup
await expect(signup).toHaveURL("https://opencode.ai/go")
await signup.close()
const connection = page.getByRole("dialog")
await expect(connection.getByRole("textbox", { name: "API key" })).toBeEditable()
await expect(connection).toContainText("opencode-go")
await page.keyboard.press("Escape")
await expect(connection).toHaveCount(0)
await fail(page, timeline, 5)
await expect(dialog).toHaveCount(0)
})

test("free limit respects the existing 24-hour suppression window", async ({ page }) => {
const now = Date.now()
await page.clock.setFixedTime(now)
await page.addInitScript(
({ preference, now }) => {
localStorage.setItem(preference, JSON.stringify({ go_upsell_last_seen_at: now - 23 * 60 * 60 * 1000 }))
},
{ preference, now },
)
const timeline = await setupTimeline(page)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await fail(page, timeline, 1)
await expect(page.getByRole("dialog")).toHaveCount(0)
await page.clock.setFixedTime(now + 2 * 60 * 60 * 1000)
await fail(page, timeline, 2)
await expect(page.getByRole("dialog", { name: title })).toBeVisible()
})

test("do not show again persists beyond the free limit suppression window", async ({ page }) => {
const timeline = await setupTimeline(page)
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
const dialog = page.getByRole("dialog", { name: title })
await fail(page, timeline, 1)
await dialog.getByRole("button", { name: "Don't show again" }).click()
await expect(dialog).toHaveCount(0)
await expect
.poll(() => page.evaluate((key) => JSON.parse(localStorage.getItem(key) ?? "{}").go_upsell_dont_show, preference))
.toBeGreaterThan(0)
await page.clock.setFixedTime(Date.now() + 25 * 60 * 60 * 1000)
await fail(page, timeline, 2)
await expect(page.getByRole("dialog")).toHaveCount(0)
})

test("free limit uses localized copy on mobile", async ({ page }, info) => {
const timeline = await setupTimeline(page, {
viewport: { width: 390, height: 844 },
locale: "de",
reducedMotion: true,
})
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
await fail(page, timeline, 1)
const dialog = page.getByRole("dialog", { name: "Kostenloses Limit erreicht" })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("button", { name: "Abonnieren", exact: true })).toBeInViewport()
await page.screenshot({ path: info.outputPath("free-limit-go-mobile.png") })
await info.attach("free-limit-go-mobile", {
path: info.outputPath("free-limit-go-mobile.png"),
contentType: "image/png",
})
})
60 changes: 39 additions & 21 deletions packages/app/src/session/usage-exceeded-dialogs.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { SessionStatus } from "@opencode-ai/client/promise"
import { onCleanup } from "solid-js"
Expand Down Expand Up @@ -42,6 +43,7 @@ function goUpsellKeys(status: SessionStatus) {

export function useUsageExceededDialogs() {
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const dialog = useDialog()
const { params } = useSessionLayout()
const { t, locale } = useI18n()
Expand All @@ -54,6 +56,41 @@ export function useUsageExceededDialogs() {
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null,
})

const showFreeTier = () => {
if (dialog.active) return
const seen = goUpsellState[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
if (goUpsellState[GO_UPSELL_FREE_TIER_DONT_SHOW]) return

void dialog.show(() => (
<DialogUsageExceeded
title={t("dialog.usageExceeded.freeTier.title")}
description={t("dialog.usageExceeded.freeTier.description")}
actionLabel={t("dialog.usageExceeded.freeTier.actionLabel")}
link="https://opencode.ai/go"
onClose={(dontShowAgain) => {
setGoUpsellState(GO_UPSELL_FREE_TIER_LAST_SEEN_AT, Date.now())
if (dontShowAgain) setGoUpsellState(GO_UPSELL_FREE_TIER_DONT_SHOW, Date.now())
else {
void import("@/providers/connect/dialog").then((x) => {
const controller = x.useProviderConnectController()
controller.select("opencode-go")
void dialog.show(() => <x.DialogConnectProvider controller={controller} />)
})
}
}}
/>
))
}

onCleanup(
serverSDK.event.on("session.execution.failed", (evt) => {
if (evt.data.sessionID !== params.id) return
if (typeof evt.data.error === "string" || evt.data.error.type !== "provider.free-tier-limit") return
showFreeTier()
}),
)

onCleanup(
sdk().event.on("session.status", (evt) => {
if (evt.data.sessionID !== params.id) return
Expand All @@ -64,32 +101,13 @@ export function useUsageExceededDialogs() {

const keys = goUpsellKeys(evt.data.status)
if (!keys) return
if (action.reason === "free_tier_limit") return showFreeTier()

const seen = goUpsellState[keys.lastSeenAt]
if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return
if (goUpsellState[keys.dontShow]) return

if (action.reason === "free_tier_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.freeTier.title")}
description={isEnglish() ? action.message : t("dialog.usageExceeded.freeTier.description")}
actionLabel={isEnglish() ? action.label : t("dialog.usageExceeded.freeTier.actionLabel")}
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
else {
void import("@/providers/connect/dialog").then((x) => {
const controller = x.useProviderConnectController()
controller.select("opencode-go")
void dialog.show(() => <x.DialogConnectProvider controller={controller} />)
})
}
}}
/>
))
} else if (action.reason === "account_rate_limit") {
if (action.reason === "account_rate_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={isEnglish() ? action.title : t("dialog.usageExceeded.accountRateLimit.title")}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/runner/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface Decision {
}

export function isRetryable(error: AIError) {
if (error.reason._tag === "QuotaExceeded" && error.reason.classification === "free-tier") return false
const override = error.reason.http?.headers["x-should-retry"]
if (override === "true") return true
if (override === "false") return false
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/session/to-session-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export function toSessionError(cause: unknown): SessionError.Error {
case "Authentication":
return providerError("provider.auth", cause.reason)
case "QuotaExceeded":
if (cause.reason.classification === "free-tier")
return providerError(
"provider.free-tier-limit",
cause.reason,
"Free usage exceeded, subscribe to Go: https://opencode.ai/go",
)
return providerError("provider.quota", cause.reason)
case "ContentPolicy":
return providerError("provider.content-filter", cause.reason)
Expand Down Expand Up @@ -58,7 +64,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
}

function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
function providerError(type: string, reason: AIError["reason"], message = reason.message): SessionError.Error {
const status = reason.http?.status
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
return { type, message, ...(status === undefined ? {} : { status }) }
}
22 changes: 22 additions & 0 deletions packages/core/test/aisdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,28 @@ it.effect("preserves complete HTTP context on AI SDK call errors", () =>
}),
)

it.effect("stops retrying free-tier limits from AI SDK providers", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
message: "Rate limit exceeded",
statusCode: 429,
responseHeaders: { "retry-after": "60" },
responseBody: JSON.stringify({
type: "error",
error: { type: "FreeUsageLimitError", message: "Rate limit exceeded" },
}),
}),
)
expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
expect(toSessionError(error)).toEqual({
type: "provider.free-tier-limit",
message: "Free usage exceeded, subscribe to Go: https://opencode.ai/go",
status: 429,
})
}),
)

it.effect("classifies retryable AI SDK failures with retry-after details", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
Expand Down
34 changes: 34 additions & 0 deletions packages/core/test/session-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,33 @@ import { Provider } from "@opencode-ai/core/provider"
import { Tool } from "@opencode-ai/schema/tool"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
import { classifyProviderFailure } from "@opencode-ai/ai/provider-error"

const llm = (reason: AIError["reason"]) => new AIError({ reason })

describe("toSessionError", () => {
test("stops retrying free-tier limits and prompts for OpenCode Go", () => {
const error = llm(
classifyProviderFailure({
message: "Rate limit exceeded",
status: 429,
rawBody: JSON.stringify({ error: { type: "FreeUsageLimitError", message: "Rate limit exceeded" } }),
http: new HttpContext({
url: "https://opencode.ai/zen/v1/chat/completions",
status: 429,
headers: { "retry-after": "60" },
}),
}),
)

expect(SessionRunnerRetry.isRetryable(error)).toBe(false)
expect(toSessionError(error)).toEqual({
type: "provider.free-tier-limit",
message: "Free usage exceeded, subscribe to Go: https://opencode.ai/go",
status: 429,
})
})

test("maps every AI error reason to the open wire type", () => {
expect(toSessionError(llm(new RateLimitError({ message: "rate", retryAfterMs: 123 })))).toEqual({
type: "provider.rate-limit",
Expand Down Expand Up @@ -225,5 +248,16 @@ describe("toSessionError", () => {
llm(new InvalidRequestError({ message: "retry", http: http({ "x-should-retry": "true" }) })),
),
).toBeTrue()
expect(
SessionRunnerRetry.isRetryable(
llm(
new QuotaExceededError({
message: "Free limit reached",
classification: "free-tier",
http: http({ "x-should-retry": "true" }),
}),
),
),
).toBeFalse()
})
})
Loading
Loading