Skip to content

feat(providers): clean-room ChatGPT Web session provider (chatgpt-session) - #12464

Closed
diegosouzapw wants to merge 23 commits into
release/v3.8.51from
feat/chatgpt-session-provider
Closed

feat(providers): clean-room ChatGPT Web session provider (chatgpt-session)#12464
diegosouzapw wants to merge 23 commits into
release/v3.8.51from
feat/chatgpt-session-provider

Conversation

@diegosouzapw

@diegosouzapw diegosouzapw commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Summary

Adds chatgpt-session (alias cgpt-session), a clean-room provider that serves ordinary
/v1/chat/completions requests from an authenticated ChatGPT browser session.

This restores the capability removed with the common chatgpt-web provider in #11754 without
restoring any of its code. The retired ids chatgpt-web / cgpt-web stay retired: migration
168, the fail-closed 410 PROVIDER_RETIRED guards and every retirement test are untouched, and
a test here asserts the new ids are not the retired ones.

The browser interaction reuses the already-vendored MIT implementation under
open-sse/vendor/codex-chatgpt-web/. No proof-of-work, sentinel or TLS-impersonation code was
written
— a real signed-in browser performs those steps. The unclearable provenance of the old
pre-key/proof-of-work implementation is what forced the retirement, so this design removes that
surface rather than recreating it.

Clean-room provenance

Two isolated tracks, no shared context. One agent was authorised to read the retired code and
produced a behaviour-only specification — no code, no symbol names, no algorithms, with 14 topics
deliberately omitted as implementation detail; it was audited for leakage before release. Separate
agents implemented from that specification plus live repository code only, each carrying a verbatim
prohibition on reading the history of any path deleted by 9c5dd676 / 7d57d9f4.

The GPL-3.0 Chat2API mirror in the local references tree was forbidden and never opened.
THIRD_PARTY_NOTICES.md is unchanged — no new third-party code enters. Specs live in the private
_tasks repo at superpowers/specs/2026-09-02-chatgpt-session-{design,behavior}.md.

Feasibility spike — run, and it found a blocker

The design rested on an unproven assumption: that the vendored adapter would accept a synthetic
request without a Codex thread/turn envelope. That assumption was wrong, and the spike proved
it against the real adapter:

Error: ChatGPT web requires native Codex turn_id metadata for browser-turn retry budgeting

As originally written this provider would have failed on every single request. No unit test
could catch it, because they all mock the adapter call — which is exactly why the spike existed.

The documented fallback (dropping to the raw browser worker) turned out to be unnecessary.
Synthesizing a minimal Codex turn envelope is enough, and buildParsedRequest now does it:
turn/thread ids in client_metadata, a Responses-shaped input array built from the same
messages, and the current-turn passthrough id on the last user item. The executor no longer hands
the adapter the OpenAI body as _rawBody, and that parameter was removed so the wrong call is
unrepresentable.

Verified through the production code path, not just unit tests: the adapter now reaches
stage=browser_page and fails only on ChatGPT web login state is missing, the expected
authentication failure for an unauthenticated session.

The spike also empirically confirmed the commentary-banner defect that had been found by
reading the vendor source. The captured stream was:

heartbeat, assistant_boundary,
text_delta[phase=commentary] "⚠️ ChatGPT Web Instant cannot access the local Codex computer…",
assistant_boundary

That banner would have prefixed every answer. The bridge now drops commentary-phase deltas.

What changed

  • Executor plus six focused modules: model routes, message translation and envelope synthesis, an
    event→OpenAI response bridge, error classification, and a runtime seam that makes every side
    effect injectable so no unit test launches a browser.
  • Registry entry, two lazy executor aliases, regenerated goldens, reserved-prefix ratchet.
  • Dashboard card, credential descriptor, icon, and a connection validator.
  • chatgpt-session shares the browser-session credential lifecycle with chatgpt-web-codex
    behind one predicate, so a pasted cookie is replaced by verified session state and the temporary
    validation directory is cleaned up. Client and server use the same predicate so they cannot drift.
  • Provider guide docs/providers/CHATGPT_SESSION.md; provider count 352 → 353.
  • OMNIROUTE_CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS (default 30000) bounds how long the response
    is withheld while the two paths' failover status is decided.

Seven routes (luna, think, instant, medium, high, extra-high, pro), each pinning one
backend model and one reasoning effort; a route the account does not expose fails closed. Tool
calling is prompt-emulated, the same contract perplexity-web and gemini-web use.

Other defects found by review and fixed here

  • The dashboard could never create or update a connection: the modals' credential encoding was
    still keyed to chatgpt-web-codex while the API routes had been widened, so every save hit a
    JSON.parse on a raw cookie and returned 400.
  • Streaming and buffered paths disagreed on failover status when a turn died after reasoning-only
    output.
  • A 503 carried by an adapter error event tripped the whole-provider circuit breaker for want of a
    cooldown hint.
  • Unrecognised content parts were silently dropped, so the model could answer about content it
    never received. An assistant refusal part is now folded in as text rather than rejected.

One deliberate contract change. In classifyChatGptSessionError, an explicit numeric status
now outranks message-pattern matching — the vendor documents this on the field itself
("Authoritative upstream/proxy status when known; avoids message-based classification"). The old
order caused real harm: an error carrying 503 whose prose said "please sign in" was classified 401,
marking credentials expired and pulling a healthy account out of rotation. One test inverts as a
result; it is renamed and carries a comment recording the change, so it cannot be mistaken for a
test weakened to reach green.

Still owed before merge

Live turn validation (Hard Rule #18). The environment is now prepared — Chromium was already
cached and Xvfb has been installed — so only a ChatGPT cookie is missing. A runbook with the exact
commands, and the three things to check beyond "it answered", is in the branch working notes.

Two behaviours remain unproven until then: that the commentary banner really is suppressed end to
end, and that a tool turn is answered with a tool call rather than a refusal about local-tool
access. The adapter wraps every turn in Codex task framing, which competes with the prompt-emulated
tool contract; this is recorded in the provider guide as unconfirmed.

Base drift

The base has advanced 55 commits since this branch forked and now reports 355 providers. This PR
bumps to 353. A base merge is needed before landing, and the count files (README, AGENTS.md,
llm.txt plus its 42 mirrors, several SVGs) will conflict mechanically. The new
docs/reference/REMOVED_PROVIDERS.md blocklist was checked: no ChatGPT entry, so it does not block
this provider.

Validation

Gate Result
npm run lint PASS
npm run typecheck:core PASS
npm run check:dashboard-typecheck PASS — 207 pre-existing, within frozen baseline
npm run check:provider-consistency PASS
npm run check:cycles PASS
npm run check:docs-all PASS
npm run test:vitest Inherited red — see below
npm run test:unit One real failure, fixed; remainder inherited or load artifacts

Focused suites: 130 passing across the eight chatgpt-session suites, the new provider-route
test and the reserved-prefix ratchet, plus all four chatgpt-web retirement guards, untouched.

test:vitest is red on the base, not from this branch: the identical command on release/v3.8.51
fails 17 tests across 10 files, this branch fails 16 across 9. The failures include
tests/unit/encryption.spec.ts and an MCP audit database-shutdown test, neither of which can be
affected by registering a provider. No "Release branch not green" issue is open, so this red
appears untracked.

test:unit triage: one failure was genuinely this branch's — the RESERVED_PREFIX_COUNT ratchet
moved 400 → 402 because the new id and alias are two members — and it is fixed with membership
assertions added. Everything else passes in isolation or fails identically on the base.

…ed SSE error chunks

- Route mid-stream error events through formatTranslatedStreamError instead of
  silently closing with finish_reason:stop, so a truncated turn is distinguishable
  from a completed one (AGENTS.md Hard Rule #6).
- Sanitize the message on the pre-stream error verdict as defense in depth.
- Cover the previously-untested gating paths: terminal event as the first
  meaningful event, source ending without a terminal event, empty source,
  heartbeat-only source, and incomplete with endTurn:true.
…and guarantee queue close

Keep the bridge's status/code authoritative on the streaming error branch instead of
re-deriving them from the message alone, which downgraded a name-classified 400 into a
breaker-tripping 502. Also guarantee events.close() runs even when the storage-state
persist path or a caller-supplied logger throws, so neither consumer can hang.
…ion credential lifecycle

Both /api/providers routes gated the raw-cookie -> verified-storage-state
finalize step on a hardcoded chatgpt-web-codex check, so chatgpt-session
connections never had their pasted cookie replaced, never released the
temporary validation directory, and leaked validationId into persisted
providerSpecificData. Introduce usesChatGptBrowserSessionCredentials() as
the single source of truth for both provider ids and use it in place of
the hardcoded equality checks. Also replace a brittle source-regex test
with one that proves the validation dispatch actually resolves.
…rovider

The chatgpt-session provider adds two distinct reserved prefixes (id
chatgpt-session, alias cgpt-session), moving the shared reserved-prefix
set from 400 to 402 members. Adds explicit membership assertions for
both new prefixes alongside the count.
Comment on lines +91 to +96
JSON.stringify(
buildErrorBody(status, sanitizeErrorMessage(message), undefined, {
type: status >= 500 ? "provider_error" : "invalid_request_error",
code,
})
),
…ailover

The vendored adapter emits its "local Codex computer is unavailable" warning as a
commentary-phase text_delta on every fresh turn, because this provider pins
localToolsEnabled: false. The bridge treated every text_delta alike and let
assistant_boundary fall through to `default`, so 100% of answers were prefixed with the
banner. Commentary-phase deltas are now dropped on both the streaming and the buffered
path (not rerouted to reasoning_content — it is transport chatter, not model reasoning),
and assistant_boundary gets an explicit ignoring case.

Same commit aligns the streaming gate with the buffered failover: the stream no longer
commits a 200 on a heartbeat, an assistant boundary, a commentary delta, an empty text
delta or a thinking delta, so `thinking_delta` followed by `error{429}` now returns HTTP
429 on both paths instead of a 200 stream carrying an in-band error. Reasoning that
arrives while the gate is closed is buffered and replayed once the gate opens.
CONTRACT CHANGE. classifyChatGptSessionError put message-pattern matching ahead of an
explicit numeric status, so an adapter error carrying status 503 whose prose mentioned
signing in was classified 401 session_expired — marking a healthy account's credentials
expired and pulling it out of rotation. The vendor documents the field as "Authoritative
upstream/proxy status when known; avoids message-based classification"
(vendor/codex-chatgpt-web/types.ts).

Explicit status now runs third (after ChatGptSessionInputError and TimeoutError, both of
which are stronger signals), uses the event's own code when present, and attaches
fallbackHint: "connection_cooldown" for 503/429 so a genuine outage cools one connection
instead of tripping the whole-provider breaker. Message matching is unchanged and still
essential — errors thrown by the executor itself carry no status.

ChatGptSessionStreamOpen's error arm now carries fallbackHint, so the executor uses the
bridge's classification directly instead of re-classifying the sanitized message.

The test "a matching message wins over an explicit upstream status" is deliberately
inverted and renamed; its comment records why.
…edicate

Both connection modals wrapped the pasted Cookie header in the {version, cookie,
runtimeKey} envelope only when provider === "chatgpt-web-codex", but the create and
update routes send every provider accepted by usesChatGptBrowserSessionCredentials()
through finalizeValidatedChatGptWebCodexSecrets, whose first statement is JSON.parse. A
chatgpt-session save therefore always failed with 400 "Unexpected token '_',
"__Secure-n"... is not valid JSON" — the dashboard could never create or update a
connection.

Both modals now key the envelope off the same predicate the routes use, so client and
server cannot drift again, and omit runtimeKey when empty (this provider never has one).

The two failure paths returned error.message raw, echoing the first characters of the
pasted credential; they now go through sanitizeErrorMessage, and their untranslated
German fallback is replaced with provider-neutral English.
… dropping them

textFromContent threw for image parts but silently skipped everything else, so a `file`
or `input_audio` part vanished and the model answered about content it never received.
Any part that is neither a text part nor an already-handled image part now throws
ChatGptSessionInputError with code "unsupported_content_part" (a terminal 400).
…pt-session

Turns are relayed through the vendored adapter's task-framing prompt, which API clients
never see; note that a tool turn may be answered with a local-tool refusal instead of a
tool block, explicitly flagged as unconfirmed pending live validation.

The markdown tables are reflowed by the lint-staged Prettier hook.
A `{ type: "refusal", refusal: "…" }` part is legal inside an assistant
message in the chat-completions spec, so a client replaying its own
conversation history sends it back. The unsupported-part guard rejected it
with a 400 and failed the whole request.

Fold a refusal part into the flattened content the way a text part is folded,
reading its `refusal` field. A refusal part whose payload is missing or is not
a string still falls through to the existing rejection — it is never dropped
in silence — and every other unrecognised part keeps throwing as before.
The gate withholds the HTTP response until the first event that also counts
as committed output on the buffered path, so both paths agree on failover
status. While it is closed nothing reaches the client — not even a keepalive,
since the first byte commits the 200 — and a turn here runs in a real browser
that can think for a long time, so a client with an idle timeout could hang up
on a perfectly healthy turn.

Race the gate against CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS (30s, overridable
per call through the new options argument). A committing event or an error
still wins exactly as before; only when the deadline elapses with neither does
the stream open anyway and keep consuming the same iterator, replaying the
buffered reasoning after the role chunk so nothing is lost or reordered. The
in-flight next() the deadline outran is carried into the stream body instead of
being abandoned, otherwise its event would vanish behind a second next(). The
timer is unref'd and cleared on every exit path.

Every failure that needs a real HTTP status — no browser, missing or expired
credentials, rate limiting, an incompatible route — surfaces within seconds,
far inside the window.
Add OMNIROUTE_CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS to override the
30s CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS default, mirroring
resolveDirectHeadersTimeoutMs's validation. Resolved per request in
the executor so a changed env var takes effect without a restart.
The executor passed the OpenAI chat-completions body through as `_rawBody`, but the
vendored browser adapter reads `_rawBody` as a native Codex Responses body and demands
turn identity from it. Every request therefore failed with "ChatGPT web requires native
Codex turn_id metadata for browser-session replay" before any browser work started; no
unit test caught it because they all mock the adapter call.

`buildParsedRequest` now builds the envelope itself: a fresh thread/turn id pair per
request (this provider serves stateless chat completions, so each request genuinely is
its own turn), the turn metadata as the JSON string the real client sends, an `input`
array mirroring the parsed messages in Responses item shape, and the current-turn
passthrough marker on the last user item only. The `rawBody` parameter is gone so no
caller can reintroduce the passthrough.

Verified against the real adapter through the production code path: the turn now reaches
stage=browser_page and fails only on the missing login state.
@diegosouzapw

Copy link
Copy Markdown
Owner Author

Superseded by #12239, which landed on release/v3.8.51 today and restores ChatGPT Web via its own clean-room browser transport. That PR reclaims the canonical chatgpt-web id and the retirement set has shrunk to just cgpt-web.

The two efforts were independent clean-room restorations of the same capability, running in parallel. #12239 merged first, so this one is being closed rather than merged — shipping both would put two ChatGPT Web providers in the catalogue.

Recorded for whoever picks this up later, since these three pieces exist here and not in #12239:

  1. The vendored adapter rejects a request without a Codex turn envelope. Proven against the real adapter: ChatGPT web requires native Codex turn_id metadata for browser-turn retry budgeting. Any browser-transport provider driving createChatGptWebAdapter must synthesize _rawBody.client_metadata["x-codex-turn-metadata"] (thread/turn ids), a Responses-shaped _rawBody.input array, and internal_chat_message_metadata_passthrough on the current-turn user item. Without all three it fails before any browser work.
  2. The adapter emits a Codex read-only banner on every turn, as a text_delta with phase: "commentary" bracketed by assistant_boundary. Captured live: "⚠️ ChatGPT Web Instant cannot access the local Codex computer in this turn…". A bridge that ignores phase will prefix every answer with it.
  3. An explicit upstream status should outrank message-pattern matching in error classification — the vendor documents this on the field itself. The reverse order classifies a 503 whose prose says "please sign in" as a 401, which marks credentials expired and pulls a healthy account out of rotation.

Design and behaviour specs, the spike verdict and the live-validation runbook are in the private _tasks repo under superpowers/specs/2026-09-02-chatgpt-session-*.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants