Skip to content

Commit 7fefaa3

Browse files
committed
fix(cloud): a lapsed token is renewed outside the chat too — not shown as "API 401"
The access token lives 8 hours; the session behind it outlives it. Chat and the agent renew a lapsed token on a gateway 401 and send the request again. Nothing else did. Seven other requests go out on a provider prepProviderRequest resolved, and each was sent exactly once: inline edit, Agent Sketch (Run, the board command, Generate flow), inline completion, Compact and the session-memory summary. The adapters retry a 502/503/504 and nothing else, prepProviderRequest only reads the stored token, and the session check runs on the webview's `ready` and on regaining focus. So in gateway mode — the default — a token that lapsed while the window stayed focused failed all seven, for a request chat would have carried through: "LevelCode AI edit failed: OpenAI API 401: Signature has expired". They now go through providers/authRetry.js: one renew-and-retry, built once in extension.js from prepProviderRequest, refreshGatewayToken and isAuthError, and handed to aiEdit.js, sketch.js and inlineComplete.js in the deps they already take. A caller wired without it sends exactly as it did. - Only a gateway request, only an auth error, only before any of the answer has arrived, never after Stop or Cancel, and once. A BYOK request is never refreshed and never retried, whatever its error says. - It ends no session. The refresh endpoint answering 401 still does that, alone. authRetry finds out by asking for a provider again: `signedOut` becomes the session sentence, coded 'session_expired' — so inline edit puts "Sign in" on the toast, as it does for a request refused up front. A renewal that merely failed keeps the tokens and rethrows the request's own error, unchanged. - One renewal at a time among its callers. The nodes of a Sketch level fail together: they wait on one refresh instead of racing for one refresh token, and the renewed token is written onto the run's request, so the nodes after them never see the 401. A request refused on a token renewed since is just sent again. - Ghost text stays silent, and may start a renewal once a minute at most: a gateway that keeps answering 401 must not turn typing into a stream of refreshes. Compact and the session-memory summary are the same gap in extension.js itself, one wrapped call each. The "OpenAI" in the error above is real — these callers pass no `label` to the adapter — and stays: no existing wording changes here. prepProviderRequest, handleSend and the agent's hook are untouched. Verified: 46 suites, 872 cases, 0 failing. authRetry.test.js is new (31 cases) and pins the rule against a stand-in host. authRetryCallers.test.js is new (68 cases) and RUNS it end to end: the real aiEdit.js, sketch.js and inlineComplete.js behind a minimal `vscode` mock, the real adapters, and the host sliced out of extension.js, with only the network faked — every caller through renewed, renewal failed, renewal refused and BYOK. With any one caller put back as it was, that suite fails on the 401 above; 40 breakages in all — each caller reverted, each rule of authRetry removed, each option a caller passes dropped — fail a case each. node --check is clean on the seven files, and tsc --checkJs reports nothing new. Trial-merged into develop (4e49295): no conflicts, 48 suites, 884 cases, 0 failing. Not run in the editor: nothing here has met a live gateway, and no toast or Sketch notification has been seen on screen.
1 parent 76445f5 commit 7fefaa3

7 files changed

Lines changed: 1537 additions & 52 deletions

File tree

‎extensions/levelcode-ai/aiEdit.js‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
const vscode = require('vscode');
1313
const path = require('path');
1414
const { SESSION_EXPIRED_MESSAGE } = require('./providers/session');
15+
const { sendWithAuthRetry } = require('./providers/authRetry');
1516

1617
const SCHEME = 'levelcode-ai';
1718
const CTX_DIFF_ACTIVE = 'levelcode.ai.diffActive';
@@ -107,7 +108,7 @@ function fullLineRange(editor) {
107108
return new vscode.Range(sel.start.line, 0, endLine, editor.document.lineAt(endLine).text.length);
108109
}
109110

110-
/** @param {{aiConfig:()=>any, prepProviderRequest:(o?:any)=>Promise<any>, streamChat:Function, accountSignIn:()=>Promise<any>}} deps */
111+
/** @param {{aiConfig:()=>any, prepProviderRequest:(o?:any)=>Promise<any>, streamChat:Function, accountSignIn:()=>Promise<any>, authRetry?:Function}} deps */
111112
async function editSelection(deps) {
112113
const ed = vscode.window.activeTextEditor;
113114
if (!ed || ed.selection.isEmpty) {
@@ -147,11 +148,13 @@ async function editSelection(deps) {
147148
: req.reason === 'insecureBaseURL' ? 'Refusing to send your API key over plain http to a non-local host. Use an https (or localhost) base URL.'
148149
: 'No API key set for ' + req.label + '.');
149150
}
150-
await deps.streamChat({
151-
providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL,
152-
model: req.model, maxTokens: req.maxTokens, system: EDIT_SYSTEM,
151+
// Sent through the host's authRetry: a lapsed cloud token is renewed and the edit asked for
152+
// once more — unless some of it has already arrived, which a second answer would repeat.
153+
await sendWithAuthRetry(deps, req, (r) => deps.streamChat({
154+
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL,
155+
model: r.model, maxTokens: r.maxTokens, system: EDIT_SYSTEM,
153156
messages: [{ role: 'user', content: userMsg }], signal: ac.signal, onDelta
154-
});
157+
}), { streamed: () => !!result, signal: ac.signal });
155158
}
156159
);
157160
} catch (e) {

‎extensions/levelcode-ai/extension.js‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const providers = require('./providers/index');
1818
const catalog = require('./providers/catalog');
1919
const { resolveGateway } = require('./providers/gateway');
2020
const session = require('./providers/session');
21+
const { createAuthRetry } = require('./providers/authRetry');
2122
const { registerAiEdit } = require('./aiEdit');
2223
const { registerLmProvider } = require('./lmProvider');
2324
const { registerInlineComplete } = require('./inlineComplete');
@@ -613,6 +614,24 @@ async function refreshGatewayToken() {
613614
return refreshCloudToken();
614615
}
615616

617+
/**
618+
* A lapsed access token, for everything that sends a provider request and is neither the chat nor
619+
* the agent: inline edit, Agent Sketch and inline completion (each is handed this in its `deps`),
620+
* and Compact and the session-memory summary further down. They sent a request once and reported
621+
* the gateway's 401 — for a token the chat would have renewed and carried on with, and one nothing
622+
* renews ahead of time in a window that simply stays focused (checkCloudSession runs on the
623+
* webview's `ready` and on regaining focus). Through this, the token is renewed and the request sent
624+
* once more.
625+
*
626+
* The rules are in providers/authRetry.js. The two that matter here: it never ends a session — the
627+
* refresh endpoint answering 401, above, is still the only thing that does — and a BYOK request is
628+
* never refreshed or retried. The chat and the agent keep their own retry (handleSend; the agent's
629+
* refreshAuth hook): they have a transcript and a sign-in card to keep in step with it.
630+
*
631+
* ONE instance for the window, so that requests which fail together wait on a single renewal.
632+
*/
633+
const authRetry = createAuthRetry({ prepProviderRequest, refreshGatewayToken, isAuthError, dbg });
634+
616635
/**
617636
* Resolve everything needed to call the active provider: id, key, model, baseURL, maxTokens.
618637
* Returns { ok:false, reason } when a required key/baseURL is missing (after optional prompting) or
@@ -1106,11 +1125,12 @@ async function summarizeSessionOutcome(messages, existingFacts) {
11061125
if (ef.length) { instr += '\n\nExisting project facts (reference by NUMBER under SUPERSEDES only; do NOT repeat them as FACTS):\n' + ef.map((f, i) => (i + 1) + '. ' + f.text).join('\n'); }
11071126
instr += '\n\nTranscript:\n\n' + flat;
11081127
try {
1109-
const out = await providers.complete({
1110-
providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL, label: req.label,
1128+
// Through authRetry: a session sealed after the cloud token lapsed still gets its summary.
1129+
const out = await authRetry(req, (r) => providers.complete({
1130+
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL, label: r.label,
11111131
model, maxTokens: 200, system: OUTCOME_SYSTEM,
11121132
messages: [{ role: 'user', content: instr }]
1113-
});
1133+
}));
11141134
return parseOutcome(out, ef);
11151135
} catch (e) { dbg('sessions.memory.summarize.error', { msg: String((e && e.message) || e) }); return { summary: '', facts: [], supersedes: [] }; }
11161136
}
@@ -1837,12 +1857,14 @@ async function compactAgentMemory() {
18371857

18381858
let summary;
18391859
try {
1840-
summary = await providers.complete({
1841-
providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL, label: req.label,
1842-
model: req.model, maxTokens: 1500,
1860+
// Through authRetry: a lapsed cloud token is renewed and the summary asked for once more, rather
1861+
// than Compact failing on a 401 that the next chat message would have recovered from.
1862+
summary = await authRetry(req, (r) => providers.complete({
1863+
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL, label: r.label,
1864+
model: r.model, maxTokens: 1500,
18431865
system: COMPACT_SYSTEM,
18441866
messages: [{ role: 'user', content: COMPACT_INSTRUCTIONS + flat }]
1845-
});
1867+
}));
18461868
} catch (e) { dbg('compact.error', { msg: String((e && e.message) || e) }); return { ok: false, reason: 'failed' }; }
18471869
if (!summary || !summary.trim()) { return { ok: false, reason: 'empty' }; }
18481870

@@ -3339,7 +3361,7 @@ function activate(context) {
33393361
// Agent Sketch: the visual multi-agent flow canvas. Lazy require — only loads when opened.
33403362
vscode.commands.registerCommand('levelcode.ai.sketch', () => {
33413363
try {
3342-
require('./sketch').openSketch(context, { prepProviderRequest, aiConfig, currentProviderId });
3364+
require('./sketch').openSketch(context, { prepProviderRequest, aiConfig, currentProviderId, authRetry });
33433365
} catch (e) {
33443366
vscode.window.showErrorMessage('Agent Sketch failed to load: ' + ((e && e.message) || e));
33453367
}
@@ -3378,6 +3400,7 @@ function activate(context) {
33783400
// AI edit-with-diff (select code → instruct → review diff → apply) — provider-agnostic.
33793401
registerAiEdit(context, {
33803402
aiConfig,
3403+
authRetry, // a lapsed cloud token is renewed and the edit sent once more
33813404
prepProviderRequest,
33823405
streamChat: providers.streamChat,
33833406
accountSignIn // the "Sign in" on an edit refused because the cloud session ended
@@ -3397,6 +3420,7 @@ function activate(context) {
33973420
// it must never pop a key dialog mid-typing.
33983421
registerInlineComplete(context, {
33993422
aiConfig,
3423+
authRetry, // …and renewed as silently: a failed renewal is one missing suggestion, never a toast
34003424
prepProviderRequest,
34013425
complete: providers.complete,
34023426
fastCompletionModel: catalog.fastCompletionModel

‎extensions/levelcode-ai/inlineComplete.js‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
'use strict';
1010

1111
const vscode = require('vscode');
12+
const { sendWithAuthRetry } = require('./providers/authRetry');
1213

1314
const PREFIX_MAX = 2500; // chars of context before the cursor (smaller = faster first token)
1415
const SUFFIX_MAX = 1000; // chars of context after the cursor
@@ -58,7 +59,8 @@ function clean(text) {
5859
* @param {vscode.ExtensionContext} context
5960
* @param {{ aiConfig: () => vscode.WorkspaceConfiguration,
6061
* prepProviderRequest: (o?:any) => Promise<any>,
61-
* complete: Function, fastCompletionModel: (providerId:string)=>(string|null) }} deps
62+
* complete: Function, fastCompletionModel: (providerId:string)=>(string|null),
63+
* authRetry?: Function }} deps
6264
*/
6365
function registerInlineComplete(context, deps) {
6466
const { aiConfig, prepProviderRequest, complete, fastCompletionModel } = deps;
@@ -124,13 +126,17 @@ function registerInlineComplete(context, deps) {
124126
const model = req.providerId === 'claude'
125127
? cfg.get('completions.model', 'claude-haiku-4-5-20251001')
126128
: (fastCompletionModel(req.providerId) || req.model);
127-
text = await complete({
128-
providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL,
129+
// Sent through the host's authRetry: a lapsed cloud token is renewed and the request sent once
130+
// more, as quietly as everything else here. `background`: nobody asked for this request and
131+
// it is sent on every pause in typing, so it may start a renewal only so often.
132+
text = await sendWithAuthRetry(deps, req, (r) => complete({
133+
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL,
129134
model, maxTokens: MAX_TOKENS, system: SYSTEM_PROMPT,
130135
messages: [{ role: 'user', content: userContent }], signal: ac.signal
131-
});
136+
}), { signal: ac.signal, background: true });
132137
} catch (e) {
133-
// Network/abort/key errors are silent — inline completion must never nag.
138+
// Network/abort/key errors are silent — inline completion must never nag. That includes a
139+
// renewal that failed, and one that found the session over: the chat shows the sign-in card.
134140
return null;
135141
} finally {
136142
sub.dispose();
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* LevelCode — AI · a lapsed LevelCode Cloud token: renewed, and the request sent once more (pure)
3+
*
4+
* The access token lives 8 hours; the session behind it lives as long as its refresh token. So the
5+
* gateway can refuse a request with a 401 while the session is perfectly renewable — the token
6+
* LAPSED, the session did not END. The chat and the agent recover from that: renew the token, send
7+
* the request again, once. Everything else that asks the host for a provider did not. Inline edit,
8+
* Agent Sketch, inline completion, Compact and the session-memory summary sent the request once
9+
* and reported the gateway's 401 — "LevelCode AI edit failed: … API 401: Signature has expired" —
10+
* for a request the chat would have carried through. And nothing renews the token ahead of time in
11+
* a window that simply stays focused: the session check runs when the chat loads and when the
12+
* window regains focus.
13+
*
14+
* This is that recovery for them, in one place: no VS Code, no IO, everything it needs handed in,
15+
* so it can be unit-tested (test/authRetry.test.js) and run for real under the callers
16+
* (test/authRetryCallers.test.js).
17+
*
18+
* What it will and will not do:
19+
*
20+
* - Only a GATEWAY request is renewed. A 401 from the user's own provider is a wrong key, and no
21+
* amount of refreshing a cloud session fixes that: it is rethrown untouched.
22+
* - Only an auth failure, only before any of the answer has arrived (a second send would repeat
23+
* it), and not once the caller has aborted. One retry: the second failure is the answer.
24+
* - It never ends a session. One thing does — the refresh endpoint answering 401, inside the
25+
* host's own refresh. This only FINDS OUT, by asking the host for a provider again: `signedOut`
26+
* means the session is over, and the caller gets the sentence the chat shows — coded
27+
* 'session_expired', as the chat's is — instead of the gateway's 401. (The chat and the agent
28+
* put the same question to the host's isEndedSessionError; the callers' test holds the two
29+
* answers together.) A renewal that merely failed (offline, a 5xx) changes nothing: the tokens
30+
* stay, and the request's own error is rethrown as the error it is.
31+
* - One renewal at a time, among the requests that come through here. The nodes of an Agent
32+
* Sketch level fail together, and the refresh token is rotated on use: five refreshes racing
33+
* for one token is something to untangle afterwards, one that five requests wait on is not.
34+
* It is not a lock on the host's refresh — the chat, the agent and the session check call that
35+
* themselves.
36+
* - A request refused on a token that has been renewed SINCE it was sent — by a sibling node, by
37+
* the chat — is just sent again on the stored one. And the renewed token is written onto the
38+
* request itself, so whatever else holds that request (the rest of a Sketch run) is on it too.
39+
* - A request nobody asked for (ghost text) may start a renewal once a minute at most. It is
40+
* sent on every pause in typing; a gateway that keeps answering 401 must not turn typing into
41+
* a stream of refreshes, each one rotating the session's credentials.
42+
*--------------------------------------------------------------------------------------------*/
43+
// @ts-check
44+
'use strict';
45+
46+
const { SESSION_EXPIRED_MESSAGE } = require('./session');
47+
48+
/** How often requests the user did not ask for may START a renewal. */
49+
const BACKGROUND_RENEWAL_INTERVAL_MS = 60 * 1000;
50+
51+
/** @param {AbortSignal|undefined} signal */
52+
function aborted(signal) { return !!(signal && signal.aborted); }
53+
54+
/**
55+
* `promise`'s value — or `undefined` the moment `signal` aborts, whichever comes first. The renewal
56+
* itself is not cancelled (other requests may be waiting on it, and its answer is worth having
57+
* either way); the caller just stops waiting, so Stop and Cancel stay as quick as they were.
58+
* `promise` must not reject.
59+
* @template T
60+
* @param {Promise<T>} promise
61+
* @param {AbortSignal|undefined} signal
62+
* @returns {Promise<T|undefined>}
63+
*/
64+
function unlessAborted(promise, signal) {
65+
if (!signal) { return promise; }
66+
return new Promise((resolve) => {
67+
if (signal.aborted) { resolve(undefined); return; }
68+
const stop = () => resolve(undefined);
69+
signal.addEventListener('abort', stop, { once: true });
70+
promise.then((v) => { signal.removeEventListener('abort', stop); resolve(v); });
71+
});
72+
}
73+
74+
/**
75+
* @typedef {object} AuthRetryOptions
76+
* @property {() => boolean} [streamed] true once any of the answer has reached the user: no second send after that
77+
* @property {AbortSignal} [signal] the request's own abort signal (Stop, Cancel, the next keystroke)
78+
* @property {boolean} [background] a request the user did not ask for — see BACKGROUND_RENEWAL_INTERVAL_MS
79+
*/
80+
81+
/**
82+
* Build the host's renew-and-retry. ONE instance per window: the renewal in flight is what requests
83+
* that fail together share.
84+
*
85+
* prepProviderRequest — the host's; asked again (never prompting) for what is stored NOW
86+
* refreshGatewayToken — the host's; true when a usable access token is in place afterwards
87+
* isAuthError — the host's test for "the provider refused the credentials"
88+
*
89+
* @param {{
90+
* prepProviderRequest: (opts?: any) => Promise<any>,
91+
* refreshGatewayToken: () => Promise<boolean>,
92+
* isAuthError: (e: any) => boolean,
93+
* dbg?: (label: string, data?: any) => void,
94+
* now?: () => number
95+
* }} host
96+
* @returns {<T>(req: any, send: (req: any) => Promise<T>, opts?: AuthRetryOptions) => Promise<T>}
97+
*/
98+
function createAuthRetry(host) {
99+
const now = host.now || Date.now;
100+
const dbg = host.dbg || (() => { });
101+
/** @type {Promise<boolean>|null} the renewal that is out, if one is */
102+
let renewing = null;
103+
let lastBackgroundRenewal = -Infinity;
104+
105+
/** Renew the token — or wait on the renewal already out. Never rejects: a refresh that throws has failed. */
106+
function renew() {
107+
if (!renewing) {
108+
renewing = Promise.resolve().then(() => host.refreshGatewayToken())
109+
.then(Boolean, () => false)
110+
.then((ok) => { renewing = null; return ok; });
111+
}
112+
return renewing;
113+
}
114+
115+
/**
116+
* What the host would send a request on NOW, read against the token that was just refused:
117+
* a different cloud token (`apiKey`), or the news that the session has ended.
118+
* @param {string} sent
119+
* @returns {Promise<{apiKey?: string, ended?: boolean}>}
120+
*/
121+
async function stored(sent) {
122+
const cur = await host.prepProviderRequest({ prompt: false }); // a retry never opens a key dialog
123+
if (cur && cur.ok && cur.gateway && cur.apiKey && cur.apiKey !== sent) { return { apiKey: cur.apiKey }; }
124+
return { ended: !!cur && !cur.ok && cur.reason === 'signedOut' };
125+
}
126+
127+
/**
128+
* A gateway request carrying `sent` was refused. Find the token to send it again on.
129+
* @param {string} sent
130+
* @param {AuthRetryOptions} o
131+
* @returns {Promise<{outcome: string, apiKey?: string}>}
132+
*/
133+
async function recover(sent, o) {
134+
let cur = await stored(sent);
135+
if (cur.apiKey) { return { outcome: 'already-renewed', apiKey: cur.apiKey }; }
136+
if (cur.ended) { return { outcome: 'ended' }; }
137+
if (o.background) {
138+
if (now() - lastBackgroundRenewal < BACKGROUND_RENEWAL_INTERVAL_MS) { return { outcome: 'throttled' }; }
139+
lastBackgroundRenewal = now();
140+
}
141+
const renewed = await unlessAborted(renew(), o.signal);
142+
if (aborted(o.signal)) { return { outcome: 'aborted' }; }
143+
cur = await stored(sent);
144+
if (cur.ended) { return { outcome: 'ended' }; }
145+
return renewed && cur.apiKey ? { outcome: 'renewed', apiKey: cur.apiKey } : { outcome: 'failed' };
146+
}
147+
148+
return async function authRetry(req, send, opts) {
149+
const o = opts || {};
150+
const sent = req.apiKey;
151+
try {
152+
return await send(req);
153+
} catch (e) {
154+
if (!req.gateway || !host.isAuthError(e) || aborted(o.signal) || (o.streamed && o.streamed())) { throw e; }
155+
/** @type {{outcome: string, apiKey?: string}} */
156+
let found;
157+
// Whatever goes wrong while looking for a way to recover, the request's own failure is the news.
158+
try { found = await recover(sent, o); } catch { found = { outcome: 'failed' }; }
159+
dbg('auth.retry', { outcome: found.outcome });
160+
if (found.outcome === 'ended') {
161+
throw Object.assign(new Error(SESSION_EXPIRED_MESSAGE), { code: 'session_expired', cause: e });
162+
}
163+
if (!found.apiKey || aborted(o.signal)) { throw e; }
164+
req.apiKey = found.apiKey;
165+
return send(req);
166+
}
167+
};
168+
}
169+
170+
/**
171+
* How a caller that is handed `deps` sends a provider request: through the host's renew-and-retry
172+
* when it was given one (`deps.authRetry`), and plainly when it was not — so a caller wired without
173+
* it behaves exactly as it did before there was one.
174+
* @template T
175+
* @param {any} deps
176+
* @param {any} req an ok prepProviderRequest() result
177+
* @param {(req: any) => Promise<T>} send sends the request on `req`; called a second time, at most, after a renewal
178+
* @param {AuthRetryOptions} [opts]
179+
* @returns {Promise<T>}
180+
*/
181+
function sendWithAuthRetry(deps, req, send, opts) {
182+
return deps && typeof deps.authRetry === 'function' ? deps.authRetry(req, send, opts) : send(req);
183+
}
184+
185+
module.exports = { BACKGROUND_RENEWAL_INTERVAL_MS, createAuthRetry, sendWithAuthRetry };

0 commit comments

Comments
 (0)