mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:10:51 +00:00
fix(openai): prefer configured Codex OAuth for images
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user