From 467fcb17912e14797bc8caa6689c30d290e39d01 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 23:12:46 +0100 Subject: [PATCH] test(openai): add docker image auth e2e --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- .../openai/image-generation-provider.test.ts | 49 +++- .../openai/image-generation-provider.ts | 4 + package.json | 1 + .../e2e/openai-image-auth-docker-client.ts | 247 ++++++++++++++++++ scripts/e2e/openai-image-auth-docker.sh | 26 ++ scripts/test-docker-all.mjs | 1 + src/media-understanding/shared.ts | 2 + src/plugin-sdk/provider-http.ts | 1 + 9 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 scripts/e2e/openai-image-auth-docker-client.ts create mode 100644 scripts/e2e/openai-image-auth-docker.sh diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 535e21e9b1b..69032b6f845 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -f30c9e61b768ca10feca401aefca3cbc8d3a57c5020f85aa9106b4f1a61032c0 plugin-sdk-api-baseline.json -9e5e3e66ac23dddb80cceb8a785f167eec8a108c6c5abe77f3346b01895f6756 plugin-sdk-api-baseline.jsonl +a7148c6c59c88e01548cbe27ba90316efb5c5be5a9bdac24fa416f2aaef83082 plugin-sdk-api-baseline.json +4401dc1d2db5ebf8825ad28606e1d3879608ce59b395a013f5e19a901eadbbd2 plugin-sdk-api-baseline.jsonl diff --git a/extensions/openai/image-generation-provider.test.ts b/extensions/openai/image-generation-provider.test.ts index f608970d0ff..61eb822bbd7 100644 --- a/extensions/openai/image-generation-provider.test.ts +++ b/extensions/openai/image-generation-provider.test.ts @@ -10,6 +10,7 @@ const { postMultipartRequestMock, assertOkOrThrowHttpErrorMock, resolveProviderHttpRequestConfigMock, + sanitizeConfiguredModelProviderRequestMock, } = vi.hoisted(() => ({ ensureAuthProfileStoreMock: vi.fn(() => ({ version: 1, profiles: {} })), isProviderApiKeyConfiguredMock: vi.fn< @@ -33,10 +34,11 @@ const { assertOkOrThrowHttpErrorMock: vi.fn(async () => {}), resolveProviderHttpRequestConfigMock: vi.fn((params) => ({ baseUrl: params.baseUrl ?? params.defaultBaseUrl, - allowPrivateNetwork: Boolean(params.allowPrivateNetwork), + allowPrivateNetwork: Boolean(params.allowPrivateNetwork ?? params.request?.allowPrivateNetwork), headers: new Headers(params.defaultHeaders), dispatcherPolicy: undefined, })), + sanitizeConfiguredModelProviderRequestMock: vi.fn((request) => request), })); vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ @@ -54,6 +56,7 @@ vi.mock("openclaw/plugin-sdk/provider-http", () => ({ postJsonRequest: postJsonRequestMock, postMultipartRequest: postMultipartRequestMock, resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock, + sanitizeConfiguredModelProviderRequest: sanitizeConfiguredModelProviderRequestMock, })); function mockGeneratedPngResponse() { @@ -135,6 +138,7 @@ describe("openai image generation provider", () => { postMultipartRequestMock.mockReset(); assertOkOrThrowHttpErrorMock.mockClear(); resolveProviderHttpRequestConfigMock.mockClear(); + sanitizeConfiguredModelProviderRequestMock.mockClear(); vi.unstubAllEnvs(); }); @@ -483,6 +487,49 @@ describe("openai image generation provider", () => { expect(result.images[0]?.buffer).toEqual(Buffer.from("codex-image")); }); + it("honors configured Codex transport overrides for OAuth image generation", async () => { + mockCodexAuthOnly(); + mockCodexImageStream({ imageData: "codex-image" }); + + const provider = buildOpenAIImageGenerationProvider(); + const authStore = createCodexOAuthAuthStore(); + const result = await provider.generateImage({ + provider: "openai", + model: "gpt-image-2", + prompt: "Draw through a configured Codex endpoint", + cfg: { + models: { + providers: { + "openai-codex": { + baseUrl: "http://127.0.0.1:44220/backend-api/codex", + api: "openai-codex-responses", + request: { allowPrivateNetwork: true }, + models: [], + }, + }, + }, + }, + authStore, + }); + + expect(sanitizeConfiguredModelProviderRequestMock).toHaveBeenCalledWith({ + allowPrivateNetwork: true, + }); + expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "http://127.0.0.1:44220/backend-api/codex", + request: { allowPrivateNetwork: true }, + }), + ); + expect(postJsonRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "http://127.0.0.1:44220/backend-api/codex/responses", + allowPrivateNetwork: true, + }), + ); + 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 }) => { diff --git a/extensions/openai/image-generation-provider.ts b/extensions/openai/image-generation-provider.ts index a7aac51a9c8..31f6e23498a 100644 --- a/extensions/openai/image-generation-provider.ts +++ b/extensions/openai/image-generation-provider.ts @@ -17,6 +17,7 @@ import { postJsonRequest, postMultipartRequest, resolveProviderHttpRequestConfig, + sanitizeConfiguredModelProviderRequest, } from "openclaw/plugin-sdk/provider-http"; import { OPENAI_DEFAULT_IMAGE_MODEL as DEFAULT_OPENAI_IMAGE_MODEL } from "./default-models.js"; import { resolveConfiguredOpenAIBaseUrl } from "./shared.js"; @@ -344,13 +345,16 @@ async function generateOpenAICodexImage(params: { }): Promise { const { req, apiKey } = params; const inputImages = req.inputImages ?? []; + const codexProviderConfig = req.cfg?.models?.providers?.["openai-codex"]; const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } = resolveProviderHttpRequestConfig({ + baseUrl: codexProviderConfig?.baseUrl, defaultBaseUrl: DEFAULT_OPENAI_CODEX_IMAGE_BASE_URL, defaultHeaders: { Authorization: `Bearer ${apiKey}`, Accept: "text/event-stream", }, + request: sanitizeConfiguredModelProviderRequest(codexProviderConfig?.request), provider: "openai-codex", api: "openai-codex-responses", capability: "image", diff --git a/package.json b/package.json index ee7e4f956ce..f77f197e974 100644 --- a/package.json +++ b/package.json @@ -1451,6 +1451,7 @@ "test:docker:mcp-channels": "bash scripts/e2e/mcp-channels-docker.sh", "test:docker:npm-onboard-channel-agent": "bash scripts/e2e/npm-onboard-channel-agent-docker.sh", "test:docker:onboard": "bash scripts/e2e/onboard-docker.sh", + "test:docker:openai-image-auth": "bash scripts/e2e/openai-image-auth-docker.sh", "test:docker:openai-web-search-minimal": "bash scripts/e2e/openai-web-search-minimal-docker.sh", "test:docker:openwebui": "bash scripts/e2e/openwebui-docker.sh", "test:docker:pi-bundle-mcp-tools": "bash scripts/e2e/pi-bundle-mcp-tools-docker.sh", diff --git a/scripts/e2e/openai-image-auth-docker-client.ts b/scripts/e2e/openai-image-auth-docker-client.ts new file mode 100644 index 00000000000..2070a14b72c --- /dev/null +++ b/scripts/e2e/openai-image-auth-docker-client.ts @@ -0,0 +1,247 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +const DIRECT_IMAGE_BYTES = Buffer.from("docker-direct-image"); +const CODEX_IMAGE_BYTES = Buffer.from("docker-codex-image"); +const DIRECT_TOKEN = "sk-openclaw-image-auth-e2e"; +const CODEX_TOKEN = "docker-codex-oauth-token"; + +type RequestRecord = { + method?: string; + url?: string; + authorization?: string; + accept?: string; + contentType?: string; + body: string; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => resolve(body)); + req.on("error", reject); + }); +} + +function writeJson(res: http.ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function writeCodexSse(res: http.ServerResponse): void { + const events = [ + { + type: "response.output_item.done", + item: { + type: "image_generation_call", + result: CODEX_IMAGE_BYTES.toString("base64"), + revised_prompt: "docker codex revised prompt", + }, + }, + { + type: "response.completed", + response: { + usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 }, + tool_usage: { image_gen: { total_tokens: 3 } }, + }, + }, + ]; + res.writeHead(200, { "content-type": "text/event-stream" }); + for (const event of events) { + res.write(`data: ${JSON.stringify(event)}\n\n`); + } + res.end("data: [DONE]\n\n"); +} + +async function startMockServer(records: RequestRecord[]): Promise<{ + baseUrl: string; + close: () => Promise; +}> { + const server = http.createServer(async (req, res) => { + try { + const body = await readBody(req); + records.push({ + method: req.method, + url: req.url, + authorization: req.headers.authorization, + accept: req.headers.accept, + contentType: req.headers["content-type"], + body, + }); + + if (req.method === "POST" && req.url === "/v1/images/generations") { + assert( + req.headers.authorization === `Bearer ${DIRECT_TOKEN}`, + `direct image route used wrong auth: ${req.headers.authorization}`, + ); + const parsed = JSON.parse(body) as { model?: string; prompt?: string; size?: string }; + assert(parsed.model === "gpt-image-2", `direct route model mismatch: ${body}`); + assert( + parsed.prompt === "docker direct image auth", + `direct route prompt mismatch: ${body}`, + ); + assert(parsed.size === "1024x1024", `direct route size mismatch: ${body}`); + writeJson(res, 200, { + data: [ + { + b64_json: DIRECT_IMAGE_BYTES.toString("base64"), + revised_prompt: "docker direct revised prompt", + }, + ], + }); + return; + } + + if (req.method === "POST" && req.url === "/backend-api/codex/responses") { + assert( + req.headers.authorization === `Bearer ${CODEX_TOKEN}`, + `codex image route used wrong auth: ${req.headers.authorization}`, + ); + const parsed = JSON.parse(body) as { + tools?: Array<{ type?: string; model?: string; size?: string }>; + input?: Array<{ content?: Array<{ type?: string; text?: string }> }>; + }; + assert( + parsed.tools?.[0]?.type === "image_generation" && + parsed.tools[0].model === "gpt-image-2" && + parsed.tools[0].size === "1024x1024", + `codex image tool mismatch: ${body}`, + ); + assert( + parsed.input?.[0]?.content?.some( + (entry) => + entry.type === "input_text" && entry.text === "docker codex oauth image auth", + ), + `codex prompt missing: ${body}`, + ); + writeCodexSse(res); + return; + } + + writeJson(res, 404, { error: `unexpected ${req.method} ${req.url}` }); + } catch (error) { + writeJson(res, 500, { error: String(error instanceof Error ? error.message : error) }); + } + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +function createCodexOAuthStore() { + return { + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: CODEX_TOKEN, + refresh: "docker-codex-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + }, + }, + } as const; +} + +async function main() { + assert( + process.env.OPENAI_API_KEY === DIRECT_TOKEN, + "Docker lane must expose the direct OpenAI API key", + ); + const records: RequestRecord[] = []; + const mock = await startMockServer(records); + try { + const { buildOpenAIImageGenerationProvider } = + await import("../../dist/extensions/openai/image-generation-provider.js"); + const provider = buildOpenAIImageGenerationProvider(); + + const directResult = await provider.generateImage({ + provider: "openai", + model: "gpt-image-2", + prompt: "docker direct image auth", + cfg: { + models: { + providers: { + openai: { + baseUrl: `${mock.baseUrl}/v1`, + request: { allowPrivateNetwork: true }, + models: [], + }, + }, + }, + }, + }); + assert( + directResult.images?.[0]?.buffer?.equals(DIRECT_IMAGE_BYTES), + "direct image route did not return expected bytes", + ); + assert( + records.some((entry) => entry.url === "/v1/images/generations"), + "direct image route was not called", + ); + + records.length = 0; + const codexResult = await provider.generateImage({ + provider: "openai", + model: "gpt-image-2", + prompt: "docker codex oauth image auth", + cfg: { + models: { + providers: { + "openai-codex": { + baseUrl: `${mock.baseUrl}/backend-api/codex`, + api: "openai-codex-responses", + request: { allowPrivateNetwork: true }, + models: [], + }, + }, + }, + }, + authStore: createCodexOAuthStore(), + }); + assert( + codexResult.images?.[0]?.buffer?.equals(CODEX_IMAGE_BYTES), + "Codex OAuth image route did not return expected bytes", + ); + assert( + records.some((entry) => entry.url === "/backend-api/codex/responses"), + "Codex OAuth image route was not called", + ); + assert( + !records.some((entry) => entry.url === "/v1/images/generations"), + "Codex OAuth image route fell back to the direct OpenAI API key", + ); + + process.stdout.write( + JSON.stringify({ + ok: true, + routes: records.map((entry) => entry.url), + directBytes: directResult.images[0]?.buffer.length, + codexBytes: codexResult.images[0]?.buffer.length, + }) + "\n", + ); + } finally { + await mock.close(); + } +} + +await main(); diff --git a/scripts/e2e/openai-image-auth-docker.sh b/scripts/e2e/openai-image-auth-docker.sh new file mode 100644 index 00000000000..b8566e3c091 --- /dev/null +++ b/scripts/e2e/openai-image-auth-docker.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" + +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-openai-image-auth-e2e" OPENCLAW_OPENAI_IMAGE_AUTH_E2E_IMAGE)" +SKIP_BUILD="${OPENCLAW_OPENAI_IMAGE_AUTH_E2E_SKIP_BUILD:-0}" + +docker_e2e_build_or_reuse "$IMAGE_NAME" openai-image-auth "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" "" "$SKIP_BUILD" + +echo "Running OpenAI image auth Docker E2E..." +run_logged openai-image-auth docker run --rm \ + -e "OPENAI_API_KEY=sk-openclaw-image-auth-e2e" \ + -e "OPENCLAW_QA_ALLOW_LOCAL_IMAGE_PROVIDER=1" \ + -i "$IMAGE_NAME" bash -lc ' +set -euo pipefail +export HOME="$(mktemp -d "/tmp/openclaw-openai-image-auth.XXXXXX")" +export OPENCLAW_STATE_DIR="$HOME/.openclaw" +export OPENCLAW_SKIP_CHANNELS=1 +export OPENCLAW_SKIP_GMAIL_WATCHER=1 +export OPENCLAW_SKIP_CRON=1 +export OPENCLAW_SKIP_CANVAS_HOST=1 + +node --import tsx scripts/e2e/openai-image-auth-docker-client.ts +' diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index 285eb42a32b..d4006e15a4b 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -36,6 +36,7 @@ const lanes = [ ["plugin-update", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:plugin-update"], ["config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload"], ["bundled-channel-deps", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:bundled-channel-deps"], + ["openai-image-auth", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-image-auth"], ["qr", "pnpm test:docker:qr"], ]; diff --git a/src/media-understanding/shared.ts b/src/media-understanding/shared.ts index 9bdc31e7a0e..f2f291027d0 100644 --- a/src/media-understanding/shared.ts +++ b/src/media-understanding/shared.ts @@ -7,6 +7,7 @@ import { buildProviderRequestDispatcherPolicy, normalizeBaseUrl, resolveProviderRequestPolicyConfig, + sanitizeConfiguredModelProviderRequest, type ProviderRequestTransportOverrides, type ResolvedProviderRequestConfig, } from "../agents/provider-request-config.js"; @@ -17,6 +18,7 @@ import type { LookupFn, PinnedDispatcherPolicy, SsrFPolicy } from "../infra/net/ import { fetchWithTimeout } from "../utils/fetch-timeout.js"; export { fetchWithTimeout }; export { normalizeBaseUrl } from "../agents/provider-request-config.js"; +export { sanitizeConfiguredModelProviderRequest } from "../agents/provider-request-config.js"; const MAX_ERROR_CHARS = 300; const MAX_ERROR_RESPONSE_BYTES = 4096; diff --git a/src/plugin-sdk/provider-http.ts b/src/plugin-sdk/provider-http.ts index 0a6fe79ea97..4af656cc503 100644 --- a/src/plugin-sdk/provider-http.ts +++ b/src/plugin-sdk/provider-http.ts @@ -16,6 +16,7 @@ export { resolveProviderHttpRequestConfig, resolveAudioTranscriptionUploadFileName, requireTranscriptionText, + sanitizeConfiguredModelProviderRequest, waitProviderOperationPollInterval, } from "../media-understanding/shared.js"; export type { ProviderOperationDeadline } from "../media-understanding/shared.js";