diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 5b75a21b41..d4b6329025 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -76,6 +76,30 @@ adding a `[features]` table. Fast mode is separate from voice transport. A supported model's service-tier speed description does not guarantee lower microphone, WebRTC, or end-to-end voice latency through OpenCodex. +### ChatGPT-family channel and latency + +Native ChatGPT-family requests routed through opencodex via the canonical ChatGPT-login `openai` +forward provider (covering both Pool and Direct modes) use the public ChatGPT endpoint. Provider +routing or account selection does not bypass the upstream ChatGPT channel. The upstream may spend +time queueing a request before the first output even when the local proxy and network path are +healthy. + +Eligible streaming turns dial the ChatGPT websocket transport — the same `responses_websockets` +lane Codex CLI defaults to — and fall back to SSE over HTTP when a turn is not eligible: an +unsupported Bun runtime, an oversized `response.create` frame, or a proxy route that cannot carry +the socket. Local provider pacing can also hold a request before it is dispatched at all. So a slow +first output has several possible contributors, and upstream queueing is only one of them. `ocx +doctor` classifies configuration and measures none of these: compare actual transport, pacing, +network, and provider observations before concluding. This routing behavior is specific to +ChatGPT-login routing and does not apply to `openai-apikey` or custom providers, which connect +directly to their respective API endpoints without public ChatGPT channel queueing. + +`service_tier: priority` is a request preference. On the ChatGPT backend the echoed +`service_tier` cannot confirm or deny the granted tier: turns scheduled as priority can still +echo `default`, so request logs show the response tier as an observation with confirmation +`assumed`. For latency-sensitive work, compare observed first-output times across the providers you +actually use rather than assuming any particular channel is faster. + The proxy listens on port `10100` by default and serves `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations`, `POST /v1/images/edits`, `GET /v1/models`, `GET /healthz`, and the `/api/*` management surface. diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index d7148530a0..91be20d8d5 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -45,6 +45,8 @@ import { probeCodexCoordinatorNamespace, resolveEffectiveUserIdentity, } from "../codex/user-identity"; +import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination"; +import type { OcxProviderConfig } from "../types/provider"; import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings"; import { collectLegacyCodexConfigKeyDiagnostics, @@ -1000,6 +1002,23 @@ export function proxyDownRestartHint(input: { return `The ocx proxy is not running. ${uncleanExit}Codex/Claude clients pinned to 127.0.0.1:${input.port} fail with errors like "error sending request for url (http://127.0.0.1:${input.port}/v1/responses)". ${restart}`; } +/** Explain the expected channel and latency trade-off for native ChatGPT routing. */ +export function chatgptPublicEndpointHint( + providers: Record | undefined, +): string | null { + const openai = providers?.openai; + if (!openai || typeof openai !== "object") { + return null; + } + // Same classification the router uses: adapter + forward auth + the exact + // canonical ChatGPT-login URL. A hostname lookalike must not get this + // guidance, and a missing authMode is the runtime "key" default, not forward. + if (!isCanonicalOpenAiForwardProvider(openai as OcxProviderConfig)) { + return null; + } + return "ChatGPT-family requests use the public ChatGPT endpoint through this proxy, in both Pool and Direct modes. Eligible streaming turns dial the ChatGPT websocket transport (the same responses_websockets lane Codex CLI defaults to) and fall back to SSE over HTTP when a turn is not eligible - an unsupported Bun runtime, an oversized create frame, or a proxy route that cannot carry the socket - and local provider pacing can hold a request before it is dispatched at all. This hint classifies configuration only and measures nothing, so upstream queueing is one possible contributor to a slow first output: compare actual transport, pacing, network, and provider observations before concluding. service_tier=priority is a request preference: this backend can echo service_tier \"default\" even on turns it scheduled as priority (#2558), so the echoed response tier in request logs stays an observation with confirmation \"assumed\" and cannot confirm or deny the granted tier."; +} + export async function runDoctor(args: string[] = []): Promise { if (args.includes("--fix-codex-runtime")) { const resolved = resolveCodexRuntime(); @@ -1330,6 +1349,8 @@ export async function runDoctor(args: string[] = []): Promise { // Hints, not fixes. const hints: string[] = []; + const chatgptHint = chatgptPublicEndpointHint(doctorConfig.providers); + if (chatgptHint) hints.push(chatgptHint); const proxyDown = proxyDownRestartHint({ proxyRunning: Boolean(live), port: live?.port ?? doctorConfig.port ?? 10100, diff --git a/tests/codex-integration/doctor.test.ts b/tests/codex-integration/doctor.test.ts index acb0f1b87e..07ec5991ba 100644 --- a/tests/codex-integration/doctor.test.ts +++ b/tests/codex-integration/doctor.test.ts @@ -12,6 +12,7 @@ import { collectConfiguredProxy, collectProxyEnv, collectRunningProxyEnv, + chatgptPublicEndpointHint, collectWslDualInstall, fetchServiceMemory, formatResponseTempLines, @@ -641,6 +642,34 @@ describe("service memory section (#314 WP4)", () => { expect(hint).toContain("ocx service install"); }); + test("ChatGPT public endpoint hint explains channel latency without claiming a fixed delay", () => { + const canonical = { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }; + const hint = chatgptPublicEndpointHint({ openai: canonical }); + expect(hint).toContain("public ChatGPT endpoint"); + expect(hint).toContain("assumed"); + expect(hint).toContain("websocket"); + expect(hint).toContain("both Pool and Direct modes"); + expect(hint).not.toContain("11s"); + // The helper classifies configuration; it measures no latency. The copy has + // to stay hedged because eligible turns can still fall back to SSE and + // local pacing can delay dispatch before any upstream work starts. + expect(hint).toContain("fall back"); + expect(hint).toContain("one possible contributor"); + expect(chatgptPublicEndpointHint({})).toBeNull(); + // A missing authMode is the runtime "key" default, not the forward login. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" } })).toBeNull(); + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "key", baseUrl: "https://chatgpt.com/backend-api/codex" } })).toBeNull(); + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://api.openai.com/v1" } })).toBeNull(); + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com.example/v1" } })).toBeNull(); + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://gateway.example/chatgpt.com/v1" } })).toBeNull(); + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "not-a-valid-url" } })).toBeNull(); + // Only the exact canonical URL qualifies: no parent path, no subdomain. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api" } })).toBeNull(); + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://subdomain.chatgpt.com/v1" } })).toBeNull(); + // Trailing slashes still normalize to the canonical URL. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex/" } })).not.toBeNull(); + }); + test("proxyDownRestartHint prefers 'ocx service start' when a service is installed", () => { const hint = proxyDownRestartHint({ proxyRunning: false, port: 12000, serviceViable: true }); expect(hint).toContain("ocx service start"); @@ -957,4 +986,20 @@ describe("doctor reports an unclean prior proxy exit", () => { expect(logged.join("\n")).not.toContain("may have exited unexpectedly"); }); + + test("runDoctor outputs ChatGPT public endpoint hint when the canonical openai provider is configured", async () => { + const { writeFileSync } = await import("fs"); + const { join } = await import("path"); + writeFileSync( + join(tempHome, "config.json"), + JSON.stringify({ port: 9, codexAutoStart: false, providers: { openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" } } }), + "utf8", + ); + + await runDoctor([]); + + const output = logged.join("\n"); + expect(output).toContain("public ChatGPT endpoint"); + expect(output).toContain("assumed"); + }); });