mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:40:49 +00:00
fix(openai): reuse Codex OAuth for OpenAI images
This commit is contained in:
@@ -1,8 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildOpenAICodexImageGenerationProvider,
|
||||
buildOpenAIImageGenerationProvider,
|
||||
} from "./image-generation-provider.js";
|
||||
import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js";
|
||||
|
||||
const {
|
||||
resolveApiKeyForProviderMock,
|
||||
@@ -11,7 +8,13 @@ const {
|
||||
assertOkOrThrowHttpErrorMock,
|
||||
resolveProviderHttpRequestConfigMock,
|
||||
} = vi.hoisted(() => ({
|
||||
resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "openai-key" })),
|
||||
resolveApiKeyForProviderMock: vi.fn(
|
||||
async (_params?: {
|
||||
provider?: string;
|
||||
}): Promise<{ apiKey?: string; source?: string; mode?: string }> => ({
|
||||
apiKey: "openai-key",
|
||||
}),
|
||||
),
|
||||
postJsonRequestMock: vi.fn(),
|
||||
postMultipartRequestMock: vi.fn(),
|
||||
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
|
||||
@@ -76,9 +79,19 @@ function mockCodexImageStream(params: { imageData?: string; revisedPrompt?: stri
|
||||
}));
|
||||
}
|
||||
|
||||
function mockCodexAuthOnly() {
|
||||
resolveApiKeyForProviderMock.mockImplementation(async (params?: { provider?: string }) => {
|
||||
if (params?.provider === "openai-codex") {
|
||||
return { apiKey: "codex-key", source: "profile:openai-codex:default", mode: "oauth" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
}
|
||||
|
||||
describe("openai image generation provider", () => {
|
||||
afterEach(() => {
|
||||
resolveApiKeyForProviderMock.mockClear();
|
||||
resolveApiKeyForProviderMock.mockReset();
|
||||
resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: "openai-key" });
|
||||
postJsonRequestMock.mockReset();
|
||||
postMultipartRequestMock.mockReset();
|
||||
assertOkOrThrowHttpErrorMock.mockClear();
|
||||
@@ -281,13 +294,14 @@ describe("openai image generation provider", () => {
|
||||
expect(result.images).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("registers Codex OAuth image generation through Responses streaming", async () => {
|
||||
it("falls back to Codex OAuth image generation through Responses streaming", async () => {
|
||||
mockCodexAuthOnly();
|
||||
mockCodexImageStream({ imageData: "codex-image", revisedPrompt: "revised codex prompt" });
|
||||
|
||||
const provider = buildOpenAICodexImageGenerationProvider();
|
||||
const provider = buildOpenAIImageGenerationProvider();
|
||||
const authStore = { version: 1, profiles: {} };
|
||||
const result = await provider.generateImage({
|
||||
provider: "openai-codex",
|
||||
provider: "openai",
|
||||
model: "gpt-image-2",
|
||||
prompt: "Draw a Codex lighthouse",
|
||||
cfg: {},
|
||||
@@ -296,6 +310,12 @@ describe("openai image generation provider", () => {
|
||||
size: "1024x1536",
|
||||
});
|
||||
|
||||
expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "openai",
|
||||
store: authStore,
|
||||
}),
|
||||
);
|
||||
expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "openai-codex",
|
||||
@@ -306,7 +326,7 @@ describe("openai image generation provider", () => {
|
||||
expect.objectContaining({
|
||||
defaultBaseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
defaultHeaders: expect.objectContaining({
|
||||
Authorization: "Bearer openai-key",
|
||||
Authorization: "Bearer codex-key",
|
||||
Accept: "text/event-stream",
|
||||
}),
|
||||
provider: "openai-codex",
|
||||
@@ -353,11 +373,12 @@ describe("openai image generation provider", () => {
|
||||
});
|
||||
|
||||
it("sends Codex reference images as Responses input images", async () => {
|
||||
mockCodexAuthOnly();
|
||||
mockCodexImageStream();
|
||||
|
||||
const provider = buildOpenAICodexImageGenerationProvider();
|
||||
const provider = buildOpenAIImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "openai-codex",
|
||||
provider: "openai",
|
||||
model: "gpt-image-2",
|
||||
prompt: "Use the reference image",
|
||||
cfg: {},
|
||||
@@ -384,11 +405,12 @@ describe("openai image generation provider", () => {
|
||||
});
|
||||
|
||||
it("satisfies Codex count by issuing one Responses request per image", async () => {
|
||||
mockCodexAuthOnly();
|
||||
mockCodexImageStream({ imageData: "codex-image" });
|
||||
|
||||
const provider = buildOpenAICodexImageGenerationProvider();
|
||||
const provider = buildOpenAIImageGenerationProvider();
|
||||
const result = await provider.generateImage({
|
||||
provider: "openai-codex",
|
||||
provider: "openai",
|
||||
model: "gpt-image-2",
|
||||
prompt: "Draw two Codex icons",
|
||||
cfg: {},
|
||||
|
||||
@@ -221,7 +221,7 @@ function extractCodexImageGenerationResult(params: {
|
||||
}
|
||||
|
||||
function createOpenAIImageGenerationProviderBase(params: {
|
||||
id: "openai" | "openai-codex";
|
||||
id: "openai";
|
||||
label: string;
|
||||
isConfigured: ImageGenerationProvider["isConfigured"];
|
||||
generateImage: ImageGenerationProvider["generateImage"];
|
||||
@@ -255,6 +255,104 @@ function createOpenAIImageGenerationProviderBase(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveOptionalApiKeyForProvider(
|
||||
params: Parameters<typeof resolveApiKeyForProvider>[0],
|
||||
) {
|
||||
try {
|
||||
return await resolveApiKeyForProvider(params);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateOpenAICodexImage(params: {
|
||||
req: Parameters<ImageGenerationProvider["generateImage"]>[0];
|
||||
apiKey: string;
|
||||
}): Promise<ImageGenerationResult> {
|
||||
const { req, apiKey } = params;
|
||||
const inputImages = req.inputImages ?? [];
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
defaultBaseUrl: DEFAULT_OPENAI_CODEX_IMAGE_BASE_URL,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
capability: "image",
|
||||
transport: "http",
|
||||
});
|
||||
|
||||
const model = req.model || DEFAULT_OPENAI_IMAGE_MODEL;
|
||||
const count = req.count ?? 1;
|
||||
const size = req.size ?? DEFAULT_SIZE;
|
||||
headers.set("Content-Type", "application/json");
|
||||
const content: Array<Record<string, unknown>> = [
|
||||
{ type: "input_text", text: req.prompt },
|
||||
...inputImages.map((image) => ({
|
||||
type: "input_image",
|
||||
image_url: toOpenAIDataUrl(image),
|
||||
detail: "auto",
|
||||
})),
|
||||
];
|
||||
const results: ImageGenerationResult[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const requestResult = await postJsonRequest({
|
||||
url: `${baseUrl}/responses`,
|
||||
headers,
|
||||
body: {
|
||||
model: "gpt-5.4",
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
},
|
||||
],
|
||||
instructions: OPENAI_CODEX_IMAGE_INSTRUCTIONS,
|
||||
tools: [
|
||||
{
|
||||
type: "image_generation",
|
||||
model,
|
||||
size,
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "image_generation" },
|
||||
stream: true,
|
||||
store: false,
|
||||
},
|
||||
timeoutMs: req.timeoutMs,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
const { response, release } = requestResult;
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "OpenAI Codex image generation failed");
|
||||
results.push(
|
||||
extractCodexImageGenerationResult({
|
||||
body: await readResponseBodyText(response),
|
||||
model,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
const images = results.flatMap((result) => result.images);
|
||||
return {
|
||||
images: images.map((image, index) =>
|
||||
Object.assign({}, image, {
|
||||
fileName: `image-${index + 1}.png`,
|
||||
}),
|
||||
),
|
||||
model,
|
||||
metadata: {
|
||||
responses: results.map((result) => result.metadata).filter(Boolean),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
|
||||
return createOpenAIImageGenerationProviderBase({
|
||||
id: "openai",
|
||||
@@ -263,18 +361,31 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
|
||||
isProviderApiKeyConfigured({
|
||||
provider: "openai",
|
||||
agentDir,
|
||||
}) ||
|
||||
isProviderApiKeyConfigured({
|
||||
provider: "openai-codex",
|
||||
agentDir,
|
||||
}),
|
||||
async generateImage(req) {
|
||||
const inputImages = req.inputImages ?? [];
|
||||
const isEdit = inputImages.length > 0;
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
const auth = await resolveOptionalApiKeyForProvider({
|
||||
provider: "openai",
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("OpenAI API key missing");
|
||||
if (!auth?.apiKey) {
|
||||
const codexAuth = await resolveOptionalApiKeyForProvider({
|
||||
provider: "openai-codex",
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (codexAuth?.apiKey) {
|
||||
return generateOpenAICodexImage({ req, apiKey: codexAuth.apiKey });
|
||||
}
|
||||
throw new Error("OpenAI API key or Codex OAuth missing");
|
||||
}
|
||||
const rawBaseUrl = resolveConfiguredOpenAIBaseUrl(req.cfg);
|
||||
const isAzure = isAzureOpenAIBaseUrl(rawBaseUrl);
|
||||
@@ -382,108 +493,3 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function buildOpenAICodexImageGenerationProvider(): ImageGenerationProvider {
|
||||
return createOpenAIImageGenerationProviderBase({
|
||||
id: "openai-codex",
|
||||
label: "OpenAI Codex",
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({
|
||||
provider: "openai-codex",
|
||||
agentDir,
|
||||
}),
|
||||
async generateImage(req) {
|
||||
const inputImages = req.inputImages ?? [];
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: "openai-codex",
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("OpenAI Codex OAuth missing");
|
||||
}
|
||||
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
defaultBaseUrl: DEFAULT_OPENAI_CODEX_IMAGE_BASE_URL,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
capability: "image",
|
||||
transport: "http",
|
||||
});
|
||||
|
||||
const model = req.model || DEFAULT_OPENAI_IMAGE_MODEL;
|
||||
const count = req.count ?? 1;
|
||||
const size = req.size ?? DEFAULT_SIZE;
|
||||
headers.set("Content-Type", "application/json");
|
||||
const content: Array<Record<string, unknown>> = [
|
||||
{ type: "input_text", text: req.prompt },
|
||||
...inputImages.map((image) => ({
|
||||
type: "input_image",
|
||||
image_url: toOpenAIDataUrl(image),
|
||||
detail: "auto",
|
||||
})),
|
||||
];
|
||||
const results: ImageGenerationResult[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const requestResult = await postJsonRequest({
|
||||
url: `${baseUrl}/responses`,
|
||||
headers,
|
||||
body: {
|
||||
model: "gpt-5.4",
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
},
|
||||
],
|
||||
instructions: OPENAI_CODEX_IMAGE_INSTRUCTIONS,
|
||||
tools: [
|
||||
{
|
||||
type: "image_generation",
|
||||
model,
|
||||
size,
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "image_generation" },
|
||||
stream: true,
|
||||
store: false,
|
||||
},
|
||||
timeoutMs: req.timeoutMs,
|
||||
fetchFn: fetch,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
const { response, release } = requestResult;
|
||||
try {
|
||||
await assertOkOrThrowHttpError(response, "OpenAI Codex image generation failed");
|
||||
results.push(
|
||||
extractCodexImageGenerationResult({
|
||||
body: await readResponseBodyText(response),
|
||||
model,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
const images = results.flatMap((result) => result.images);
|
||||
return {
|
||||
images: images.map((image, index) =>
|
||||
Object.assign({}, image, {
|
||||
fileName: `image-${index + 1}.png`,
|
||||
}),
|
||||
),
|
||||
model,
|
||||
metadata: {
|
||||
responses: results.map((result) => result.metadata).filter(Boolean),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,10 +2,7 @@ import { resolvePluginConfigObject } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
|
||||
import { buildOpenAICodexCliBackend } from "./cli-backend.js";
|
||||
import {
|
||||
buildOpenAICodexImageGenerationProvider,
|
||||
buildOpenAIImageGenerationProvider,
|
||||
} from "./image-generation-provider.js";
|
||||
import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import {
|
||||
openaiCodexMediaUnderstandingProvider,
|
||||
openaiMediaUnderstandingProvider,
|
||||
@@ -52,7 +49,6 @@ export default definePluginEntry({
|
||||
api.registerProvider(buildProviderWithPromptContribution(buildOpenAICodexProviderPlugin()));
|
||||
api.registerMemoryEmbeddingProvider(openAiMemoryEmbeddingProviderAdapter);
|
||||
api.registerImageGenerationProvider(buildOpenAIImageGenerationProvider());
|
||||
api.registerImageGenerationProvider(buildOpenAICodexImageGenerationProvider());
|
||||
api.registerRealtimeTranscriptionProvider(buildOpenAIRealtimeTranscriptionProvider());
|
||||
api.registerRealtimeVoiceProvider(buildOpenAIRealtimeVoiceProvider());
|
||||
api.registerSpeechProvider(buildOpenAISpeechProvider());
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"realtimeVoiceProviders": ["openai"],
|
||||
"memoryEmbeddingProviders": ["openai"],
|
||||
"mediaUnderstandingProviders": ["openai", "openai-codex"],
|
||||
"imageGenerationProviders": ["openai", "openai-codex"],
|
||||
"imageGenerationProviders": ["openai"],
|
||||
"videoGenerationProviders": ["openai"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
|
||||
Reference in New Issue
Block a user