diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 5e643d8a0..baacb04c1 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -108,6 +108,10 @@ export function shouldKeepCodexOAuthModel(modelId: string, apiId: string): boole return major > 5 || (major === 5 && minor > 4) } +export function hasCodexOAuthGpt55Limit(apiId: string): boolean { + return /^gpt-5\.5(?:$|-)/.test(apiId) +} + function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string { const params = new URLSearchParams({ response_type: "code", @@ -449,6 +453,15 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { output: 0, cache: { read: 0, write: 0 }, } + + if (hasCodexOAuthGpt55Limit(model.api.id)) { + const limit: typeof model.limit & { input: number } = { + context: 400_000, + input: 272_000, + output: 128_000, + } + model.limit = limit + } } return { diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 243e99579..68f9513fe 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -102,6 +102,10 @@ function json(input: unknown) { return undefined } +function isRecord(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) +} + export type ParsedStreamError = | { type: "context_overflow" @@ -111,18 +115,24 @@ export type ParsedStreamError = | { type: "api_error" message: string - isRetryable: false + isRetryable: boolean responseBody: string } export function parseStreamError(input: unknown): ParsedStreamError | undefined { - const body = json(input) - if (!body) return + const raw = json(input) + if (!isRecord(raw)) return + + const inner = typeof raw.message === "string" ? json(raw.message) : undefined + // OpenAI stream errors can arrive wrapped in an Error-like object. Use the + // inner provider payload so responseBody matches the payload users need. + const body = isRecord(inner) && inner.type === "error" ? inner : raw const responseBody = JSON.stringify(body) if (body.type !== "error") return - switch (body?.error?.code) { + const error = isRecord(body.error) ? body.error : undefined + switch (error?.code) { case "context_length_exceeded": return { type: "context_overflow", @@ -146,10 +156,17 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined case "invalid_prompt": return { type: "api_error", - message: typeof body?.error?.message === "string" ? body?.error?.message : "Invalid prompt.", + message: typeof error.message === "string" ? error.message : "Invalid prompt.", isRetryable: false, responseBody, } + case "server_error": + return { + type: "api_error", + message: typeof error.message === "string" ? error.message : "Server error.", + isRetryable: true, + responseBody, + } } } diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index eaf58ba47..4f83f92a2 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test" import { + CodexAuthPlugin, parseJwtClaims, extractAccountIdFromClaims, extractAccountId, formatOAuthFailure, + hasCodexOAuthGpt55Limit, shouldKeepCodexOAuthModel, type IdTokenClaims, } from "../../src/plugin/codex" @@ -143,6 +145,81 @@ describe("plugin.codex", () => { }) }) + describe("hasCodexOAuthGpt55Limit", () => { + test("matches GPT-5.5 API ids and explicit variants", () => { + expect(hasCodexOAuthGpt55Limit("gpt-5.5")).toBe(true) + expect(hasCodexOAuthGpt55Limit("gpt-5.5-codex")).toBe(true) + expect(hasCodexOAuthGpt55Limit("gpt-5.5-mini")).toBe(true) + }) + + test("does not match unrelated future models", () => { + expect(hasCodexOAuthGpt55Limit("gpt-5.50")).toBe(false) + expect(hasCodexOAuthGpt55Limit("chatgpt-5.5")).toBe(false) + expect(hasCodexOAuthGpt55Limit("gpt-5.6")).toBe(false) + }) + }) + + describe("CodexAuthPlugin", () => { + test("overrides GPT-5.5 limits for OAuth Codex plans", async () => { + const provider = { + models: { + "gpt-5.5": { + id: "gpt-5.5", + api: { id: "gpt-5.5" }, + cost: { + input: 2, + output: 8, + cache: { read: 1, write: 2 }, + }, + limit: { + context: 1_050_000, + input: 922_000, + output: 128_000, + }, + }, + }, + } + + expect(provider.models["gpt-5.5"].limit).toEqual({ + context: 1_050_000, + input: 922_000, + output: 128_000, + }) + + const hooks = await CodexAuthPlugin({ + client: {} as never, + project: {} as never, + directory: "", + worktree: "", + experimental_workspace: { + register() {}, + }, + } as never) + + await hooks.auth!.loader!( + async () => + ({ + type: "oauth", + access: "access", + refresh: "refresh", + expires: Date.now() + 60_000, + }) as never, + provider as never, + ) + + expect(provider.models["gpt-5.5"].limit).toEqual({ + context: 400_000, + input: 272_000, + output: 128_000, + }) + expect(provider.models["gpt-5.5"].cost).toEqual({ + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }) + }) + }) + describe("formatOAuthFailure", () => { test("includes safe JSON error fields and request metadata", async () => { const response = new Response( diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 712bacfdb..47f679296 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -294,4 +294,60 @@ describe("session.message-v2.fromError", () => { const result = MessageV2.fromError(error, { providerID: ProviderID.make("openai") }) as MessageV2.APIError expect(result.data.isRetryable).toBe(true) }) + + test("converts OpenAI server_error stream chunks to retryable APIError", () => { + const result = MessageV2.fromError( + { + message: JSON.stringify({ + type: "error", + error: { + code: "server_error", + message: "An error occurred while processing your request.", + }, + }), + }, + { providerID: ProviderID.make("openai") }, + ) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect(SessionRetry.retryable(result)).toBe("An error occurred while processing your request.") + }) + + test("uses fallback message for OpenAI server_error stream chunks without message", () => { + const result = MessageV2.fromError( + { + message: JSON.stringify({ + type: "error", + error: { + code: "server_error", + }, + }), + }, + { providerID: ProviderID.make("openai") }, + ) + + expect(MessageV2.APIError.isInstance(result)).toBe(true) + expect((result as MessageV2.APIError).data.isRetryable).toBe(true) + expect((result as MessageV2.APIError).data.message).toBe("Server error.") + expect(SessionRetry.retryable(result)).toBe("Server error.") + }) + + test("does not convert unknown OpenAI stream error chunks to retryable APIError", () => { + const result = MessageV2.fromError( + { + message: JSON.stringify({ + type: "error", + error: { + code: "bad_request", + message: "Bad request", + }, + }), + }, + { providerID: ProviderID.make("openai") }, + ) + + expect(MessageV2.APIError.isInstance(result)).toBe(false) + expect(SessionRetry.retryable(result)).toBeUndefined() + }) })