mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
revert(openai): remove loopback Codex proxy support (#114616)
Reverting PR from runaway clanker, sorry! Won't happen again!
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user