revert(openai): remove loopback Codex proxy support (#114616)

Reverting PR from runaway clanker, sorry! Won't happen again!
This commit is contained in:
Onur Solmaz
2026-07-27 23:15:42 +08:00
committed by GitHub
parent d2dc61cddf
commit 89c5b02832
9 changed files with 31 additions and 577 deletions
-38
View File
@@ -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
`<codexProxyBaseUrl>/models`, and Codex Responses models use
`<codexProxyBaseUrl>/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
-26
View File
@@ -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,
-33
View File
@@ -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;
}
@@ -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 }) => {
@@ -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 &&
+6 -197
View File
@@ -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<ModelProviderConfig> {
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<ModelProviderConfig> {
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<ModelProviderConfig> {
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";
+20 -102
View File
@@ -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<ModelProviderConfig> {
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<string, unknown> } } | undefined)
?.models?.providers;
const provider = Object.entries(providers ?? {}).find(
([providerId]) => normalizeProviderId(providerId) === PROVIDER_ID,
)?.[1] as { params?: Record<string, unknown> } | 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<T extends ProviderRuntimeModel>(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),
@@ -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();
});
});
@@ -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<string, string> | undefined,
additionalHeaders: Record<string, string> | 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<string, string> | undefined,
additionalHeaders: Record<string, string> | undefined,
accountId: string | undefined,
accountId: string,
token: string,
sessionId?: string,
): Headers {
@@ -1733,7 +1699,7 @@ function buildSSEHeaders(
function buildWebSocketHeaders(
initHeaders: Record<string, string> | undefined,
additionalHeaders: Record<string, string> | undefined,
accountId: string | undefined,
accountId: string,
token: string,
requestId: string,
): Headers {