diff --git a/packages/app/src/providers/connect/chatgpt-usage-limit.stories.tsx b/packages/app/src/providers/connect/chatgpt-usage-limit.stories.tsx new file mode 100644 index 00000000000..2059b60d8c7 --- /dev/null +++ b/packages/app/src/providers/connect/chatgpt-usage-limit.stories.tsx @@ -0,0 +1,23 @@ +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { onMount } from "solid-js" +import { DialogChatGPTUsageLimit } from "./chatgpt-usage-limit" + +function UsageLimitStory() { + const dialog = useDialog() + const open = () => dialog.show(() => ) + onMount(open) + + return ( + + Open ChatGPT usage limit + + ) +} + +export default { + title: "App/Dialogs/Connect Provider", + id: "app-dialog-chatgpt-usage-limit", +} + +export const ChatGPTUsageLimit = { render: () => } diff --git a/packages/app/src/providers/connect/chatgpt-usage-limit.tsx b/packages/app/src/providers/connect/chatgpt-usage-limit.tsx new file mode 100644 index 00000000000..249c0603a8e --- /dev/null +++ b/packages/app/src/providers/connect/chatgpt-usage-limit.tsx @@ -0,0 +1,48 @@ +import { Button } from "@opencode/ui/button" +import { useDialog } from "@opencode/ui/context/dialog" +import { Dialog, DialogTitle } from "@opencode/ui/dialog" +import { ProviderIcon } from "@opencode/ui/provider-icon" +import { useLanguage } from "@/runtime/i18n/language" +import { usePlatform } from "@/runtime/platform/platform" + +export function DialogChatGPTUsageLimit() { + const dialog = useDialog() + const language = useLanguage() + const platform = usePlatform() + + return ( + + + + + + {language.t("provider.connect.chatgptUsageLimit.title")} + + + + {language.t("provider.connect.chatgptUsageLimit.description")} + + { + platform.openExternal("https://chatgpt.com/#settings/Usage") + dialog.close() + }} + > + {language.t("provider.connect.chatgptUsageLimit.manage")} + + dialog.close()} + > + {language.t("provider.connect.chatgptUsageLimit.close")} + + + + ) +} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 0c44e05dad1..ff4ef6504ec 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -218,6 +218,10 @@ export const dict = { "provider.connect.models.description": "Choose a model to start with. You can switch models anytime.", "provider.connect.models.available": "Available models", "provider.connect.models.list": "Models available from {{provider}}", + "provider.connect.chatgptUsageLimit.title": "ChatGPT usage limit reached", + "provider.connect.chatgptUsageLimit.description": "Review your usage settings in ChatGPT.", + "provider.connect.chatgptUsageLimit.manage": "Manage usage", + "provider.connect.chatgptUsageLimit.close": "Close", "provider.connect.console.refreshFailed": "Your account is connected, but we couldn't load your models. Try again to refresh them.", "provider.connect.console.connected": "OpenCode connected", diff --git a/packages/app/src/session/usage-exceeded-dialogs.test.ts b/packages/app/src/session/usage-exceeded-dialogs.test.ts index c57aba79d84..cfd5478580f 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 } from "./usage-exceeded-dialogs" +import { GoUpsellState, isChatGPTUsageLimit } from "./usage-exceeded-dialogs" import { Persistence } from "@/runtime/persistence/schema" const decode = Schema.decodeUnknownSync( @@ -38,3 +38,31 @@ 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." + + test("detects the mapped message regardless of failure type", () => { + expect( + isChatGPTUsageLimit({ + type: "provider.rate-limit", + message, + status: 429, + }), + ).toBe(true) + expect(isChatGPTUsageLimit({ type: "provider.unknown", message })).toBe(true) + }) + + test("ignores other rate limits and similar messages", () => { + expect(isChatGPTUsageLimit({ type: "provider.rate-limit", message: "Rate limit exceeded", status: 429 })).toBe( + false, + ) + expect( + isChatGPTUsageLimit({ + type: "provider.rate-limit", + message: "ChatGPT usage limit reached", + }), + ).toBe(false) + }) +}) diff --git a/packages/app/src/session/usage-exceeded-dialogs.tsx b/packages/app/src/session/usage-exceeded-dialogs.tsx index 3a7cb996913..33d3d792f1b 100644 --- a/packages/app/src/session/usage-exceeded-dialogs.tsx +++ b/packages/app/src/session/usage-exceeded-dialogs.tsx @@ -1,6 +1,6 @@ import { useWorkspaceLocation } from "@/workspaces/location" import { Persist, persisted } from "@/runtime/persistence/storage" -import type { SessionStatus } from "@opencode/client/promise" +import type { SessionStatus, SessionStepFailed } from "@opencode/client/promise" import { onCleanup } from "solid-js" import { Schema } from "effect" import { Persistence } from "@/runtime/persistence/schema" @@ -14,6 +14,14 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show" const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"]) +const CHATGPT_USAGE_LIMIT_WINDOW = 86_400_000 // 24 hrs + +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." + ) +} export const GoUpsellState = Persistence.struct({ [GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite), @@ -52,6 +60,25 @@ export function useUsageExceededDialogs() { [GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null, [GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null, }) + const [chatgptUsageLimit, setChatGPTUsageLimit] = persisted( + Persist.global("chatgpt-usage-limit"), + Persistence.struct({ lastSeenAt: Schema.NullOr(Schema.Finite) }), + { lastSeenAt: null }, + ) + + onCleanup( + sdk().event.on("session.step.failed", (evt) => { + if (evt.data.sessionID !== params.id) return + if (!isChatGPTUsageLimit(evt.data.error) || dialog.active) return + if (chatgptUsageLimit.lastSeenAt && Date.now() - chatgptUsageLimit.lastSeenAt < CHATGPT_USAGE_LIMIT_WINDOW) return + + void import("@/providers/connect/chatgpt-usage-limit").then((usage) => { + if (dialog.active) return + setChatGPTUsageLimit("lastSeenAt", Date.now()) + dialog.show(() => ) + }) + }), + ) onCleanup( sdk().event.on("session.status", (evt) => {
+ {language.t("provider.connect.chatgptUsageLimit.description")} +