feat(providers): clean-room ChatGPT Web session provider (chatgpt-session) - #12464
Closed
diegosouzapw wants to merge 23 commits into
Closed
feat(providers): clean-room ChatGPT Web session provider (chatgpt-session)#12464diegosouzapw wants to merge 23 commits into
diegosouzapw wants to merge 23 commits into
Conversation
…ng in chatgpt-session error classification
…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.
Owner
Author
|
Superseded by #12239, which landed on 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:
Design and behaviour specs, the spike verdict and the live-validation runbook are in the private |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
chatgpt-session(aliascgpt-session), a clean-room provider that serves ordinary/v1/chat/completionsrequests from an authenticated ChatGPT browser session.This restores the capability removed with the common
chatgpt-webprovider in #11754 withoutrestoring any of its code. The retired ids
chatgpt-web/cgpt-webstay retired: migration168, the fail-closed
410 PROVIDER_RETIREDguards and every retirement test are untouched, anda 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 waswritten — 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
Chat2APImirror in the local references tree was forbidden and never opened.THIRD_PARTY_NOTICES.mdis unchanged — no new third-party code enters. Specs live in the private_tasksrepo atsuperpowers/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:
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
buildParsedRequestnow does it:turn/thread ids in
client_metadata, a Responses-shapedinputarray built from the samemessages, 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 isunrepresentable.
Verified through the production code path, not just unit tests: the adapter now reaches
stage=browser_pageand fails only onChatGPT web login state is missing, the expectedauthentication 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:
That banner would have prefixed every answer. The bridge now drops commentary-phase deltas.
What changed
event→OpenAI response bridge, error classification, and a runtime seam that makes every side
effect injectable so no unit test launches a browser.
chatgpt-sessionshares the browser-session credential lifecycle withchatgpt-web-codexbehind 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.
docs/providers/CHATGPT_SESSION.md; provider count 352 → 353.OMNIROUTE_CHATGPT_SESSION_STREAM_OPEN_TIMEOUT_MS(default 30000) bounds how long the responseis withheld while the two paths' failover status is decided.
Seven routes (
luna,think,instant,medium,high,extra-high,pro), each pinning onebackend model and one reasoning effort; a route the account does not expose fails closed. Tool
calling is prompt-emulated, the same contract
perplexity-webandgemini-webuse.Other defects found by review and fixed here
still keyed to
chatgpt-web-codexwhile the API routes had been widened, so every save hit aJSON.parseon a raw cookie and returned 400.output.
cooldown hint.
never received. An assistant
refusalpart is now folded in as text rather than rejected.One deliberate contract change. In
classifyChatGptSessionError, an explicit numeric statusnow 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.txtplus its 42 mirrors, several SVGs) will conflict mechanically. The newdocs/reference/REMOVED_PROVIDERS.mdblocklist was checked: no ChatGPT entry, so it does not blockthis provider.
Validation
npm run lintnpm run typecheck:corenpm run check:dashboard-typechecknpm run check:provider-consistencynpm run check:cyclesnpm run check:docs-allnpm run test:vitestnpm run test:unitFocused suites: 130 passing across the eight
chatgpt-sessionsuites, the new provider-routetest and the reserved-prefix ratchet, plus all four
chatgpt-webretirement guards, untouched.test:vitestis red on the base, not from this branch: the identical command onrelease/v3.8.51fails 17 tests across 10 files, this branch fails 16 across 9. The failures include
tests/unit/encryption.spec.tsand an MCP audit database-shutdown test, neither of which can beaffected by registering a provider. No "Release branch not green" issue is open, so this red
appears untracked.
test:unittriage: one failure was genuinely this branch's — theRESERVED_PREFIX_COUNTratchetmoved 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.