Skip to content

Commit 799996d

Browse files
authored
fix: adjust tui retry dialog logic to be more provider specific and error case specific (#26366)
1 parent df75bfe commit 799996d

8 files changed

Lines changed: 98 additions & 64 deletions

File tree

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,29 @@ import { useBindings, useCommandShortcut } from "../../keymap"
9393

9494
addDefaultParsers(parsers.parsers)
9595

96-
const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at"
97-
const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show"
96+
const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at"
97+
const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show"
98+
const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at"
99+
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
98100
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
101+
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
102+
103+
function goUpsellKeys(action: SessionRetry.Retryable["action"]) {
104+
if (!action) return
105+
if (!GO_UPSELL_PROVIDERS.has(action.provider)) return
106+
if (action.reason === "free_tier_limit") {
107+
return {
108+
lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT,
109+
dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW,
110+
}
111+
}
112+
if (action.reason === "account_rate_limit") {
113+
return {
114+
lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT,
115+
dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW,
116+
}
117+
}
118+
}
99119

100120
const context = createContext<{
101121
width: number
@@ -263,14 +283,17 @@ export function Session() {
263283
if (!evt.properties.status.action) return
264284
if (dialog.stack.length > 0) return
265285

266-
const seen = kv.get(GO_UPSELL_LAST_SEEN_AT)
286+
const keys = goUpsellKeys(evt.properties.status.action)
287+
if (!keys) return
288+
289+
const seen = kv.get(keys.lastSeenAt)
267290
if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return
268291

269-
if (kv.get(GO_UPSELL_DONT_SHOW)) return
292+
if (kv.get(keys.dontShow)) return
270293

271294
void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => {
272-
if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true)
273-
kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now())
295+
if (dontShowAgain) kv.set(keys.dontShow, true)
296+
kv.set(keys.lastSeenAt, Date.now())
274297
})
275298
})
276299

packages/opencode/src/session/processor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,7 @@ export const layer: Layer.Layer<
701701
),
702702
Effect.retry(
703703
SessionRetry.policy({
704+
provider: input.model.providerID,
704705
parse,
705706
set: (info) => {
706707
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.

packages/opencode/src/session/retry.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ export type Err = ReturnType<NamedError["toObject"]>
77

88
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go"
99
export const GO_UPSELL_URL = "https://opencode.ai/go"
10+
export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {})
1011

1112
export type Retryable = {
1213
message: string
1314
action?: {
15+
reason: RetryReason
16+
provider: string
1417
title: string
1518
message: string
1619
label: string
@@ -60,7 +63,7 @@ export function delay(attempt: number, error?: MessageV2.APIError) {
6063
return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
6164
}
6265

63-
export function retryable(error: Err) {
66+
export function retryable(error: Err, provider: string) {
6467
// context overflow errors should not be retried
6568
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
6669
if (MessageV2.APIError.isInstance(error)) {
@@ -72,6 +75,8 @@ export function retryable(error: Err) {
7275
return {
7376
message: GO_UPSELL_MESSAGE,
7477
action: {
78+
reason: "free_tier_limit",
79+
provider,
7580
title: "Free limit reached",
7681
message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
7782
label: "subscribe",
@@ -97,12 +102,14 @@ export function retryable(error: Err) {
97102
return minutes > 0 ? unit(minutes, "minute") : "less than a minute"
98103
})
99104

100-
const message = `${limitName} usage limit reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance`
105+
const message = `${limitName ? `${limitName} usage limit` : "Usage limit"} reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance`
101106

102107
const link = `https://opencode.ai/workspace/${workspace}/go`
103108
return {
104109
message: `${message} - ${link}`,
105110
action: {
111+
reason: "account_rate_limit",
112+
provider,
106113
title: "Go limit reached",
107114
message,
108115
label: "open settings",
@@ -165,13 +172,14 @@ function parseJSON(value: unknown) {
165172
}
166173

167174
export function policy(opts: {
175+
provider: string
168176
parse: (error: unknown) => Err
169177
set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect<void>
170178
}) {
171179
return Schedule.fromStepWithMetadata(
172180
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
173181
const error = opts.parse(meta.input)
174-
const retry = retryable(error)
182+
const retry = retryable(error, opts.provider)
175183
if (!retry) return Cause.done(meta.attempt)
176184
return Effect.gen(function* () {
177185
const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)

packages/opencode/src/session/status.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export const Info = Schema.Union([
1717
message: Schema.String,
1818
action: Schema.optional(
1919
Schema.Struct({
20+
reason: Schema.String,
21+
provider: Schema.String,
2022
title: Schema.String,
2123
message: Schema.String,
2224
label: Schema.String,

packages/opencode/test/session/retry.test.ts

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { provideTmpdirInstance } from "../fixture/fixture"
1313
import { testEffect } from "../lib/effect"
1414

1515
const providerID = ProviderID.make("test")
16+
const retryProvider = "test"
1617
const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
1718

1819
function apiError(headers?: Record<string, string>): MessageV2.APIError {
@@ -92,6 +93,7 @@ describe("session.retry.delay", () => {
9293

9394
const step = yield* Schedule.toStepWithMetadata(
9495
SessionRetry.policy({
96+
provider: "test",
9597
parse: (err) => MessageV2.APIError.Schema.parse(err),
9698
set: (info) =>
9799
status.set(sessionID, {
@@ -118,47 +120,47 @@ describe("session.retry.delay", () => {
118120
describe("session.retry.retryable", () => {
119121
test("maps too_many_requests json messages", () => {
120122
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
121-
expect(SessionRetry.retryable(error)).toEqual({ message: "Too Many Requests" })
123+
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" })
122124
})
123125

124126
test("maps overloaded provider codes", () => {
125127
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
126-
expect(SessionRetry.retryable(error)).toEqual({ message: "Provider is overloaded" })
128+
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" })
127129
})
128130

129131
test("does not retry unknown json messages", () => {
130132
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
131-
expect(SessionRetry.retryable(error)).toBeUndefined()
133+
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
132134
})
133135

134136
test("does not throw on numeric error codes", () => {
135137
const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } }))
136-
const result = SessionRetry.retryable(error)
138+
const result = SessionRetry.retryable(error, retryProvider)
137139
expect(result).toBeUndefined()
138140
})
139141

140142
test("returns undefined for non-json message", () => {
141143
const error = wrap("not-json")
142-
expect(SessionRetry.retryable(error)).toBeUndefined()
144+
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
143145
})
144146

145147
test("retries plain text rate limit errors from Alibaba", () => {
146148
const msg =
147149
"Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time."
148150
const error = wrap(msg)
149-
expect(SessionRetry.retryable(error)).toEqual({ message: msg })
151+
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
150152
})
151153

152154
test("retries plain text rate limit errors", () => {
153155
const msg = "Rate limit exceeded, please try again later"
154156
const error = wrap(msg)
155-
expect(SessionRetry.retryable(error)).toEqual({ message: msg })
157+
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
156158
})
157159

158160
test("retries too many requests in plain text", () => {
159161
const msg = "Too many requests, please slow down"
160162
const error = wrap(msg)
161-
expect(SessionRetry.retryable(error)).toEqual({ message: msg })
163+
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
162164
})
163165

164166
test("does not retry context overflow errors", () => {
@@ -167,7 +169,7 @@ describe("session.retry.retryable", () => {
167169
responseBody: '{"error":{"code":"context_length_exceeded"}}',
168170
}).toObject()
169171

170-
expect(SessionRetry.retryable(error)).toBeUndefined()
172+
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
171173
})
172174

173175
test("retries 500 errors even when isRetryable is false", () => {
@@ -180,7 +182,7 @@ describe("session.retry.retryable", () => {
180182
}).toObject(),
181183
)
182184

183-
expect(SessionRetry.retryable(error)).toEqual({ message: "Internal server error" })
185+
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Internal server error" })
184186
})
185187

186188
test("retries 502 bad gateway errors", () => {
@@ -192,7 +194,7 @@ describe("session.retry.retryable", () => {
192194
}).toObject(),
193195
)
194196

195-
expect(SessionRetry.retryable(error)).toEqual({ message: "Bad gateway" })
197+
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Bad gateway" })
196198
})
197199

198200
test("retries 503 service unavailable errors", () => {
@@ -204,7 +206,7 @@ describe("session.retry.retryable", () => {
204206
}).toObject(),
205207
)
206208

207-
expect(SessionRetry.retryable(error)).toEqual({ message: "Service unavailable" })
209+
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Service unavailable" })
208210
})
209211

210212
test("does not retry 4xx errors when isRetryable is false", () => {
@@ -216,7 +218,7 @@ describe("session.retry.retryable", () => {
216218
}).toObject(),
217219
)
218220

219-
expect(SessionRetry.retryable(error)).toBeUndefined()
221+
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
220222
})
221223

222224
test("retries ZlibError decompression failures", () => {
@@ -228,7 +230,7 @@ describe("session.retry.retryable", () => {
228230
}).toObject(),
229231
)
230232

231-
const retryable = SessionRetry.retryable(error)
233+
const retryable = SessionRetry.retryable(error, retryProvider)
232234
expect(retryable).toBeDefined()
233235
expect(retryable).toEqual({ message: "Response decompression failed" })
234236
})
@@ -246,9 +248,11 @@ describe("session.retry.retryable", () => {
246248
}).toObject(),
247249
)
248250

249-
expect(SessionRetry.retryable(error)).toEqual({
251+
expect(SessionRetry.retryable(error, "opencode")).toEqual({
250252
message: SessionRetry.GO_UPSELL_MESSAGE,
251253
action: {
254+
reason: "free_tier_limit",
255+
provider: "opencode",
252256
title: "Free limit reached",
253257
message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.",
254258
label: "subscribe",
@@ -280,10 +284,12 @@ describe("session.retry.retryable", () => {
280284
}).toObject(),
281285
)
282286

283-
expect(SessionRetry.retryable(error)).toEqual({
287+
expect(SessionRetry.retryable(error, "opencode-go")).toEqual({
284288
message:
285289
"5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go",
286290
action: {
291+
reason: "account_rate_limit",
292+
provider: "opencode-go",
287293
title: "Go limit reached",
288294
message:
289295
"5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance",
@@ -292,6 +298,33 @@ describe("session.retry.retryable", () => {
292298
},
293299
})
294300
})
301+
302+
test("maps Go subscription limits without limit metadata", () => {
303+
const error = MessageV2.APIError.Schema.parse(
304+
new MessageV2.APIError({
305+
message: "Subscription quota exceeded. You can continue using free models.",
306+
isRetryable: true,
307+
statusCode: 429,
308+
responseHeaders: {
309+
"retry-after": "900",
310+
},
311+
responseBody: JSON.stringify({
312+
type: "error",
313+
error: {
314+
type: "GoUsageLimitError",
315+
message: "Subscription quota exceeded. You can continue using free models.",
316+
},
317+
metadata: {
318+
workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH",
319+
},
320+
}),
321+
}).toObject(),
322+
)
323+
324+
expect(SessionRetry.retryable(error, "opencode-go")?.action?.message).toBe(
325+
"Usage limit reached. It will reset in 15 minutes. To continue using this model now, enable usage from your available balance",
326+
)
327+
})
295328
})
296329

297330
describe("session.message-v2.fromError", () => {
@@ -341,7 +374,7 @@ describe("session.message-v2.fromError", () => {
341374
}).toObject(),
342375
)
343376

344-
const retryable = SessionRetry.retryable(error)
377+
const retryable = SessionRetry.retryable(error, retryProvider)
345378
expect(retryable).toBeDefined()
346379
expect(retryable).toEqual({ message: "Connection reset by server" })
347380
})
@@ -381,6 +414,6 @@ describe("session.message-v2.fromError", () => {
381414
expect(MessageV2.APIError.isInstance(result)).toBe(true)
382415
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
383416
expect(result.data.isRetryable).toBe(true)
384-
expect(SessionRetry.retryable(result)).toEqual({ message: "An error occurred while processing your request." })
417+
expect(SessionRetry.retryable(result, retryProvider)).toEqual({ message: "An error occurred while processing your request." })
385418
})
386419
})

packages/opencode/test/session/schema-decoding.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ describe("SessionStatus.Info", () => {
236236
attempt: 1,
237237
message: "transient",
238238
action: {
239+
reason: "free_tier_limit",
240+
provider: "opencode",
239241
title: "Free limit reached",
240242
message: "Subscribe to OpenCode Go.",
241243
label: "subscribe",

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ export type SessionStatus =
267267
attempt: number
268268
message: string
269269
action?: {
270+
reason: string
271+
provider: string
270272
title: string
271273
message: string
272274
label: string

script/zen-limit-server.ts

Lines changed: 0 additions & 37 deletions
This file was deleted.

0 commit comments

Comments
 (0)