Skip to content

Commit 38e1654

Browse files
authored
fix: route Codex image API keys through OpenAI
1 parent 5fbaf2a commit 38e1654

3 files changed

Lines changed: 252 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai
6060
### Fixes
6161

6262
- Ollama: bypass the managed proxy for configured local embedding origins while keeping SSRF guardrails on unconfigured targets. Thanks @Kaspre.
63+
- OpenAI/images: route Codex API-key image generation through the native OpenAI Images API instead of the Codex OAuth streaming backend, avoiding 401s from valid API keys.
6364
- Checks/Windows: route full `pnpm check` stage commands through the managed child runner so Windows avoids Node shell-argv deprecation warnings there too.
6465
- Checks/Windows: run managed child commands through explicit `cmd.exe` wrapping instead of Node shell mode with argv, avoiding Node 24 subprocess deprecation warnings during changed checks.
6566
- Models: prune retired Groq, GitHub Copilot, OpenAI, xAI, and old Claude catalog entries, with doctor migration to upgrade existing configs to current provider refs.

extensions/openai/image-generation-provider.test.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,42 @@ function createCodexOAuthAuthStore() {
172172
};
173173
}
174174

175+
function createCodexApiKeyAuthStore() {
176+
return {
177+
version: 1 as const,
178+
profiles: {
179+
"openai-codex:manual": {
180+
type: "api_key" as const,
181+
provider: "openai-codex",
182+
key: "codex-api-key",
183+
},
184+
},
185+
};
186+
}
187+
188+
function createCodexTokenAuthStore() {
189+
return {
190+
version: 1 as const,
191+
profiles: {
192+
"openai-codex:token": {
193+
type: "token" as const,
194+
provider: "openai-codex",
195+
token: "codex-token",
196+
},
197+
},
198+
};
199+
}
200+
201+
function createMixedCodexAuthStore() {
202+
return {
203+
version: 1 as const,
204+
profiles: {
205+
...createCodexTokenAuthStore().profiles,
206+
...createCodexApiKeyAuthStore().profiles,
207+
},
208+
};
209+
}
210+
175211
type MockWithCalls = {
176212
mock: {
177213
calls: readonly (readonly unknown[])[];
@@ -841,6 +877,180 @@ describe("openai image generation provider", () => {
841877
});
842878
});
843879

880+
it("does not treat Codex API key profiles as configured Codex OAuth image auth", async () => {
881+
mockGeneratedPngResponse();
882+
resolveApiKeyForProviderMock.mockImplementation(async (params?: { provider?: string }) => {
883+
if (params?.provider === "openai") {
884+
return { apiKey: "openai-key", source: "profile:openai", mode: "api-key" };
885+
}
886+
throw new Error(`Unexpected auth provider ${params?.provider ?? ""}`);
887+
});
888+
889+
const provider = buildOpenAIImageGenerationProvider();
890+
await provider.generateImage({
891+
provider: "openai",
892+
model: "gpt-image-2",
893+
prompt: "Draw with OpenAI auth despite a Codex API key profile",
894+
cfg: {},
895+
authStore: createCodexApiKeyAuthStore(),
896+
});
897+
898+
expect(resolveApiKeyForProviderMock).toHaveBeenCalledTimes(1);
899+
expect(authResolutionCall().provider).toBe("openai");
900+
expect(jsonRequestCall().url).toBe("https://api.openai.com/v1/images/generations");
901+
expect(httpConfigCall().provider).toBe("openai");
902+
expect(logInfoMock).not.toHaveBeenCalledWith(
903+
expect.stringContaining("transport=codex-responses"),
904+
);
905+
});
906+
907+
it("uses native OpenAI image requests when only Codex API key auth is available", async () => {
908+
mockGeneratedPngResponse();
909+
resolveApiKeyForProviderMock.mockImplementation(async (params?: { provider?: string }) => {
910+
if (params?.provider === "openai") {
911+
return {};
912+
}
913+
if (params?.provider === "openai-codex") {
914+
return {
915+
apiKey: "codex-api-key",
916+
source: "profile:openai-codex:manual",
917+
mode: "api-key",
918+
};
919+
}
920+
return {};
921+
});
922+
923+
const provider = buildOpenAIImageGenerationProvider();
924+
await provider.generateImage({
925+
provider: "openai",
926+
model: "gpt-image-2",
927+
prompt: "Draw through Codex API key auth",
928+
cfg: {},
929+
authStore: createCodexApiKeyAuthStore(),
930+
});
931+
932+
expect(resolveApiKeyForProviderMock).toHaveBeenCalledTimes(2);
933+
expect(authResolutionCall(0).provider).toBe("openai");
934+
expect(authResolutionCall(1).provider).toBe("openai-codex");
935+
const configCall = httpConfigCall();
936+
expect(configCall.defaultBaseUrl).toBe("https://api.openai.com/v1");
937+
expect(configCall.defaultHeaders).toEqual({
938+
Authorization: "Bearer codex-api-key",
939+
});
940+
expect(configCall.provider).toBe("openai");
941+
expect(configCall.api).toBeUndefined();
942+
expect(jsonRequestCall().url).toBe("https://api.openai.com/v1/images/generations");
943+
expect(postMultipartRequestMock).not.toHaveBeenCalled();
944+
expect(logInfoMock).not.toHaveBeenCalledWith(
945+
expect.stringContaining("transport=codex-responses"),
946+
);
947+
});
948+
949+
it("uses native OpenAI image requests when mixed Codex profiles resolve to an API key", async () => {
950+
mockGeneratedPngResponse();
951+
resolveApiKeyForProviderMock.mockImplementation(async (params?: { provider?: string }) => {
952+
if (params?.provider === "openai-codex") {
953+
return {
954+
apiKey: "codex-api-key",
955+
source: "profile:openai-codex:manual",
956+
mode: "api-key",
957+
};
958+
}
959+
throw new Error(`Unexpected auth provider ${params?.provider ?? ""}`);
960+
});
961+
962+
const provider = buildOpenAIImageGenerationProvider();
963+
await provider.generateImage({
964+
provider: "openai",
965+
model: "gpt-image-2",
966+
prompt: "Draw through resolved Codex API key auth",
967+
cfg: {},
968+
authStore: createMixedCodexAuthStore(),
969+
});
970+
971+
expect(resolveApiKeyForProviderMock).toHaveBeenCalledTimes(1);
972+
expect(authResolutionCall().provider).toBe("openai-codex");
973+
expect(httpConfigCall().defaultBaseUrl).toBe("https://api.openai.com/v1");
974+
expect(httpConfigCall().defaultHeaders).toEqual({
975+
Authorization: "Bearer codex-api-key",
976+
});
977+
expect(httpConfigCall().provider).toBe("openai");
978+
expect(jsonRequestCall().url).toBe("https://api.openai.com/v1/images/generations");
979+
expect(logInfoMock).not.toHaveBeenCalledWith(
980+
expect.stringContaining("transport=codex-responses"),
981+
);
982+
});
983+
984+
it("keeps Codex token auth on the Codex image transport", async () => {
985+
mockCodexImageStream({ imageData: "codex-token-image" });
986+
resolveApiKeyForProviderMock.mockImplementation(async (params?: { provider?: string }) => {
987+
if (params?.provider === "openai") {
988+
return {};
989+
}
990+
if (params?.provider === "openai-codex") {
991+
return {
992+
apiKey: "codex-token",
993+
source: "profile:openai-codex:token",
994+
mode: "token",
995+
};
996+
}
997+
return {};
998+
});
999+
1000+
const provider = buildOpenAIImageGenerationProvider();
1001+
const result = await provider.generateImage({
1002+
provider: "openai",
1003+
model: "gpt-image-2",
1004+
prompt: "Draw through Codex token auth",
1005+
cfg: {},
1006+
authStore: createCodexTokenAuthStore(),
1007+
});
1008+
1009+
expect(resolveApiKeyForProviderMock).toHaveBeenCalledTimes(1);
1010+
expect(authResolutionCall().provider).toBe("openai-codex");
1011+
expect(httpConfigCall().defaultBaseUrl).toBe("https://chatgpt.com/backend-api/codex");
1012+
expect(httpConfigCall().provider).toBe("openai-codex");
1013+
expect(httpConfigCall().api).toBe("openai-codex-responses");
1014+
expect(jsonRequestCall().url).toBe("https://chatgpt.com/backend-api/codex/responses");
1015+
expect(postMultipartRequestMock).not.toHaveBeenCalled();
1016+
expect(result.images[0]?.buffer).toEqual(Buffer.from("codex-token-image"));
1017+
});
1018+
1019+
it("uses configured Codex token auth before probing an available OpenAI API key", async () => {
1020+
mockCodexImageStream({ imageData: "codex-token-image" });
1021+
resolveApiKeyForProviderMock.mockImplementation(async (params?: { provider?: string }) => {
1022+
if (params?.provider === "openai") {
1023+
return { apiKey: "openai-key", source: "OPENAI_API_KEY", mode: "api-key" };
1024+
}
1025+
if (params?.provider === "openai-codex") {
1026+
return {
1027+
apiKey: "codex-token",
1028+
source: "profile:openai-codex:token",
1029+
mode: "token",
1030+
};
1031+
}
1032+
return {};
1033+
});
1034+
1035+
const provider = buildOpenAIImageGenerationProvider();
1036+
await provider.generateImage({
1037+
provider: "openai",
1038+
model: "gpt-image-2",
1039+
prompt: "Draw using configured Codex token auth",
1040+
cfg: {},
1041+
authStore: createCodexTokenAuthStore(),
1042+
});
1043+
1044+
expect(resolveApiKeyForProviderMock).toHaveBeenCalledTimes(1);
1045+
expect(authResolutionCall().provider).toBe("openai-codex");
1046+
expect(
1047+
resolveApiKeyForProviderMock.mock.calls.some(
1048+
([call]) => (call as AuthResolutionCall).provider === "openai",
1049+
),
1050+
).toBe(false);
1051+
expect(jsonRequestCall().url).toBe("https://chatgpt.com/backend-api/codex/responses");
1052+
});
1053+
8441054
it("routes transparent default-model Codex OAuth requests to the alpha-capable image model", async () => {
8451055
mockCodexAuthOnly();
8461056
mockCodexImageStream({ imageData: "codex-transparent-image" });

extensions/openai/image-generation-provider.ts

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -340,12 +340,18 @@ function resolveRequestAuthStore(req: {
340340
});
341341
}
342342

343-
function hasCodexOAuthProfileConfigured(req: {
343+
function hasCodexResponseTransportProfileConfigured(req: {
344344
authStore?: AuthProfileStore;
345345
agentDir?: string;
346346
}): boolean {
347347
const store = resolveRequestAuthStore(req);
348-
return Boolean(store && listProfilesForProvider(store, "openai-codex").length > 0);
348+
if (!store) {
349+
return false;
350+
}
351+
return listProfilesForProvider(store, "openai-codex").some(
352+
(profileId) =>
353+
store.profiles[profileId]?.type === "oauth" || store.profiles[profileId]?.type === "token",
354+
);
349355
}
350356

351357
type OpenAICodexImageGenerationEvent = {
@@ -733,11 +739,12 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
733739
const isEdit = inputImages.length > 0;
734740
const rawBaseUrl = resolveConfiguredOpenAIBaseUrl(req.cfg);
735741
const publicOpenAIBaseUrl = isPublicOpenAIImageBaseUrl(rawBaseUrl);
736-
const useCodexOAuthRoute =
742+
const useCodexResponseTransportRoute =
737743
publicOpenAIBaseUrl &&
738744
!hasExplicitOpenAIDirectProviderConfig(req.cfg) &&
739-
hasCodexOAuthProfileConfigured(req);
740-
if (useCodexOAuthRoute) {
745+
hasCodexResponseTransportProfileConfigured(req);
746+
let preResolvedImageAuth: Awaited<ReturnType<typeof resolveApiKeyForProvider>> | undefined;
747+
if (useCodexResponseTransportRoute) {
741748
const codexAuth = await resolveApiKeyForProvider({
742749
provider: "openai-codex",
743750
cfg: req.cfg,
@@ -747,18 +754,25 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
747754
if (!codexAuth.apiKey) {
748755
throw new Error("OpenAI Codex OAuth missing");
749756
}
750-
const timeoutMs = resolveOpenAIImageTimeoutMs(req.timeoutMs);
751-
logCodexImageAuthSelected({ req, authMode: codexAuth.mode, timeoutMs });
752-
return generateOpenAICodexImage({ req, apiKey: codexAuth.apiKey });
757+
if (codexAuth.mode === "api-key") {
758+
preResolvedImageAuth = codexAuth;
759+
} else {
760+
const timeoutMs = resolveOpenAIImageTimeoutMs(req.timeoutMs);
761+
logCodexImageAuthSelected({ req, authMode: codexAuth.mode, timeoutMs });
762+
return generateOpenAICodexImage({ req, apiKey: codexAuth.apiKey });
763+
}
753764
}
754765

755-
const auth = await resolveOptionalApiKeyForProvider({
756-
provider: "openai",
757-
cfg: req.cfg,
758-
agentDir: req.agentDir,
759-
store: req.authStore,
760-
});
761-
if (!auth?.apiKey) {
766+
const auth =
767+
preResolvedImageAuth ??
768+
(await resolveOptionalApiKeyForProvider({
769+
provider: "openai",
770+
cfg: req.cfg,
771+
agentDir: req.agentDir,
772+
store: req.authStore,
773+
}));
774+
let imageAuth = auth;
775+
if (!imageAuth?.apiKey) {
762776
if (!publicOpenAIBaseUrl) {
763777
throw new Error("OpenAI API key missing");
764778
}
@@ -769,11 +783,17 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
769783
store: req.authStore,
770784
});
771785
if (codexAuth?.apiKey) {
772-
const timeoutMs = resolveOpenAIImageTimeoutMs(req.timeoutMs);
773-
logCodexImageAuthSelected({ req, authMode: codexAuth.mode, timeoutMs });
774-
return generateOpenAICodexImage({ req, apiKey: codexAuth.apiKey });
786+
if (codexAuth.mode === "api-key") {
787+
imageAuth = codexAuth;
788+
} else {
789+
const timeoutMs = resolveOpenAIImageTimeoutMs(req.timeoutMs);
790+
logCodexImageAuthSelected({ req, authMode: codexAuth.mode, timeoutMs });
791+
return generateOpenAICodexImage({ req, apiKey: codexAuth.apiKey });
792+
}
793+
}
794+
if (!imageAuth?.apiKey) {
795+
throw new Error("OpenAI API key or Codex OAuth missing");
775796
}
776-
throw new Error("OpenAI API key or Codex OAuth missing");
777797
}
778798
const isAzure = isAzureOpenAIBaseUrl(rawBaseUrl);
779799

@@ -783,8 +803,8 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
783803
defaultBaseUrl: DEFAULT_OPENAI_IMAGE_BASE_URL,
784804
allowPrivateNetwork: shouldAllowPrivateImageEndpoint(req),
785805
defaultHeaders: isAzure
786-
? { "api-key": auth.apiKey }
787-
: { Authorization: `Bearer ${auth.apiKey}` },
806+
? { "api-key": imageAuth.apiKey }
807+
: { Authorization: `Bearer ${imageAuth.apiKey}` },
788808
provider: "openai",
789809
capability: "image",
790810
transport: "http",

0 commit comments

Comments
 (0)