Skip to content

Commit faa8eb5

Browse files
authored
Merge pull request #43 from levelcodeai/fix/cloud-roster-token-refresh
fix(ai): refresh an expired token when loading the Cloud model roster
2 parents 8ac0aef + 367dc64 commit faa8eb5

1 file changed

Lines changed: 30 additions & 6 deletions

File tree

extensions/levelcode-ai/extension.js

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,23 +226,47 @@ function gatewayModelLabel(id) {
226226
/** Fetch the plan's model roster (entitled models + credits + ≈ turns-left). Caches it for the
227227
* footer/picker. Best-effort; returns null when signed out / offline / not gateway. */
228228
async function fetchCloudRoster() {
229-
if (providerMode() !== 'gateway' || !cloudSignedIn || !ctx) { return null; }
230-
const token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
229+
// The presence of a stored token IS the signed-in truth; don't gate on the cloudSignedIn flag, which
230+
// can still be false mid-activation while a valid token already exists (another way the picker was
231+
// degrading to the 2-model fallback on a fresh open).
232+
if (providerMode() !== 'gateway' || !ctx) { return null; }
233+
let token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
231234
if (!token) { return null; }
232235
const api = cloudApiUrl();
233236
if (!/^https:\/\//i.test(api) && !/^http:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(api)) { return null; }
237+
// Last-known-good roster: a transient failure keeps the FULL model list rather than collapsing to the
238+
// 2-model offline fallback. The per-model fields — INCLUDING "≈ turns left" — are whatever the last
239+
// good fetch returned, so they may be slightly stale. The account-level credit BALANCE is NOT carried
240+
// here, so pickCloudModel just omits the "$X credits left" header until the next successful fetch.
241+
const cached = () => (cloudRoster && cloudRoster.length ? { plan: cloudPlanName(), models: cloudRoster } : null);
242+
const get = (bearer) => fetch(api + '/api/levelcode/v1/account/models', { headers: { authorization: 'Bearer ' + bearer } });
234243
try {
235-
const res = await fetch(api + '/api/levelcode/v1/account/models', { headers: { authorization: 'Bearer ' + token } });
236-
if (!res.ok) { dbg('cloud.roster', { ok: false, status: res.status }); return null; }
244+
let res = await get(token);
245+
// THE FIX: on a fresh open, last session's short-lived access token is usually EXPIRED, so this
246+
// first call 401s. Refresh once and retry. Without it the 401 silently degrades the picker to the
247+
// 2-model offline fallback and hides the plan's real roster (Opus, K3, …) — exactly the reported
248+
// bug. The profile fetch and the agent loop already refresh on 401; the roster fetch didn't.
249+
if (res.status === 401 && await refreshCloudToken()) {
250+
// Guard the refreshed token: if it comes back falsy for any reason, retrying would send
251+
// `Authorization: Bearer null` — noise that masks the real 401. Skip the retry instead and let
252+
// the !res.ok path below fall back to the cached roster.
253+
const fresh = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
254+
if (fresh) { token = fresh; res = await get(token); }
255+
}
256+
if (!res.ok) { dbg('cloud.roster', { ok: false, status: res.status }); return cached(); }
237257
const data = await res.json().catch(() => null);
258+
// Only a payload with a real models array IS a roster. A 200 carrying an error object, a partial
259+
// response, or a schema drift is not — returning it would make pickCloudModel see "no models" and
260+
// collapse to the 2-model fallback despite a valid last-known-good list. Prefer the cache then.
238261
if (data && Array.isArray(data.models)) {
239262
cloudRoster = data.models;
240263
// The roster carries each model's short label — refresh the footer chip so it shows
241264
// "Opus 4.8" instead of the raw id it fell back to before the roster finished loading.
242265
sendConfigToWebview();
266+
return data;
243267
}
244-
return data;
245-
} catch (e) { dbg('cloud.roster', { error: String((e && e.message) || e) }); return null; }
268+
return cached();
269+
} catch (e) { dbg('cloud.roster', { error: String((e && e.message) || e) }); return cached(); }
246270
}
247271

248272
/**

0 commit comments

Comments
 (0)