Compare commits

...
7 changed files with 180 additions and 12 deletions
+52 -9
View File
@@ -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()
},
}
},
@@ -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,
+4
View File
@@ -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
@@ -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(
{
+90 -2
View File
@@ -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<string | null> = []
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<void>,
) {
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()) {
@@ -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"))
@@ -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",