mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-22 18:56:10 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ee0c123ec | ||
|
|
075b02a15e |
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user