mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04: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 { createServer } from "http"
|
||||||
import { OpenAIWebSocketPool } from "./ws-pool"
|
import { OpenAIWebSocketPool } from "./ws-pool"
|
||||||
import { escapeHtml } from "@/util/html"
|
import { escapeHtml } from "@/util/html"
|
||||||
|
import { ProviderError } from "@/provider/error"
|
||||||
|
|
||||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
const ISSUER = "https://auth.openai.com"
|
const ISSUER = "https://auth.openai.com"
|
||||||
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
|
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
|
||||||
const OAUTH_PORT = 1455
|
const OAUTH_PORT = 1455
|
||||||
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
|
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 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"])
|
const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"])
|
||||||
|
|
||||||
@@ -133,6 +135,25 @@ async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promis
|
|||||||
}).toString(),
|
}).toString(),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
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}`)
|
throw new Error(`Token refresh failed: ${response.status}`)
|
||||||
}
|
}
|
||||||
return response.json()
|
return response.json()
|
||||||
@@ -401,7 +422,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
|||||||
async loader(getAuth) {
|
async loader(getAuth) {
|
||||||
const auth = await getAuth()
|
const auth = await getAuth()
|
||||||
const websocketFetch = options.experimentalWebSockets
|
const websocketFetch = options.experimentalWebSockets
|
||||||
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch })
|
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch, recoverWithHttp: auth.type === "oauth" })
|
||||||
: undefined
|
: undefined
|
||||||
if (websocketFetch) {
|
if (websocketFetch) {
|
||||||
websocketFetches.push(websocketFetch)
|
websocketFetches.push(websocketFetch)
|
||||||
@@ -411,7 +432,9 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
|||||||
|
|
||||||
let refreshPromise:
|
let refreshPromise:
|
||||||
| Promise<{
|
| Promise<{
|
||||||
|
refresh: string
|
||||||
access: string
|
access: string
|
||||||
|
expires: number
|
||||||
accountId: string | undefined
|
accountId: string | undefined
|
||||||
}>
|
}>
|
||||||
| undefined
|
| undefined
|
||||||
@@ -436,24 +459,26 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
|||||||
return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init)
|
return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init)
|
||||||
|
|
||||||
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
|
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
|
||||||
|
const refresh = async () => {
|
||||||
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
|
||||||
if (!refreshPromise) {
|
if (!refreshPromise) {
|
||||||
refreshPromise = refreshAccessToken(currentAuth.refresh, issuer)
|
refreshPromise = refreshAccessToken(currentAuth.refresh, issuer)
|
||||||
.then(async (tokens) => {
|
.then(async (tokens) => {
|
||||||
const accountId = extractAccountId(tokens) || authWithAccount.accountId
|
const accountId = extractAccountId(tokens) || authWithAccount.accountId
|
||||||
|
const expires = Date.now() + (tokens.expires_in ?? 3600) * 1000
|
||||||
await input.client.auth.set({
|
await input.client.auth.set({
|
||||||
path: { id: "openai" },
|
path: { id: "openai" },
|
||||||
body: {
|
body: {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
refresh: tokens.refresh_token,
|
refresh: tokens.refresh_token,
|
||||||
access: tokens.access_token,
|
access: tokens.access_token,
|
||||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
expires,
|
||||||
...(accountId && { accountId }),
|
...(accountId && { accountId }),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
refresh: tokens.refresh_token,
|
||||||
access: tokens.access_token,
|
access: tokens.access_token,
|
||||||
|
expires,
|
||||||
accountId,
|
accountId,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -463,10 +488,14 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refreshed = await refreshPromise
|
const refreshed = await refreshPromise
|
||||||
|
currentAuth.refresh = refreshed.refresh
|
||||||
currentAuth.access = refreshed.access
|
currentAuth.access = refreshed.access
|
||||||
|
currentAuth.expires = refreshed.expires
|
||||||
authWithAccount.accountId = refreshed.accountId
|
authWithAccount.accountId = refreshed.accountId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!currentAuth.access || currentAuth.expires < Date.now() + TOKEN_REFRESH_WINDOW_MS) await refresh()
|
||||||
|
|
||||||
const headers = new Headers()
|
const headers = new Headers()
|
||||||
if (init?.headers) {
|
if (init?.headers) {
|
||||||
if (init.headers instanceof Headers) {
|
if (init.headers instanceof Headers) {
|
||||||
@@ -482,9 +511,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
headers.set("authorization", `Bearer ${currentAuth.access}`)
|
||||||
if (authWithAccount.accountId) {
|
if (authWithAccount.accountId) headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
|
||||||
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed =
|
const parsed =
|
||||||
requestInput instanceof URL
|
requestInput instanceof URL
|
||||||
@@ -499,8 +526,24 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
|||||||
...init,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
}
|
}
|
||||||
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
|
const request = () => {
|
||||||
return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
|
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
|
idleTimeout?: number
|
||||||
maxConnectionAge?: number
|
maxConnectionAge?: number
|
||||||
streamRetries?: number
|
streamRetries?: number
|
||||||
|
recoverWithHttp?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PoolEntry {
|
interface PoolEntry {
|
||||||
@@ -151,7 +152,7 @@ export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
|
|||||||
|
|
||||||
recordStreamFailure(entry)
|
recordStreamFailure(entry)
|
||||||
invalidate(entry)
|
invalidate(entry)
|
||||||
if (entry.fallback) return httpFetch(input, httpInit)
|
if (options?.recoverWithHttp || entry.fallback) return httpFetch(input, httpInit)
|
||||||
return failedResponse(
|
return failedResponse(
|
||||||
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
|
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
|
||||||
cause: 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) {
|
function isOpenAiErrorRetryable(e: APICallError) {
|
||||||
const status = e.statusCode
|
const status = e.statusCode
|
||||||
if (!status) return e.isRetryable
|
if (!status) return e.isRetryable
|
||||||
|
|||||||
@@ -676,6 +676,14 @@ export function fromError(
|
|||||||
},
|
},
|
||||||
{ cause: e },
|
{ cause: e },
|
||||||
).toObject()
|
).toObject()
|
||||||
|
case e instanceof ProviderError.AuthenticationError:
|
||||||
|
return new AuthError(
|
||||||
|
{
|
||||||
|
providerID: ctx.providerID,
|
||||||
|
message: e.message,
|
||||||
|
},
|
||||||
|
{ cause: e },
|
||||||
|
).toObject()
|
||||||
case e instanceof ProviderError.ResponseStreamError:
|
case e instanceof ProviderError.ResponseStreamError:
|
||||||
return new APIError(
|
return new APIError(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
renderOAuthError,
|
renderOAuthError,
|
||||||
type IdTokenClaims,
|
type IdTokenClaims,
|
||||||
} from "../../src/plugin/openai/codex"
|
} from "../../src/plugin/openai/codex"
|
||||||
|
import { ProviderError } from "../../src/provider/error"
|
||||||
|
|
||||||
function createTestJwt(payload: object): string {
|
function createTestJwt(payload: object): string {
|
||||||
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
|
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
|
||||||
@@ -153,8 +154,8 @@ describe("plugin.codex", () => {
|
|||||||
let auth = {
|
let auth = {
|
||||||
type: "oauth" as const,
|
type: "oauth" as const,
|
||||||
refresh: "refresh-old",
|
refresh: "refresh-old",
|
||||||
access: "",
|
access: "access-old",
|
||||||
expires: 0,
|
expires: Date.now() + 60_000,
|
||||||
}
|
}
|
||||||
const authUpdates: Array<{
|
const authUpdates: Array<{
|
||||||
body: { refresh: string; access: string; expires: number; accountId?: string }
|
body: { refresh: string; access: string; expires: number; accountId?: string }
|
||||||
@@ -245,8 +246,95 @@ describe("plugin.codex", () => {
|
|||||||
{ authorization: "Bearer access-new", accountId: "acc-123" },
|
{ 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) {
|
async function waitFor(predicate: () => boolean) {
|
||||||
const started = Date.now()
|
const started = Date.now()
|
||||||
while (!predicate()) {
|
while (!predicate()) {
|
||||||
|
|||||||
@@ -188,6 +188,23 @@ describe("plugin.openai.ws-pool", () => {
|
|||||||
fetch.close()
|
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 () => {
|
test("falls back to HTTP after websocket setup retries are exhausted", async () => {
|
||||||
const attempts: string[] = []
|
const attempts: string[] = []
|
||||||
await using server = await createRejectingWebSocketServer(() => attempts.push("websocket"))
|
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", () => {
|
test("does not retry context overflow errors", () => {
|
||||||
const error = new SessionV1.ContextOverflowError({
|
const error = new SessionV1.ContextOverflowError({
|
||||||
message: "Input exceeds context window of this model",
|
message: "Input exceeds context window of this model",
|
||||||
|
|||||||
Reference in New Issue
Block a user