From f4278bbc88d59eff04527f1e9999120e065661c4 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 25 Jul 2026 14:33:32 -0400 Subject: [PATCH 1/3] fix(ai): refresh an expired token when loading the Cloud model roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- extensions/levelcode-ai/extension.js | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 08fc1c6..19f14e8 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -226,14 +226,29 @@ function gatewayModelLabel(id) { /** 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() { - if (providerMode() !== 'gateway' || !cloudSignedIn || !ctx) { return null; } - const token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY); + // The presence of a stored token IS the signed-in truth; don't gate on the cloudSignedIn flag, which + // can still be false mid-activation while a valid token already exists (another way the picker was + // degrading to the 2-model fallback on a fresh open). + if (providerMode() !== 'gateway' || !ctx) { return null; } + let token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY); if (!token) { return null; } const api = cloudApiUrl(); if (!/^https:\/\//i.test(api) && !/^http:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(api)) { return null; } + // Last-known-good roster: a transient failure keeps the FULL model list rather than collapsing to the + // 2-model offline fallback. credits/turns aren't cached, so the menu just omits those detail lines. + const cached = () => (cloudRoster && cloudRoster.length ? { plan: cloudPlanName(), models: cloudRoster } : null); + const get = (bearer) => fetch(api + '/api/levelcode/v1/account/models', { headers: { authorization: 'Bearer ' + bearer } }); try { - const res = await fetch(api + '/api/levelcode/v1/account/models', { headers: { authorization: 'Bearer ' + token } }); - if (!res.ok) { dbg('cloud.roster', { ok: false, status: res.status }); return null; } + let res = await get(token); + // THE FIX: on a fresh open, last session's short-lived access token is usually EXPIRED, so this + // first call 401s. Refresh once and retry. Without it the 401 silently degrades the picker to the + // 2-model offline fallback and hides the plan's real roster (Opus, K3, …) — exactly the reported + // bug. The profile fetch and the agent loop already refresh on 401; the roster fetch didn't. + if (res.status === 401 && await refreshCloudToken()) { + token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY); + res = await get(token); + } + if (!res.ok) { dbg('cloud.roster', { ok: false, status: res.status }); return cached(); } const data = await res.json().catch(() => null); if (data && Array.isArray(data.models)) { cloudRoster = data.models; @@ -241,8 +256,8 @@ async function fetchCloudRoster() { // "Opus 4.8" instead of the raw id it fell back to before the roster finished loading. sendConfigToWebview(); } - return data; - } catch (e) { dbg('cloud.roster', { error: String((e && e.message) || e) }); return null; } + return data || cached(); + } catch (e) { dbg('cloud.roster', { error: String((e && e.message) || e) }); return cached(); } } /** From ef99a8fdf9edc93ac2497736e169c1848ef1f2b4 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 25 Jul 2026 14:42:15 -0400 Subject: [PATCH 2/3] fix(ai): correct the cache comment; guard the refreshed token (PR #43 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- extensions/levelcode-ai/extension.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index 19f14e8..d715c2c 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -235,7 +235,9 @@ async function fetchCloudRoster() { const api = cloudApiUrl(); if (!/^https:\/\//i.test(api) && !/^http:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(api)) { return null; } // Last-known-good roster: a transient failure keeps the FULL model list rather than collapsing to the - // 2-model offline fallback. credits/turns aren't cached, so the menu just omits those detail lines. + // 2-model offline fallback. The per-model fields — INCLUDING "≈ turns left" — are whatever the last + // good fetch returned, so they may be slightly stale. The account-level credit BALANCE is NOT carried + // here, so pickCloudModel just omits the "$X credits left" header until the next successful fetch. const cached = () => (cloudRoster && cloudRoster.length ? { plan: cloudPlanName(), models: cloudRoster } : null); const get = (bearer) => fetch(api + '/api/levelcode/v1/account/models', { headers: { authorization: 'Bearer ' + bearer } }); try { @@ -245,8 +247,11 @@ async function fetchCloudRoster() { // 2-model offline fallback and hides the plan's real roster (Opus, K3, …) — exactly the reported // bug. The profile fetch and the agent loop already refresh on 401; the roster fetch didn't. if (res.status === 401 && await refreshCloudToken()) { - token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY); - res = await get(token); + // Guard the refreshed token: if it comes back falsy for any reason, retrying would send + // `Authorization: Bearer null` — noise that masks the real 401. Skip the retry instead and let + // the !res.ok path below fall back to the cached roster. + const fresh = await ctx.secrets.get(ACCOUNT_TOKEN_KEY); + if (fresh) { token = fresh; res = await get(token); } } if (!res.ok) { dbg('cloud.roster', { ok: false, status: res.status }); return cached(); } const data = await res.json().catch(() => null); From 367dc647f2d469755352c50a67b524d427863f98 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Sat, 25 Jul 2026 14:45:52 -0400 Subject: [PATCH 3/3] fix(ai): fall back to the cached roster unless data.models is a real array (PR #43 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- extensions/levelcode-ai/extension.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/extensions/levelcode-ai/extension.js b/extensions/levelcode-ai/extension.js index d715c2c..6dc7b04 100644 --- a/extensions/levelcode-ai/extension.js +++ b/extensions/levelcode-ai/extension.js @@ -255,13 +255,17 @@ async function fetchCloudRoster() { } if (!res.ok) { dbg('cloud.roster', { ok: false, status: res.status }); return cached(); } const data = await res.json().catch(() => null); + // Only a payload with a real models array IS a roster. A 200 carrying an error object, a partial + // response, or a schema drift is not — returning it would make pickCloudModel see "no models" and + // collapse to the 2-model fallback despite a valid last-known-good list. Prefer the cache then. if (data && Array.isArray(data.models)) { cloudRoster = data.models; // The roster carries each model's short label — refresh the footer chip so it shows // "Opus 4.8" instead of the raw id it fell back to before the roster finished loading. sendConfigToWebview(); + return data; } - return data || cached(); + return cached(); } catch (e) { dbg('cloud.roster', { error: String((e && e.message) || e) }); return cached(); } }