diff --git a/docs/providers/openai.md b/docs/providers/openai.md index be15c9d97e72..e73a53452831 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -473,44 +473,6 @@ 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 26dafaf0ee21..1dadb169d80d 100644 --- a/extensions/openai/base-url.test.ts +++ b/extensions/openai/base-url.test.ts @@ -6,7 +6,6 @@ import { isOpenAIApiBaseUrl, isOpenAICodexBaseUrl, isOpenAIHttpsApiBaseUrl, - normalizeOpenAICodexLoopbackBaseUrl, OPENAI_API_BASE_URL, OPENAI_CODEX_RESPONSES_BASE_URL, resolveOpenAIDefaultBaseUrl, @@ -87,31 +86,6 @@ 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 767a58778f11..a9d687c4bf4c 100644 --- a/extensions/openai/base-url.ts +++ b/extensions/openai/base-url.ts @@ -86,39 +86,6 @@ 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 fb10e3121424..66144ceba31c 100644 --- a/extensions/openai/image-generation-provider.test.ts +++ b/extensions/openai/image-generation-provider.test.ts @@ -1295,43 +1295,6 @@ 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 f91f3640728e..a3a94516c5bc 100644 --- a/extensions/openai/image-generation-provider.ts +++ b/extensions/openai/image-generation-provider.ts @@ -39,7 +39,6 @@ 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"; @@ -407,12 +406,6 @@ 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 ( @@ -857,9 +850,6 @@ 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. @@ -895,11 +885,9 @@ 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); @@ -946,15 +934,6 @@ 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 bbc7245c3bad..cdfbd399469e 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" | "token"; + mode: "api_key" | "oauth"; apiKey: string; discoveryApiKey?: string; profileId?: string; @@ -32,9 +32,8 @@ async function runCatalogWithFetchGuard(params: { }; accountId?: string; baseUrl?: string; - codexProxyBaseUrl?: unknown; }): Promise { - if (params.auth.mode === "oauth" || params.auth.mode === "token") { + if (params.auth.mode === "oauth") { mocks.resolveApiKeyForProvider.mockResolvedValue({ ...params.auth, source: params.auth.source, @@ -59,22 +58,9 @@ async function runCatalogWithFetchGuard(params: { apiKey: params.auth.apiKey, discoveryApiKey: params.auth.discoveryApiKey, }), - config: - params.baseUrl || params.codexProxyBaseUrl !== undefined - ? { - models: { - providers: { - openai: { - ...(params.baseUrl ? { baseUrl: params.baseUrl } : {}), - ...(params.codexProxyBaseUrl !== undefined - ? { params: { codexProxyBaseUrl: params.codexProxyBaseUrl } } - : {}), - models: [], - }, - }, - }, - } - : { auth: { profiles: {} } }, + config: params.baseUrl + ? { models: { providers: { openai: { baseUrl: params.baseUrl, models: [] } } } } + : { auth: { profiles: {} } }, agentDir: "/tmp/openai-agent", workspaceDir: "/tmp/openai-workspace", } as never); @@ -90,34 +76,29 @@ 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: params.authMode ?? "oauth", + mode: "oauth", apiKey: params.discoveryApiKey, profileId: "openai:chatgpt", source: "profile", }, accountId: params.accountId, - codexProxyBaseUrl: params.codexProxyBaseUrl, }); } @@ -966,86 +947,6 @@ 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({ @@ -1746,98 +1647,6 @@ 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 931e90fb1739..ed8d8c8dc416 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -28,7 +28,6 @@ import { classifyOpenAIBaseUrl, isOpenAICodexBaseUrl, isOpenAIHttpsApiBaseUrl, - normalizeOpenAICodexLoopbackBaseUrl, resolveOpenAIDefaultBaseUrl, } from "./base-url.js"; import { @@ -86,7 +85,6 @@ 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; @@ -465,10 +463,7 @@ function resolveCodexModelFallback(modelId: string): ModelDefinitionConfig | und return fallbackModel ? normalizeOpenAICodexCatalogModel(fallbackModel) : undefined; } -function buildOpenAICodexModelFromLiveRow( - row: unknown, - baseUrl = OPENAI_CODEX_RESPONSES_BASE_URL, -): ModelDefinitionConfig | undefined { +function buildOpenAICodexModelFromLiveRow(row: unknown): ModelDefinitionConfig | undefined { if (!shouldIncludeCodexModelRow(row)) { return undefined; } @@ -523,7 +518,7 @@ function buildOpenAICodexModelFromLiveRow( id: modelId, name: readCodexModelString(row, "display_name") ?? fallback?.name ?? modelId, api: "openai-chatgpt-responses", - baseUrl, + baseUrl: OPENAI_CODEX_RESPONSES_BASE_URL, reasoning: (reasoningLevels?.length ?? 0) > 0 || fallback?.reasoning || false, input: resolveCodexModelInput(row, fallback), cost: fallback?.cost ?? OPENAI_UNKNOWN_MODEL_COST, @@ -538,15 +533,11 @@ function buildOpenAICodexModelFromLiveRow( }; } -function buildOpenAICodexStaticProviderConfig(params?: { - baseUrl?: string; - auth?: "oauth" | "token"; -}): ModelProviderConfig { - const baseUrl = params?.baseUrl ?? OPENAI_CODEX_RESPONSES_BASE_URL; +function buildOpenAICodexStaticProviderConfig(): ModelProviderConfig { return { - baseUrl, + baseUrl: OPENAI_CODEX_RESPONSES_BASE_URL, api: "openai-chatgpt-responses", - auth: params?.auth ?? "oauth", + 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 @@ -555,7 +546,7 @@ function buildOpenAICodexStaticProviderConfig(params?: { return []; } const normalized = normalizeOpenAICodexCatalogModel(model); - return normalized ? [{ ...normalized, api: "openai-chatgpt-responses", baseUrl }] : []; + return normalized ? [normalized] : []; }), }; } @@ -563,19 +554,13 @@ function buildOpenAICodexStaticProviderConfig(params?: { 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: modelsEndpoint, + endpoint: OPENAI_CODEX_MODELS_ENDPOINT, discoveryApiKey: params.discoveryApiKey, fetchGuard: params.fetchGuard, signal: params.signal, @@ -590,20 +575,20 @@ async function buildOpenAICodexLiveProviderConfig(params: { cacheKeyParts: [ PROVIDER_ID, "codex-model-rows", - modelsEndpoint, + OPENAI_CODEX_MODELS_ENDPOINT, params.discoveryApiKey, params.accountId ?? "", ], }); const models = rows - .map((row) => buildOpenAICodexModelFromLiveRow(row, baseUrl)) + .map(buildOpenAICodexModelFromLiveRow) .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, + baseUrl: OPENAI_CODEX_RESPONSES_BASE_URL, api: "openai-chatgpt-responses", - auth: params.auth ?? "oauth", + auth: "oauth", models, }; } catch (error) { @@ -611,62 +596,15 @@ async function buildOpenAICodexLiveProviderConfig(params: { error instanceof LiveModelCatalogHttpError && (error.status === 401 || error.status === 403) ) { - return { - ...buildOpenAICodexStaticProviderConfig({ baseUrl, auth: params.auth }), - models: [], - }; + return { ...buildOpenAICodexStaticProviderConfig(), models: [] }; } // Codex/ChatGPT discovery is advisory. Static OpenAI rows stay available // when OAuth refresh or the remote model list is unavailable. } - return buildOpenAICodexStaticProviderConfig({ baseUrl, auth: params.auth }); + return buildOpenAICodexStaticProviderConfig(); } -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" { +function isCodexCatalogAuthMode(mode: string): boolean { return mode === "oauth" || mode === "token"; } @@ -1019,15 +957,10 @@ 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 (error) { - if (error instanceof OpenAICodexProxyConfigError) { - throw error; - } + } catch { // OAuth discovery is advisory; fall through so configured API-key // auth can still publish the standard OpenAI catalog. } @@ -1063,7 +996,7 @@ export function buildOpenAIProvider(): ProviderPlugin { }, resolveDynamicModel: (ctx) => shouldResolveDynamicModelThroughCodex(ctx) - ? codexHooks.resolveDynamicModel?.(withOpenAICodexProxyRoute(ctx)) + ? codexHooks.resolveDynamicModel?.(ctx) : resolveOpenAIGptForwardCompatModel(ctx), preferRuntimeResolvedModel: (ctx) => codexHooks.preferRuntimeResolvedModel?.(ctx) ?? false, normalizeResolvedModel: (ctx) => { @@ -1081,9 +1014,7 @@ export function buildOpenAIProvider(): ProviderPlugin { baseUrl: ctx.model.baseUrl, }) ) { - const normalized = codexHooks.normalizeResolvedModel?.(ctx) ?? ctx.model; - const routed = applyOpenAICodexProxyRoute(normalized, ctx.config); - return routed === ctx.model ? undefined : routed; + return codexHooks.normalizeResolvedModel?.(ctx); } return normalizeOpenAITransport(ctx.model, ctx); }, @@ -1096,12 +1027,7 @@ export function buildOpenAIProvider(): ProviderPlugin { : authoredCompletionsRoute; } if (shouldUseCodexResponsesHooks(ctx)) { - const normalized = codexHooks.normalizeTransport?.(ctx); - const baseUrl = resolveOpenAICodexProxyBaseUrl(ctx.config); - if (baseUrl) { - return { api: normalized?.api ?? "openai-chatgpt-responses", baseUrl }; - } - return normalized; + return codexHooks.normalizeTransport?.(ctx); } return shouldUseOpenAIResponsesTransport(ctx) ? { api: "openai-responses", baseUrl: ctx.baseUrl } @@ -1119,17 +1045,9 @@ export function buildOpenAIProvider(): ProviderPlugin { (normalizeProviderId(ctx.provider) === PROVIDER_ID && (!providerConfig?.baseUrl || isOpenAIHttpsApiBaseUrl(providerConfig.baseUrl)) && resolveConfiguredProviderAuthTransport(providerConfig) === "codex"); - const prepared = ( - useCodexTransport ? codexResponsesHooks : responsesHooks - ).prepareExtraParams?.(ctx); - return useCodexTransport && resolveOpenAICodexProxyBaseUrl(ctx.config) - ? { ...prepared, transport: "sse" } - : prepared; + return (useCodexTransport ? codexResponsesHooks : responsesHooks).prepareExtraParams?.(ctx); }, - resolveUsageAuth: (ctx) => - resolveOpenAICodexProxyBaseUrl(ctx.config) - ? { handled: true } - : codexHooks.resolveUsageAuth?.(ctx), + resolveUsageAuth: codexHooks.resolveUsageAuth, 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 deleted file mode 100644 index 459f932e07c5..000000000000 --- a/packages/ai/src/providers/openai-chatgpt-responses.proxy.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 841b51ed5596..687a44e910aa 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 = resolveOpenAICodexRequestAccountId(apiKey, model.baseUrl); + const accountId = extractOpenAICodexAccountId(apiKey); let body = buildRequestBody(model, context, options); const nextBody = await options?.onPayload?.(body, model); if (nextBody !== undefined) { @@ -1643,36 +1643,6 @@ 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") { @@ -1689,7 +1659,7 @@ function createCodexRequestId(): string { function buildBaseCodexHeaders( initHeaders: Record | undefined, additionalHeaders: Record | undefined, - accountId: string | undefined, + accountId: string, token: string, ): Headers { const headers = new Headers(initHeaders); @@ -1697,11 +1667,7 @@ function buildBaseCodexHeaders( headers.set(key, value); } headers.set("Authorization", `Bearer ${token}`); - if (accountId) { - headers.set("chatgpt-account-id", accountId); - } else { - headers.delete("chatgpt-account-id"); - } + headers.set("chatgpt-account-id", accountId); headers.set("originator", "openclaw"); const userAgent = os ? `openclaw (${os.platform()} ${os.release()}; ${os.arch()})` @@ -1713,7 +1679,7 @@ function buildBaseCodexHeaders( function buildSSEHeaders( initHeaders: Record | undefined, additionalHeaders: Record | undefined, - accountId: string | undefined, + accountId: string, token: string, sessionId?: string, ): Headers { @@ -1733,7 +1699,7 @@ function buildSSEHeaders( function buildWebSocketHeaders( initHeaders: Record | undefined, additionalHeaders: Record | undefined, - accountId: string | undefined, + accountId: string, token: string, requestId: string, ): Headers {