fix(models, oauth): placeholder-key suppression, subscription badge, 401 hint, OAuth flow timeout#7
Merged
Conversation
…ers, hint on 401 (#4) Closes the loop on the 'demoed deck rejected a viewer's prompt with 401' bug from issue #4. Root cause: the SDK ships overlapping model namespaces — `openai` (uses `OPENAI_API_KEY` against platform.openai.com) and `openai-codex` (uses the ChatGPT Plus/Pro OAuth subscription). Both expose `gpt-5`, `gpt-5.1`, `gpt-5.2`, etc. A common `.env.example` placeholder like `OPENAI_API_KEY=sk-your-...here` in the viewer's environment was enough for the SDK's `hasConfiguredAuth` to mark the openai-provider variant as 'available' (it only checks 'env var non-empty'). The picker then surfaced `openai/gpt-5` next to `openai-codex/gpt-5` with nothing distinguishing them; the click routed through the API-key path; OpenAI rejected the placeholder with 401. Three deck-side fixes: A. Suppress placeholder API keys when computing model availability. New `apps/server/src/credential-quality.ts` with `looksLikePlaceholderKey` — matches `sk-your-*`, `sk-XXXX`, `your-api-key`, `<your-key>`, `changeme`, unsubstituted shell vars, all-X strings, and length-too-short-to-be-real keys per provider prefix family. Wired into `modelInfoFromSdk` in bridge/in-process.ts. OAuth credentials in auth.db (`isUsingOAuth`) always bypass the check — they're orthogonal to env values. B. Badge subscription providers in the model picker. Added `isSubscription?: boolean` to the ModelInfo protocol type, computed server-side from the SDK's `getOAuthProviders()` list, rendered as a green 'subscription' badge in ModelPickerModal. Users can now see at a glance which `gpt-5` row uses their ChatGPT Plus subscription vs which uses an API key. C. 401-recovery hint. Bridge listens for SDK `notice` events with `level: 'error'` and an auth-shaped message (401, 'incorrect api key', 'unauthorized', etc.). When the current session model is on an API-key provider AND a subscription provider carries the same model id with a real OAuth credential, fires a deck notification telling the operator to switch. Tests: 6 new credential-quality tests (44 assertions). Full server suite still green (167/167). Typecheck across 4 packages clean.
Initial PR used the SDK's `getOAuthProviders()` to decide which models
get the 'subscription' badge. That list is broader than 'consumer
subscription'; it includes:
- Local runtimes: ollama, lm-studio, vllm
- Gateway services: litellm, kilo, cloudflare-ai-gateway, zenmux
- API-tier providers: cerebras, fireworks, together, huggingface,
nanogpt, nvidia, venice, moonshot, ...
None of these are 'subscriptions' in the user-facing sense. Labeling
Ollama as a subscription is actively misleading (it runs on your
laptop).
Replace with an explicit allowlist of providers that genuinely require
a paid consumer subscription:
anthropic, openai-codex, github-copilot, cursor, perplexity,
alibaba-coding-plan, zai, minimax-code, minimax-code-cn, kimi-code,
google-antigravity
Allowlist is intentional — false negatives (missing a real
subscription) are graceful (no badge), false positives are confusing.
When the SDK adds a new subscription-style provider upstream, add it
here.
Also drops the `getOAuthProviders` import that's no longer used and
the broken memoization layer (set is now a module-level const).
Caught by: user noticed 'ollama' was tagged subscription in the picker.
…l cleanup (#5) Closes #5. Three related fixes to the OAuth route's flow lifecycle: 1. Server-side 5-minute timeout per flow. The SDK's own DEFAULT_TIMEOUT (5 min) only fires on the loopback callback listener — it never fires on flows driven by `onPrompt` (e.g. ollama's 'where's your local endpoint?' prompt). Result: closing the ollama OAuth modal without typing a URL leaves the SDK's login promise pending forever, the flow stays in the deck's `flows` map, and every subsequent `POST /:provider/start` returns 409 'already in progress' until the deck process restarts. The deck now schedules its own `setTimeout(... OAUTH_FLOW_MAX_MS)` per flow that force-cancels and broadcasts `oauth_failed` if the flow hasn't naturally completed in time. Cleared on natural completion via `.finally()`. 2. Stale-flow eviction on `/start`. Defensive belt-and-suspenders: if a duplicate `start` arrives and the held flow is past OAUTH_FLOW_MAX_MS, the timer should already have fired — but evict it inline here too so a wedged flow (e.g. timer somehow lost, event loop starved) can't block new attempts indefinitely. 3. `cancel` cleans up promptResolvers too (latent bug). The pre-fix `cancel` handler only aborted the AbortController and rejected `manualCode`. It never touched `promptResolvers`, so cancelling a flow blocked on an `onPrompt` (ollama's endpoint input) left the SDK's promise pending and the flow effectively un-cleaned. Factored the teardown into a single `abortFlow(flow, reason)` helper used by all three paths (cancel, timeout, stale eviction). The helper: - Aborts the SDK AbortController - Clears the lifetime timer - Rejects `consentReady` and `manualCode` deferreds - Resolves all pending `promptResolvers` with empty string (rejecting would surface as an uncaught error inside the SDK's onPrompt caller) - Removes from both `flows` and `flowsById` maps - Is idempotent — second call is a no-op Tests: 6 new tests for the cleanup helper covering pending-deferred rejection, promptResolvers drain, AbortController abort, idempotency, and survival across a throwing resolver. Full server suite still green (173/173).
bjb2
added a commit
that referenced
this pull request
May 29, 2026
…fixes See CHANGELOG.md for the full release notes. Bumps all 4 package.json versions to 0.6.0 in lockstep (root, server, web, protocol). Adds docs/upgrading.md as the canonical place for version-by-version migration notes — explicitly documents that the new onboarding wizard silently settles for existing users (any session OR moved welcome task short-circuits the gate), so this is a non-breaking upgrade. Headline changes since v0.5.0: - Onboarding wizard at /onboarding for fresh installs (#9) - Model picker subscription badge + placeholder-key suppression + 401-recovery hint (#7, closes #4) - OAuth flow 5-min timeout + stale eviction + drained cancel (#7, closes #5) - Bun executable PATH fallback (#8, closes #6) - README install section rewritten + LF normalization + private template stash on pack 177 -> 181 server tests (4 new from user's parallel Windows rename-fix WIP that landed; my onboarding work adds 4 of those). Typecheck clean across 4 packages.
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 #4 and #5.
Two related bug reports from the same demo session, both tied to provider/auth confusion. Bundled in one PR because the second fix (#5) was discovered while reviewing the surface area for #4.
#4 —
gpt-5routes through API key instead of ChatGPT Plus subscriptionA viewer with a ChatGPT Plus subscription tried to send a prompt during a live demo. They picked
gpt-5in the model picker. OpenAI rejected the call with:sk-your-...hereis a common.env.exampleplaceholder. The viewer had it lying around in their env (typical tutorial leftover). The SDK saw a non-emptyOPENAI_API_KEY, marked theopenai-provider variant ofgpt-5as available, and that beat their actualopenai-codex(ChatGPT Plus OAuth) subscription credential in the picker because nothing in the UI distinguished the two.Root cause: SDK ships overlapping model namespaces. Same
gpt-5exists underopenai(API key) ANDopenai-codex(subscription).hasConfiguredAuthonly checks "env var non-empty"; UI didn't distinguish providers.Fixes:
apps/server/src/credential-quality.ts):looksLikePlaceholderKeymatchessk-your-*,sk-XXXX,your-api-key,<your-key>,changeme, unsubstituted shell vars, all-X strings, and per-prefix-family length floors. Wired intomodelInfoFromSdk. OAuth credentials inauth.dbbypass the check.ModelPickerModal.tsx+ModelInfo.isSubscription): greensubscriptionbadge on rows for real consumer-subscription providers. Uses an explicit allowlist (anthropic,openai-codex,github-copilot,cursor,perplexity, coding-plan providers, etc.) — not the SDK's broadergetOAuthProviders()which would have falsely badged Ollama, LM Studio, and gateway services.noticeevents with auth-shaped error messages; when current model is API-key and a subscription provider carries the same model id with a real OAuth credential, fires a decknotificationService.notifytelling the operator to switch.#5 — OAuth flow gets stuck "already in progress" forever
User closed the Ollama OAuth modal without typing in an endpoint URL. Subsequent attempts to sign in to Ollama returned
409 {"error":"already-in-flight"}until the deck process was restarted.Root cause: Ollama's OAuth flow uses
onPromptto ask for the local endpoint URL — not the loopback callback listener that the SDK'sDEFAULT_TIMEOUT(5 min) covers. Closing the modal without responding leaves the SDK's login promise pending forever, the flow stays in theflowsmap, and every subsequent/start409s.The pre-fix
cancelhandler was also incomplete — only rejectedmanualCode, never iteratedpromptResolvers. So even hitting Cancel wouldn't have unstuck this case.Fixes (in
apps/server/src/routes-auth-oauth.ts):/start. If a duplicatestartarrives and the held flow is past 5 min, evict it inline (defensive belt-and-suspenders if the timer somehow lost).abortFlowhelper that drainspromptResolverstoo (resolves with empty string — rejecting would surface as uncaught inside the SDK). Used by all three teardown paths (cancel, timeout, stale eviction). Idempotent.Verification
looksLikePlaceholderKey(44 assertions), 6 forabortFlowcleanup (9 assertions)OPENAI_API_KEY=sk-your-XXXXherecorrectly hidesopenai/gpt-5from default picker view while leavingopenai-codex/gpt-5visible with thesubscriptionbadgeFiles touched
Out of scope
pi-aichange)