Skip to content

Commit 214e67d

Browse files
authored
Merge pull request #96 from levelcodeai/claude/nice-snyder-aa92c2
fix(cloud): a lapsed token is renewed outside the chat too — not shown as "API 401"
2 parents d056588 + 95178cf commit 214e67d

9 files changed

Lines changed: 2010 additions & 60 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: 36 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');
@@ -653,6 +654,28 @@ async function refreshGatewayToken() {
653654
return refreshCloudToken();
654655
}
655656

657+
/**
658+
* A lapsed access token, for everything that sends a provider request and is neither the chat nor
659+
* the agent: inline edit, Agent Sketch and inline completion (each is handed this in its `deps`),
660+
* and Compact and the session-memory summary further down. They sent a request once and reported
661+
* the gateway's 401 — for a token the chat would have renewed and carried on with, and one nothing
662+
* renews ahead of time in a window that simply stays focused (checkCloudSession runs on the
663+
* webview's `ready` and on regaining focus). Through this, the token is renewed and the request sent
664+
* once more.
665+
*
666+
* The rules are in providers/authRetry.js. The two that matter here: it never ends a session — the
667+
* refresh endpoint answering 401, above, is still the only thing that does — and a BYOK request is
668+
* never refreshed or retried. The chat and the agent keep their own retry (handleSend; the agent's
669+
* refreshAuth hook): they have a transcript and a sign-in card to keep in step with it.
670+
*
671+
* ONE instance for the window, so that requests which fail together wait on a single renewal. And it
672+
* is told which session this window is on (sessionGeneration), for the reason the refresh keeps that
673+
* count: a request belongs to the session it was sent in. A token stored by a LATER sign-in is not a
674+
* renewal of the old one, and is never put on a request the earlier session sent. (A sign-in made in
675+
* ANOTHER window this count cannot see; for that, authRetry goes by who the tokens say they are for.)
676+
*/
677+
const authRetry = createAuthRetry({ prepProviderRequest, refreshGatewayToken, isAuthError, sessionGeneration: () => sessionGeneration, dbg });
678+
656679
/**
657680
* Resolve everything needed to call the active provider: id, key, model, baseURL, maxTokens.
658681
* Returns { ok:false, reason } when a required key/baseURL is missing (after optional prompting) or
@@ -1146,11 +1169,12 @@ async function summarizeSessionOutcome(messages, existingFacts) {
11461169
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'); }
11471170
instr += '\n\nTranscript:\n\n' + flat;
11481171
try {
1149-
const out = await providers.complete({
1150-
providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL, label: req.label,
1172+
// Through authRetry: a session sealed after the cloud token lapsed still gets its summary.
1173+
const out = await authRetry(req, (r) => providers.complete({
1174+
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL, label: r.label,
11511175
model, maxTokens: 200, system: OUTCOME_SYSTEM,
11521176
messages: [{ role: 'user', content: instr }]
1153-
});
1177+
}));
11541178
return parseOutcome(out, ef);
11551179
} catch (e) { dbg('sessions.memory.summarize.error', { msg: String((e && e.message) || e) }); return { summary: '', facts: [], supersedes: [] }; }
11561180
}
@@ -1877,12 +1901,14 @@ async function compactAgentMemory() {
18771901

18781902
let summary;
18791903
try {
1880-
summary = await providers.complete({
1881-
providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL, label: req.label,
1882-
model: req.model, maxTokens: 1500,
1904+
// Through authRetry: a lapsed cloud token is renewed and the summary asked for once more, rather
1905+
// than Compact failing on a 401 that the next chat message would have recovered from.
1906+
summary = await authRetry(req, (r) => providers.complete({
1907+
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL, label: r.label,
1908+
model: r.model, maxTokens: 1500,
18831909
system: COMPACT_SYSTEM,
18841910
messages: [{ role: 'user', content: COMPACT_INSTRUCTIONS + flat }]
1885-
});
1911+
}));
18861912
} catch (e) { dbg('compact.error', { msg: String((e && e.message) || e) }); return { ok: false, reason: 'failed' }; }
18871913
if (!summary || !summary.trim()) { return { ok: false, reason: 'empty' }; }
18881914

@@ -3393,7 +3419,7 @@ function activate(context) {
33933419
// Agent Sketch: the visual multi-agent flow canvas. Lazy require — only loads when opened.
33943420
vscode.commands.registerCommand('levelcode.ai.sketch', () => {
33953421
try {
3396-
require('./sketch').openSketch(context, { prepProviderRequest, aiConfig, currentProviderId });
3422+
require('./sketch').openSketch(context, { prepProviderRequest, aiConfig, currentProviderId, authRetry });
33973423
} catch (e) {
33983424
vscode.window.showErrorMessage('Agent Sketch failed to load: ' + ((e && e.message) || e));
33993425
}
@@ -3432,6 +3458,7 @@ function activate(context) {
34323458
// AI edit-with-diff (select code → instruct → review diff → apply) — provider-agnostic.
34333459
registerAiEdit(context, {
34343460
aiConfig,
3461+
authRetry, // a lapsed cloud token is renewed and the edit sent once more
34353462
prepProviderRequest,
34363463
streamChat: providers.streamChat,
34373464
accountSignIn // the "Sign in" on an edit refused because the cloud session ended
@@ -3451,6 +3478,7 @@ function activate(context) {
34513478
// it must never pop a key dialog mid-typing.
34523479
registerInlineComplete(context, {
34533480
aiConfig,
3481+
authRetry, // …and renewed as silently: a failed renewal is one missing suggestion, never a toast
34543482
prepProviderRequest,
34553483
complete: providers.complete,
34563484
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();

0 commit comments

Comments
 (0)