mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-31 23:20:47 +00:00
fix: show the Go usage limit's reset time, Go page link and a composer tray (#60284)
Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
parent
d3ad3da99e
commit
d9ecbbc874
11 changed files with 357 additions and 64 deletions
|
|
@ -107,7 +107,12 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a
|
|||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"big-pickle": { id: "big-pickle", name: "Big Pickle", cost: { input: 0, output: 0 }, limit: { context: 200_000 } },
|
||||
"big-pickle": {
|
||||
id: "big-pickle",
|
||||
name: "Big Pickle",
|
||||
cost: { input: 0, output: 0 },
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
...(state.authorized
|
||||
|
|
@ -117,7 +122,12 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a
|
|||
integrationID: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
models: {
|
||||
"glm-5-go": { id: "glm-5-go", name: "GLM-5 Go", cost: { input: 1, output: 1 }, limit: { context: 200_000 } },
|
||||
"glm-5-go": {
|
||||
id: "glm-5-go",
|
||||
name: "GLM-5 Go",
|
||||
cost: { input: 1, output: 1 },
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -131,7 +141,9 @@ test("the free usage limit opens the Go dialog once, leaves a readable notice, a
|
|||
id: "opencode",
|
||||
name: "OpenCode Console",
|
||||
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
|
||||
connections: state.authorized ? [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }] : [],
|
||||
connections: state.authorized
|
||||
? [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }]
|
||||
: [],
|
||||
},
|
||||
{
|
||||
id: "opencode-go",
|
||||
|
|
@ -261,62 +273,116 @@ 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 restores the prompt and leaves a muted notice without a dialog", async ({ page }) => {
|
||||
await page.clock.setFixedTime(T0)
|
||||
const sessionID = "ses_go_limit"
|
||||
const model = { id: "glm-5-go", providerID: "opencode-go" }
|
||||
for (const action of ["tray", "picker"] as const) {
|
||||
test(`the Go usage limit restores the prompt and dismisses the tray when switching models through the ${action}`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.clock.setFixedTime(T0)
|
||||
await context.route("https://opencode.ai/**", (route) => route.fulfill({ body: "Go" }))
|
||||
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 } },
|
||||
],
|
||||
}),
|
||||
})
|
||||
const view = await openSession(page, {
|
||||
name: "GoLimit",
|
||||
sessions: [{ id: sessionID, agent: "build", model }],
|
||||
onFileWrite: () => undefined,
|
||||
provider: () => ({
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"big-pickle": {
|
||||
id: "big-pickle",
|
||||
name: "Big Pickle",
|
||||
cost: { input: 0, output: 0 },
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
models: {
|
||||
"glm-5-go": {
|
||||
id: "glm-5-go",
|
||||
name: "GLM-5 Go",
|
||||
cost: { input: 1, output: 1 },
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode", "opencode-go"],
|
||||
default: { build: "opencode-go/glm-5-go" },
|
||||
}),
|
||||
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()
|
||||
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 }
|
||||
// The refusal OpenCode Console sends a Go subscriber at a limit, as core records it.
|
||||
const body = {
|
||||
type: "error",
|
||||
error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" },
|
||||
metadata: { workspace: "wrk_go_limit", limitName: "5 hour" },
|
||||
}
|
||||
|
||||
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,
|
||||
await view.push([
|
||||
{
|
||||
id: "evt_go_limit_1",
|
||||
created: T0 + 2,
|
||||
type: "session.step.failed",
|
||||
location: { directory: view.directory },
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_go_assistant",
|
||||
error: {
|
||||
type: "provider.go-limit",
|
||||
message: "OpenCode Go 5 hour usage limit reached.",
|
||||
status: 429,
|
||||
response: { body: JSON.stringify(body) },
|
||||
retryAfterMs: 3_600_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
])
|
||||
|
||||
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 expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
})
|
||||
const tray = page.locator('[data-component="session-go-limit-tray"]')
|
||||
await expect(tray).toContainText(/Go 5 hour usage limit reached\. It resets at .+?\./)
|
||||
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 5 hour usage limit reached.")).toHaveCount(0)
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
|
||||
// Extra Usage is turned on from the workspace's Go page in OpenCode Console.
|
||||
const popup = page.waitForEvent("popup")
|
||||
await tray.getByRole("button", { name: "Turn on Extra Usage", exact: true }).click()
|
||||
await expect(await popup).toHaveURL("https://opencode.ai/console/wrk_go_limit/go")
|
||||
|
||||
if (action === "tray") await tray.getByRole("button", { name: "Switch to a free model", exact: true }).click()
|
||||
|
||||
if (action === "picker") {
|
||||
await page.locator('[data-action="composer-model"]').click()
|
||||
const picker = page.getByRole("dialog", { name: "Select model", exact: true })
|
||||
await picker.getByRole("combobox", { name: "Search models", exact: true }).fill("Big Pickle")
|
||||
await picker.getByRole("option", { name: /Big Pickle/ }).click()
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Big Pickle")
|
||||
await expect(tray).toHaveCount(0)
|
||||
await expect(view.editor).toHaveText("Refactor the parser")
|
||||
})
|
||||
}
|
||||
|
||||
test("ChatGPT's usage limit opens the ChatGPT usage dialog", async ({ page }) => {
|
||||
await page.clock.setFixedTime(T0)
|
||||
|
|
|
|||
|
|
@ -602,6 +602,11 @@ export const dict = {
|
|||
"session.background.moveRunning": "Move to background",
|
||||
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
|
||||
"command.session.background": "Move to background",
|
||||
"session.goLimit.title": "Go usage limit reached.",
|
||||
"session.goLimit.titleNamed": "Go {{name}} usage limit reached.",
|
||||
"session.goLimit.reset": "It resets at {{time}}.",
|
||||
"session.goLimit.extraUsage": "Turn on Extra Usage",
|
||||
"session.goLimit.switchFree": "Switch to a free model",
|
||||
"session.queue.count.one": "{{count}} queued",
|
||||
"session.queue.count.other": "{{count}} queued",
|
||||
"session.queue.steer": "Steer",
|
||||
|
|
|
|||
137
packages/app/src/session/composer/go-limit-tray.tsx
Normal file
137
packages/app/src/session/composer/go-limit-tray.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "solid-js"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { SessionStepFailed } from "@opencode/client/promise"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { isFreeModel } from "@/providers/models/models"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import { formatLimitReset } from "../usage-exceeded-dialogs"
|
||||
import { Grow } from "./spring-height"
|
||||
|
||||
// OpenCode Console names the workspace and the limit (5 hour, weekly or monthly) in its Go usage limit refusal.
|
||||
const decodeGoLimitBody = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
metadata: Schema.Struct({ workspace: Schema.optional(Schema.String), limitName: Schema.optional(Schema.String) }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
type GoLimit = { name?: string; reset?: string; link: string }
|
||||
|
||||
function goLimit(locale: string, event: SessionStepFailed): GoLimit {
|
||||
const metadata = Option.getOrUndefined(decodeGoLimitBody(event.data.error.response?.body))?.metadata
|
||||
const workspace = metadata?.workspace
|
||||
|
||||
return {
|
||||
name: metadata?.limitName,
|
||||
reset: formatLimitReset(locale, event.created, event.data.error.retryAfterMs),
|
||||
// The Go page is where the workspace turns on Extra Usage to keep going on its balance.
|
||||
link: workspace ? `https://opencode.ai/console/${encodeURIComponent(workspace)}/go` : "https://opencode.ai/console",
|
||||
}
|
||||
}
|
||||
|
||||
// A tray that grows out from under the composer, like the queue panel, when an OpenCode Go subscriber reaches a usage
|
||||
// limit: when Go is back, and how to keep going now.
|
||||
export function SessionGoLimitTray(props: {
|
||||
sessionID: string | undefined
|
||||
selection: () => ModelSelection | undefined
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const location = useWorkspaceLocation()
|
||||
const [limit, setLimit] = createSignal<GoLimit>()
|
||||
|
||||
const model = createMemo(() => {
|
||||
const current = props.selection()?.current()
|
||||
|
||||
return current && `${current.provider.id}:${current.id}`
|
||||
})
|
||||
|
||||
createEffect(on(model, () => setLimit(undefined), { defer: true }))
|
||||
|
||||
onCleanup(
|
||||
location().event.on("session.step.failed", (event) => {
|
||||
if (!props.sessionID || event.data.sessionID !== props.sessionID) return
|
||||
|
||||
if (event.data.error.type === "provider.go-limit") setLimit(goLimit(language.locale(), event))
|
||||
}),
|
||||
)
|
||||
|
||||
// The next step means the user found a way to keep going, so the tray has done its job.
|
||||
onCleanup(
|
||||
location().event.on("session.step.started", (event) => {
|
||||
if (event.data.sessionID === props.sessionID) setLimit(undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
const title = () => {
|
||||
const name = limit()?.name
|
||||
|
||||
return name ? language.t("session.goLimit.titleNamed", { name }) : language.t("session.goLimit.title")
|
||||
}
|
||||
|
||||
const freeModel = () => props.selection()?.list().find(isFreeModel)
|
||||
|
||||
const switchToFree = () => {
|
||||
const model = freeModel()
|
||||
|
||||
if (!model) return
|
||||
props.selection()?.set({ providerID: model.provider.id, modelID: model.id }, { recent: true })
|
||||
setLimit(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<Grow when={limit()} fade class="relative z-0">
|
||||
{(current) => (
|
||||
<div
|
||||
data-component="session-go-limit-tray"
|
||||
role="status"
|
||||
class="relative z-0 -mb-3 flex items-center gap-2.5 rounded-xl bg-v2-background-bg-base ps-3 pe-1.5 pt-1.5 pb-[18px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)]"
|
||||
>
|
||||
<div class="min-w-0 flex-1 truncate text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted [font-variant-numeric:tabular-nums]">
|
||||
<span class="font-[530] text-v2-text-text-base">{title()}</span>
|
||||
<Show when={current.reset}>{(time) => <> {language.t("session.goLimit.reset", { time: time() })}</>}</Show>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
data-action="session-go-limit-extra-usage"
|
||||
type="button"
|
||||
size="small"
|
||||
variant={freeModel() ? "ghost-faint" : "contrast"}
|
||||
class="![font-weight:530]"
|
||||
onClick={() => platform.openExternal(current.link)}
|
||||
>
|
||||
{language.t("session.goLimit.extraUsage")}
|
||||
</Button>
|
||||
<Show when={freeModel()}>
|
||||
<Button
|
||||
data-action="session-go-limit-free"
|
||||
type="button"
|
||||
size="small"
|
||||
variant="contrast"
|
||||
class="![font-weight:530]"
|
||||
onClick={switchToFree}
|
||||
>
|
||||
{language.t("session.goLimit.switchFree")}
|
||||
</Button>
|
||||
</Show>
|
||||
<IconButton
|
||||
data-action="session-go-limit-dismiss"
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon={<Icon name="outline-xmark" />}
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={() => setLimit(undefined)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Grow>
|
||||
)
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ import { useSessionFork } from "../fork"
|
|||
import { SessionComposerRegion } from "./session-composer-region"
|
||||
import { createSessionComposerController, type SessionComposerController } from "./controller"
|
||||
import { SessionQueuePanel } from "./queue-panel"
|
||||
import { SessionGoLimitTray } from "./go-limit-tray"
|
||||
import { SessionActivityChips, type SessionActivityItem } from "./activity-chips"
|
||||
import { SessionComposerLane, useComposerLaneAlignment } from "./composer-lane"
|
||||
import { SessionTodoPill } from "./todo-pill"
|
||||
|
|
@ -578,6 +579,10 @@ export function ActiveSessionComposerRegion(props: {
|
|||
<SessionPluginPills session={props.view()} screen={props.screen} alignment={alignment} />
|
||||
</SessionComposerLane>
|
||||
<SessionQueuePanel queue={props.model.queue} hidden={props.model.region.state.blocked()} />
|
||||
<SessionGoLimitTray
|
||||
sessionID={props.session.identity.params.id}
|
||||
selection={() => props.model.composer.model.selection}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
composer={
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const MAX_RETRY_AFTER_MS = 7 * 86_400_000
|
|||
|
||||
const CHATGPT_USAGE_LIMIT_WINDOW = 86_400_000 // 24 hrs
|
||||
|
||||
function formatLimitReset(locale: string, created: number, retryAfterMs: number | undefined) {
|
||||
export function formatLimitReset(locale: string, created: number, retryAfterMs: number | undefined) {
|
||||
if (
|
||||
retryAfterMs === undefined ||
|
||||
!Number.isFinite(retryAfterMs) ||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,6 @@ const FREE_USAGE_LIMIT_MESSAGE =
|
|||
"Free usage limit reached. Connect a provider or subscribe to OpenCode Go to keep going, or switch models."
|
||||
const BUDGET_LIMIT_MESSAGE = "Workspace usage limit reached. Model use is paused until the limit resets."
|
||||
const MODEL_BUDGET_LIMIT_MESSAGE = "You've reached the spend limit for this model. Switch models to keep going."
|
||||
const GO_USAGE_LIMIT_MESSAGE =
|
||||
"OpenCode Go usage limit reached. To keep using this model, enable spending from your available balance in the Console."
|
||||
|
||||
export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof AIError) {
|
||||
|
|
@ -46,8 +44,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
|||
if (cause.reason.body?.includes("FreeUsageLimitError"))
|
||||
return { ...providerError("provider.free-limit", cause.reason), message: FREE_USAGE_LIMIT_MESSAGE }
|
||||
if (cause.reason.body?.includes("BudgetLimitError")) return budgetLimit(cause.reason)
|
||||
if (cause.reason.body?.includes("GoUsageLimitError"))
|
||||
return { ...providerError("provider.go-limit", cause.reason), message: GO_USAGE_LIMIT_MESSAGE }
|
||||
if (cause.reason.body?.includes("GoUsageLimitError")) return goLimit(cause.reason)
|
||||
return providerError("provider.quota", cause.reason)
|
||||
case "ContentPolicy":
|
||||
return providerError("provider.content-filter", cause.reason)
|
||||
|
|
@ -116,6 +113,15 @@ function budgetLimit(reason: AIError["reason"]): SessionError.Error {
|
|||
return { ...providerError("provider.budget-limit", reason), message: BUDGET_LIMIT_MESSAGE }
|
||||
}
|
||||
|
||||
// OpenCode Console names the Go limit that refused the request (5 hour, weekly or monthly) in its envelope's metadata.
|
||||
function goLimit(reason: AIError["reason"]): SessionError.Error {
|
||||
const name = /"limitName"\s*:\s*"([^"]+)"/.exec(reason.body ?? "")?.[1]
|
||||
return {
|
||||
...providerError("provider.go-limit", reason),
|
||||
message: `OpenCode Go ${name ? `${name} ` : ""}usage limit reached. Switch to a free model, or turn on Extra Usage on the Go page in OpenCode Console to keep going.`,
|
||||
}
|
||||
}
|
||||
|
||||
function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
|
||||
const status = reason.http?.status
|
||||
const retryAfterMs = "retryAfterMs" in reason ? reason.retryAfterMs : undefined
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ describe("toSessionError", () => {
|
|||
expect(toSessionError(go)).toEqual({
|
||||
type: "provider.go-limit",
|
||||
message:
|
||||
"OpenCode Go usage limit reached. To keep using this model, enable spending from your available balance in the Console.",
|
||||
"OpenCode Go 5 hour usage limit reached. Switch to a free model, or turn on Extra Usage on the Go page in OpenCode Console to keep going.",
|
||||
status: 429,
|
||||
response: { body: JSON.stringify(goBody) },
|
||||
retryAfterMs: 120_000,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
import { OpenAIChat } from "@opencode/ai/protocols/openai-chat"
|
||||
import { AnthropicMessages, OpenAIResponses } from "@opencode/ai/protocols"
|
||||
import { TestLLM, compileRequest } from "@opencode/ai/testing"
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
|
|
@ -6447,6 +6448,44 @@ describe("SessionRunnerLLM", () => {
|
|||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
||||
})
|
||||
|
||||
scenario("records OpenCode Console's Go usage limit 429 as a typed step failure without retrying", function* (s) {
|
||||
// The exact refusal Console sends a Go subscriber at a limit.
|
||||
const body = JSON.stringify({
|
||||
type: "error",
|
||||
error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" },
|
||||
metadata: { workspace: "wrk_go_limit", limitName: "5 hour" },
|
||||
})
|
||||
const failure = RequestExecutor.httpFailure({
|
||||
message: "Go usage limit exceeded",
|
||||
url: "https://opencode.ai/inference/go/openai/v1/chat/completions",
|
||||
status: 429,
|
||||
responseHeaders: { "retry-after": "3600" },
|
||||
responseBody: body,
|
||||
})
|
||||
yield* s.llm.push(Stream.fail(failure))
|
||||
|
||||
expect(yield* s.runPrompt("Hit the Go limit").pipe(Effect.flip)).toBe(failure)
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain("session.retry.scheduled.1")
|
||||
const assistant = requireAssistant(yield* s.context)
|
||||
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
|
||||
{ type: "session.step.started.1" },
|
||||
{
|
||||
type: "session.step.failed.1",
|
||||
data: {
|
||||
error: {
|
||||
type: "provider.go-limit",
|
||||
message:
|
||||
"OpenCode Go 5 hour usage limit reached. Switch to a free model, or turn on Extra Usage on the Go page in OpenCode Console to keep going.",
|
||||
status: 429,
|
||||
response: { body },
|
||||
retryAfterMs: 3_600_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
scenario("settles malformed streamed tool input before the provider failure", function* (s) {
|
||||
const failure = new AIError({
|
||||
reason: new InvalidProviderOutputError({ message: "Invalid JSON input for tool call echo" }),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useLocal } from "../context/local"
|
|||
import { useLocation } from "../context/location"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { SplitBorder } from "../ui/border"
|
||||
import { Link } from "../ui/link"
|
||||
import { DIALOG_LIST_INSET, DialogHeader, DialogLayout, useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
|
@ -15,6 +16,8 @@ import {
|
|||
formatReset,
|
||||
limitScope,
|
||||
modelUsage,
|
||||
goLimitLink,
|
||||
goLimitName,
|
||||
refusalReset,
|
||||
retryReset,
|
||||
servedByConsole,
|
||||
|
|
@ -263,9 +266,10 @@ export function useLimitNotice(message: () => SessionMessageAssistant) {
|
|||
value.model.id
|
||||
if (error.type === "provider.go-limit")
|
||||
return {
|
||||
title: "OpenCode Go usage limit reached",
|
||||
body: `${resets}Switch model with`,
|
||||
title: `OpenCode Go ${[goLimitName(error), "usage limit reached"].filter(Boolean).join(" ")}`,
|
||||
body: `${resets}Switch to a free model with`,
|
||||
key: shortcut() ?? "/models",
|
||||
link: { label: "Or turn on Extra Usage on your Go page to keep going now:", href: goLimitLink(error) },
|
||||
}
|
||||
if (error.type === "provider.model-budget-limit")
|
||||
return { title: `${name} usage limit reached`, body: `${resets}Switch model with`, key: shortcut() ?? "/models" }
|
||||
|
|
@ -284,7 +288,9 @@ export function useLimitNotice(message: () => SessionMessageAssistant) {
|
|||
})
|
||||
}
|
||||
|
||||
export function UsageLimitNotice(props: { notice: { title: string; body: string; key?: string } }) {
|
||||
export function UsageLimitNotice(props: {
|
||||
notice: { title: string; body: string; key?: string; link?: { label: string; href: string } }
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<box
|
||||
|
|
@ -308,6 +314,16 @@ export function UsageLimitNotice(props: { notice: { title: string; body: string;
|
|||
)}
|
||||
</Show>
|
||||
</text>
|
||||
<Show when={props.notice.link}>
|
||||
{(link) => (
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme.text.muted} wrapMode="word">
|
||||
{link().label}
|
||||
</text>
|
||||
<Link href={link().href} fg={theme.text.base} />
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,17 @@ export function retryReset(error: { readonly retryAfterMs?: number } | undefined
|
|||
return Number.isNaN(new Date(at).getTime()) ? undefined : at
|
||||
}
|
||||
|
||||
/** Which Go limit refused a request (5 hour, weekly or monthly), as OpenCode Console names it in the metadata. */
|
||||
export function goLimitName(error: { readonly response?: { readonly body: string } } | undefined) {
|
||||
return /"limitName"\s*:\s*"([^"]+)"/.exec(error?.response?.body ?? "")?.[1]
|
||||
}
|
||||
|
||||
/** The OpenCode Console Go page for the workspace a Go usage limit names, where it can turn on Extra Usage. */
|
||||
export function goLimitLink(error: { readonly response?: { readonly body: string } } | undefined) {
|
||||
const workspace = /"workspace"\s*:\s*"(wrk_\w+)"/.exec(error?.response?.body ?? "")?.[1]
|
||||
return workspace ? `https://opencode.ai/console/${workspace}/go` : "https://opencode.ai/console"
|
||||
}
|
||||
|
||||
/**
|
||||
* Who spends from a limit and, when it pools spend across models, which ones. A limit on each model separately names
|
||||
* only who spends from it.
|
||||
|
|
|
|||
|
|
@ -2562,8 +2562,15 @@ test("Go usage limit restores the prompt and explains the limit with its reset t
|
|||
}
|
||||
const error = {
|
||||
type: "provider.go-limit",
|
||||
message: "OpenCode Go usage limit reached.",
|
||||
message: "OpenCode Go 5 hour usage limit reached.",
|
||||
status: 429,
|
||||
response: {
|
||||
body: JSON.stringify({
|
||||
type: "error",
|
||||
error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" },
|
||||
metadata: { workspace: "wrk_go_limit", limitName: "5 hour" },
|
||||
}),
|
||||
},
|
||||
retryAfterMs: 3_600_000,
|
||||
}
|
||||
await using setup = await createAppFixture({
|
||||
|
|
@ -2605,12 +2612,13 @@ test("Go usage limit restores the prompt and explains the limit with its reset t
|
|||
})
|
||||
await setup.waitForFrame(
|
||||
(frame) =>
|
||||
frame.includes("OpenCode Go usage limit reached") &&
|
||||
frame.includes("OpenCode Go 5 hour usage limit reached") &&
|
||||
frame.includes("Resets ") &&
|
||||
frame.split("Refactor the parser").length === 3,
|
||||
)
|
||||
const frame = setup.captureCharFrame()
|
||||
expect(frame).toContain("Switch model with")
|
||||
expect(frame).toContain("Switch to a free model with")
|
||||
expect(frame).toContain("https://opencode.ai/console/wrk_go_limit/go")
|
||||
expect(frame).not.toContain("Error:")
|
||||
expect(frame).not.toContain("Free limit reached")
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue