Skip to content

fix(ai): refresh an expired token when loading the Cloud model roster - #43

Merged
ndemianc merged 3 commits into
developfrom
fix/cloud-roster-token-refresh
Jul 25, 2026
Merged

fix(ai): refresh an expired token when loading the Cloud model roster#43
ndemianc merged 3 commits into
developfrom
fix/cloud-roster-token-refresh

Conversation

@ndemianc

Copy link
Copy Markdown
Contributor

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:

  • it pushes exactly GATEWAY_FREE_MODEL + GATEWAY_PRO_MODEL — the two models shown;
  • the live branch adds a detail line (4× credits · 200K ctx · ≈24 turns left) and $(lock) … coming soon rows. The reported rows have neither — just the bare model id. That only happens in the fallback, which sets no detail.

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

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 uses last session's expired token → 401 → null → 2-model fallback.

Fix (mirrors 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 be false mid-activation while a valid token exists, a second path to the same degraded menu.
  • Transient failure returns the last-known-good roster instead of collapsing to two models; it just omits the credits/turns detail it can't recompute offline.

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.debug on — a cloud.roster {ok:false, status:401} in the log before this fix is the fingerprint.

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings July 25, 2026 18:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 than cloudSignedIn).
  • 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 new return 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.

Comment thread extensions/levelcode-ai/extension.js
Comment thread extensions/levelcode-ai/extension.js
…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>
Copilot AI review requested due to automatic review settings July 25, 2026 18:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.

Comment thread extensions/levelcode-ai/extension.js
…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>
Copilot AI review requested due to automatic review settings July 25, 2026 18:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.

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() {
@ndemianc
ndemianc merged commit faa8eb5 into develop Jul 25, 2026
2 checks passed
@ndemianc
ndemianc deleted the fix/cloud-roster-token-refresh branch July 25, 2026 18:49
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