Compare commits
3 commits
dev
...
fix/openai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
423b1cb41b | ||
|
|
e4a9538289 | ||
|
|
f64319366f |
6 changed files with 128 additions and 6 deletions
|
|
@ -6,6 +6,7 @@ import os from "os"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
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 { ProviderError } from "../../provider/error"
|
||||||
|
|
||||||
const log = Log.create({ service: "plugin.codex" })
|
const log = Log.create({ service: "plugin.codex" })
|
||||||
|
|
||||||
|
|
@ -14,8 +15,24 @@ 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 DEFAULT_HTTP_HEADER_TIMEOUT = 10_000
|
||||||
|
// This adapter applies a fresh header timeout only when it actually uses HTTP.
|
||||||
|
const HEADER_TIMEOUT = Symbol.for("opencode.provider.header-timeout")
|
||||||
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"])
|
||||||
|
|
||||||
|
export function fetchWithHeaderTimeout(
|
||||||
|
fetch: OpenAIWebSocketPool.Fetch,
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init: RequestInit | undefined,
|
||||||
|
timeout: number | false,
|
||||||
|
) {
|
||||||
|
if (timeout === false) return fetch(input, init)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(new ProviderError.HeaderTimeoutError(timeout)), timeout)
|
||||||
|
const signal = init?.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal
|
||||||
|
return fetch(input, { ...init, signal }).finally(() => clearTimeout(timer))
|
||||||
|
}
|
||||||
|
|
||||||
interface PkceCodes {
|
interface PkceCodes {
|
||||||
verifier: string
|
verifier: string
|
||||||
challenge: string
|
challenge: string
|
||||||
|
|
@ -354,10 +371,17 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
|
||||||
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
|
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
|
||||||
const issuer = options.issuer ?? ISSUER
|
const issuer = options.issuer ?? ISSUER
|
||||||
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
|
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
|
||||||
|
let httpHeaderTimeout: number | false = DEFAULT_HTTP_HEADER_TIMEOUT
|
||||||
let websocketFetchInstalled = false
|
let websocketFetchInstalled = false
|
||||||
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []
|
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []
|
||||||
|
const httpFetch: OpenAIWebSocketPool.Fetch = (requestInput, init) =>
|
||||||
|
fetchWithHeaderTimeout(fetch, requestInput, init, httpHeaderTimeout)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
async config(config) {
|
||||||
|
const value = config.provider?.openai?.options?.headerTimeout
|
||||||
|
httpHeaderTimeout = typeof value === "number" || value === false ? value : DEFAULT_HTTP_HEADER_TIMEOUT
|
||||||
|
},
|
||||||
async dispose() {
|
async dispose() {
|
||||||
for (const websocketFetch of websocketFetches) websocketFetch.close()
|
for (const websocketFetch of websocketFetches) websocketFetch.close()
|
||||||
websocketFetches.length = 0
|
websocketFetches.length = 0
|
||||||
|
|
@ -404,7 +428,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 })
|
? Object.assign(OpenAIWebSocketPool.createWebSocketFetch({ httpFetch }), { [HEADER_TIMEOUT]: false })
|
||||||
: undefined
|
: undefined
|
||||||
if (websocketFetch) {
|
if (websocketFetch) {
|
||||||
websocketFetches.push(websocketFetch)
|
websocketFetches.push(websocketFetch)
|
||||||
|
|
@ -419,7 +443,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||||
}>
|
}>
|
||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
apiKey: OAUTH_DUMMY_KEY,
|
apiKey: OAUTH_DUMMY_KEY,
|
||||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||||
if (init?.headers) {
|
if (init?.headers) {
|
||||||
|
|
@ -504,9 +528,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
||||||
headers,
|
headers,
|
||||||
}
|
}
|
||||||
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
|
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
|
||||||
return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
|
return (websocketFetch ? httpFetch : fetch)(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if (websocketFetch) Object.assign(result.fetch, { [HEADER_TIMEOUT]: false })
|
||||||
|
return result
|
||||||
},
|
},
|
||||||
methods: [
|
methods: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,12 @@ import { isRecord } from "@/util/record"
|
||||||
import { OpenAIWebSocket } from "./ws"
|
import { OpenAIWebSocket } from "./ws"
|
||||||
|
|
||||||
export const TITLE_HEADER = "x-opencode-title"
|
export const TITLE_HEADER = "x-opencode-title"
|
||||||
|
export type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||||
|
|
||||||
const log = Log.create({ service: "plugin.openai.ws" })
|
const log = Log.create({ service: "plugin.openai.ws" })
|
||||||
|
|
||||||
export interface CreateWebSocketFetchOptions {
|
export interface CreateWebSocketFetchOptions {
|
||||||
httpFetch?: typeof globalThis.fetch
|
httpFetch?: Fetch
|
||||||
url?: string
|
url?: string
|
||||||
connectTimeout?: number
|
connectTimeout?: number
|
||||||
idleTimeout?: number
|
idleTimeout?: number
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ import { ProviderError } from "./error"
|
||||||
|
|
||||||
const log = Log.create({ service: "provider" })
|
const log = Log.create({ service: "provider" })
|
||||||
const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000
|
const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000
|
||||||
|
// Custom fetch adapters can opt out when they apply route-specific header timing internally.
|
||||||
|
const HEADER_TIMEOUT = Symbol.for("opencode.provider.header-timeout")
|
||||||
|
|
||||||
|
export function headerTimeoutForFetch(fetch: unknown, timeout: number | false | undefined) {
|
||||||
|
if (timeout === false) return undefined
|
||||||
|
if (typeof fetch === "function" && (fetch as { [HEADER_TIMEOUT]?: false })[HEADER_TIMEOUT] === false) return undefined
|
||||||
|
return timeout
|
||||||
|
}
|
||||||
|
|
||||||
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||||
if (typeof ms !== "number" || ms <= 0) return res
|
if (typeof ms !== "number" || ms <= 0) return res
|
||||||
|
|
@ -1603,7 +1611,7 @@ export const layer = Layer.effect(
|
||||||
const fetchFn = customFetch ?? fetch
|
const fetchFn = customFetch ?? fetch
|
||||||
const opts = init ?? {}
|
const opts = init ?? {}
|
||||||
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
|
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
|
||||||
const headerTimeoutMs = headerTimeout === false ? undefined : headerTimeout
|
const headerTimeoutMs = headerTimeoutForFetch(customFetch, headerTimeout)
|
||||||
const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined
|
const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined
|
||||||
const signals: AbortSignal[] = []
|
const signals: AbortSignal[] = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import {
|
||||||
CodexAuthPlugin,
|
CodexAuthPlugin,
|
||||||
|
fetchWithHeaderTimeout,
|
||||||
parseJwtClaims,
|
parseJwtClaims,
|
||||||
extractAccountIdFromClaims,
|
extractAccountIdFromClaims,
|
||||||
extractAccountId,
|
extractAccountId,
|
||||||
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")
|
||||||
|
|
@ -137,9 +139,63 @@ describe("plugin.codex", () => {
|
||||||
|
|
||||||
expect(disabledOptions.fetch).toBeUndefined()
|
expect(disabledOptions.fetch).toBeUndefined()
|
||||||
expect(enabledOptions.fetch).toBeFunction()
|
expect(enabledOptions.fetch).toBeFunction()
|
||||||
|
expect(enabledOptions.fetch?.[Symbol.for("opencode.provider.header-timeout")]).toBe(false)
|
||||||
await enabled.dispose?.()
|
await enabled.dispose?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("applies configured header timeout to websocket HTTP fallback", async () => {
|
||||||
|
using server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
async fetch() {
|
||||||
|
await Bun.sleep(50)
|
||||||
|
return new Response("http")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const hooks = await CodexAuthPlugin({} as never, { experimentalWebSockets: true })
|
||||||
|
await hooks.config!({ provider: { openai: { options: { headerTimeout: 20 } } } } as never)
|
||||||
|
const loaded = await hooks.auth!.loader!(async () => ({ type: "api", key: "sk-test" }) as never, {} as never)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loaded.fetch!(new URL("/v1/responses", server.url), {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ stream: true }),
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(ProviderError.HeaderTimeoutError)
|
||||||
|
await hooks.dispose?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("marks websocket OAuth transport as managing its own header timeout", async () => {
|
||||||
|
const hooks = await CodexAuthPlugin({} as never, { experimentalWebSockets: true })
|
||||||
|
const loaded = await hooks.auth!.loader!(
|
||||||
|
async () => ({ type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 }) as never,
|
||||||
|
{} as never,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(loaded.fetch?.[Symbol.for("opencode.provider.header-timeout")]).toBe(false)
|
||||||
|
await hooks.dispose?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can disable websocket HTTP fallback header timeout", async () => {
|
||||||
|
using server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
async fetch() {
|
||||||
|
await Bun.sleep(30)
|
||||||
|
return new Response("http")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const hooks = await CodexAuthPlugin({} as never, { experimentalWebSockets: true })
|
||||||
|
await hooks.config!({ provider: { openai: { options: { headerTimeout: false } } } } as never)
|
||||||
|
const loaded = await hooks.auth!.loader!(async () => ({ type: "api", key: "sk-test" }) as never, {} as never)
|
||||||
|
|
||||||
|
const response = await loaded.fetch!(new URL("/v1/responses", server.url), {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ stream: true }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await response.text()).toBe("http")
|
||||||
|
await hooks.dispose?.()
|
||||||
|
})
|
||||||
|
|
||||||
test("deduplicates concurrent Codex token refreshes", async () => {
|
test("deduplicates concurrent Codex token refreshes", async () => {
|
||||||
let auth = {
|
let auth = {
|
||||||
type: "oauth" as const,
|
type: "oauth" as const,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { APICallError } from "ai"
|
||||||
import { ProviderError } from "../../src/provider/error"
|
import { ProviderError } from "../../src/provider/error"
|
||||||
import { OpenAIWebSocket } from "../../src/plugin/openai/ws"
|
import { OpenAIWebSocket } from "../../src/plugin/openai/ws"
|
||||||
import { OpenAIWebSocketPool, TITLE_HEADER } from "../../src/plugin/openai/ws-pool"
|
import { OpenAIWebSocketPool, TITLE_HEADER } from "../../src/plugin/openai/ws-pool"
|
||||||
|
import { fetchWithHeaderTimeout } from "../../src/plugin/openai/codex"
|
||||||
|
|
||||||
describe("plugin.openai.ws", () => {
|
describe("plugin.openai.ws", () => {
|
||||||
test("derives websocket URLs and sends auth plus protocol headers", async () => {
|
test("derives websocket URLs and sends auth plus protocol headers", async () => {
|
||||||
|
|
@ -166,6 +167,26 @@ describe("plugin.openai.ws-pool", () => {
|
||||||
fetch.close()
|
fetch.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("does not apply HTTP header timeout while waiting for the first websocket event", async () => {
|
||||||
|
await using server = await createWebSocketServer((socket) => {
|
||||||
|
socket.once("message", () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_delayed" } }))
|
||||||
|
}, 50)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||||
|
url: server.url,
|
||||||
|
httpFetch: (input, init) => fetchWithHeaderTimeout(globalThis.fetch, input, init, 20),
|
||||||
|
idleTimeout: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(server.url, streamRequest())
|
||||||
|
|
||||||
|
expect(await response.text()).toContain("data: [DONE]")
|
||||||
|
fetch.close()
|
||||||
|
})
|
||||||
|
|
||||||
test("rotates a socket that exceeds max connection age", async () => {
|
test("rotates a socket that exceeds max connection age", async () => {
|
||||||
let connections = 0
|
let connections = 0
|
||||||
await using server = await createWebSocketServer((socket) => {
|
await using server = await createWebSocketServer((socket) => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { afterEach, expect } from "bun:test"
|
import { afterEach, expect, test } from "bun:test"
|
||||||
import { createServer, type Server } from "node:http"
|
import { createServer, type Server } from "node:http"
|
||||||
import { streamText } from "ai"
|
import { streamText } from "ai"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
|
|
@ -20,6 +20,16 @@ const it = testEffect(
|
||||||
Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer),
|
Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test("marked custom fetch adapters opt out of outer header timeout", () => {
|
||||||
|
const fetch = Object.assign(() => Promise.resolve(new Response()), {
|
||||||
|
[Symbol.for("opencode.provider.header-timeout")]: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Provider.headerTimeoutForFetch(fetch, 10_000)).toBeUndefined()
|
||||||
|
expect(Provider.headerTimeoutForFetch(() => Promise.resolve(new Response()), 10_000)).toBe(10_000)
|
||||||
|
expect(Provider.headerTimeoutForFetch(fetch, false)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it.live("headerTimeout does not abort delayed SSE body after headers arrive", () =>
|
it.live("headerTimeout does not abort delayed SSE body after headers arrive", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const server = yield* Effect.acquireRelease(
|
const server = yield* Effect.acquireRelease(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue