fix: codex data residency (#42432)
This commit is contained in:
@@ -37,10 +37,12 @@ function base64UrlEncode(buffer: ArrayBuffer): string {
|
|||||||
|
|
||||||
export interface IdTokenClaims {
|
export interface IdTokenClaims {
|
||||||
chatgpt_account_id?: string
|
chatgpt_account_id?: string
|
||||||
|
chatgpt_compute_residency?: string
|
||||||
organizations?: Array<{ id: string }>
|
organizations?: Array<{ id: string }>
|
||||||
email?: string
|
email?: string
|
||||||
"https://api.openai.com/auth"?: {
|
"https://api.openai.com/auth"?: {
|
||||||
chatgpt_account_id?: string
|
chatgpt_account_id?: string
|
||||||
|
chatgpt_compute_residency?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +77,14 @@ export function extractAccountId(tokens: TokenResponse): string | undefined {
|
|||||||
return 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 {
|
function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
@@ -406,10 +416,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
|
|||||||
requestInput instanceof URL
|
requestInput instanceof URL
|
||||||
? requestInput
|
? requestInput
|
||||||
: new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
|
: new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
|
||||||
const url =
|
const rewrite = parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
|
||||||
parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
|
const url = rewrite ? new URL(codexApiEndpoint) : parsed
|
||||||
? new URL(codexApiEndpoint)
|
if (rewrite) {
|
||||||
: parsed
|
const residency = extractResidency(currentAuth.access)
|
||||||
|
if (residency) headers.set("x-openai-internal-codex-residency", residency)
|
||||||
|
}
|
||||||
|
|
||||||
const requestInit = {
|
const requestInit = {
|
||||||
...init,
|
...init,
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
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 {
|
import {
|
||||||
CodexAuthPlugin,
|
CodexAuthPlugin,
|
||||||
parseJwtClaims,
|
parseJwtClaims,
|
||||||
extractAccountIdFromClaims,
|
extractAccountIdFromClaims,
|
||||||
extractAccountId,
|
extractAccountId,
|
||||||
|
extractResidency,
|
||||||
renderOAuthError,
|
renderOAuthError,
|
||||||
type IdTokenClaims,
|
type IdTokenClaims,
|
||||||
} from "../../src/plugin/openai/codex"
|
} 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 () => {
|
test("installs websocket transport only when experimental websockets are enabled", async () => {
|
||||||
const disabled = await CodexAuthPlugin({} as never)
|
const disabled = await CodexAuthPlugin({} as never)
|
||||||
const enabled = await CodexAuthPlugin({} as never, { experimentalWebSockets: true })
|
const enabled = await CodexAuthPlugin({} as never, { experimentalWebSockets: true })
|
||||||
@@ -149,6 +216,73 @@ describe("plugin.codex", () => {
|
|||||||
await enabled.dispose?.()
|
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 () => {
|
test("filters unsupported modes and uses Codex context limits for OAuth GPT models", async () => {
|
||||||
const hooks = await CodexAuthPlugin({} as never)
|
const hooks = await CodexAuthPlugin({} as never)
|
||||||
const limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
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 () => {
|
test("deduplicates concurrent Codex token refreshes", async () => {
|
||||||
|
const refreshedAccess = createTestJwt({
|
||||||
|
"https://api.openai.com/auth": { chatgpt_compute_residency: "eu" },
|
||||||
|
})
|
||||||
let auth = {
|
let auth = {
|
||||||
type: "oauth" as const,
|
type: "oauth" as const,
|
||||||
refresh: "refresh-old",
|
refresh: "refresh-old",
|
||||||
@@ -207,7 +344,7 @@ describe("plugin.codex", () => {
|
|||||||
resolveRefresh = resolve
|
resolveRefresh = resolve
|
||||||
})
|
})
|
||||||
let refreshRequests = 0
|
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({
|
using server = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
@@ -219,7 +356,7 @@ describe("plugin.codex", () => {
|
|||||||
await refreshReady
|
await refreshReady
|
||||||
return Response.json({
|
return Response.json({
|
||||||
id_token: createTestJwt({ chatgpt_account_id: "acc-123" }),
|
id_token: createTestJwt({ chatgpt_account_id: "acc-123" }),
|
||||||
access_token: "access-new",
|
access_token: refreshedAccess,
|
||||||
refresh_token: "refresh-new",
|
refresh_token: "refresh-new",
|
||||||
expires_in: 3600,
|
expires_in: 3600,
|
||||||
})
|
})
|
||||||
@@ -229,6 +366,7 @@ describe("plugin.codex", () => {
|
|||||||
apiRequests.push({
|
apiRequests.push({
|
||||||
authorization: request.headers.get("authorization"),
|
authorization: request.headers.get("authorization"),
|
||||||
accountId: request.headers.get("ChatGPT-Account-Id"),
|
accountId: request.headers.get("ChatGPT-Account-Id"),
|
||||||
|
residency: request.headers.get("x-openai-internal-codex-residency"),
|
||||||
})
|
})
|
||||||
return new Response("{}", { status: 200 })
|
return new Response("{}", { status: 200 })
|
||||||
}
|
}
|
||||||
@@ -281,11 +419,11 @@ describe("plugin.codex", () => {
|
|||||||
expect(refreshRequests).toBe(1)
|
expect(refreshRequests).toBe(1)
|
||||||
expect(authUpdates).toHaveLength(1)
|
expect(authUpdates).toHaveLength(1)
|
||||||
expect(authUpdates[0]?.body.refresh).toBe("refresh-new")
|
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(authUpdates[0]?.body.accountId).toBe("acc-123")
|
||||||
expect(apiRequests).toEqual([
|
expect(apiRequests).toEqual([
|
||||||
{ authorization: "Bearer access-new", accountId: "acc-123" },
|
{ authorization: `Bearer ${refreshedAccess}`, accountId: "acc-123", residency: "eu" },
|
||||||
{ authorization: "Bearer access-new", accountId: "acc-123" },
|
{ authorization: `Bearer ${refreshedAccess}`, accountId: "acc-123", residency: "eu" },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -297,3 +435,29 @@ async function waitFor(predicate: () => boolean) {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
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<void>((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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,13 +17,18 @@ describe("plugin.openai.ws", () => {
|
|||||||
|
|
||||||
const socket = await OpenAIWebSocket.connectResponsesWebSocket({
|
const socket = await OpenAIWebSocket.connectResponsesWebSocket({
|
||||||
url: server.wsUrl,
|
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("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(OpenAIWebSocket.toWebSocketUrl("https://example.com/v1/responses")).toBe("wss://example.com/v1/responses")
|
||||||
expect(headers?.authorization).toBe("Bearer test")
|
expect(headers?.authorization).toBe("Bearer test")
|
||||||
expect(headers?.["openai-beta"]).toBe(OpenAIWebSocket.PROTOCOL_HEADER)
|
expect(headers?.["openai-beta"]).toBe(OpenAIWebSocket.PROTOCOL_HEADER)
|
||||||
|
expect(headers?.["x-openai-internal-codex-residency"]).toBe("eu")
|
||||||
expect(headers?.["content-length"]).toBeUndefined()
|
expect(headers?.["content-length"]).toBeUndefined()
|
||||||
socket.terminate()
|
socket.terminate()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1718,6 +1718,10 @@ We recommend signing up for [ChatGPT Plus or Pro](https://chatgpt.com/pricing).
|
|||||||
/models
|
/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
|
##### Using API keys
|
||||||
|
|
||||||
If you already have an API key, you can select **Manually enter API Key** and paste it in your terminal.
|
If you already have an API key, you can select **Manually enter API Key** and paste it in your terminal.
|
||||||
|
|||||||
Reference in New Issue
Block a user