From 38f157a1486d6ac68d5b7d21309ea7252509d04f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 22:49:11 +0100 Subject: [PATCH] fix(openai): prefer configured Codex OAuth for images --- CHANGELOG.md | 1 + docs/providers/openai.md | 11 +- docs/tools/image-generation.md | 19 ++- .../openai/image-generation-provider.test.ts | 122 ++++++++++++++++++ .../openai/image-generation-provider.ts | 90 ++++++++++++- 5 files changed, 231 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0585fae89ba..0204e608086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/OpenAI: surface selected-model capacity failures from PI, Codex, and auto-reply harness paths with a model-switch hint instead of the generic empty-response error. Thanks @vincentkoc. +- Providers/OpenAI: route `openai/gpt-image-2` through configured Codex OAuth directly when an `openai-codex` profile is active, instead of probing `OPENAI_API_KEY` first. - Providers/OpenAI: stop advertising the removed `gpt-5.3-codex-spark` Codex model through fallback catalogs, and suppress stale rows with a GPT-5.5 recovery hint. - Plugins/QR: replace legacy `qrcode-terminal` QR rendering with bounded `qrcode-tui` helpers for plugin login/setup flows. (#65969) Thanks @vincentkoc. - Voice-call/realtime: wait for OpenAI session configuration before greeting or forwarding buffered audio, and reject non-allowlisted Twilio callers before stream setup. (#43501) Thanks @forrestblount. diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 196a678b0f6..c3721436c88 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -214,10 +214,13 @@ See [Image Generation](/tools/image-generation) for shared tool parameters, prov editing. `gpt-image-1` remains usable as an explicit model override, but new OpenAI image workflows should use `openai/gpt-image-2`. -For Codex OAuth installs, keep the same `openai/gpt-image-2` ref. If no -`OPENAI_API_KEY` is available, OpenClaw resolves the stored OAuth access token -for the `openai-codex` auth profile and sends image requests through the Codex -Responses backend, so this path works without the public OpenAI Images API key. +For Codex OAuth installs, keep the same `openai/gpt-image-2` ref. When an +`openai-codex` OAuth profile is configured, OpenClaw resolves that stored OAuth +access token and sends image requests through the Codex Responses backend. It +does not first try `OPENAI_API_KEY` or silently fall back to an API key for that +request. Configure `models.providers.openai` explicitly with an API key, +custom base URL, or Azure endpoint when you want the direct OpenAI Images API +route instead. Generate: diff --git a/docs/tools/image-generation.md b/docs/tools/image-generation.md index 9b99a072fd4..18c66645533 100644 --- a/docs/tools/image-generation.md +++ b/docs/tools/image-generation.md @@ -30,9 +30,11 @@ The tool only appears when at least one image generation provider is available. } ``` -Codex OAuth uses the same `openai/gpt-image-2` model ref. If no `OPENAI_API_KEY` -is available, OpenClaw resolves the existing `openai-codex` OAuth profile and -sends the image request through the Codex Responses backend. +Codex OAuth uses the same `openai/gpt-image-2` model ref. When an +`openai-codex` OAuth profile is configured, OpenClaw routes image requests +through that same OAuth profile instead of first trying `OPENAI_API_KEY`. +Explicit custom `models.providers.openai` image config, such as an API key or +custom/Azure base URL, opts back into the direct OpenAI Images API route. 3. Ask the agent: _"Generate an image of a friendly robot mascot."_ @@ -128,10 +130,13 @@ OpenAI, Google, and xAI support up to 5 reference images via the `images` parame ### OpenAI `gpt-image-2` -OpenAI image generation defaults to `openai/gpt-image-2`. It uses -`OPENAI_API_KEY` when available. If no API key is configured, OpenClaw reuses the -same `openai-codex` OAuth profile used by Codex subscription chat models and -sends the image request through the Codex Responses backend. The older +OpenAI image generation defaults to `openai/gpt-image-2`. If an +`openai-codex` OAuth profile is configured, OpenClaw reuses the same OAuth +profile used by Codex subscription chat models and sends the image request +through the Codex Responses backend; it does not silently fall back to +`OPENAI_API_KEY` for that request. To force direct OpenAI Images API routing, +configure `models.providers.openai` explicitly with an API key, custom base URL, +or Azure endpoint. The older `openai/gpt-image-1` model can still be selected explicitly, but new OpenAI image-generation and image-editing requests should use `gpt-image-2`. diff --git a/extensions/openai/image-generation-provider.test.ts b/extensions/openai/image-generation-provider.test.ts index 9aa189b22af..f608970d0ff 100644 --- a/extensions/openai/image-generation-provider.test.ts +++ b/extensions/openai/image-generation-provider.test.ts @@ -2,16 +2,25 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js"; const { + ensureAuthProfileStoreMock, isProviderApiKeyConfiguredMock, + listProfilesForProviderMock, resolveApiKeyForProviderMock, postJsonRequestMock, postMultipartRequestMock, assertOkOrThrowHttpErrorMock, resolveProviderHttpRequestConfigMock, } = vi.hoisted(() => ({ + ensureAuthProfileStoreMock: vi.fn(() => ({ version: 1, profiles: {} })), isProviderApiKeyConfiguredMock: vi.fn< (params: { provider: string; agentDir?: string }) => boolean >(() => false), + listProfilesForProviderMock: vi.fn( + (store: { profiles?: Record }, provider: string) => + Object.entries(store.profiles ?? {}) + .filter(([, profile]) => profile.provider === provider) + .map(([profileId]) => profileId), + ), resolveApiKeyForProviderMock: vi.fn( async (_params?: { provider?: string; @@ -31,7 +40,9 @@ const { })); vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ + ensureAuthProfileStore: ensureAuthProfileStoreMock, isProviderApiKeyConfigured: isProviderApiKeyConfiguredMock, + listProfilesForProvider: listProfilesForProviderMock, })); vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({ @@ -96,10 +107,28 @@ function mockCodexAuthOnly() { }); } +function createCodexOAuthAuthStore() { + return { + version: 1 as const, + profiles: { + "openai-codex:default": { + type: "oauth" as const, + provider: "openai-codex", + access: "codex-access", + refresh: "codex-refresh", + expires: Date.now() + 60_000, + }, + }, + }; +} + describe("openai image generation provider", () => { afterEach(() => { + ensureAuthProfileStoreMock.mockReset(); + ensureAuthProfileStoreMock.mockReturnValue({ version: 1, profiles: {} }); isProviderApiKeyConfiguredMock.mockReset(); isProviderApiKeyConfiguredMock.mockReturnValue(false); + listProfilesForProviderMock.mockClear(); resolveApiKeyForProviderMock.mockReset(); resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: "openai-key" }); postJsonRequestMock.mockReset(); @@ -412,6 +441,99 @@ describe("openai image generation provider", () => { }); }); + it("uses configured Codex OAuth directly instead of probing an available OpenAI API key", async () => { + resolveApiKeyForProviderMock.mockImplementation(async (params?: { provider?: string }) => { + if (params?.provider === "openai") { + return { apiKey: "openai-key", source: "OPENAI_API_KEY", mode: "api-key" }; + } + if (params?.provider === "openai-codex") { + return { apiKey: "codex-key", source: "profile:openai-codex:default", mode: "oauth" }; + } + return {}; + }); + mockCodexImageStream({ imageData: "codex-image" }); + + const provider = buildOpenAIImageGenerationProvider(); + const authStore = createCodexOAuthAuthStore(); + const result = await provider.generateImage({ + provider: "openai", + model: "gpt-image-2", + prompt: "Draw using configured Codex auth", + cfg: {}, + authStore, + }); + + expect(resolveApiKeyForProviderMock).toHaveBeenCalledTimes(1); + expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai-codex", + store: authStore, + }), + ); + expect(resolveApiKeyForProviderMock).not.toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + }), + ); + expect(postJsonRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://chatgpt.com/backend-api/codex/responses", + }), + ); + expect(result.images[0]?.buffer).toEqual(Buffer.from("codex-image")); + }); + + it("uses direct OpenAI auth when custom OpenAI image config is explicit", async () => { + mockGeneratedPngResponse(); + resolveApiKeyForProviderMock.mockImplementation(async (params?: { provider?: string }) => { + if (params?.provider === "openai") { + return { apiKey: "openai-key", source: "models.json", mode: "api-key" }; + } + if (params?.provider === "openai-codex") { + return { apiKey: "codex-key", source: "profile:openai-codex:default", mode: "oauth" }; + } + return {}; + }); + + const provider = buildOpenAIImageGenerationProvider(); + const authStore = createCodexOAuthAuthStore(); + await provider.generateImage({ + provider: "openai", + model: "gpt-image-2", + prompt: "Draw using explicit direct config", + cfg: { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "OPENAI_API_KEY", + models: [], + }, + }, + }, + }, + authStore, + }); + + expect(resolveApiKeyForProviderMock).toHaveBeenCalledTimes(1); + expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai", + store: authStore, + }), + ); + expect(resolveApiKeyForProviderMock).not.toHaveBeenCalledWith( + expect.objectContaining({ + provider: "openai-codex", + }), + ); + expect(postJsonRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://api.openai.com/v1/images/generations", + }), + ); + }); + it("sends Codex reference images as Responses input images", async () => { mockCodexAuthOnly(); mockCodexImageStream(); diff --git a/extensions/openai/image-generation-provider.ts b/extensions/openai/image-generation-provider.ts index d01702a77e2..a7aac51a9c8 100644 --- a/extensions/openai/image-generation-provider.ts +++ b/extensions/openai/image-generation-provider.ts @@ -5,7 +5,12 @@ import type { ImageGenerationResult, ImageGenerationSourceImage, } from "openclaw/plugin-sdk/image-generation"; -import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; +import { + ensureAuthProfileStore, + isProviderApiKeyConfigured, + listProfilesForProvider, + type AuthProfileStore, +} from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; import { assertOkOrThrowHttpError, @@ -84,6 +89,74 @@ function shouldAllowPrivateImageEndpoint(req: { return process.env.OPENCLAW_QA_ALLOW_LOCAL_IMAGE_PROVIDER === "1"; } +function normalizeProviderId(value: string | undefined): string { + return value?.trim().toLowerCase() ?? ""; +} + +function hasExplicitOpenAIDirectAuthConfig(cfg: OpenClawConfig | undefined): boolean { + const profiles = cfg?.auth?.profiles; + if (!profiles) { + return false; + } + return Object.values(profiles).some( + (profile) => normalizeProviderId(profile.provider) === "openai", + ); +} + +function hasExplicitOpenAIDirectProviderConfig(cfg: OpenClawConfig | undefined): boolean { + if (hasExplicitOpenAIDirectAuthConfig(cfg)) { + return true; + } + const providerConfig = cfg?.models?.providers?.openai; + if (!providerConfig) { + return false; + } + if (providerConfig.apiKey !== undefined) { + return true; + } + const configuredBaseUrl = resolveConfiguredOpenAIBaseUrl(cfg); + if ( + configuredBaseUrl.trim() && + configuredBaseUrl.replace(/\/+$/, "") !== DEFAULT_OPENAI_IMAGE_BASE_URL + ) { + return true; + } + if (providerConfig.api !== undefined) { + return true; + } + if (providerConfig.headers && Object.keys(providerConfig.headers).length > 0) { + return true; + } + if (providerConfig.authHeader === false || providerConfig.request !== undefined) { + return true; + } + return false; +} + +function resolveRequestAuthStore(req: { + authStore?: AuthProfileStore; + agentDir?: string; +}): AuthProfileStore | undefined { + if (req.authStore) { + return req.authStore; + } + const agentDir = req.agentDir?.trim(); + if (!agentDir) { + return undefined; + } + return ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); +} + +function hasCodexOAuthProfileConfigured(req: { + authStore?: AuthProfileStore; + agentDir?: string; +}): boolean { + const store = resolveRequestAuthStore(req); + return Boolean(store && listProfilesForProvider(store, "openai-codex").length > 0); +} + type OpenAIImageApiResponse = { data?: Array<{ b64_json?: string; @@ -369,6 +442,21 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider { async generateImage(req) { const inputImages = req.inputImages ?? []; const isEdit = inputImages.length > 0; + const useCodexOAuthRoute = + !hasExplicitOpenAIDirectProviderConfig(req.cfg) && hasCodexOAuthProfileConfigured(req); + if (useCodexOAuthRoute) { + const codexAuth = await resolveApiKeyForProvider({ + provider: "openai-codex", + cfg: req.cfg, + agentDir: req.agentDir, + store: req.authStore, + }); + if (!codexAuth.apiKey) { + throw new Error("OpenAI Codex OAuth missing"); + } + return generateOpenAICodexImage({ req, apiKey: codexAuth.apiKey }); + } + const auth = await resolveOptionalApiKeyForProvider({ provider: "openai", cfg: req.cfg,