Skip to content
Open
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
109 changes: 109 additions & 0 deletions packages/app/e2e/regression/composer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,115 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a
await expect(view.editor).toHaveText("")
})

test("the Go usage limit opens the Go limit dialog with its reset time and restores the prompt", async ({ page }) => {
await page.clock.setFixedTime(T0)
const sessionID = "ses_go_limit"
const model = { id: "glm-5-go", providerID: "opencode-go" }

const view = await openSession(page, {
name: "GoLimit",
sessions: [{ id: sessionID, agent: "build", model }],
onFileWrite: () => undefined,
pageMessages: () => ({
items: [
{ id: "msg_go_user", type: "user", text: "Refactor the parser", time: { created: T0 } },
{ id: "msg_go_assistant", type: "assistant", agent: "build", model, content: [], time: { created: T0 + 1 } },
],
}),
})

await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible()

const durable = (seq: number) => ({ aggregateID: sessionID, seq, version: 1 as const })
const location = { directory: view.directory }

const body = {
type: "error",
error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" },
metadata: { workspace: "wrk_go_limit", limitName: "5 hour" },
}

await view.push([
{
id: "evt_go_limit_1",
created: T0 + 2,
type: "session.step.failed",
location,
durable: durable(1),
data: {
sessionID,
assistantMessageID: "msg_go_assistant",
error: {
type: "provider.go-limit",
message: "OpenCode Go usage limit reached.",
status: 429,
response: { body: JSON.stringify(body) },
retryAfterMs: 3_600_000,
},
},
},
])

const dialog = page.getByRole("dialog", { name: "Go limit reached" })
await expect(dialog).toContainText("You've reached your OpenCode Go usage limit. It resets at")
await expect(dialog.getByRole("button", { name: "Open settings", exact: true })).toBeVisible()
await expect(
page.getByRole("status").filter({ hasText: /^You've reached your OpenCode Go usage limit\. It resets at / }),
).toBeVisible()
await expect(view.editor).toHaveText("Refactor the parser")
await expect(page.getByText("OpenCode Go usage limit reached.")).toHaveCount(0)
await dialog.getByRole("button", { name: "Don't show again", exact: true }).click()
await expect(dialog).toHaveCount(0)
})

test("ChatGPT's usage limit opens the ChatGPT usage dialog", async ({ page }) => {
await page.clock.setFixedTime(T0)
const sessionID = "ses_chatgpt_limit"
const model = { id: "gpt-5.5", providerID: "openai" }

const view = await openSession(page, {
name: "ChatGPTLimit",
sessions: [{ id: sessionID, agent: "build", model }],
onFileWrite: () => undefined,
pageMessages: () => ({
items: [
{ id: "msg_chatgpt_user", type: "user", text: "Refactor the parser", time: { created: T0 } },
{
id: "msg_chatgpt_assistant",
type: "assistant",
agent: "build",
model,
content: [],
time: { created: T0 + 1 },
},
],
}),
})

await expect(page.getByText("Refactor the parser", { exact: true })).toBeVisible()

await view.push([
{
id: "evt_chatgpt_limit_1",
created: T0 + 2,
type: "session.step.failed",
location: { directory: view.directory },
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: {
sessionID,
assistantMessageID: "msg_chatgpt_assistant",
error: {
type: "provider.chatgpt-limit",
message: "ChatGPT usage limit reached. Check ChatGPT Settings → Usage for details.",
status: 429,
},
},
},
])

await expect(page.getByRole("dialog", { name: "ChatGPT usage limit reached" })).toBeVisible()
})

test("a catalog refresh cannot replace an existing session's model", async ({ page }) => {
const alpha = { id: "alpha", name: "Alpha", variants: { high: {} } }
const beta = { id: "beta", name: "Beta" }
Expand Down
9 changes: 6 additions & 3 deletions packages/app/src/session/composer/region.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,16 @@ export function createActiveSessionRegion(input: {

const recalled = new Set<string>()

// The free allowance stops a prompt after it was sent. Hand its text back so switching models and resending is one
// step, unless the user has already started typing something else.
// The free allowance or a Go usage limit stops a prompt after it was sent. Hand its text back so switching models and
// resending is one step, unless the user has already started typing something else.
onCleanup(
location().event.on("session.step.failed", (event) => {
const sessionID = input.session.identity.params.id
const type = event.data.error.type

if (!sessionID || event.data.sessionID !== sessionID || event.data.error.type !== "provider.free-limit") return
if (!sessionID || event.data.sessionID !== sessionID) return

if (type !== "provider.free-limit" && type !== "provider.go-limit") return

if (recalled.has(event.data.assistantMessageID)) return
recalled.add(event.data.assistantMessageID)
Expand Down
40 changes: 29 additions & 11 deletions packages/app/src/session/usage-exceeded-dialogs.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { GoUpsellState, isChatGPTUsageLimit } from "./usage-exceeded-dialogs"
import { GoUpsellState, goLimitLink } from "./usage-exceeded-dialogs"
import { Persistence } from "@/runtime/persistence/schema"

const decode = Schema.decodeUnknownSync(
Expand Down Expand Up @@ -39,16 +39,34 @@ describe("usage exceeded preferences", () => {
})
})

describe("ChatGPT usage limit", () => {
const message =
"ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details."
describe("Go limit link", () => {
const body = (json: string) => ({
type: "provider.go-limit",
message: "OpenCode Go usage limit reached.",
response: { body: json },
})

test("opens the Go settings of the workspace that hit the limit", () => {
expect(
goLimitLink(
body(
JSON.stringify({
type: "error",
error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" },
metadata: { workspace: "wrk_go_limit", limitName: "5 hour" },
}),
),
),
).toBe("https://opencode.ai/console/wrk_go_limit/go")
})

test.each([
[{ type: "provider.rate-limit", message, status: 429 }, true],
[{ type: "provider.unknown", message }, true],
[{ type: "provider.rate-limit", message: "Rate limit exceeded", status: 429 }, false],
[{ type: "provider.rate-limit", message: "ChatGPT usage limit reached" }, false],
] as const)("matches %o: %p", (failure, expected) => {
expect(isChatGPTUsageLimit(failure)).toBe(expected)
test("falls back to the Console when the body has no workspace", () => {
expect(goLimitLink(body(JSON.stringify({ error: { type: "GoUsageLimitError" } })))).toBe(
"https://opencode.ai/console",
)
expect(goLimitLink({ type: "provider.go-limit", message: "limit", response: { body: "not json" } })).toBe(
"https://opencode.ai/console",
)
expect(goLimitLink({ type: "provider.go-limit", message: "limit" })).toBe("https://opencode.ai/console")
})
})
122 changes: 51 additions & 71 deletions packages/app/src/session/usage-exceeded-dialogs.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useWorkspaceLocation } from "@/workspaces/location"
import { Persist, persisted } from "@/runtime/persistence/storage"
import type { SessionStatus, SessionStepFailed } from "@opencode/client/promise"
import type { SessionStepFailed } from "@opencode/client/promise"
import { onCleanup } from "solid-js"
import { Schema } from "effect"
import { Option, Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import { useSessionLayout } from "./session-layout"
import { useDialog, useI18n } from "@opencode/ui/context"
Expand All @@ -20,15 +20,13 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don

const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs

const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])

const GO_URL = "https://opencode.ai/go"

const MAX_RETRY_AFTER_MS = 7 * 86_400_000

const CHATGPT_USAGE_LIMIT_WINDOW = 86_400_000 // 24 hrs

function formatFreeLimitReset(locale: string, created: number, retryAfterMs: number | undefined) {
function formatLimitReset(locale: string, created: number, retryAfterMs: number | undefined) {
if (
retryAfterMs === undefined ||
!Number.isFinite(retryAfterMs) ||
Expand All @@ -51,11 +49,15 @@ function formatFreeLimitReset(locale: string, created: number, retryAfterMs: num
).format(at)
}

export function isChatGPTUsageLimit(error: SessionStepFailed["data"]["error"]) {
return (
error.message ===
"ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details."
)
const GoLimitBody = Schema.fromJsonString(Schema.Struct({ metadata: Schema.Struct({ workspace: Schema.String }) }))

const decodeGoLimitBody = Schema.decodeUnknownOption(GoLimitBody)

/** The Console page where a Go subscriber turns on usage from their balance, scoped to the workspace that hit the limit. */
export function goLimitLink(error: SessionStepFailed["data"]["error"]) {
const workspace = Option.getOrUndefined(decodeGoLimitBody(error.response?.body))?.metadata.workspace

return workspace ? `https://opencode.ai/console/${encodeURIComponent(workspace)}/go` : "https://opencode.ai/console"
}

export const GoUpsellState = Persistence.struct({
Expand All @@ -65,20 +67,6 @@ export const GoUpsellState = Persistence.struct({
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: Schema.NullOr(Schema.Finite),
})

function goUpsellKeys(status: SessionStatus) {
if (status.type !== "retry" || !status.action) return
const { action } = status

if (!GO_UPSELL_PROVIDERS.has(action.provider)) return

if (action.reason === "account_rate_limit") {
return {
lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT,
dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW,
} as const
}
}

export function useUsageExceededDialogs(input?: {
selection?: () => ModelSelection | undefined
onDone?: () => void
Expand All @@ -87,7 +75,7 @@ export function useUsageExceededDialogs(input?: {
const dialog = useDialog()
const local = useLocal()
const { params } = useSessionLayout()
const { t, tDynamic, locale } = useI18n()
const { t, locale } = useI18n()

const [goUpsellState, setGoUpsellState, goUpsellInit, goUpsellReady] = persisted(
Persist.global("go-upsell"),
Expand Down Expand Up @@ -117,7 +105,7 @@ export function useUsageExceededDialogs(input?: {
// Counted when shown, so dismissing with Escape or the backdrop also waits out the window.
setGoUpsellState(GO_UPSELL_FREE_TIER_LAST_SEEN_AT, Date.now())

const reset = formatFreeLimitReset(locale(), evt.created, evt.data.error.retryAfterMs)
const reset = formatLimitReset(locale(), evt.created, evt.data.error.retryAfterMs)

dialog.show(() => (
<DialogUsageExceeded
Expand Down Expand Up @@ -151,19 +139,50 @@ export function useUsageExceededDialogs(input?: {
))
}

const showGoLimitDialog = (evt: SessionStepFailed) => {
if (evt.data.sessionID !== params.id || dialog.active) return
const seen = goUpsellState[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]

if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return

if (goUpsellState[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]) return

setGoUpsellState(GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT, Date.now())

const reset = formatLimitReset(locale(), evt.created, evt.data.error.retryAfterMs)

dialog.show(() => (
<DialogUsageExceeded
title={t("dialog.usageExceeded.accountRateLimit.title")}
description={
reset
? t("dialog.usageExceeded.accountRateLimit.descriptionReset", { time: reset })
: t("dialog.usageExceeded.accountRateLimit.description")
}
actionLabel={t("dialog.usageExceeded.accountRateLimit.actionLabel")}
link={goLimitLink(evt.data.error)}
onClose={(dontShowAgain) => {
if (dontShowAgain) setGoUpsellState(GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW, Date.now())
}}
/>
))
}

const whenGoUpsellReady = (show: (evt: SessionStepFailed) => void, evt: SessionStepFailed) => {
if (goUpsellReady()) show(evt)
else if (goUpsellInit instanceof Promise) void goUpsellInit.then(() => show(evt))
}

onCleanup(
sdk().event.on("session.step.failed", (evt) => {
if (evt.data.sessionID !== params.id || dialog.active) return
const error = evt.data.error

if (error.type === "provider.free-limit") {
if (goUpsellReady()) showFreeLimitDialog(evt)
else if (goUpsellInit instanceof Promise) void goUpsellInit.then(() => showFreeLimitDialog(evt))
if (error.type === "provider.free-limit") return whenGoUpsellReady(showFreeLimitDialog, evt)

return
}
if (error.type === "provider.go-limit") return whenGoUpsellReady(showGoLimitDialog, evt)

if (!isChatGPTUsageLimit(error)) return
if (error.type !== "provider.chatgpt-limit") return

if (chatgptUsageLimit.lastSeenAt && Date.now() - chatgptUsageLimit.lastSeenAt < CHATGPT_USAGE_LIMIT_WINDOW) return

Expand All @@ -174,43 +193,4 @@ export function useUsageExceededDialogs(input?: {
})
}),
)

onCleanup(
sdk().event.on("session.status", (evt) => {
if (evt.data.sessionID !== params.id) return

if (evt.data.status.type !== "retry") return
const { action } = evt.data.status

if (!action) return

if (dialog.active) return

const keys = goUpsellKeys(evt.data.status)

if (!keys) return

const seen = goUpsellState[keys.lastSeenAt]

if (seen && Date.now() - seen < GO_UPSELL_WINDOW) return

if (goUpsellState[keys.dontShow]) return

if (action.reason === "account_rate_limit") {
dialog.show(() => (
<DialogUsageExceeded
title={tDynamic("dialog.usageExceeded.accountRateLimit.title", action.title)}
description={tDynamic("dialog.usageExceeded.accountRateLimit.description", action.message)}
actionLabel={tDynamic("dialog.usageExceeded.accountRateLimit.actionLabel", action.label)}
link={action.link}
onClose={(dontShowAgain) => {
setGoUpsellState(keys.lastSeenAt, Date.now())

if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
}}
/>
))
}
}),
)
}
Loading
Loading