fix(ai): refresh an expired token when loading the Cloud model roster - #43
Merged
Conversation
On a fresh open of LevelCode, a paid plan's model picker showed only TWO models (the free engine + the flagship) instead of the full roster — no Opus 4.8, no Kimi K3, and none of the "×credits · ctx · turns left" detail or "coming soon" rows. That is the OFFLINE fallback branch of pickCloudModel, reached whenever fetchCloudRoster returns no models. Cause: fetchCloudRoster returned null on ANY non-OK response and, unlike the profile fetch (webHandoffUrl) and the agent loop, did NOT refresh the token on a 401. Access tokens are short-lived, so the FIRST roster request after a fresh open is made with last session's expired token → 401 → null → 2-model fallback. The account WEB page looked correct because it authenticates differently and refreshes. Fix, mirroring the proven webHandoffUrl retry: - On 401, refreshCloudToken() once and retry with the fresh token. - Gate on the stored TOKEN, not the cloudSignedIn flag — the flag can still be false mid-activation while a valid token exists, which was a second path to the same degraded menu. - On a genuinely transient failure, return the last-known-good roster (cloudRoster) instead of collapsing to the 2-model fallback; the menu just omits the credits/turns detail it can't recompute offline. Not unit-tested — this is vscode-requiring host glue with no pure seam to extract; it is verified by reading and by matching webHandoffUrl's battle-tested pattern (now the second of two 401-refresh call sites). I could not reproduce the live 401 without a running, signed-in editor, so this fixes the identified code path rather than an observed repro. Full gate: 24 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes a LevelCode Cloud gateway UX regression where the model picker could incorrectly fall back to the 2-model “offline” list on fresh launch due to an expired access token causing the first roster fetch to 401.
Changes:
- Updates
fetchCloudRoster()to gate on the presence of a stored token (rather thancloudSignedIn). - Adds a single 401-triggered
refreshCloudToken()retry for the roster fetch (mirrors the existing handoff retry pattern). - Returns a “last-known-good” cached roster on transient failures instead of collapsing immediately to the minimal fallback.
Comments suppressed due to low confidence (1)
extensions/levelcode-ai/extension.js:251
fetchCloudRoster()’s docstring says it returns null when signed out; however, the newreturn cached()on any non-OK response will return a stale roster even for auth failures (e.g. 401/403 after the refresh attempt). That can present selectable models while the session is actually invalid, leading to confusing failures later.
if (!res.ok) { dbg('cloud.roster', { ok: false, status: res.status }); return cached(); }
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…review) 1. The comment claimed "credits/turns aren't cached", but cloudRoster = data.models caches the per-model fields including turns_left, so the cached-roster fallback CAN show a slightly stale "≈ turns left". Only the account-level credit BALANCE is genuinely not carried (the "$X credits left" header is omitted until the next good fetch). Comment now says exactly that. 2. After refreshCloudToken() succeeds, the refreshed secret was used unchecked — if it came back falsy the retry would send `Authorization: Bearer null`, noise that masks the real 401. Now the retry only fires when the refreshed token is truthy; otherwise it falls through to the cached-roster path. Comment-and-guard only, no behaviour change on the happy path. Full gate: 24 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…array (PR #43 review) The retry returned `data || cached()`, so any truthy JSON that lacked a valid `models` array — a 200 with an error object, a partial response, a schema drift — was returned as-is. pickCloudModel then sees "no models" and collapses to the 2-model fallback even though cloudRoster still holds a good list, defeating the whole point of the cache. Now a payload only counts as a roster when data.models is an array; otherwise it returns cached(). Also fixes the mirror case where a 200 arrives but json() failed (data null) — same fall-through to the cache. Full gate: 24 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment on lines
226
to
228
| /** Fetch the plan's model roster (entitled models + credits + ≈ turns-left). Caches it for the | ||
| * footer/picker. Best-effort; returns null when signed out / offline / not gateway. */ | ||
| async function fetchCloudRoster() { |
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.
Reported: on a fresh open, the Pro+ model picker shows only two models (gpt-oss-120b + Kimi K2.7 Code) instead of the full roster.
It's the offline fallback, not an entitlement bug
The screenshot is unmistakably
pickCloudModel's offline fallback branch:GATEWAY_FREE_MODEL+GATEWAY_PRO_MODEL— the two models shown;detailline (4× credits · 200K ctx · ≈24 turns left) and$(lock) … coming soonrows. The reported rows have neither — just the bare model id. That only happens in the fallback, which sets nodetail.So the roster came back empty and it degraded. The account web page shows Opus 4.8 + Kimi K3 as live because it authenticates differently and refreshes on 401.
Root cause
fetchCloudRosterreturnednullon any non-OK response and — unlike the profile fetch (webHandoffUrl) and the agent loop — did not refresh the token on a 401. Access tokens are short-lived, so the first roster request after a fresh open uses last session's expired token → 401 → null → 2-model fallback.Fix (mirrors the proven
webHandoffUrlretry)refreshCloudToken()once and retry with the fresh token.cloudSignedInflag — the flag can be false mid-activation while a valid token exists, a second path to the same degraded menu.Honest verification note
This is vscode-requiring host glue with no pure seam to unit-test. It's verified by reading and by matching
webHandoffUrl's battle-tested pattern (now the second of two 401-refresh call sites). I could not reproduce the live 401 without a running, signed-in editor — so this fixes the identified code path, not an observed repro. Full gate: 24 suites, 0 failures.To confirm on your side: open the picker on a fresh launch with
levelcode.ai.debugon — acloud.roster {ok:false, status:401}in the log before this fix is the fingerprint.🤖 Generated with Claude Code