fix(openai): prefer configured Codex OAuth for images

This commit is contained in:
Peter Steinberger
2026-04-23 22:49:11 +01:00
parent 68cb054d20
commit 38f157a148
5 changed files with 231 additions and 12 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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`.

View File

@@ -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<string, { provider?: string }> }, 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();

View File

@@ -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,