diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b13e987db..be64b063d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed custom `openai-codex-responses` providers with opaque proxy/API keys failing before dispatch when no ChatGPT `chatgpt_account_id` claim exists; Codex requests now omit `chatgpt-account-id` when it cannot be derived. ([#4526](https://github.com/can1357/oh-my-pi/issues/4526)) + ## [16.3.6] - 2026-07-04 ### Added diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 981f3a875..04359f77c 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -286,7 +286,7 @@ interface CodexProviderSessionState extends ProviderSessionState { interface CodexRequestContext { apiKey: string; - accountId: string; + accountId?: string; baseUrl: string; url: string; requestHeaders: Record; @@ -834,7 +834,7 @@ async function buildCodexRequestContext( throw new AIError.MissingApiKeyError(model.provider); } - const accountId = getAccountId(apiKey); + const accountId = getCodexAccountId(apiKey); const baseUrl = model.baseUrl || CODEX_BASE_URL; const url = resolveCodexResponsesUrl(baseUrl); const promptCacheKey = normalizeOpenAIResponsesPromptCacheKey(options?.promptCacheKey ?? options?.sessionId); @@ -853,7 +853,7 @@ async function buildCodexRequestContext( const providerSessionState = getCodexProviderSessionState(options?.providerSessionState); const responsesLite = shouldUseCodexResponsesLite(transformedBody, options?.responsesLite); - const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, baseUrl, responsesLite); + const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, apiKey, baseUrl, responsesLite); const publicSessionKey = transportSessionId ? `${baseUrl}:${model.id}:${transportSessionId}` : undefined; if (sessionKey && publicSessionKey) { providerSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey); @@ -2055,14 +2055,14 @@ export async function prewarmOpenAICodexResponses( ): Promise { const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; if (!apiKey) return; - const accountId = getAccountId(apiKey); + const accountId = getCodexAccountId(apiKey); const baseUrl = model.baseUrl || CODEX_BASE_URL; const url = resolveCodexResponsesUrl(baseUrl); const transportSessionId = normalizeOpenAIResponsesPromptCacheKey(options?.sessionId); const promptCacheKey = transportSessionId; const providerSessionState = getCodexProviderSessionState(options?.providerSessionState); const responsesLite = options?.responsesLite === true; - const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, baseUrl, responsesLite); + const sessionKey = getCodexWebSocketSessionKey(transportSessionId, model, accountId, apiKey, baseUrl, responsesLite); const publicSessionKey = transportSessionId ? `${baseUrl}:${model.id}:${transportSessionId}` : undefined; if (publicSessionKey && sessionKey) { providerSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey); @@ -2095,15 +2095,17 @@ export async function prewarmOpenAICodexResponses( function getCodexWebSocketSessionKey( normalizedSessionId: string | undefined, model: Model<"openai-codex-responses">, - accountId: string, + accountId: string | undefined, + apiKey: string, baseUrl: string, responsesLite: boolean, ): string | undefined { if (!normalizedSessionId) return undefined; + const credentialKey = accountId ? `account:${accountId}` : `token:${Bun.hash(apiKey).toString(36)}`; // Responses Lite is connection-scoped on the WebSocket upgrade, so lite and // non-lite turns must never share a pooled socket or append state. const liteSuffix = responsesLite ? ":lite" : ""; - return `${accountId}:${baseUrl}:${model.id}:${normalizedSessionId}${liteSuffix}`; + return `${credentialKey}:${baseUrl}:${model.id}:${normalizedSessionId}${liteSuffix}`; } function getCodexWebSocketSessionState( @@ -2979,7 +2981,7 @@ async function getOrCreateCodexWebSocketConnection( async function openCodexSseEventStream( url: string, requestHeaders: Record | undefined, - accountId: string, + accountId: string | undefined, apiKey: string, sessionId: string | undefined, body: RequestBody, @@ -3044,7 +3046,7 @@ async function openCodexSseEventStream( function createCodexHeaders( initHeaders: Record | undefined, - accountId: string, + accountId: string | undefined, accessToken: string, sessionId?: string, transport: CodexTransport = "sse", @@ -3054,7 +3056,7 @@ function createCodexHeaders( const headers = new Headers(initHeaders ?? {}); headers.delete("x-api-key"); headers.set("Authorization", `Bearer ${accessToken}`); - headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId); + if (accountId) headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId); const betaHeader = transport === "websocket" ? OPENAI_HEADER_VALUES.BETA_RESPONSES_WEBSOCKETS_V2 @@ -3129,17 +3131,6 @@ function resolveCodexResponsesUrl(baseUrl: string | undefined): string { return `${normalized}/codex/responses`; } -function getAccountId(accessToken: string): string { - const accountId = getCodexAccountId(accessToken); - if (!accountId) { - throw new AIError.OAuthError("Failed to extract accountId from token", { - kind: "validation", - provider: "openai", - }); - } - return accountId; -} - function convertMessages(model: Model<"openai-codex-responses">, context: Context): ResponseInput { const messages: ResponseInput = []; diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 931604f05..0f92dccb6 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -277,6 +277,98 @@ describe("openai-codex streaming", () => { ]); }); + it("omits chatgpt account headers for opaque custom provider API keys", async () => { + const tempDir = TempDir.createSync("@pi-codex-stream-"); + setAgentDir(tempDir.path()); + const context = createCodexTestContext(); + const model: Model<"openai-codex-responses"> = buildModel({ + id: "gpt-5.4-mini", + name: "GPT-5.4 mini", + api: "openai-codex-responses", + provider: "codex-proxy", + baseUrl: "http://127.0.0.1:2455/backend-api/codex", + reasoning: true, + preferWebsockets: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 272000, + maxTokens: 128000, + }); + let requestHeaders: Headers | undefined; + let requestUrl: string | undefined; + let requestCount = 0; + const fetchMock: FetchImpl = async (input, init) => { + requestCount += 1; + requestUrl = input instanceof Request ? input.url : input.toString(); + requestHeaders = init?.headers instanceof Headers ? init.headers : new Headers(init?.headers); + return new Response(createCompletedCodexSse("pong"), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + + const result = await streamOpenAICodexResponses(model, context, { + apiKey: "opaque-proxy-key", + fetch: fetchMock, + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(requestCount).toBe(1); + expect(requestUrl).toBe("http://127.0.0.1:2455/backend-api/codex/responses"); + expect(requestHeaders?.get("Authorization")).toBe("Bearer opaque-proxy-key"); + expect(requestHeaders?.has("chatgpt-account-id")).toBe(false); + expect(requestHeaders?.get("OpenAI-Beta")).toBe("responses=experimental"); + expect(requestHeaders?.get("originator")).toBe("pi"); + }); + + it("omits chatgpt account headers on opaque custom provider websockets", async () => { + const tempDir = TempDir.createSync("@pi-codex-stream-"); + setAgentDir(tempDir.path()); + let capturedHeaders: WsHeaders | undefined; + class OpaqueKeyWebSocket extends MockWebSocket { + constructor(url: string, options?: { headers?: WsHeaders }) { + super(url, options); + capturedHeaders = options?.headers; + expect(url).toBe("ws://127.0.0.1:2455/backend-api/codex/responses"); + this.scheduleOpen(); + } + + send(): void { + this.emitCodexResponse({ messageId: "msg_opaque", responseId: "resp_opaque", text: "pong" }); + } + } + Object.defineProperty(globalThis, "WebSocket", { + configurable: true, + writable: true, + value: OpaqueKeyWebSocket, + }); + const model: Model<"openai-codex-responses"> = buildModel({ + id: "gpt-5.4-mini", + name: "GPT-5.4 mini", + api: "openai-codex-responses", + provider: "codex-proxy", + baseUrl: "http://127.0.0.1:2455/backend-api/codex", + reasoning: true, + preferWebsockets: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 272000, + maxTokens: 128000, + }); + + const result = await streamOpenAICodexResponses(model, createCodexTestContext(), { + apiKey: "opaque-proxy-key", + sessionId: "opaque-ws-session", + providerSessionState: new Map(), + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(capturedHeaders?.authorization).toBe("Bearer opaque-proxy-key"); + expect(capturedHeaders?.["chatgpt-account-id"]).toBeUndefined(); + expect(capturedHeaders?.["openai-beta"]).toBe("responses_websockets=2026-02-06"); + expect(capturedHeaders?.originator).toBe("pi"); + }); + it("sends an async onPayload replacement body", async () => { const tempDir = TempDir.createSync("@pi-codex-stream-"); setAgentDir(tempDir.path());