diff --git a/docs/providers/openai.md b/docs/providers/openai.md index e73a53452831..be15c9d97e72 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -473,6 +473,44 @@ for the full example. or session state, `openclaw doctor --fix` rewrites them to `openai/*` with the Codex runtime unless OpenClaw is explicitly configured. + ### Trusted loopback credential proxies + + A trusted local credential broker can preserve the native `openai/*` + catalog and Responses transport while handling upstream authentication on + loopback. Configure the broker's Codex base URL under provider parameters: + + ```json5 + { + models: { + providers: { + openai: { + params: { + codexProxyBaseUrl: "http://127.0.0.1:7862/backend-api/codex", + }, + }, + }, + }, + } + ``` + + Token or OAuth profile model discovery uses + `/models`, and Codex Responses models use + `/responses`. OpenAI API-key profiles keep using the + Platform API route. The proxy URL must use the exact `127.0.0.1` or `[::1]` + loopback literal and end in `/codex`; OpenClaw rejects other URLs before + sending the profile token. + + OpenClaw sends the selected token or OAuth credential only to the local + model-discovery and Responses proxy routes. To keep upstream OAuth material + outside OpenClaw, configure the profile with an opaque proxy capability + instead of the upstream OAuth token. The local proxy then owns upstream + authentication, account headers, refresh, and revocation. + + Proxy capabilities are not used for OpenAI usage reporting or image + generation because this contract exposes no usage or image route. Select an + API-key profile for image generation; API-key profiles keep using the + Platform API. + ### Context window defaults and long-context opt-in OpenClaw treats native model capacity and the active runtime budget as diff --git a/extensions/openai/base-url.test.ts b/extensions/openai/base-url.test.ts index 1dadb169d80d..26dafaf0ee21 100644 --- a/extensions/openai/base-url.test.ts +++ b/extensions/openai/base-url.test.ts @@ -6,6 +6,7 @@ import { isOpenAIApiBaseUrl, isOpenAICodexBaseUrl, isOpenAIHttpsApiBaseUrl, + normalizeOpenAICodexLoopbackBaseUrl, OPENAI_API_BASE_URL, OPENAI_CODEX_RESPONSES_BASE_URL, resolveOpenAIDefaultBaseUrl, @@ -86,6 +87,31 @@ describe("openai base URL helpers", () => { expect(isOpenAICodexBaseUrl(undefined)).toBe(false); }); + it("accepts only exact loopback Codex proxy base URLs", () => { + expect(normalizeOpenAICodexLoopbackBaseUrl("http://127.0.0.1:7862/backend-api/codex/")).toBe( + "http://127.0.0.1:7862/backend-api/codex", + ); + expect(normalizeOpenAICodexLoopbackBaseUrl("https://[::1]:8443/codex")).toBe( + "https://[::1]:8443/codex", + ); + for (const invalid of [ + "http://127.0.0.1", + "http://127.0.0.1/backend-api", + "http://127.0.0.1:7862/backend-api/CODEX", + "http://localhost:7862/codex", + "http://127.0.0.2:7862/codex", + "https://proxy.example.test/codex", + "ftp://127.0.0.1/codex", + "http://user@127.0.0.1/codex", + "http://127.0.0.1/codex?token=one", + "http://127.0.0.1/codex#fragment", + "not a URL", + "", + ]) { + expect(normalizeOpenAICodexLoopbackBaseUrl(invalid)).toBeUndefined(); + } + }); + it("canonicalizes legacy Codex Responses base URLs", () => { expect(canonicalizeCodexResponsesBaseUrl("https://chatgpt.com/backend-api")).toBe( OPENAI_CODEX_RESPONSES_BASE_URL, diff --git a/extensions/openai/base-url.ts b/extensions/openai/base-url.ts index a9d687c4bf4c..767a58778f11 100644 --- a/extensions/openai/base-url.ts +++ b/extensions/openai/base-url.ts @@ -86,6 +86,39 @@ export function isOpenAIHttpsApiBaseUrl(baseUrl?: string): boolean { return new URL(baseUrl.trim()).protocol === "https:"; } +/** + * Normalizes a trusted local Codex proxy base URL. + * + * Codex bearer tokens must never be redirected to a remote custom host. Accept + * only exact IPv4 or IPv6 loopback literals, with no user info, query, or hash. + */ +export function normalizeOpenAICodexLoopbackBaseUrl(baseUrl: unknown): string | undefined { + if (typeof baseUrl !== "string" || !baseUrl.trim()) { + return undefined; + } + try { + const url = new URL(baseUrl.trim()); + const hostname = url.hostname.toLowerCase(); + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + (hostname !== "127.0.0.1" && hostname !== "[::1]") || + url.username || + url.password || + url.search || + url.hash + ) { + return undefined; + } + const path = url.pathname.replace(/\/+$/u, ""); + if (!path.endsWith("/codex")) { + return undefined; + } + return `${url.origin}${path}`; + } catch { + return undefined; + } +} + export function canonicalizeCodexResponsesBaseUrl(baseUrl?: string): string | undefined { return isOpenAICodexBaseUrl(baseUrl) ? OPENAI_CODEX_RESPONSES_BASE_URL : baseUrl; } diff --git a/extensions/openai/image-generation-provider.test.ts b/extensions/openai/image-generation-provider.test.ts index 66144ceba31c..fb10e3121424 100644 --- a/extensions/openai/image-generation-provider.test.ts +++ b/extensions/openai/image-generation-provider.test.ts @@ -1295,6 +1295,43 @@ describe("openai image generation provider", () => { expect(result.images[0]?.buffer).toEqual(Buffer.from("codex-token-image")); }); + it("keeps loopback proxy capabilities out of image generation", async () => { + const authStore = createCodexTokenAuthStore(); + ensureAuthProfileStoreMock.mockReturnValue(authStore); + resolveApiKeyForProviderMock.mockResolvedValue({ + apiKey: "opaque-loopback-capability", + source: "profile:openai:token", + mode: "token", + }); + const cfg = { + models: { + providers: { + openai: { + params: { + codexProxyBaseUrl: "http://127.0.0.1:7862/backend-api/codex", + }, + }, + }, + }, + } as never; + + const provider = buildOpenAIImageGenerationProvider(); + expect(provider.isConfigured?.({ cfg, agentDir: "/tmp/agent" })).toBe(false); + await expect( + provider.generateImage({ + provider: "openai", + model: "gpt-image-2", + prompt: "Do not leak the loopback capability", + cfg, + authStore, + }), + ).rejects.toThrow( + "OpenAI image generation requires an API-key profile when a Codex credential proxy is configured", + ); + expect(postJsonRequestMock).not.toHaveBeenCalled(); + expect(postMultipartRequestMock).not.toHaveBeenCalled(); + }); + it("uses configured Codex token auth before probing an available OpenAI API key", async () => { mockCodexImageStream({ imageData: "codex-token-image" }); resolveApiKeyForProviderMock.mockImplementation(async (params?: { provider?: string }) => { diff --git a/extensions/openai/image-generation-provider.ts b/extensions/openai/image-generation-provider.ts index a3a94516c5bc..f91f3640728e 100644 --- a/extensions/openai/image-generation-provider.ts +++ b/extensions/openai/image-generation-provider.ts @@ -39,6 +39,7 @@ import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { canonicalizeCodexResponsesBaseUrl, isOpenAICodexBaseUrl, + normalizeOpenAICodexLoopbackBaseUrl, OPENAI_CODEX_RESPONSES_BASE_URL, } from "./base-url.js"; import { OPENAI_DEFAULT_IMAGE_MODEL as DEFAULT_OPENAI_IMAGE_MODEL } from "./default-models.js"; @@ -406,6 +407,12 @@ function hasExplicitDirectOpenAIImageConfig(cfg: OpenClawConfig | undefined): bo ); } +function hasOpenAICodexCredentialProxy(cfg: OpenClawConfig | undefined): boolean { + return Boolean( + normalizeOpenAICodexLoopbackBaseUrl(cfg?.models?.providers?.openai?.params?.codexProxyBaseUrl), + ); +} + function hasChatGPTImageRouteConfig(cfg: OpenClawConfig | undefined): boolean { const providerConfig = cfg?.models?.providers?.openai; return ( @@ -850,6 +857,9 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider { id: "openai", label: "OpenAI", isConfigured: ({ cfg, agentDir }) => { + if (hasOpenAICodexCredentialProxy(cfg)) { + return hasDirectOpenAIImageApiKeyAuth({ cfg, agentDir }); + } // generateImage already authenticates from a config apiKey; count a // usable one (non-blank literal or secret ref) as configured here too, // so image gen works from config alone, like chat. @@ -885,9 +895,11 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider { const codexResponsesConfigured = req.cfg?.models?.providers?.openai?.api === "openai-chatgpt-responses"; const explicitOpenAIApiKeyConfig = hasExplicitOpenAIImageApiKeyConfig(req.cfg); + const codexCredentialProxyConfigured = hasOpenAICodexCredentialProxy(req.cfg); const explicitDirectOpenAIConfig = !chatGPTBaseUrl && !codexResponsesConfigured && hasExplicitDirectOpenAIImageConfig(req.cfg); const useCodexResponseTransportRoute = + !codexCredentialProxyConfigured && (publicOpenAIBaseUrl || chatGPTBaseUrl || codexResponsesConfigured) && !explicitDirectOpenAIConfig && hasCodexResponseTransportProfileConfigured(req); @@ -934,6 +946,15 @@ export function buildOpenAIImageGenerationProvider(): ImageGenerationProvider { agentDir: req.agentDir, store: req.authStore, }); + if ( + codexCredentialProxyConfigured && + imageAuth?.apiKey && + isCodexSubscriptionAuthMode(imageAuth.mode) + ) { + throw new Error( + "OpenAI image generation requires an API-key profile when a Codex credential proxy is configured", + ); + } if ( !explicitDirectOpenAIConfig && imageAuth?.apiKey && diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index cdfbd399469e..bbc7245c3bad 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -24,7 +24,7 @@ const mocks = vi.hoisted(() => ({ async function runCatalogWithFetchGuard(params: { fetchGuard: LiveModelCatalogFetchGuard; auth: { - mode: "api_key" | "oauth"; + mode: "api_key" | "oauth" | "token"; apiKey: string; discoveryApiKey?: string; profileId?: string; @@ -32,8 +32,9 @@ async function runCatalogWithFetchGuard(params: { }; accountId?: string; baseUrl?: string; + codexProxyBaseUrl?: unknown; }): Promise { - if (params.auth.mode === "oauth") { + if (params.auth.mode === "oauth" || params.auth.mode === "token") { mocks.resolveApiKeyForProvider.mockResolvedValue({ ...params.auth, source: params.auth.source, @@ -58,9 +59,22 @@ async function runCatalogWithFetchGuard(params: { apiKey: params.auth.apiKey, discoveryApiKey: params.auth.discoveryApiKey, }), - config: params.baseUrl - ? { models: { providers: { openai: { baseUrl: params.baseUrl, models: [] } } } } - : { auth: { profiles: {} } }, + config: + params.baseUrl || params.codexProxyBaseUrl !== undefined + ? { + models: { + providers: { + openai: { + ...(params.baseUrl ? { baseUrl: params.baseUrl } : {}), + ...(params.codexProxyBaseUrl !== undefined + ? { params: { codexProxyBaseUrl: params.codexProxyBaseUrl } } + : {}), + models: [], + }, + }, + }, + } + : { auth: { profiles: {} } }, agentDir: "/tmp/openai-agent", workspaceDir: "/tmp/openai-workspace", } as never); @@ -76,29 +90,34 @@ async function runCatalogWithFetchGuard(params: { async function buildOpenAILiveProviderConfig(params: { apiKey: string; baseUrl?: string; + codexProxyBaseUrl?: unknown; fetchGuard: LiveModelCatalogFetchGuard; }): Promise { return await runCatalogWithFetchGuard({ fetchGuard: params.fetchGuard, auth: { mode: "api_key", apiKey: params.apiKey, source: "profile" }, baseUrl: params.baseUrl, + codexProxyBaseUrl: params.codexProxyBaseUrl, }); } async function buildOpenAICodexLiveProviderConfig(params: { discoveryApiKey: string; accountId?: string; + authMode?: "oauth" | "token"; + codexProxyBaseUrl?: unknown; fetchGuard: LiveModelCatalogFetchGuard; }): Promise { return await runCatalogWithFetchGuard({ fetchGuard: params.fetchGuard, auth: { - mode: "oauth", + mode: params.authMode ?? "oauth", apiKey: params.discoveryApiKey, profileId: "openai:chatgpt", source: "profile", }, accountId: params.accountId, + codexProxyBaseUrl: params.codexProxyBaseUrl, }); } @@ -947,6 +966,86 @@ describe("buildOpenAIProvider", () => { expect(release).toHaveBeenCalledOnce(); }); + it("discovers token-profile models through a loopback Codex proxy", async () => { + const proxyBaseUrl = "http://127.0.0.1:7862/backend-api/codex"; + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async (params) => ({ + response: Response.json({ + models: [ + { + slug: "gpt-5.6-sol", + display_name: "GPT-5.6 Sol", + visibility: "list", + supported_reasoning_levels: ["high", "xhigh"], + }, + ], + }), + finalUrl: params.url, + release: async () => undefined, + })); + + const provider = await buildOpenAICodexLiveProviderConfig({ + discoveryApiKey: "loopback-capability", + authMode: "token", + codexProxyBaseUrl: `${proxyBaseUrl}/`, + fetchGuard, + }); + + expect(provider).toMatchObject({ + api: "openai-chatgpt-responses", + auth: "token", + baseUrl: proxyBaseUrl, + }); + expect(provider.models).toEqual([ + expect.objectContaining({ + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + baseUrl: proxyBaseUrl, + }), + ]); + const request = vi.mocked(fetchGuard).mock.calls[0]?.[0]; + expect(request?.url).toBe( + `${proxyBaseUrl}/models?client_version=${readPinnedCodexClientVersion()}`, + ); + expect(new Headers(request?.init?.headers).get("Authorization")).toBe( + "Bearer loopback-capability", + ); + expect(new Headers(request?.init?.headers).get("ChatGPT-Account-ID")).toBeNull(); + }); + + it("rejects remote Codex proxy URLs before sending a token", async () => { + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(); + + await expect( + buildOpenAICodexLiveProviderConfig({ + discoveryApiKey: "must-not-leak", + authMode: "token", + codexProxyBaseUrl: "https://proxy.example.test/backend-api/codex", + fetchGuard, + }), + ).rejects.toThrow( + "models.providers.openai.params.codexProxyBaseUrl must be an HTTP(S) URL using 127.0.0.1 or [::1]", + ); + expect(fetchGuard).not.toHaveBeenCalled(); + }); + + it("keeps API-key model discovery on the Platform endpoint when a Codex proxy is configured", async () => { + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async (params) => ({ + response: Response.json({ data: [] }), + finalUrl: params.url, + release: async () => undefined, + })); + + const provider = await buildOpenAILiveProviderConfig({ + apiKey: "platform-api-key", + baseUrl: OPENAI_API_BASE_URL, + codexProxyBaseUrl: "http://127.0.0.1:7862/backend-api/codex", + fetchGuard, + }); + + expect(provider.baseUrl).toBe(OPENAI_API_BASE_URL); + expect(vi.mocked(fetchGuard).mock.calls[0]?.[0].url).toBe("https://api.openai.com/v1/models"); + }); + it("caps base and forward-compatible GPT-5.6 Codex catalog rows", async () => { const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async () => ({ response: Response.json({ @@ -1647,6 +1746,98 @@ describe("buildOpenAIProvider", () => { }); }); + it("preserves loopback proxy routing through dynamic Codex resolution", () => { + const provider = buildOpenAIProvider(); + const proxyBaseUrl = "http://127.0.0.1:7862/backend-api/codex"; + const config = { + models: { + providers: { + openai: { + params: { codexProxyBaseUrl: proxyBaseUrl }, + }, + }, + }, + }; + + const dynamic = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-5.6-sol", + modelRegistry: { find: () => null }, + authProfileMode: "token", + providerConfig: { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + models: [], + }, + config, + } as never); + expectFields(dynamic, { + provider: "openai", + id: "gpt-5.6-sol", + api: "openai-chatgpt-responses", + baseUrl: proxyBaseUrl, + }); + + const normalized = provider.normalizeResolvedModel?.({ + provider: "openai", + modelId: "gpt-5.6-sol", + model: { + provider: "openai", + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + }, + config, + } as never); + expect(normalized?.baseUrl).toBe(proxyBaseUrl); + + expect( + provider.normalizeTransport?.({ + provider: "openai", + modelId: "gpt-5.6-sol", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + config, + } as never), + ).toEqual({ api: "openai-chatgpt-responses", baseUrl: proxyBaseUrl }); + + expect( + provider.prepareExtraParams?.({ + provider: "openai", + modelId: "gpt-5.6-sol", + model: dynamic, + extraParams: { effort: "xhigh", transport: "auto" }, + config, + } as never), + ).toMatchObject({ effort: "xhigh", transport: "sse" }); + }); + + it("keeps loopback proxy capabilities out of remote usage endpoints", () => { + const resolveOAuthToken = vi.fn(async () => ({ token: "opaque-loopback-capability" })); + const provider = buildOpenAIProvider(); + expect( + provider.resolveUsageAuth?.({ + provider: "openai", + config: { + models: { + providers: { + openai: { + params: { + codexProxyBaseUrl: "http://127.0.0.1:7862/backend-api/codex", + }, + }, + }, + }, + }, + env: {}, + resolveApiKeyFromConfigAndStore: () => undefined, + resolveOAuthToken, + } as never), + ).toEqual({ handled: true }); + expect(resolveOAuthToken).not.toHaveBeenCalled(); + }); + it("keeps HTTP Platform routes out of Codex transport gates", () => { const provider = buildOpenAIProvider(); const baseUrl = "http://api.openai.com/v1"; diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index ed8d8c8dc416..931e90fb1739 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -28,6 +28,7 @@ import { classifyOpenAIBaseUrl, isOpenAICodexBaseUrl, isOpenAIHttpsApiBaseUrl, + normalizeOpenAICodexLoopbackBaseUrl, resolveOpenAIDefaultBaseUrl, } from "./base-url.js"; import { @@ -85,6 +86,7 @@ const OPENAI_MODELS_ENDPOINT = "https://api.openai.com/v1/models"; // the provider contract test fails when that managed-runtime pin changes. const OPENAI_CODEX_CLIENT_VERSION = "0.145.0"; const OPENAI_CODEX_MODELS_ENDPOINT = `${OPENAI_CODEX_RESPONSES_BASE_URL}/models?client_version=${OPENAI_CODEX_CLIENT_VERSION}`; +const OPENAI_CODEX_PROXY_BASE_URL_PARAM = "codexProxyBaseUrl"; const OPENAI_MODELS_CACHE_TTL_MS = 60_000; const OPENAI_CODEX_MODELS_CACHE_TTL_MS = 60_000; const OPENAI_GPT_56_DIRECT_CONTEXT_WINDOW = 1_050_000; @@ -463,7 +465,10 @@ function resolveCodexModelFallback(modelId: string): ModelDefinitionConfig | und return fallbackModel ? normalizeOpenAICodexCatalogModel(fallbackModel) : undefined; } -function buildOpenAICodexModelFromLiveRow(row: unknown): ModelDefinitionConfig | undefined { +function buildOpenAICodexModelFromLiveRow( + row: unknown, + baseUrl = OPENAI_CODEX_RESPONSES_BASE_URL, +): ModelDefinitionConfig | undefined { if (!shouldIncludeCodexModelRow(row)) { return undefined; } @@ -518,7 +523,7 @@ function buildOpenAICodexModelFromLiveRow(row: unknown): ModelDefinitionConfig | id: modelId, name: readCodexModelString(row, "display_name") ?? fallback?.name ?? modelId, api: "openai-chatgpt-responses", - baseUrl: OPENAI_CODEX_RESPONSES_BASE_URL, + baseUrl, reasoning: (reasoningLevels?.length ?? 0) > 0 || fallback?.reasoning || false, input: resolveCodexModelInput(row, fallback), cost: fallback?.cost ?? OPENAI_UNKNOWN_MODEL_COST, @@ -533,11 +538,15 @@ function buildOpenAICodexModelFromLiveRow(row: unknown): ModelDefinitionConfig | }; } -function buildOpenAICodexStaticProviderConfig(): ModelProviderConfig { +function buildOpenAICodexStaticProviderConfig(params?: { + baseUrl?: string; + auth?: "oauth" | "token"; +}): ModelProviderConfig { + const baseUrl = params?.baseUrl ?? OPENAI_CODEX_RESPONSES_BASE_URL; return { - baseUrl: OPENAI_CODEX_RESPONSES_BASE_URL, + baseUrl, api: "openai-chatgpt-responses", - auth: "oauth", + auth: params?.auth ?? "oauth", models: OPENAI_MANIFEST_PROVIDER.models.flatMap((model) => { const modelId = normalizeLowercaseStringOrEmpty(model.id); // Static OAuth rows are offline hints, not entitlement claims. Keep only @@ -546,7 +555,7 @@ function buildOpenAICodexStaticProviderConfig(): ModelProviderConfig { return []; } const normalized = normalizeOpenAICodexCatalogModel(model); - return normalized ? [normalized] : []; + return normalized ? [{ ...normalized, api: "openai-chatgpt-responses", baseUrl }] : []; }), }; } @@ -554,13 +563,19 @@ function buildOpenAICodexStaticProviderConfig(): ModelProviderConfig { async function buildOpenAICodexLiveProviderConfig(params: { discoveryApiKey: string; accountId?: string; + baseUrl?: string; + auth?: "oauth" | "token"; fetchGuard?: LiveModelCatalogFetchGuard; signal?: AbortSignal; }): Promise { + const baseUrl = params.baseUrl ?? OPENAI_CODEX_RESPONSES_BASE_URL; + const modelsEndpoint = params.baseUrl + ? `${baseUrl}/models?client_version=${OPENAI_CODEX_CLIENT_VERSION}` + : OPENAI_CODEX_MODELS_ENDPOINT; try { const rows = await getCachedLiveProviderModelRows({ providerId: PROVIDER_ID, - endpoint: OPENAI_CODEX_MODELS_ENDPOINT, + endpoint: modelsEndpoint, discoveryApiKey: params.discoveryApiKey, fetchGuard: params.fetchGuard, signal: params.signal, @@ -575,20 +590,20 @@ async function buildOpenAICodexLiveProviderConfig(params: { cacheKeyParts: [ PROVIDER_ID, "codex-model-rows", - OPENAI_CODEX_MODELS_ENDPOINT, + modelsEndpoint, params.discoveryApiKey, params.accountId ?? "", ], }); const models = rows - .map(buildOpenAICodexModelFromLiveRow) + .map((row) => buildOpenAICodexModelFromLiveRow(row, baseUrl)) .filter((model): model is ModelDefinitionConfig => Boolean(model)); // A successful account-scoped response is authoritative even when all // rows are hidden; static hints must not invent subscription access. return { - baseUrl: OPENAI_CODEX_RESPONSES_BASE_URL, + baseUrl, api: "openai-chatgpt-responses", - auth: "oauth", + auth: params.auth ?? "oauth", models, }; } catch (error) { @@ -596,15 +611,62 @@ async function buildOpenAICodexLiveProviderConfig(params: { error instanceof LiveModelCatalogHttpError && (error.status === 401 || error.status === 403) ) { - return { ...buildOpenAICodexStaticProviderConfig(), models: [] }; + return { + ...buildOpenAICodexStaticProviderConfig({ baseUrl, auth: params.auth }), + models: [], + }; } // Codex/ChatGPT discovery is advisory. Static OpenAI rows stay available // when OAuth refresh or the remote model list is unavailable. } - return buildOpenAICodexStaticProviderConfig(); + return buildOpenAICodexStaticProviderConfig({ baseUrl, auth: params.auth }); } -function isCodexCatalogAuthMode(mode: string): boolean { +class OpenAICodexProxyConfigError extends Error {} + +function resolveOpenAICodexProxyBaseUrl(config: unknown): string | undefined { + const providers = (config as { models?: { providers?: Record } } | undefined) + ?.models?.providers; + const provider = Object.entries(providers ?? {}).find( + ([providerId]) => normalizeProviderId(providerId) === PROVIDER_ID, + )?.[1] as { params?: Record } | undefined; + const configured = provider?.params?.[OPENAI_CODEX_PROXY_BASE_URL_PARAM]; + if (configured === undefined) { + return undefined; + } + const normalized = normalizeOpenAICodexLoopbackBaseUrl(configured); + if (!normalized) { + throw new OpenAICodexProxyConfigError( + `models.providers.openai.params.${OPENAI_CODEX_PROXY_BASE_URL_PARAM} must be an HTTP(S) URL using 127.0.0.1 or [::1]`, + ); + } + return normalized; +} + +function withOpenAICodexProxyRoute( + ctx: ProviderResolveDynamicModelContext, +): ProviderResolveDynamicModelContext { + const baseUrl = resolveOpenAICodexProxyBaseUrl(ctx.config); + if (!baseUrl || ctx.providerConfig?.baseUrl === baseUrl) { + return ctx; + } + return { + ...ctx, + providerConfig: ctx.providerConfig + ? { ...ctx.providerConfig, baseUrl } + : { baseUrl, models: [] }, + }; +} + +function applyOpenAICodexProxyRoute(model: T, config: unknown): T { + const baseUrl = resolveOpenAICodexProxyBaseUrl(config); + if (!baseUrl || model.baseUrl === baseUrl) { + return model; + } + return { ...model, api: "openai-chatgpt-responses", baseUrl }; +} + +function isCodexCatalogAuthMode(mode: string): mode is "oauth" | "token" { return mode === "oauth" || mode === "token"; } @@ -957,10 +1019,15 @@ export function buildOpenAIProvider(): ProviderPlugin { const provider = await buildOpenAICodexLiveProviderConfig({ discoveryApiKey: runtimeAuth.apiKey, accountId: metadata.accountId, + baseUrl: resolveOpenAICodexProxyBaseUrl(ctx.config), + auth: runtimeAuth.mode, }); return { providers: { [PROVIDER_ID]: provider } }; } - } catch { + } catch (error) { + if (error instanceof OpenAICodexProxyConfigError) { + throw error; + } // OAuth discovery is advisory; fall through so configured API-key // auth can still publish the standard OpenAI catalog. } @@ -996,7 +1063,7 @@ export function buildOpenAIProvider(): ProviderPlugin { }, resolveDynamicModel: (ctx) => shouldResolveDynamicModelThroughCodex(ctx) - ? codexHooks.resolveDynamicModel?.(ctx) + ? codexHooks.resolveDynamicModel?.(withOpenAICodexProxyRoute(ctx)) : resolveOpenAIGptForwardCompatModel(ctx), preferRuntimeResolvedModel: (ctx) => codexHooks.preferRuntimeResolvedModel?.(ctx) ?? false, normalizeResolvedModel: (ctx) => { @@ -1014,7 +1081,9 @@ export function buildOpenAIProvider(): ProviderPlugin { baseUrl: ctx.model.baseUrl, }) ) { - return codexHooks.normalizeResolvedModel?.(ctx); + const normalized = codexHooks.normalizeResolvedModel?.(ctx) ?? ctx.model; + const routed = applyOpenAICodexProxyRoute(normalized, ctx.config); + return routed === ctx.model ? undefined : routed; } return normalizeOpenAITransport(ctx.model, ctx); }, @@ -1027,7 +1096,12 @@ export function buildOpenAIProvider(): ProviderPlugin { : authoredCompletionsRoute; } if (shouldUseCodexResponsesHooks(ctx)) { - return codexHooks.normalizeTransport?.(ctx); + const normalized = codexHooks.normalizeTransport?.(ctx); + const baseUrl = resolveOpenAICodexProxyBaseUrl(ctx.config); + if (baseUrl) { + return { api: normalized?.api ?? "openai-chatgpt-responses", baseUrl }; + } + return normalized; } return shouldUseOpenAIResponsesTransport(ctx) ? { api: "openai-responses", baseUrl: ctx.baseUrl } @@ -1045,9 +1119,17 @@ export function buildOpenAIProvider(): ProviderPlugin { (normalizeProviderId(ctx.provider) === PROVIDER_ID && (!providerConfig?.baseUrl || isOpenAIHttpsApiBaseUrl(providerConfig.baseUrl)) && resolveConfiguredProviderAuthTransport(providerConfig) === "codex"); - return (useCodexTransport ? codexResponsesHooks : responsesHooks).prepareExtraParams?.(ctx); + const prepared = ( + useCodexTransport ? codexResponsesHooks : responsesHooks + ).prepareExtraParams?.(ctx); + return useCodexTransport && resolveOpenAICodexProxyBaseUrl(ctx.config) + ? { ...prepared, transport: "sse" } + : prepared; }, - resolveUsageAuth: codexHooks.resolveUsageAuth, + resolveUsageAuth: (ctx) => + resolveOpenAICodexProxyBaseUrl(ctx.config) + ? { handled: true } + : codexHooks.resolveUsageAuth?.(ctx), fetchUsageSnapshot: codexHooks.fetchUsageSnapshot, refreshOAuth: codexHooks.refreshOAuth, buildUnknownModelHint: ({ modelId }) => buildOpenAIUnknownModelHint(modelId), diff --git a/packages/ai/src/providers/openai-chatgpt-responses.proxy.test.ts b/packages/ai/src/providers/openai-chatgpt-responses.proxy.test.ts new file mode 100644 index 000000000000..459f932e07c5 --- /dev/null +++ b/packages/ai/src/providers/openai-chatgpt-responses.proxy.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Context, Model } from "../types.js"; +import { + closeOpenAICodexWebSocketSessions, + resetOpenAICodexWebSocketStateForTest, + streamOpenAICodexResponses, +} from "./openai-chatgpt-responses.js"; + +const model = { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + api: "openai-chatgpt-responses", + provider: "openai", + baseUrl: "http://127.0.0.1:7862/backend-api/codex", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 372_000, + maxTokens: 128_000, +} satisfies Model<"openai-chatgpt-responses">; + +const context = { + messages: [{ role: "user", content: "hi", timestamp: 1 }], +} satisfies Context; + +function completedSseResponse(): Response { + return new Response( + `data: ${JSON.stringify({ + type: "response.completed", + response: { + id: "resp_proxy", + status: "completed", + output: [], + usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 }, + }, + })}\n\n`, + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); +} + +describe("OpenAI ChatGPT Responses loopback proxies", () => { + afterEach(() => { + closeOpenAICodexWebSocketSessions(); + resetOpenAICodexWebSocketStateForTest(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("sends opaque capabilities only to loopback Codex proxies", async () => { + const capability = "opaque-loopback-capability"; + let requestUrl: string | undefined; + let headers: Headers | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (input, init) => { + requestUrl = String(input); + headers = new Headers(init?.headers); + return completedSseResponse(); + }), + ); + + const result = await streamOpenAICodexResponses(model, context, { + apiKey: capability, + transport: "sse", + }).result(); + + expect(result.stopReason).toBe("stop"); + expect(requestUrl).toBe("http://127.0.0.1:7862/backend-api/codex/responses"); + expect(headers?.get("authorization")).toBe(`Bearer ${capability}`); + expect(headers?.get("chatgpt-account-id")).toBeNull(); + + vi.mocked(fetch).mockClear(); + const remote = await streamOpenAICodexResponses( + { ...model, baseUrl: "https://relay.example.test/backend-api/codex" }, + context, + { apiKey: capability, transport: "sse" }, + ).result(); + expect(remote).toMatchObject({ + stopReason: "error", + errorMessage: "Failed to extract accountId from token", + }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ai/src/providers/openai-chatgpt-responses.ts b/packages/ai/src/providers/openai-chatgpt-responses.ts index 687a44e910aa..841b51ed5596 100644 --- a/packages/ai/src/providers/openai-chatgpt-responses.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses.ts @@ -290,7 +290,7 @@ export const streamOpenAICodexResponses: StreamFunction< const modelHeaders = resolveAiTransportHeaderSentinels(model.headers); const optionHeaders = resolveAiTransportHeaderSentinels(options?.headers); - const accountId = extractOpenAICodexAccountId(apiKey); + const accountId = resolveOpenAICodexRequestAccountId(apiKey, model.baseUrl); let body = buildRequestBody(model, context, options); const nextBody = await options?.onPayload?.(body, model); if (nextBody !== undefined) { @@ -1643,6 +1643,36 @@ export function extractOpenAICodexAccountId(token: string): string { throw new Error("Failed to extract accountId from token"); } +function resolveOpenAICodexRequestAccountId(token: string, baseUrl: string): string | undefined { + const accountId = resolveOpenAICodexAccountId(token); + if (accountId) { + return accountId; + } + if (isLoopbackCodexProxyBaseUrl(baseUrl)) { + return undefined; + } + throw new Error("Failed to extract accountId from token"); +} + +function isLoopbackCodexProxyBaseUrl(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + const hostname = url.hostname.toLowerCase(); + const path = url.pathname.replace(/\/+$/u, ""); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + (hostname === "127.0.0.1" || hostname === "[::1]") && + !url.username && + !url.password && + !url.search && + !url.hash && + path.endsWith("/codex") + ); + } catch { + return false; + } +} + function createCodexRequestId(): string { const crypto = globalThis.crypto; if (typeof crypto?.randomUUID === "function") { @@ -1659,7 +1689,7 @@ function createCodexRequestId(): string { function buildBaseCodexHeaders( initHeaders: Record | undefined, additionalHeaders: Record | undefined, - accountId: string, + accountId: string | undefined, token: string, ): Headers { const headers = new Headers(initHeaders); @@ -1667,7 +1697,11 @@ function buildBaseCodexHeaders( headers.set(key, value); } headers.set("Authorization", `Bearer ${token}`); - headers.set("chatgpt-account-id", accountId); + if (accountId) { + headers.set("chatgpt-account-id", accountId); + } else { + headers.delete("chatgpt-account-id"); + } headers.set("originator", "openclaw"); const userAgent = os ? `openclaw (${os.platform()} ${os.release()}; ${os.arch()})` @@ -1679,7 +1713,7 @@ function buildBaseCodexHeaders( function buildSSEHeaders( initHeaders: Record | undefined, additionalHeaders: Record | undefined, - accountId: string, + accountId: string | undefined, token: string, sessionId?: string, ): Headers { @@ -1699,7 +1733,7 @@ function buildSSEHeaders( function buildWebSocketHeaders( initHeaders: Record | undefined, additionalHeaders: Record | undefined, - accountId: string, + accountId: string | undefined, token: string, requestId: string, ): Headers {