diff --git a/packages/app/e2e/regression/composer.spec.ts b/packages/app/e2e/regression/composer.spec.ts index 4d653950eef1..dcf5aaec2f3f 100644 --- a/packages/app/e2e/regression/composer.spec.ts +++ b/packages/app/e2e/regression/composer.spec.ts @@ -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" } diff --git a/packages/app/src/session/composer/region.tsx b/packages/app/src/session/composer/region.tsx index 1315a869f353..adca48d3a96e 100644 --- a/packages/app/src/session/composer/region.tsx +++ b/packages/app/src/session/composer/region.tsx @@ -192,13 +192,16 @@ export function createActiveSessionRegion(input: { const recalled = new Set() - // 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) diff --git a/packages/app/src/session/usage-exceeded-dialogs.test.ts b/packages/app/src/session/usage-exceeded-dialogs.test.ts index 001d5003e6a8..afa6b6711435 100644 --- a/packages/app/src/session/usage-exceeded-dialogs.test.ts +++ b/packages/app/src/session/usage-exceeded-dialogs.test.ts @@ -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( @@ -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") }) }) diff --git a/packages/app/src/session/usage-exceeded-dialogs.tsx b/packages/app/src/session/usage-exceeded-dialogs.tsx index 38519da56c1b..b75c70dbcc49 100644 --- a/packages/app/src/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/session/usage-exceeded-dialogs.tsx @@ -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" @@ -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) || @@ -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({ @@ -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 @@ -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"), @@ -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(() => ( { + 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(() => ( + { + 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 @@ -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(() => ( - { - setGoUpsellState(keys.lastSeenAt, Date.now()) - - if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now()) - }} - /> - )) - } - }), - ) } diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index 2fa9f70a1b34..4cddb5d36b23 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -28,19 +28,24 @@ const tokenSharingMessages = { const FREE_USAGE_LIMIT_MESSAGE = "Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models." +const GO_USAGE_LIMIT_MESSAGE = + "OpenCode Go usage limit reached. Switch models, or enable usage from your balance in the Console to keep going." + export function toSessionError(cause: unknown): SessionError.Error { if (cause instanceof AIError) { + if (cause.reason.body?.includes("subscription_sharing_usage_limit_exceeded")) + return providerError("provider.chatgpt-limit", cause.reason) switch (cause.reason._tag) { case "RateLimit": return providerError("provider.rate-limit", cause.reason) case "Authentication": return providerError("provider.auth", cause.reason) case "QuotaExceeded": - if (!cause.reason.body?.includes("FreeUsageLimitError")) return providerError("provider.quota", cause.reason) - return { - ...providerError("provider.free-limit", cause.reason), - message: FREE_USAGE_LIMIT_MESSAGE, - } + if (cause.reason.body?.includes("FreeUsageLimitError")) + return { ...providerError("provider.free-limit", cause.reason), message: FREE_USAGE_LIMIT_MESSAGE } + if (cause.reason.body?.includes("GoUsageLimitError")) + return { ...providerError("provider.go-limit", cause.reason), message: GO_USAGE_LIMIT_MESSAGE } + return providerError("provider.quota", cause.reason) case "ContentPolicy": return providerError("provider.content-filter", cause.reason) case "Transport": diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index fcf8fe210838..72b235f2b7c8 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -113,13 +113,32 @@ describe("toSessionError", () => { expect(SessionRunnerRetry.isRetryable(paid)).toBe(true) 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" }, - ) + const goBody = { + type: "error", + error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" }, + metadata: { workspace: "wrk_go_limit", limitName: "5 hour" }, + } + const go = fail("https://opencode.ai/inference/go/openai/v1/chat/completions", goBody, { "retry-after": "120" }) expect(SessionRunnerRetry.isRetryable(go)).toBe(false) - expect(toSessionError(go)).toMatchObject({ type: "provider.quota", retryAfterMs: 120_000 }) + expect(toSessionError(go)).toEqual({ + type: "provider.go-limit", + message: + "OpenCode Go usage limit reached. Switch models, or enable usage from your balance in the Console to keep going.", + status: 429, + response: { body: JSON.stringify(goBody) }, + retryAfterMs: 120_000, + }) + + const credit = RequestExecutor.httpFailure({ + message: "Credit limit exceeded.", + url: "https://opencode.ai/inference/openai/v1/chat/completions", + status: 402, + responseBody: JSON.stringify({ + type: "error", + error: { type: "CreditLimitExceeded", message: "Credit limit exceeded." }, + }), + }) + expect(toSessionError(credit)).toMatchObject({ type: "provider.quota" }) const other = fail( "https://api.openai.com/v1/chat/completions", @@ -215,6 +234,9 @@ describe("toSessionError", () => { const error = toSessionError(llm(new UnknownProviderError({ message: "Request failed", body }))) expect(error.message).toContain(guidance) expect(error.response).toEqual({ body }) + expect(error.type).toBe( + code === "subscription_sharing_usage_limit_exceeded" ? "provider.chatgpt-limit" : "provider.unknown", + ) } } }) diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 9a3ea55242ff..bff54f4a25b1 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -567,7 +567,7 @@ export namespace Timeline { new TimelineRow.Error({ userMessageID: turnID, text: unwrapErrorMessage(lastAssistant.error.message), - ...freeLimit(lastAssistant), + ...usageLimit(lastAssistant), }), ) } @@ -588,7 +588,7 @@ export namespace Timeline { new TimelineRow.Error({ userMessageID: turnID, text: unwrapErrorMessage(entry.message.error.message), - ...freeLimit(entry.message), + ...usageLimit(entry.message), }), ) @@ -641,11 +641,15 @@ export namespace Timeline { const MAX_RETRY_AFTER_MS = 7 * 86_400_000 -function freeLimit( +function usageLimit( message: Pick & { time: { created: number; completed?: number } }, ) { - if (message.error?.type !== "provider.free-limit") return {} - const delay = message.error.retryAfterMs + const type = message.error?.type + + if (type !== "provider.free-limit" && type !== "provider.go-limit") return {} + + const kind = type === "provider.go-limit" ? ("go-limit" as const) : ("free-limit" as const) + const delay = message.error?.retryAfterMs const base = message.time.completed ?? message.time.created if ( @@ -656,12 +660,9 @@ function freeLimit( !Number.isFinite(base) || Number.isNaN(new Date(base + delay).getTime()) ) - return { kind: "free-limit" as const } + return { kind } - return { - kind: "free-limit" as const, - resetAt: base + delay, - } + return { kind, resetAt: base + delay } } function isInterrupted(error: SessionMessageAssistant["error"]) { diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index b0039cb60dd2..9520b048cfec 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -1451,7 +1451,7 @@ describe("current session timeline rows", () => { ) }) - test("marks the free usage limit and its reset time, whichever message records the failure", () => { + test("marks the free and Go usage limits and their reset times, whichever message records the failure", () => { const error = { type: "provider.free-limit", message: "Free usage limit reached.", retryAfterMs: 60_000 } const user = { id: "msg_user", type: "user", text: "prompt", time: { created: 1 } } satisfies SessionMessageInfo @@ -1475,7 +1475,12 @@ describe("current session timeline rows", () => { const quota = [ user, - { ...assistant, error: { type: "provider.quota", message: "Go usage limit exceeded", retryAfterMs: 60_000 } }, + { ...assistant, error: { type: "provider.quota", message: "Credit limit exceeded.", retryAfterMs: 60_000 } }, + ] satisfies SessionMessageInfo[] + + const go = [ + user, + { ...assistant, error: { type: "provider.go-limit", message: "Go usage limit reached.", retryAfterMs: 120_000 } }, ] satisfies SessionMessageInfo[] expect(Timeline.constructSessionMessageRows(step, true, { type: "idle" }).rows.at(-1)).toEqual( @@ -1503,8 +1508,16 @@ describe("current session timeline rows", () => { kind: "free-limit", }), ) + expect(Timeline.constructSessionMessageRows(go, true, { type: "idle" }).rows.at(-1)).toEqual( + new TimelineRow.Error({ + userMessageID: "msg_user", + text: "Go usage limit reached.", + kind: "go-limit", + resetAt: 121_000, + }), + ) expect(Timeline.constructSessionMessageRows(quota, true, { type: "idle" }).rows.at(-1)).toEqual( - new TimelineRow.Error({ userMessageID: "msg_user", text: "Go usage limit exceeded" }), + new TimelineRow.Error({ userMessageID: "msg_user", text: "Credit limit exceeded." }), ) }) diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 2d4d3e0b0392..2a6dd2525a17 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -1010,12 +1010,19 @@ export function createSessionTimelineRowRenderer(input: { return value } - const freeLimit = createMemo(() => { - if (current().kind !== "free-limit") return + const usageLimit = createMemo(() => { + const kind = current().kind + + if (!kind) return + + const keys = + kind === "go-limit" + ? ({ title: "ui.sessionTurn.goLimit.title", lineReset: "ui.sessionTurn.goLimit.lineReset" } as const) + : ({ title: "ui.sessionTurn.freeLimit.title", lineReset: "ui.sessionTurn.freeLimit.lineReset" } as const) + const at = current().resetAt - if (at === undefined || !Number.isFinite(at) || Number.isNaN(new Date(at).getTime())) - return i18n.t("ui.sessionTurn.freeLimit.title") + if (at === undefined || !Number.isFinite(at) || Number.isNaN(new Date(at).getTime())) return i18n.t(keys.title) const now = Date.now() const sameDay = new Date(at).toDateString() === new Date(now).toDateString() && at - now < 86_400_000 @@ -1025,13 +1032,13 @@ export function createSessionTimelineRowRenderer(input: { sameDay ? { timeStyle: "short" } : { dateStyle: "medium", timeStyle: "short" }, ).format(at) - return i18n.t("ui.sessionTurn.freeLimit.lineReset", { time }) + return i18n.t(keys.lineReset, { time }) }) return (
- }> + }> {(text) => (
{} /** - * `free-limit` marks OpenCode Zen's daily free allowance running out; `resetAt` is when the allowance comes back, if - * known. + * `free-limit` marks OpenCode Zen's daily free allowance running out and `go-limit` an OpenCode Go subscription's + * usage limit; `resetAt` is when the allowance comes back, if known. */ export class Error extends Data.TaggedClass("Error")<{ userMessageID: string text: string - kind?: "free-limit" + kind?: "free-limit" | "go-limit" resetAt?: number }> {} @@ -148,7 +148,7 @@ export type TimelineRowMap = { } Thinking: { userMessageID: string; ref: PartRef } Retry: { userMessageID: string } - Error: { userMessageID: string; text: string; kind?: "free-limit"; resetAt?: number } + Error: { userMessageID: string; text: string; kind?: "free-limit" | "go-limit"; resetAt?: number } CompactionQueued: { userMessageID: string; inboxID: string } TurnDiff: { userMessageID: string; messageID: string; from: string; files: readonly string[] } Activity: { userMessageID: string } diff --git a/packages/tui/src/feature-plugins/system/notifications.ts b/packages/tui/src/feature-plugins/system/notifications.ts index fe21e258a000..8876080ffc3c 100644 --- a/packages/tui/src/feature-plugins/system/notifications.ts +++ b/packages/tui/src/feature-plugins/system/notifications.ts @@ -66,7 +66,7 @@ export default Plugin.define({ return } errored.add(sessionID) - if (event.data.error.type !== "provider.free-limit") { + if (event.data.error.type !== "provider.free-limit" && event.data.error.type !== "provider.go-limit") { notify(context, sessionID, event.data.error.message, "error") context.ui.toast.show({ sessionID, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 895ad2e26f92..e2f33dcab2d0 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -640,10 +640,13 @@ export function Session(props: { onCleanup(() => { sessionDisposed = true }) - // Restore the prompt when the free allowance stops it, and offer OpenCode Go on the first hit in a day. + // Restore the prompt when the free allowance or a Go usage limit stops it, and offer OpenCode Go on the first free-limit + // hit in a day. onCleanup( data.on("session.step.failed", (event) => { - if (event.data.sessionID !== route.sessionID || event.data.error.type !== "provider.free-limit") return + const type = event.data.error.type + if (event.data.sessionID !== route.sessionID) return + if (type !== "provider.free-limit" && type !== "provider.go-limit") return if (handledFreeLimits.has(event.data.assistantMessageID)) return handledFreeLimits.add(event.data.assistantMessageID) const current = prompt() @@ -666,7 +669,7 @@ export function Session(props: { break } } - if (dialog.stack.length > 0) return + if (type === "provider.go-limit" || dialog.stack.length > 0) return if (goUpsell.dontShow || goUpsellMemory.dontShow) return const now = Date.now() const seen = Math.max(goUpsell.lastSeenAt ?? 0, goUpsellMemory.lastSeenAt ?? 0) @@ -726,7 +729,7 @@ export function Session(props: { if (!claimed || !canOpen()) return dialog.replace(() => ( { updateGoUpsellMemory((draft) => { @@ -2027,19 +2030,22 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { turnTokensPerSecond(props.message, messages(), ctx.messageIndex(props.message.id), ctx.legacyTurns()), ) const interrupted = createMemo(() => props.message.error?.message === "Step interrupted") - const freeLimit = createMemo(() => props.message.error?.type === "provider.free-limit") + const usageLimit = createMemo(() => { + const type = props.message.error?.type + return type === "provider.free-limit" || type === "provider.go-limit" + }) return ( <> Error: {errorMessage(props.message.error)} } > - + @@ -2511,7 +2517,7 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp ) } -function freeLimitReset(delay: number | undefined, base: number) { +function limitReset(delay: number | undefined, base: number) { if (delay === undefined || !Number.isFinite(delay) || delay < 0 || delay > 7 * 86_400_000 || !Number.isFinite(base)) return const at = base + delay @@ -2522,18 +2528,33 @@ function freeLimitReset(delay: number | undefined, base: number) { : Locale.datetime(at) } -function FreeLimitNotice(props: { message: Pick }) { +function UsageLimitNotice(props: { message: Pick }) { const theme = useTheme() + const go = createMemo(() => props.message.error?.type === "provider.go-limit") const reset = createMemo(() => - freeLimitReset(props.message.error?.retryAfterMs, props.message.time.completed ?? props.message.time.created), + limitReset(props.message.error?.retryAfterMs, props.message.time.completed ?? props.message.time.created), ) + const headline = createMemo(() => { + if (go()) return `You've reached your OpenCode Go usage limit.${reset() ? ` It resets at ${reset()}.` : ""}` + return `You've used today's free messages.${reset() ? ` They reset at ${reset()}.` : ""}` + }) return ( - You've used today's free messages.{reset() ? ` They reset at ${reset()}.` : ""} - - Upgrade to OpenCode Go → /connect · Switch model →{" "} - /models - + {headline()} + + Upgrade to OpenCode Go → /connect · Switch model →{" "} + /models + + } + > + + Switch model → /models · Enable usage from your balance in the + Console + + ) } diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index d3050c43cefb..87b4179c4da7 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -2549,6 +2549,71 @@ test("free usage limit claims suppression atomically across two TUI instances an expect(frame).not.toContain("Connect OpenCode Go") }) +test("Go usage limit restores the prompt and leaves a muted notice with the reset time instead of an error", async () => { + await using state = await tmpdir() + const session = { + id: "ses_go_limit", + projectID: "proj_test", + location: { directory }, + title: "Go limit", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + } + const error = { + type: "provider.go-limit", + message: "OpenCode Go usage limit reached.", + status: 429, + retryAfterMs: 3_600_000, + } + await using setup = await createAppFixture({ + width: 100, + state: state.path, + args: { sessionID: session.id }, + config: { animations: false, tabs: { mode: "off" } }, + fetch: (url) => { + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === `/api/session/${session.id}/message`) + return json({ + data: [ + { + id: "msg_go_assistant", + type: "assistant", + agent: "build", + model: { id: "glm-5-go", providerID: "opencode-go" }, + content: [], + time: { created: 2 }, + }, + { id: "msg_go_user", type: "user", text: "Refactor the parser", time: { created: 1 } }, + ], + cursor: {}, + }) + if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname)) + return json({ data: [] }) + return undefined + }, + }) + await setup.ready + await setup.waitForFrame((frame) => frame.includes("Refactor the parser")) + setup.events.emit({ + id: "evt_go_limit_1", + created: 3, + type: "session.step.failed", + durable: { aggregateID: session.id, seq: 1, version: 1 }, + data: { sessionID: session.id, assistantMessageID: "msg_go_assistant", error }, + }) + await setup.waitForFrame( + (frame) => + frame.includes("You've reached your OpenCode Go usage limit. It resets at") && + frame.split("Refactor the parser").length === 3, + ) + const frame = setup.captureCharFrame() + expect(frame).toContain("Switch model → /models") + expect(frame).not.toContain("Error:") + expect(frame).not.toContain("Free limit reached") +}) + test("free usage limit modal manages live animation FPS and adapts to 44x24 and 60x12 terminals", async () => { const session = { id: "ses_free_limit_anim", diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index 6e2ed7e2cdb5..9232cabbc857 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -170,6 +170,19 @@ describe("internal notifications TUI plugin", () => { } as OpenCodeEvent) expect(harness.toasts).toHaveLength(2) expect(harness.notifications).toHaveLength(2) + + harness.emit(executionStarted("go-limit")) + harness.emit({ + id: "go-limit-failed", + created: 0, + type: "session.execution.failed", + data: { + sessionID: "session", + error: { type: "provider.go-limit", message: "OpenCode Go usage limit reached." }, + }, + } as OpenCodeEvent) + expect(harness.toasts).toHaveLength(2) + expect(harness.notifications).toHaveLength(2) }) test.each([{ type: "home" }, { type: "session", sessionID: "other" }])( diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 7186c1f0f472..bcc646c60b8c 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -77,6 +77,8 @@ const source = { "ui.sessionTurn.retry.geminiHot": "gemini is way too hot right now", "ui.sessionTurn.freeLimit.title": "You've used today's free messages.", "ui.sessionTurn.freeLimit.lineReset": "You've used today's free messages. They reset at {{time}}.", + "ui.sessionTurn.goLimit.title": "You've reached your OpenCode Go usage limit.", + "ui.sessionTurn.goLimit.lineReset": "You've reached your OpenCode Go usage limit. It resets at {{time}}.", "ui.sessionTurn.error.freeUsageExceeded": "Free usage exceeded", "ui.sessionTurn.error.addCredits": "Add credits", @@ -87,7 +89,9 @@ const source = { "dialog.usageExceeded.freeTier.actionLabel": "Subscribe", "dialog.usageExceeded.accountRateLimit.title": "Go limit reached", "dialog.usageExceeded.accountRateLimit.description": - "Usage limit reached. To continue using this model now, enable usage from your available balance", + "You've reached your OpenCode Go usage limit. To keep using Go models now, enable usage from your balance.", + "dialog.usageExceeded.accountRateLimit.descriptionReset": + "You've reached your OpenCode Go usage limit. It resets at {{time}}. To keep using Go models now, enable usage from your balance.", "dialog.usageExceeded.accountRateLimit.actionLabel": "Open settings", "ui.sessionTurn.status.delegating": "Delegating work",