diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index d16b7495654c..b9f29ed42c53 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -37,10 +37,12 @@ function base64UrlEncode(buffer: ArrayBuffer): string { export interface IdTokenClaims { chatgpt_account_id?: string + chatgpt_compute_residency?: string organizations?: Array<{ id: string }> email?: string "https://api.openai.com/auth"?: { chatgpt_account_id?: string + chatgpt_compute_residency?: string } } @@ -75,6 +77,14 @@ export function extractAccountId(tokens: TokenResponse): string | undefined { return undefined } +export function extractResidency(token: string): string | undefined { + const claims = parseJwtClaims(token) + const residency = + claims?.["https://api.openai.com/auth"]?.chatgpt_compute_residency ?? claims?.chatgpt_compute_residency + if (!residency || residency === "no_constraint") return undefined + return residency +} + function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string { const params = new URLSearchParams({ response_type: "code", @@ -411,10 +421,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug requestInput instanceof URL ? requestInput : new URL(typeof requestInput === "string" ? requestInput : requestInput.url) - const url = - parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") - ? new URL(codexApiEndpoint) - : parsed + const rewrite = parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions") + const url = rewrite ? new URL(codexApiEndpoint) : parsed + if (rewrite) { + const residency = extractResidency(currentAuth.access) + if (residency) headers.set("x-openai-internal-codex-residency", residency) + } const requestInit = { ...init, diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 1381c4ee8adb..668ac9916a52 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -1,9 +1,13 @@ import { describe, expect, test } from "bun:test" +import { createServer, type IncomingMessage } from "node:http" +import { type AddressInfo } from "node:net" +import { WebSocketServer } from "ws" import { CodexAuthPlugin, parseJwtClaims, extractAccountIdFromClaims, extractAccountId, + extractResidency, renderOAuthError, type IdTokenClaims, } from "../../src/plugin/openai/codex" @@ -131,6 +135,69 @@ describe("plugin.codex", () => { }) }) + describe("extractResidency", () => { + test("extracts compute residency from the namespaced auth claims", () => { + expect( + extractResidency( + createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "eu" }, + }), + ), + ).toBe("eu") + }) + + test("falls back to a root compute residency claim", () => { + expect(extractResidency(createTestJwt({ chatgpt_compute_residency: "us" }))).toBe("us") + }) + + test("supports compute residency values without maintaining a region list", () => { + expect( + extractResidency( + createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "ae" }, + }), + ), + ).toBe("ae") + expect( + extractResidency( + createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "future-region_1" }, + }), + ), + ).toBe("future-region_1") + }) + + test("ignores unconstrained and data residency values", () => { + expect( + extractResidency( + createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "no_constraint" }, + }), + ), + ).toBeUndefined() + expect( + extractResidency( + createTestJwt({ + "https://api.openai.com/auth": { chatgpt_data_residency: "gb" }, + }), + ), + ).toBeUndefined() + expect(extractResidency(createTestJwt({ chatgpt_compute_residency: "" }))).toBeUndefined() + expect(extractResidency("not-a-jwt")).toBeUndefined() + }) + + test("prefers a namespaced unconstrained value over a root residency", () => { + expect( + extractResidency( + createTestJwt({ + chatgpt_compute_residency: "eu", + "https://api.openai.com/auth": { chatgpt_compute_residency: "no_constraint" }, + }), + ), + ).toBeUndefined() + }) + }) + test("installs websocket transport only when experimental websockets are enabled", async () => { const disabled = await CodexAuthPlugin({} as never) const enabled = await CodexAuthPlugin({} as never, { experimentalWebSockets: true }) @@ -149,6 +216,73 @@ describe("plugin.codex", () => { await enabled.dispose?.() }) + test("sends token residency only to the ChatGPT Codex backend", async () => { + const requests: Array<{ path: string; residency: string | null }> = [] + using server = Bun.serve({ + port: 0, + fetch(request) { + requests.push({ + path: new URL(request.url).pathname, + residency: request.headers.get("x-openai-internal-codex-residency"), + }) + return new Response("{}") + }, + }) + const hooks = await CodexAuthPlugin({} as never, { + codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(), + }) + const loaded = await hooks.auth!.loader!( + async () => + ({ + type: "oauth", + refresh: "refresh", + access: createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "eu" }, + }), + expires: Date.now() + 60_000, + }) as never, + {} as never, + ) + + await loaded.fetch!("https://api.openai.com/v1/responses") + await loaded.fetch!(new URL("/other", server.url)) + + expect(requests).toEqual([ + { path: "/backend-api/codex/responses", residency: "eu" }, + { path: "/other", residency: null }, + ]) + }) + + test("sends token residency through the WebSocket transport", async () => { + await using server = await createCodexWebSocketServer() + const hooks = await CodexAuthPlugin({} as never, { + codexApiEndpoint: server.url, + experimentalWebSockets: true, + }) + const loaded = await hooks.auth!.loader!( + async () => + ({ + type: "oauth", + refresh: "refresh", + access: createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "eu" }, + }), + expires: Date.now() + 60_000, + }) as never, + {} as never, + ) + + const response = await loaded.fetch!("https://api.openai.com/v1/responses", { + method: "POST", + headers: { "session-id": "session-1" }, + body: JSON.stringify({ stream: true, input: "hi" }), + }) + + expect(await response.text()).toContain("data: [DONE]") + expect(server.headers()?.["x-openai-internal-codex-residency"]).toBe("eu") + await hooks.dispose?.() + }) + test("filters unsupported modes and uses Codex context limits for OAuth GPT models", async () => { const hooks = await CodexAuthPlugin({} as never) const limit = { context: 1_050_000, input: 922_000, output: 128_000 } @@ -193,6 +327,9 @@ describe("plugin.codex", () => { }) test("deduplicates concurrent Codex token refreshes", async () => { + const refreshedAccess = createTestJwt({ + "https://api.openai.com/auth": { chatgpt_compute_residency: "eu" }, + }) let auth = { type: "oauth" as const, refresh: "refresh-old", @@ -207,7 +344,7 @@ describe("plugin.codex", () => { resolveRefresh = resolve }) let refreshRequests = 0 - const apiRequests: { authorization: string | null; accountId: string | null }[] = [] + const apiRequests: { authorization: string | null; accountId: string | null; residency: string | null }[] = [] using server = Bun.serve({ port: 0, @@ -219,7 +356,7 @@ describe("plugin.codex", () => { await refreshReady return Response.json({ id_token: createTestJwt({ chatgpt_account_id: "acc-123" }), - access_token: "access-new", + access_token: refreshedAccess, refresh_token: "refresh-new", expires_in: 3600, }) @@ -229,6 +366,7 @@ describe("plugin.codex", () => { apiRequests.push({ authorization: request.headers.get("authorization"), accountId: request.headers.get("ChatGPT-Account-Id"), + residency: request.headers.get("x-openai-internal-codex-residency"), }) return new Response("{}", { status: 200 }) } @@ -281,11 +419,11 @@ describe("plugin.codex", () => { expect(refreshRequests).toBe(1) expect(authUpdates).toHaveLength(1) expect(authUpdates[0]?.body.refresh).toBe("refresh-new") - expect(authUpdates[0]?.body.access).toBe("access-new") + expect(authUpdates[0]?.body.access).toBe(refreshedAccess) expect(authUpdates[0]?.body.accountId).toBe("acc-123") expect(apiRequests).toEqual([ - { authorization: "Bearer access-new", accountId: "acc-123" }, - { authorization: "Bearer access-new", accountId: "acc-123" }, + { authorization: `Bearer ${refreshedAccess}`, accountId: "acc-123", residency: "eu" }, + { authorization: `Bearer ${refreshedAccess}`, accountId: "acc-123", residency: "eu" }, ]) }) }) @@ -297,3 +435,29 @@ async function waitFor(predicate: () => boolean) { await new Promise((resolve) => setTimeout(resolve, 1)) } } + +async function createCodexWebSocketServer() { + let headers: IncomingMessage["headers"] | undefined + const server = createServer() + const sockets = new WebSocketServer({ server }) + sockets.on("connection", (socket, request) => { + headers = request.headers + socket.once("message", () => { + socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_123" } })) + }) + }) + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", resolve) + }) + const address = server.address() as AddressInfo + return { + url: `http://127.0.0.1:${address.port}/backend-api/codex/responses`, + headers: () => headers, + async [Symbol.asyncDispose]() { + for (const socket of sockets.clients) socket.terminate() + sockets.close() + server.close() + }, + } +} diff --git a/packages/opencode/test/plugin/openai-ws.test.ts b/packages/opencode/test/plugin/openai-ws.test.ts index 7a125824e0bf..e8025d0a920a 100644 --- a/packages/opencode/test/plugin/openai-ws.test.ts +++ b/packages/opencode/test/plugin/openai-ws.test.ts @@ -17,13 +17,18 @@ describe("plugin.openai.ws", () => { const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, - headers: { authorization: "Bearer test", "content-length": "123" }, + headers: { + authorization: "Bearer test", + "content-length": "123", + "x-openai-internal-codex-residency": "eu", + }, }) expect(OpenAIWebSocket.toWebSocketUrl("http://example.com/v1/responses")).toBe("ws://example.com/v1/responses") expect(OpenAIWebSocket.toWebSocketUrl("https://example.com/v1/responses")).toBe("wss://example.com/v1/responses") expect(headers?.authorization).toBe("Bearer test") expect(headers?.["openai-beta"]).toBe(OpenAIWebSocket.PROTOCOL_HEADER) + expect(headers?.["x-openai-internal-codex-residency"]).toBe("eu") expect(headers?.["content-length"]).toBeUndefined() socket.terminate() }) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 1a5d0fd23a97..e7be6f3a7130 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1718,6 +1718,10 @@ We recommend signing up for [ChatGPT Plus or Pro](https://chatgpt.com/pricing). /models ``` +##### Compute residency + +For ChatGPT OAuth, OpenCode automatically applies a regional inference residency requirement when one is advertised by your workspace credentials. It forwards the compute residency value from the credential instead of maintaining a fixed list of regions. Data residency at rest does not imply regional inference. + ##### Using API keys If you already have an API key, you can select **Manually enter API Key** and paste it in your terminal.