fix(ai): allowed opaque codex provider keys

Made Codex account ids optional for openai-codex-responses custom providers, omitting chatgpt-account-id when opaque API keys cannot provide a ChatGPT account claim.

Added SSE and websocket regression coverage for custom Codex proxy API keys.

Fixes #4526
This commit is contained in:
roboomp
2026-07-04 15:44:49 +00:00
parent 38565c58cb
commit f6ec7a613e
3 changed files with 108 additions and 21 deletions
+4
View File
@@ -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
@@ -286,7 +286,7 @@ interface CodexProviderSessionState extends ProviderSessionState {
interface CodexRequestContext {
apiKey: string;
accountId: string;
accountId?: string;
baseUrl: string;
url: string;
requestHeaders: Record<string, string>;
@@ -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<void> {
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<string, string> | undefined,
accountId: string,
accountId: string | undefined,
apiKey: string,
sessionId: string | undefined,
body: RequestBody,
@@ -3044,7 +3046,7 @@ async function openCodexSseEventStream(
function createCodexHeaders(
initHeaders: Record<string, string> | 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 = [];
@@ -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<string, ProviderSessionState>(),
}).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());