Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> | 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<void> {
if (args.includes("--fix-codex-runtime")) {
const resolved = resolveCodexRuntime();
Expand Down Expand Up @@ -1330,6 +1368,8 @@ export async function runDoctor(args: string[] = []): Promise<void> {

// 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,
Expand Down
53 changes: 53 additions & 0 deletions tests/codex-integration/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
collectConfiguredProxy,
collectProxyEnv,
collectRunningProxyEnv,
chatgptPublicEndpointHint,
collectWslDualInstall,
fetchServiceMemory,
formatResponseTempLines,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
});
});
Loading