diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index c13a9c439d4b..da1398cf4e1c 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -6,12 +6,14 @@ import { setTimeout as sleep } from "node:timers/promises" import { createServer } from "http" import { OpenAIWebSocketPool } from "./ws-pool" import { escapeHtml } from "@/util/html" +import { ProviderError } from "@/provider/error" const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 +const TOKEN_REFRESH_WINDOW_MS = 5 * 60 * 1000 const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"]) @@ -133,6 +135,25 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis }).toString(), }) if (!response.ok) { + const body = await response.text() + const code = (() => { + try { + const parsed = JSON.parse(body) + return parsed?.error?.code ?? parsed?.code + } catch { + return undefined + } + })() + if ( + response.status === 401 || + code === "refresh_token_expired" || + code === "refresh_token_reused" || + code === "refresh_token_invalidated" + ) { + throw new ProviderError.AuthenticationError( + "Your ChatGPT login could not be refreshed. Please sign in again.", + ) + } throw new Error(`Token refresh failed: ${response.status}`) } return response.json() @@ -401,7 +422,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug async loader(getAuth) { const auth = await getAuth() const websocketFetch = options.experimentalWebSockets - ? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch }) + ? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch, recoverWithHttp: auth.type === "oauth" }) : undefined if (websocketFetch) { websocketFetches.push(websocketFetch) @@ -411,7 +432,9 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug let refreshPromise: | Promise<{ + refresh: string access: string + expires: number accountId: string | undefined }> | undefined @@ -436,24 +459,26 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init) const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string } - - if (!currentAuth.access || currentAuth.expires < Date.now()) { + const refresh = async () => { if (!refreshPromise) { refreshPromise = refreshAccessToken(currentAuth.refresh, issuer) .then(async (tokens) => { const accountId = extractAccountId(tokens) || authWithAccount.accountId + const expires = Date.now() + (tokens.expires_in ?? 3600) * 1000 await input.client.auth.set({ path: { id: "openai" }, body: { type: "oauth", refresh: tokens.refresh_token, access: tokens.access_token, - expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + expires, ...(accountId && { accountId }), }, }) return { + refresh: tokens.refresh_token, access: tokens.access_token, + expires, accountId, } }) @@ -463,10 +488,14 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug } const refreshed = await refreshPromise + currentAuth.refresh = refreshed.refresh currentAuth.access = refreshed.access + currentAuth.expires = refreshed.expires authWithAccount.accountId = refreshed.accountId } + if (!currentAuth.access || currentAuth.expires < Date.now() + TOKEN_REFRESH_WINDOW_MS) await refresh() + const headers = new Headers() if (init?.headers) { if (init.headers instanceof Headers) { @@ -482,9 +511,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug } } headers.set("authorization", `Bearer ${currentAuth.access}`) - if (authWithAccount.accountId) { - headers.set("ChatGPT-Account-Id", authWithAccount.accountId) - } + if (authWithAccount.accountId) headers.set("ChatGPT-Account-Id", authWithAccount.accountId) const parsed = requestInput instanceof URL @@ -499,8 +526,24 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug ...init, headers, } - if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit) - return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit)) + const request = () => { + if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit) + return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit)) + } + const response = await request() + if (response.status !== 401) return response + await response.body?.cancel() + const latestAuth = await getAuth() + if (latestAuth.type === "oauth") { + currentAuth.refresh = latestAuth.refresh + currentAuth.access = latestAuth.access + currentAuth.expires = latestAuth.expires + authWithAccount.accountId = (latestAuth as typeof latestAuth & { accountId?: string }).accountId + } + await refresh() + headers.set("authorization", `Bearer ${currentAuth.access}`) + if (authWithAccount.accountId) headers.set("ChatGPT-Account-Id", authWithAccount.accountId) + return request() }, } }, diff --git a/packages/opencode/src/plugin/openai/ws-pool.ts b/packages/opencode/src/plugin/openai/ws-pool.ts index 3cbb29a3012a..e2b02f1eddd5 100644 --- a/packages/opencode/src/plugin/openai/ws-pool.ts +++ b/packages/opencode/src/plugin/openai/ws-pool.ts @@ -12,6 +12,7 @@ export interface CreateWebSocketFetchOptions { idleTimeout?: number maxConnectionAge?: number streamRetries?: number + recoverWithHttp?: boolean } interface PoolEntry { @@ -151,7 +152,7 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) { recordStreamFailure(entry) invalidate(entry) - if (entry.fallback) return httpFetch(input, httpInit) + if (options?.recoverWithHttp || entry.fallback) return httpFetch(input, httpInit) return failedResponse( new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), { cause: error, diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 21149a2cf389..487e3f11c33c 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -20,6 +20,10 @@ export class ResponseStreamError extends Error { } } +export class AuthenticationError extends Error { + public override readonly name = "ProviderAuthenticationError" +} + function isOpenAiErrorRetryable(e: APICallError) { const status = e.statusCode if (!status) return e.isRetryable diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 813fe49f325d..c848af54ccfd 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -676,6 +676,14 @@ export function fromError( }, { cause: e }, ).toObject() + case e instanceof ProviderError.AuthenticationError: + return new AuthError( + { + providerID: ctx.providerID, + message: e.message, + }, + { cause: e }, + ).toObject() case e instanceof ProviderError.ResponseStreamError: return new APIError( { diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 7142bb3e20c9..782b864318a5 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -7,6 +7,7 @@ import { renderOAuthError, type IdTokenClaims, } from "../../src/plugin/openai/codex" +import { ProviderError } from "../../src/provider/error" function createTestJwt(payload: object): string { const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url") @@ -153,8 +154,8 @@ describe("plugin.codex", () => { let auth = { type: "oauth" as const, refresh: "refresh-old", - access: "", - expires: 0, + access: "access-old", + expires: Date.now() + 60_000, } const authUpdates: Array<{ body: { refresh: string; access: string; expires: number; accountId?: string } @@ -245,8 +246,95 @@ describe("plugin.codex", () => { { authorization: "Bearer access-new", accountId: "acc-123" }, ]) }) + + test("refreshes and retries once after an unauthorized response", async () => { + let auth = { + type: "oauth" as const, + refresh: "refresh-old", + access: "access-old", + expires: Date.now() + 60 * 60 * 1000, + } + const authorizations: Array = [] + let refreshRequests = 0 + + using server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/oauth/token") { + refreshRequests += 1 + return Response.json({ + id_token: createTestJwt({ chatgpt_account_id: "acc-123" }), + access_token: "access-new", + refresh_token: "refresh-new", + expires_in: 3600, + }) + } + if (url.pathname === "/backend-api/codex/responses") { + authorizations.push(request.headers.get("authorization")) + return new Response("{}", { status: authorizations.length === 1 ? 401 : 200 }) + } + return new Response("unexpected request", { status: 500 }) + }, + }) + const hooks = await CodexAuthPlugin( + pluginInput(async (next) => { + auth = { type: "oauth", ...next.body } + }), + { + issuer: server.url.origin, + codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(), + }, + ) + const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never) + + const response = await loaded.fetch!("https://api.openai.com/v1/responses") + + expect(response.status).toBe(200) + expect(refreshRequests).toBe(1) + expect(authorizations).toEqual(["Bearer access-old", "Bearer access-new"]) + }) + + test("requests reauthentication when token refresh is permanently rejected", async () => { + const auth = { + type: "oauth" as const, + refresh: "refresh-old", + access: "access-old", + expires: 0, + } + using server = Bun.serve({ + port: 0, + fetch() { + return Response.json({ error: { code: "refresh_token_invalidated" } }, { status: 401 }) + }, + }) + const hooks = await CodexAuthPlugin( + pluginInput(async () => {}), + { issuer: server.url.origin }, + ) + const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never) + + const error = await loaded.fetch!("https://api.openai.com/v1/responses").catch((error: unknown) => error) + + expect(error).toBeInstanceOf(ProviderError.AuthenticationError) + expect(error.message).toBe("Your ChatGPT login could not be refreshed. Please sign in again.") + }) }) +function pluginInput( + set: (input: { body: { refresh: string; access: string; expires: number; accountId?: string } }) => Promise, +) { + return { + client: { auth: { set } }, + project: {}, + directory: "", + worktree: "", + experimental_workspace: { register() {} }, + serverUrl: new URL("https://example.com"), + $: {}, + } as never +} + async function waitFor(predicate: () => boolean) { const started = Date.now() while (!predicate()) { diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index 7a125824e0bf..2e57816af85d 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -188,6 +188,23 @@ describe("plugin.openai.ws-pool", () => { fetch.close() }) + test("recovers websocket connection failures over HTTP when requested", async () => { + let attempts = 0 + await using server = await createRejectingWebSocketServer(() => attempts++) + const fetch = OpenAIWebSocketPool.createWebSocketFetch({ + url: server.url, + recoverWithHttp: true, + httpFetch: Object.assign(async () => new Response("unauthorized", { status: 401 }), { preconnect() {} }), + }) + + const response = await fetch(server.url, streamRequest()) + + expect(response.status).toBe(401) + expect(await response.text()).toBe("unauthorized") + expect(attempts).toBe(1) + fetch.close() + }) + test("falls back to HTTP after websocket setup retries are exhausted", async () => { const attempts: string[] = [] await using server = await createRejectingWebSocketServer(() => attempts.push("websocket")) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index e53a6c1f18ee..874a05632273 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -182,6 +182,13 @@ describe("session.retry.retryable", () => { }) }) + test("does not retry provider authentication errors", () => { + const request = MessageV2.fromError(new ProviderError.AuthenticationError("Sign in again"), { providerID }) + + expect(SessionV1.AuthError.isInstance(request)).toBe(true) + expect(SessionRetry.retryable(request, retryProvider)).toBeUndefined() + }) + test("does not retry context overflow errors", () => { const error = new SessionV1.ContextOverflowError({ message: "Input exceeds context window of this model",