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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user