feat(llm): Sign in with ChatGPT — OAuth device flow (#294) - #823
Merged
Conversation
… (4b, experimental) EXPERIMENTAL / ToS grey area. Adds the pure, tested token-lifecycle core for connecting a provider via a ChatGPT subscription instead of an API key (RFC 8628 device grant): requestDeviceCode / pollDeviceToken / refreshAccessToken / isAccessTokenExpired against the OpenAI Codex public client, plus vault storage (provider:<id>/oauth_*) read/write helpers. All functions take an injected fetch + clock so the lifecycle is unit-tested without the live endpoints. NOT wired yet (left for follow-up + live verification): binding the OAuth bearer into resolveLlmProvider with the correct base URL (the ChatGPT token's audience is OpenAI's Codex/Responses backend, not standard Chat Completions — may need a Responses shim), the web-UI connect button + ToS notice, and a real login test. Forge GPT-5.4 security review: FIX-APPLIED — fixed a token-leak (untrusted error text reflected into Error.message) and a stale-token persistence bug, +2 tests. No token leakage confirmed. 15/15 tests, build + typecheck green.
…er (#294) Finishes 4b: connect a ChatGPT subscription as an LLM provider via the real device-code login (no API key), driving the ChatGPT/Codex Responses backend. Empirically settled the issue's gating question with a live subscription token: - api.openai.com/v1/chat/completions and /responses both 401 (wrong audience) - chatgpt.com/backend-api/codex/responses returns 200 SSE; tools, forced tool_choice, parallel tool calls and vision all work. The prototype's RFC 8628 endpoints were wrong (404); rewrote oauthDeviceFlow.ts to the real 3-step flow (usercode -> poll -> authorization-code exchange, PKCE server-side) verified against auth.openai.com + the open-source codex CLI. - packages/llm-provider-api -> 1.1.0: wireFormat 'openai-responses', descriptor .oauth, adapter .bearerProvider (all additive) - new @omadia/llm-adapter-openai-responses: SDK-free SSE adapter (own parser), neutral<->Responses mapping, stream/complete, error classification - providerOAuthTokenStore: process-wide, single-flight refresh, rotation fanned out to all LLM vault scopes newest-wins, terminal reuse -> reconnect_required - adminProviders: POST /oauth/{start,poll}, oauthConnect DTO + status branch - openai-chatgpt provider gated behind CHATGPT_SUBSCRIPTION_EXPERIMENTAL (off) - web-ui: ChatGptConnectModal (device code + ToS notice) + panel connect button, i18n en+de - tests: device-flow, token store (single-flight/rotation/reconnect), SSE adapter, connect routes, connect modal Experimental / ToS grey area — env-gated off, prominent in-UI notice.
…ation (#294) - token store: commit rotated tokens to memory BEFORE persisting, and degrade a persist failure to a logged warning — a transient vault write no longer drops a live token and wedges the grant into reconnect_required (HIGH) - OAuthReconnectRequiredError sets .name, so the openai-responses adapter's cross-package name-match classifies a dead grant as auth, not other (HIGH) - token store hydration is a shared promise, not a bool — a second cold caller awaits the same load instead of racing past a half-populated entry (MEDIUM) - adminProviders GET: an OAuth provider in reconnect_required reports no_key so the row steers to "Reconnect" instead of a green chip that lies (MEDIUM) - /oauth/start sweeps expired pending flows and caps in-flight flows at 32, so a closed modal / StrictMode double-mount can't leak entries (MEDIUM) - web-ui pollProviderOAuth maps 404/502 back to expired/error terminal states instead of a generic failure (LOW) - tests: persist-throw keeps the rotated token, slow-hydration concurrency, and dead-grant classified as auth
# Conflicts: # docs/CHANGELOG.md # middleware/.env.example
# Conflicts: # docs/CHANGELOG.md
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.
Closes #294 (4b, experimental).
Summary
Connect a ChatGPT subscription as an LLM provider via an OAuth device login (no API key), driving the ChatGPT/Codex Responses backend over SSE. Gated behind
CHATGPT_SUBSCRIPTION_EXPERIMENTAL(off by default) — driving programmatic calls through a consumer subscription is a ToS grey area, so the connect modal shows a prominent notice and it is not an enterprise feature.The gating question, settled empirically
The issue asked whether a ChatGPT-subscription token can speak Chat Completions. Tested live with a real subscription token:
api.openai.com/v1/chat/completions→ 401 (model.requestscope missing)api.openai.com/v1/responses→ 401 (api.responses.writemissing)chatgpt.com/backend-api/codex/responses→ 200 SSE — and custom function tools, forcedtool_choice, parallel tool calls and vision all work.So 4b needs a Responses-API adapter targeting the Codex backend, not a baseURL switch. The prototype's RFC 8628 endpoints were also wrong (
/oauth/device/code→ 404); the device flow was rewritten to the real 3-step protocol (/api/accounts/deviceauth/usercode→ poll/deviceauth/token→ authorization-code exchange, PKCE server-side), verified againstauth.openai.comand the open-sourceopenai/codexCLI.What landed
@omadia/llm-provider-api→ 1.1.0 (additive): wire formatopenai-responses, descriptoroauth, adapterbearerProvider.@omadia/llm-adapter-openai-responses: SDK-free SSE adapter (own parser), neutral↔Responses mapping,stream/complete, error classification. Forcesstream:true/store:false, sends the experimental headers, resolves a fresh bearer per request.providerOAuthTokenStore: process-wide, single-flight refresh; rotated tokens (rotation + reuse-detection are real) fan out to all three LLM vault scopes newest-wins; a dead grant parks the provider inreconnect_required(surfaced asno_key→ "Reconnect") instead of retry-hammering.POST /api/v1/admin/providers/oauth/{start,poll}(device secret never leaves the server; expired-flow sweep + in-flight cap),oauthConnectDTO + status branch.openai-chatgptprovider inEXPERIMENTAL_LLM_PROVIDERS, registered only under the flag.ChatGptConnectModal(device code + verification link + ToS notice + poll loop) + panel connect button, i18n en+de.Review
An
omadia-reviewerpass flagged two HIGH bugs (rotation-persistence race, dead-grant classification via unsetError.name) and MEDIUM items (hydration race, latch not surfaced, unbounded pending-flow map) — all fixed in the second commit with regression tests.Test plan
i18n:check+ vitest (modal 3/3, i18n parity 10/10)node:22-slim(separate PR feat(admin): runtime install of subscription CLIs from the admin UI #816)Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.