Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions extensions/levelcode-ai/aiEdit.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
const vscode = require('vscode');
const path = require('path');
const { SESSION_EXPIRED_MESSAGE } = require('./providers/session');
const { sendWithAuthRetry } = require('./providers/authRetry');

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

/** @param {{aiConfig:()=>any, prepProviderRequest:(o?:any)=>Promise<any>, streamChat:Function, accountSignIn:()=>Promise<any>}} deps */
/** @param {{aiConfig:()=>any, prepProviderRequest:(o?:any)=>Promise<any>, streamChat:Function, accountSignIn:()=>Promise<any>, authRetry?:Function}} deps */
async function editSelection(deps) {
const ed = vscode.window.activeTextEditor;
if (!ed || ed.selection.isEmpty) {
Expand Down Expand Up @@ -147,11 +148,13 @@ async function editSelection(deps) {
: req.reason === 'insecureBaseURL' ? 'Refusing to send your API key over plain http to a non-local host. Use an https (or localhost) base URL.'
: 'No API key set for ' + req.label + '.');
}
await deps.streamChat({
providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL,
model: req.model, maxTokens: req.maxTokens, system: EDIT_SYSTEM,
// Sent through the host's authRetry: a lapsed cloud token is renewed and the edit asked for
// once more — unless some of it has already arrived, which a second answer would repeat.
await sendWithAuthRetry(deps, req, (r) => deps.streamChat({
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL,
model: r.model, maxTokens: r.maxTokens, system: EDIT_SYSTEM,
messages: [{ role: 'user', content: userMsg }], signal: ac.signal, onDelta
});
}), { streamed: () => !!result, signal: ac.signal });
}
);
} catch (e) {
Expand Down
40 changes: 32 additions & 8 deletions extensions/levelcode-ai/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const providers = require('./providers/index');
const catalog = require('./providers/catalog');
const { resolveGateway } = require('./providers/gateway');
const session = require('./providers/session');
const { createAuthRetry } = require('./providers/authRetry');
const { registerAiEdit } = require('./aiEdit');
const { registerLmProvider } = require('./lmProvider');
const { registerInlineComplete } = require('./inlineComplete');
Expand Down Expand Up @@ -613,6 +614,24 @@ async function refreshGatewayToken() {
return refreshCloudToken();
}

/**
* A lapsed access token, for everything that sends a provider request and is neither the chat nor
* the agent: inline edit, Agent Sketch and inline completion (each is handed this in its `deps`),
* and Compact and the session-memory summary further down. They sent a request once and reported
* the gateway's 401 — for a token the chat would have renewed and carried on with, and one nothing
* renews ahead of time in a window that simply stays focused (checkCloudSession runs on the
* webview's `ready` and on regaining focus). Through this, the token is renewed and the request sent
* once more.
*
* The rules are in providers/authRetry.js. The two that matter here: it never ends a session — the
* refresh endpoint answering 401, above, is still the only thing that does — and a BYOK request is
* never refreshed or retried. The chat and the agent keep their own retry (handleSend; the agent's
* refreshAuth hook): they have a transcript and a sign-in card to keep in step with it.
*
* ONE instance for the window, so that requests which fail together wait on a single renewal.
*/
const authRetry = createAuthRetry({ prepProviderRequest, refreshGatewayToken, isAuthError, dbg });

/**
* Resolve everything needed to call the active provider: id, key, model, baseURL, maxTokens.
* Returns { ok:false, reason } when a required key/baseURL is missing (after optional prompting) or
Expand Down Expand Up @@ -1106,11 +1125,12 @@ async function summarizeSessionOutcome(messages, existingFacts) {
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'); }
instr += '\n\nTranscript:\n\n' + flat;
try {
const out = await providers.complete({
providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL, label: req.label,
// Through authRetry: a session sealed after the cloud token lapsed still gets its summary.
const out = await authRetry(req, (r) => providers.complete({
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL, label: r.label,
model, maxTokens: 200, system: OUTCOME_SYSTEM,
messages: [{ role: 'user', content: instr }]
});
}));
return parseOutcome(out, ef);
} catch (e) { dbg('sessions.memory.summarize.error', { msg: String((e && e.message) || e) }); return { summary: '', facts: [], supersedes: [] }; }
}
Expand Down Expand Up @@ -1837,12 +1857,14 @@ async function compactAgentMemory() {

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

Expand Down Expand Up @@ -3339,7 +3361,7 @@ function activate(context) {
// Agent Sketch: the visual multi-agent flow canvas. Lazy require — only loads when opened.
vscode.commands.registerCommand('levelcode.ai.sketch', () => {
try {
require('./sketch').openSketch(context, { prepProviderRequest, aiConfig, currentProviderId });
require('./sketch').openSketch(context, { prepProviderRequest, aiConfig, currentProviderId, authRetry });
} catch (e) {
vscode.window.showErrorMessage('Agent Sketch failed to load: ' + ((e && e.message) || e));
}
Expand Down Expand Up @@ -3378,6 +3400,7 @@ function activate(context) {
// AI edit-with-diff (select code → instruct → review diff → apply) — provider-agnostic.
registerAiEdit(context, {
aiConfig,
authRetry, // a lapsed cloud token is renewed and the edit sent once more
prepProviderRequest,
streamChat: providers.streamChat,
accountSignIn // the "Sign in" on an edit refused because the cloud session ended
Expand All @@ -3397,6 +3420,7 @@ function activate(context) {
// it must never pop a key dialog mid-typing.
registerInlineComplete(context, {
aiConfig,
authRetry, // …and renewed as silently: a failed renewal is one missing suggestion, never a toast
prepProviderRequest,
complete: providers.complete,
fastCompletionModel: catalog.fastCompletionModel
Expand Down
16 changes: 11 additions & 5 deletions extensions/levelcode-ai/inlineComplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
'use strict';

const vscode = require('vscode');
const { sendWithAuthRetry } = require('./providers/authRetry');

const PREFIX_MAX = 2500; // chars of context before the cursor (smaller = faster first token)
const SUFFIX_MAX = 1000; // chars of context after the cursor
Expand Down Expand Up @@ -58,7 +59,8 @@ function clean(text) {
* @param {vscode.ExtensionContext} context
* @param {{ aiConfig: () => vscode.WorkspaceConfiguration,
* prepProviderRequest: (o?:any) => Promise<any>,
* complete: Function, fastCompletionModel: (providerId:string)=>(string|null) }} deps
* complete: Function, fastCompletionModel: (providerId:string)=>(string|null),
* authRetry?: Function }} deps
*/
function registerInlineComplete(context, deps) {
const { aiConfig, prepProviderRequest, complete, fastCompletionModel } = deps;
Expand Down Expand Up @@ -124,13 +126,17 @@ function registerInlineComplete(context, deps) {
const model = req.providerId === 'claude'
? cfg.get('completions.model', 'claude-haiku-4-5-20251001')
: (fastCompletionModel(req.providerId) || req.model);
text = await complete({
providerId: req.providerId, apiKey: req.apiKey, baseURL: req.baseURL,
// Sent through the host's authRetry: a lapsed cloud token is renewed and the request sent once
// more, as quietly as everything else here. `background`: nobody asked for this request and
// it is sent on every pause in typing, so it may start a renewal only so often.
text = await sendWithAuthRetry(deps, req, (r) => complete({
providerId: r.providerId, apiKey: r.apiKey, baseURL: r.baseURL,
model, maxTokens: MAX_TOKENS, system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: userContent }], signal: ac.signal
});
}), { signal: ac.signal, background: true });
} catch (e) {
// Network/abort/key errors are silent — inline completion must never nag.
// Network/abort/key errors are silent — inline completion must never nag. That includes a
// renewal that failed, and one that found the session over: the chat shows the sign-in card.
return null;
} finally {
sub.dispose();
Expand Down
185 changes: 185 additions & 0 deletions extensions/levelcode-ai/providers/authRetry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/*---------------------------------------------------------------------------------------------
* LevelCode — AI · a lapsed LevelCode Cloud token: renewed, and the request sent once more (pure)
*
* The access token lives 8 hours; the session behind it lives as long as its refresh token. So the
* gateway can refuse a request with a 401 while the session is perfectly renewable — the token
* LAPSED, the session did not END. The chat and the agent recover from that: renew the token, send
* the request again, once. Everything else that asks the host for a provider did not. Inline edit,
* Agent Sketch, inline completion, Compact and the session-memory summary sent the request once
* and reported the gateway's 401 — "LevelCode AI edit failed: … API 401: Signature has expired" —
* for a request the chat would have carried through. And nothing renews the token ahead of time in
* a window that simply stays focused: the session check runs when the chat loads and when the
* window regains focus.
*
* This is that recovery for them, in one place: no VS Code, no IO, everything it needs handed in,
* so it can be unit-tested (test/authRetry.test.js) and run for real under the callers
* (test/authRetryCallers.test.js).
*
* What it will and will not do:
*
* - Only a GATEWAY request is renewed. A 401 from the user's own provider is a wrong key, and no
* amount of refreshing a cloud session fixes that: it is rethrown untouched.
* - Only an auth failure, only before any of the answer has arrived (a second send would repeat
* it), and not once the caller has aborted. One retry: the second failure is the answer.
* - It never ends a session. One thing does — the refresh endpoint answering 401, inside the
* host's own refresh. This only FINDS OUT, by asking the host for a provider again: `signedOut`
* means the session is over, and the caller gets the sentence the chat shows — coded
* 'session_expired', as the chat's is — instead of the gateway's 401. (The chat and the agent
* put the same question to the host's isEndedSessionError; the callers' test holds the two
* answers together.) A renewal that merely failed (offline, a 5xx) changes nothing: the tokens
* stay, and the request's own error is rethrown as the error it is.
* - One renewal at a time, among the requests that come through here. The nodes of an Agent
* Sketch level fail together, and the refresh token is rotated on use: five refreshes racing
* for one token is something to untangle afterwards, one that five requests wait on is not.
* It is not a lock on the host's refresh — the chat, the agent and the session check call that
* themselves.
* - A request refused on a token that has been renewed SINCE it was sent — by a sibling node, by
* the chat — is just sent again on the stored one. And the renewed token is written onto the
* request itself, so whatever else holds that request (the rest of a Sketch run) is on it too.
* - A request nobody asked for (ghost text) may start a renewal once a minute at most. It is
* sent on every pause in typing; a gateway that keeps answering 401 must not turn typing into
* a stream of refreshes, each one rotating the session's credentials.
*--------------------------------------------------------------------------------------------*/
// @ts-check
'use strict';

const { SESSION_EXPIRED_MESSAGE } = require('./session');

/** How often requests the user did not ask for may START a renewal. */
const BACKGROUND_RENEWAL_INTERVAL_MS = 60 * 1000;

/** @param {AbortSignal|undefined} signal */
function aborted(signal) { return !!(signal && signal.aborted); }

/**
* `promise`'s value — or `undefined` the moment `signal` aborts, whichever comes first. The renewal
* itself is not cancelled (other requests may be waiting on it, and its answer is worth having
* either way); the caller just stops waiting, so Stop and Cancel stay as quick as they were.
* `promise` must not reject.
* @template T
* @param {Promise<T>} promise
* @param {AbortSignal|undefined} signal
* @returns {Promise<T|undefined>}
*/
function unlessAborted(promise, signal) {
if (!signal) { return promise; }
return new Promise((resolve) => {
if (signal.aborted) { resolve(undefined); return; }
const stop = () => resolve(undefined);
signal.addEventListener('abort', stop, { once: true });
promise.then((v) => { signal.removeEventListener('abort', stop); resolve(v); });
});
}

/**
* @typedef {object} AuthRetryOptions
* @property {() => boolean} [streamed] true once any of the answer has reached the user: no second send after that
* @property {AbortSignal} [signal] the request's own abort signal (Stop, Cancel, the next keystroke)
* @property {boolean} [background] a request the user did not ask for — see BACKGROUND_RENEWAL_INTERVAL_MS
*/

/**
* Build the host's renew-and-retry. ONE instance per window: the renewal in flight is what requests
* that fail together share.
*
* prepProviderRequest — the host's; asked again (never prompting) for what is stored NOW
* refreshGatewayToken — the host's; true when a usable access token is in place afterwards
* isAuthError — the host's test for "the provider refused the credentials"
*
* @param {{
* prepProviderRequest: (opts?: any) => Promise<any>,
* refreshGatewayToken: () => Promise<boolean>,
* isAuthError: (e: any) => boolean,
* dbg?: (label: string, data?: any) => void,
* now?: () => number
* }} host
* @returns {<T>(req: any, send: (req: any) => Promise<T>, opts?: AuthRetryOptions) => Promise<T>}
*/
function createAuthRetry(host) {
const now = host.now || Date.now;
const dbg = host.dbg || (() => { });
/** @type {Promise<boolean>|null} the renewal that is out, if one is */
let renewing = null;
let lastBackgroundRenewal = -Infinity;

/** Renew the token — or wait on the renewal already out. Never rejects: a refresh that throws has failed. */
function renew() {
if (!renewing) {
renewing = Promise.resolve().then(() => host.refreshGatewayToken())
.then(Boolean, () => false)
.then((ok) => { renewing = null; return ok; });
}
return renewing;
}

/**
* What the host would send a request on NOW, read against the token that was just refused:
* a different cloud token (`apiKey`), or the news that the session has ended.
* @param {string} sent
* @returns {Promise<{apiKey?: string, ended?: boolean}>}
*/
async function stored(sent) {
const cur = await host.prepProviderRequest({ prompt: false }); // a retry never opens a key dialog
if (cur && cur.ok && cur.gateway && cur.apiKey && cur.apiKey !== sent) { return { apiKey: cur.apiKey }; }
Comment thread
ndemianc marked this conversation as resolved.
Resolved
return { ended: !!cur && !cur.ok && cur.reason === 'signedOut' };
}

/**
* A gateway request carrying `sent` was refused. Find the token to send it again on.
* @param {string} sent
* @param {AuthRetryOptions} o
* @returns {Promise<{outcome: string, apiKey?: string}>}
*/
async function recover(sent, o) {
let cur = await stored(sent);
if (cur.apiKey) { return { outcome: 'already-renewed', apiKey: cur.apiKey }; }
if (cur.ended) { return { outcome: 'ended' }; }
if (o.background) {
Comment thread
ndemianc marked this conversation as resolved.
Resolved
if (now() - lastBackgroundRenewal < BACKGROUND_RENEWAL_INTERVAL_MS) { return { outcome: 'throttled' }; }
lastBackgroundRenewal = now();
}
const renewed = await unlessAborted(renew(), o.signal);
if (aborted(o.signal)) { return { outcome: 'aborted' }; }
cur = await stored(sent);
if (cur.ended) { return { outcome: 'ended' }; }
return renewed && cur.apiKey ? { outcome: 'renewed', apiKey: cur.apiKey } : { outcome: 'failed' };
}

return async function authRetry(req, send, opts) {
const o = opts || {};
const sent = req.apiKey;
try {
return await send(req);
} catch (e) {
if (!req.gateway || !host.isAuthError(e) || aborted(o.signal) || (o.streamed && o.streamed())) { throw e; }
/** @type {{outcome: string, apiKey?: string}} */
let found;
// Whatever goes wrong while looking for a way to recover, the request's own failure is the news.
try { found = await recover(sent, o); } catch { found = { outcome: 'failed' }; }
dbg('auth.retry', { outcome: found.outcome });
if (found.outcome === 'ended') {
throw Object.assign(new Error(SESSION_EXPIRED_MESSAGE), { code: 'session_expired', cause: e });
}
if (!found.apiKey || aborted(o.signal)) { throw e; }
req.apiKey = found.apiKey;
return send(req);
}
};
}

/**
* How a caller that is handed `deps` sends a provider request: through the host's renew-and-retry
* when it was given one (`deps.authRetry`), and plainly when it was not — so a caller wired without
* it behaves exactly as it did before there was one.
* @template T
* @param {any} deps
* @param {any} req an ok prepProviderRequest() result
* @param {(req: any) => Promise<T>} send sends the request on `req`; called a second time, at most, after a renewal
* @param {AuthRetryOptions} [opts]
* @returns {Promise<T>}
*/
function sendWithAuthRetry(deps, req, send, opts) {
return deps && typeof deps.authRetry === 'function' ? deps.authRetry(req, send, opts) : send(req);
}

module.exports = { BACKGROUND_RENEWAL_INTERVAL_MS, createAuthRetry, sendWithAuthRetry };
Loading
Loading