Skip to content
Closed
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
15 changes: 13 additions & 2 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,11 @@ export type ParsedAPICallError =
metadata?: Record<string, string>
}

export function parseAPICallError(input: { providerID: ProviderV2.ID; error: APICallError }): ParsedAPICallError {
export function parseAPICallError(input: {
providerID: ProviderV2.ID
retry400RateLimit?: boolean
error: APICallError
}): ParsedAPICallError {
const m = message(input.providerID, input.error)
const body = json(input.error.responseBody)
if (isContextOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") {
Expand All @@ -174,11 +178,18 @@ export function parseAPICallError(input: { providerID: ProviderV2.ID; error: API
}

const metadata = input.error.url ? { url: input.error.url } : undefined
const rateLimitExceeded =
input.retry400RateLimit === true &&
input.error.statusCode === 400 &&
typeof body?.detail === "string" &&
body.detail.toLowerCase().includes("rate limit exceeded")
return {
type: "api_error",
message: m,
statusCode: input.error.statusCode,
isRetryable: input.providerID.startsWith("openai") ? isOpenAiErrorRetryable(input.error) : input.error.isRetryable,
isRetryable:
rateLimitExceeded ||
(input.providerID.startsWith("openai") ? isOpenAiErrorRetryable(input.error) : input.error.isRetryable),
responseHeaders: input.error.responseHeaders,
responseBody: input.error.responseBody,
metadata,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ export const ProviderApi = HttpApi.make("provider")
description: "Retrieve available authentication methods for all AI providers.",
}),
),
HttpApiEndpoint.post("refresh", `${root}/refresh`, {
query: WorkspaceRoutingQuery,
success: described(Provider.ListResult, "Refreshed list of providers"),
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.refresh",
summary: "Refresh model catalog",
description:
"Force-refresh the model catalog from models.opencode.ai and return the updated provider list.",
}),
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderV2.ID },
query: WorkspaceRoutingQuery,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
}
})

const refresh = Effect.fn("ProviderHttpApi.refresh")(function* () {
yield* ModelsDev.Service.use((s) => s.refresh(true))
return yield* list()
})

const auth = Effect.fn("ProviderHttpApi.auth")(function* () {
return yield* svc.methods()
})
Expand Down Expand Up @@ -106,6 +111,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"

return handlers
.handle("list", list)
.handle("refresh", refresh)
.handle("auth", auth)
.handleRaw("authorize", authorizeRaw)
.handle("callback", callback)
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ export function latest(msgs: WithParts[]) {

export function fromError(
e: unknown,
ctx: { providerID: ProviderV2.ID; aborted?: boolean },
ctx: { providerID: ProviderV2.ID; aborted?: boolean; retry400RateLimit?: boolean },
): NonNullable<Assistant["error"]> {
switch (true) {
case e instanceof DOMException && e.name === "AbortError":
Expand Down Expand Up @@ -676,6 +676,7 @@ export function fromError(
case APICallError.isInstance(e):
const parsed = ProviderError.parseAPICallError({
providerID: ctx.providerID,
retry400RateLimit: ctx.retry400RateLimit,
error: e,
})
if (parsed.type === "context_overflow") {
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,14 @@ const layer = Layer.effect(
reasoningMap: {},
}
let aborted = false
const cfg = yield* config.get()
const retry400RateLimit = cfg.provider?.[input.model.providerID]?.options?.retry400RateLimit === true

const parse = (e: unknown) =>
MessageV2.fromError(e, {
providerID: input.model.providerID,
aborted,
retry400RateLimit,
})

const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) {
Expand Down
12 changes: 12 additions & 0 deletions packages/opencode/test/config/tui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,18 @@ it.instance("loads tui config with the same precedence order as server config pa
),
)

it.instance("loads the TUI locale from tui.json", () =>
withCleanState(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const test = yield* TestInstance
yield* fs.writeJson(path.join(test.directory, "tui.json"), { locale: "zh-CN" })

expect((yield* getTuiConfig(test.directory)).locale).toBe("zh-CN")
}),
),
)

it.instance("resolves attention config defaults and overrides", () =>
withCleanState(
Effect.gen(function* () {
Expand Down
49 changes: 48 additions & 1 deletion packages/opencode/test/session/processor-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const cfg = {
},
}

function providerCfg(url: string) {
function providerCfg(url: string, retry400RateLimit = false) {
return {
...cfg,
provider: {
Expand All @@ -80,6 +80,7 @@ function providerCfg(url: string) {
options: {
...cfg.provider.test.options,
baseURL: url,
retry400RateLimit,
},
},
},
Expand Down Expand Up @@ -557,6 +558,52 @@ it.live("session.processor effect tests do not retry unknown json errors", () =>
),
)

it.live("session.processor effect tests retry provider-local 400 rate limits when enabled", () =>
provideTmpdirServer(
({ dir, llm }) =>
Effect.gen(function* () {
const { processors, session, provider } = yield* boot()

yield* llm.error(400, { detail: "Rate limit exceeded" })
yield* llm.text("after")

const chat = yield* session.create({})
const parent = yield* user(chat.id, "retry configured 400")
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
const handle = yield* processors.create({
assistantMessage: msg,
sessionID: chat.id,
model: mdl,
})

const value = yield* handle.process({
user: {
id: parent.id,
sessionID: chat.id,
role: "user",
time: parent.time,
agent: parent.agent,
model: { providerID: ref.providerID, modelID: ref.modelID },
} satisfies SessionV1.User,
sessionID: chat.id,
model: mdl,
agent: agent(),
system: [],
messages: [{ role: "user", content: "retry configured 400" }],
tools: {},
})

const parts = yield* MessageV2.parts(msg.id)
expect(value).toBe("continue")
expect(yield* llm.calls).toBe(2)
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
expect(handle.message.error).toBeUndefined()
}),
{ config: (url) => providerCfg(url, true) },
),
)

it.live("session.processor effect tests retry recognized structured json errors", () =>
provideTmpdirServer(
({ dir, llm }) =>
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/test/session/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,4 +437,56 @@ describe("session.message-v2.fromError", () => {
message: "An error occurred while processing your request.",
})
})

test("marks provider 400 rate limit response bodies as retryable when enabled", () => {
const error = new APICallError({
message: "Bad request",
url: "https://example.com/v1/chat/completions",
requestBodyValues: {},
statusCode: 400,
responseHeaders: { "content-type": "application/json" },
responseBody: JSON.stringify({
detail:
"Rate limit exceeded for end_user: test@example.com. Limit type: tokens. Current limit: 500000, Remaining: 0. Limit resets at: 2026-06-07 22:57:15 UTC",
}),
isRetryable: false,
})
const result = MessageV2.fromError(error, { providerID, retry400RateLimit: true })
if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.statusCode).toBe(400)
expect(result.data.isRetryable).toBe(true)
expect(SessionRetry.retryable(result, retryProvider)).toEqual({ message: result.data.message })
})

test("keeps provider 400 rate limit response bodies non-retryable when disabled", () => {
const error = new APICallError({
message: "Bad request",
url: "https://example.com/v1/chat/completions",
requestBodyValues: {},
statusCode: 400,
responseHeaders: { "content-type": "application/json" },
responseBody: JSON.stringify({ detail: "Rate limit exceeded" }),
isRetryable: false,
})
const result = MessageV2.fromError(error, { providerID })
if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(false)
expect(SessionRetry.retryable(result, retryProvider)).toBeUndefined()
})

test("keeps generic provider 400 API call errors non-retryable when enabled", () => {
const error = new APICallError({
message: "Bad request",
url: "https://example.com/v1/chat/completions",
requestBodyValues: {},
statusCode: 400,
responseHeaders: { "content-type": "application/json" },
responseBody: JSON.stringify({ error: "invalid request" }),
isRetryable: false,
})
const result = MessageV2.fromError(error, { providerID, retry400RateLimit: true })
if (!SessionV1.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(false)
expect(SessionRetry.retryable(result, retryProvider)).toBeUndefined()
})
})
32 changes: 32 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ import type {
ProviderOauthAuthorizeResponses,
ProviderOauthCallbackErrors,
ProviderOauthCallbackResponses,
ProviderRefreshErrors,
ProviderRefreshResponses,
PtyConnectErrors,
PtyConnectResponses,
PtyConnectTokenErrors,
Expand Down Expand Up @@ -3353,6 +3355,36 @@ export class Provider extends HeyApiClient {
})
}

/**
* Refresh model catalog
*
* Force-refresh the model catalog from models.opencode.ai and return the updated provider list.
*/
public refresh<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).post<ProviderRefreshResponses, ProviderRefreshErrors, ThrowOnError>({
url: "/provider/refresh",
...options,
...params,
})
}

private _oauth?: Oauth
get oauth(): Oauth {
return (this._oauth ??= new Oauth({ client: this.client }))
Expand Down
34 changes: 34 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9362,6 +9362,40 @@ export type ProviderAuthResponses = {

export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses]

export type ProviderRefreshData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/provider/refresh"
}

export type ProviderRefreshErrors = {
/**
* Bad request
*/
400: BadRequestError
}

export type ProviderRefreshError = ProviderRefreshErrors[keyof ProviderRefreshErrors]

export type ProviderRefreshResponses = {
/**
* Refreshed list of providers
*/
200: {
all: Array<Provider>
default: {
[key: string]: string
}
connected: Array<string>
}
}

export type ProviderRefreshResponse = ProviderRefreshResponses[keyof ProviderRefreshResponses]

export type ProviderOauthAuthorizeData = {
body?: {
/**
Expand Down
Loading
Loading