diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 5b75a21b41..19bd5e4a1a 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -76,6 +76,42 @@ 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 + +Requests routed through opencodex via the canonical ChatGPT-login `openai` provider — adapter +`openai-responses`, `authMode: "forward"`, and the `https://chatgpt.com/backend-api/codex` +endpoint, 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. + +Only some turns take the ChatGPT websocket transport — the same `responses_websockets` lane Codex +CLI defaults to. A turn is eligible when the Bun runtime supports the bounded relay, the request +is a `POST` to the canonical Responses URL or a configured WebSocket route, and its JSON body sets +`stream` to `true` at the root. Everything else stays on SSE over HTTP, and an eligible turn still +falls back to it when the request cannot be prepared, the `response.create` frame exceeds its size +limit, or the proxy route 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. + +What decides whether a request takes that public channel is the destination it resolves to, not +the name of the provider entry. A provider that resolves somewhere else — `openai-apikey`, or a +custom entry pointing at its own API — reaches that endpoint directly and sees no ChatGPT queueing. +A custom-named entry that resolves to `https://chatgpt.com/backend-api/codex` with forward auth +takes the same public channel as the built-in row, because the classification reads the adapter, +auth mode and destination rather than the entry's name. + +The `ocx doctor` hint is narrower than the endpoint behavior it describes: it inspects only the +built-in `openai` row, so its absence tells you nothing about where any other provider resolves. + +`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..a769252620 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -45,6 +45,9 @@ import { probeCodexCoordinatorNamespace, resolveEffectiveUserIdentity, } from "../codex/user-identity"; +import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination"; +import type { OcxProviderConfig } from "../types/provider"; +import { routedProviderConfig } from "../router"; import { collectProjectCodexConfigWarnings, formatProjectCodexConfigWarningsForDoctor } from "../codex/project-config-warnings"; import { collectLegacyCodexConfigKeyDiagnostics, @@ -1000,6 +1003,41 @@ 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; + } + // A disabled row never routes, so it must not be described as the route in use. + const configured = openai as OcxProviderConfig; + if (configured.disabled === true) { + return null; + } + // Classify the destination the router resolves, not the raw config text. Two things follow + // from the registry entry for the built-in `openai` id: a row that omits `authMode` still + // forwards, and a row carrying some other `baseUrl` has it discarded in favour of the + // canonical ChatGPT endpoint. Both keep using the public endpoint, so both want this hint; + // reading the raw row would have suppressed the first and misjudged the second. + // + // `routedProviderConfig` throws for an unresolved URL only when the registry entry allows a + // baseUrl override, which this entry does not, so no input reaches that path today. The guard + // is here because doctor is read-only diagnostics: a later registry change must not turn a + // diagnostic into a crash. + let routed: OcxProviderConfig; + try { + routed = routedProviderConfig("openai", configured); + } catch { + return null; + } + if (!isCanonicalOpenAiForwardProvider(routed)) { + 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 +1368,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..3f52ea4e45 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,42 @@ 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(); + // Resolution, not raw text. The registry entry for the built-in `openai` id has + // authKind "forward", so a row that omits `authMode` still forwards to ChatGPT and still + // needs the hint. Reading the raw row suppressed it. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex" } })).not.toBeNull(); + // Same reason the other way round: the entry is not key-auth-overridable, so writing + // `authMode: "key"` on this id does not change where requests go. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "key", baseUrl: "https://chatgpt.com/backend-api/codex" } })).not.toBeNull(); + // The entry sets no baseUrl override, so a differing URL is discarded and the request + // still goes to the canonical endpoint. Describing that route is correct, and a lookalike + // host never becomes the destination. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com.example/v1" } })).not.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(); + // A disabled row never routes, so it is not the route in use. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", disabled: true } })).toBeNull(); + // A blank baseUrl is discarded like any other override on this id, so it resolves to the + // canonical endpoint and still gets the hint. Resolution has no reachable throw here: + // src/router.ts only rejects an unresolved URL when the registry entry allows a baseUrl + // override, and the `openai` entry does not. + expect(chatgptPublicEndpointHint({ openai: { adapter: "openai-responses", authMode: "forward", baseUrl: " " } })).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 +994,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"); + }); });