CI / test (push) Successful in 9m14s
From review. - /profile asked before signing in keeps its query for ten minutes, and the sign-in (password or OIDC) returns to it: VS Code's sign-in state came back lost, and the confirmation came every time. Only /profile is ever returned to. - The extension keeps an https gateway at https when a link names it by plain http (a proxy ending TLS unannounced), so the key is never sent in the clear. The install command says --force, since dev builds share a version and VS Code kept the old one. - Every frame of every install recording is checked for a key, a password or a private key, not only the page. - The admin API document again matches the API: f94d902's MCPServerRequest.clear was not in it. - CHANGELOG for this round's fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
9.9 KiB
JavaScript
260 lines
9.9 KiB
JavaScript
'use strict';
|
|
|
|
// llmgw for VS Code: the models a gateway serves, offered to VS Code as a
|
|
// language-model provider, so its chat and any extension that asks VS Code
|
|
// for a model can use them.
|
|
//
|
|
// The key lives in VS Code's secret storage, one per gateway, and is put
|
|
// there one of two ways: the gateway's account page makes one and hands it
|
|
// back through a vscode:// link, or the person pastes one.
|
|
|
|
const vscode = require('vscode');
|
|
const crypto = require('crypto');
|
|
const lib = require('./lib');
|
|
|
|
const VENDOR = 'llmgw';
|
|
const SIGN_IN_TTL_MS = 15 * 60 * 1000;
|
|
|
|
/** @param {vscode.ExtensionContext} context */
|
|
function activate(context) {
|
|
const changed = new vscode.EventEmitter();
|
|
const version = context.extension.packageJSON.version || '0.0.0';
|
|
const userAgent = `llmgw-vscode/${version} vscode/${vscode.version}`;
|
|
|
|
const gateway = () => lib.gatewayBase(vscode.workspace.getConfiguration('llmgw').get('gatewayUrl'));
|
|
const secretName = (base) => `llmgw.key:${base}`;
|
|
const keyFor = async (base) => (base ? context.secrets.get(secretName(base)) : undefined);
|
|
|
|
// The sign-in this window started: what the link back must carry to be
|
|
// taken without asking.
|
|
let pending = null;
|
|
|
|
async function askGateway() {
|
|
const now = gateway();
|
|
const typed = await vscode.window.showInputBox({
|
|
title: 'llmgw gateway',
|
|
prompt: 'The address of your llmgw gateway, such as https://llm.example.com',
|
|
value: now,
|
|
ignoreFocusOut: true,
|
|
validateInput: (v) => (lib.gatewayBase(v) ? undefined : 'An http:// or https:// address'),
|
|
});
|
|
if (typed === undefined) return '';
|
|
const base = lib.gatewayBase(typed);
|
|
if (base !== now) {
|
|
await vscode.workspace.getConfiguration('llmgw').update('gatewayUrl', base, vscode.ConfigurationTarget.Global);
|
|
}
|
|
return base;
|
|
}
|
|
|
|
async function signIn() {
|
|
const base = gateway() || (await askGateway());
|
|
if (!base) return;
|
|
pending = { state: crypto.randomBytes(18).toString('base64url'), base, at: Date.now() };
|
|
const page = vscode.Uri.parse(`${base}/profile?tool=vscode&state=${pending.state}#connect`);
|
|
const opened = await vscode.env.openExternal(page);
|
|
if (!opened) return;
|
|
vscode.window.showInformationMessage(
|
|
`Sign in on ${base} in your browser and choose "Make a key and the configuration". The page sends the key back to VS Code.`,
|
|
);
|
|
}
|
|
|
|
async function enterKey() {
|
|
const base = gateway() || (await askGateway());
|
|
if (!base) return;
|
|
const key = await vscode.window.showInputBox({
|
|
title: `llmgw key for ${base}`,
|
|
prompt: 'A key from your account page on the gateway, starting sk-llmgw-',
|
|
password: true,
|
|
ignoreFocusOut: true,
|
|
validateInput: (v) => (v.trim() ? undefined : 'The key'),
|
|
});
|
|
if (!key) return;
|
|
await storeKey(base, key.trim());
|
|
}
|
|
|
|
async function storeKey(base, key) {
|
|
// Tried before it is kept: a wrong key or a gateway that is not there is
|
|
// better said now than as an empty model list later.
|
|
try {
|
|
await listModels(base, key);
|
|
} catch (err) {
|
|
vscode.window.showErrorMessage(String(err && err.message ? err.message : err));
|
|
return;
|
|
}
|
|
await context.secrets.store(secretName(base), key);
|
|
changed.fire();
|
|
vscode.window.showInformationMessage(`llmgw: connected to ${base}. Its models are in the chat's model picker.`);
|
|
}
|
|
|
|
async function signOut() {
|
|
const base = gateway();
|
|
if (!base) return;
|
|
await context.secrets.delete(secretName(base));
|
|
changed.fire();
|
|
vscode.window.showInformationMessage(
|
|
`llmgw: signed out of ${base}. The key still works until it is revoked on the gateway's account page.`,
|
|
);
|
|
}
|
|
|
|
// The account page's "Open in VS Code" link: vscode://llmgw.llmgw/connect
|
|
// with the gateway, the key it just made, and the state this window sent.
|
|
// A link from anywhere else is asked about first: a page that could hand
|
|
// VS Code a key of its own choosing would also choose where code is sent.
|
|
async function handleUri(uri) {
|
|
if (uri.path !== '/connect') return;
|
|
const q = new URLSearchParams(uri.query);
|
|
let base = lib.gatewayBase(q.get('gateway'));
|
|
const key = (q.get('key') || '').trim();
|
|
if (!base || !key) {
|
|
vscode.window.showErrorMessage('llmgw: the link has no gateway or no key in it.');
|
|
return;
|
|
}
|
|
// The same gateway by plain http, behind a proxy that ends TLS without
|
|
// saying so: kept at https, so the key is never sent in the clear.
|
|
const current = gateway();
|
|
if (current && lib.downgrades(current, base)) {
|
|
base = current;
|
|
}
|
|
const expected =
|
|
pending && pending.base === base && q.get('state') === pending.state && Date.now() - pending.at < SIGN_IN_TTL_MS;
|
|
pending = null;
|
|
if (!expected) {
|
|
const yes = 'Connect';
|
|
const choice = await vscode.window.showWarningMessage(
|
|
`Connect VS Code to the llmgw gateway at ${base}?`,
|
|
{
|
|
modal: true,
|
|
detail: 'What you ask its models, and the code they are given, will go to that address. Connect only if you just made this key on its account page.',
|
|
},
|
|
yes,
|
|
);
|
|
if (choice !== yes) return;
|
|
}
|
|
if (base !== gateway()) {
|
|
await vscode.workspace.getConfiguration('llmgw').update('gatewayUrl', base, vscode.ConfigurationTarget.Global);
|
|
}
|
|
await storeKey(base, key);
|
|
}
|
|
|
|
async function call(base, key, path, init) {
|
|
const res = await fetch(base + path, {
|
|
...init,
|
|
headers: {
|
|
Authorization: `Bearer ${key}`,
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': userAgent,
|
|
...(init && init.headers),
|
|
},
|
|
});
|
|
if (!res.ok) throw new Error(lib.errorMessage(res.status, await res.text()));
|
|
return res;
|
|
}
|
|
|
|
async function listModels(base, key) {
|
|
let res;
|
|
try {
|
|
res = await call(base, key, '/v1/models', { method: 'GET', signal: AbortSignal.timeout(15000) });
|
|
} catch (err) {
|
|
if (err && err.message && err.message.startsWith('llmgw:')) throw err;
|
|
throw new Error(`llmgw: could not reach ${base} (${err && err.cause ? err.cause.message || err.cause : err}).`);
|
|
}
|
|
const body = await res.json();
|
|
return Array.isArray(body.data) ? body.data : [];
|
|
}
|
|
|
|
const provider = {
|
|
onDidChangeLanguageModelChatInformation: changed.event,
|
|
|
|
async provideLanguageModelChatInformation(options) {
|
|
const base = gateway();
|
|
const key = await keyFor(base);
|
|
if (!base || !key) {
|
|
// Offered, not waited for: VS Code is waiting on this list, and a
|
|
// notification can sit unanswered for as long as the person likes.
|
|
// Signing in fires the change event, and VS Code asks again.
|
|
if (!options.silent) {
|
|
vscode.window
|
|
.showInformationMessage('llmgw: sign in to a gateway to use its models.', 'Sign In', 'Enter API Key')
|
|
.then((pick) => {
|
|
if (pick === 'Sign In') return signIn();
|
|
if (pick === 'Enter API Key') return enterKey();
|
|
});
|
|
}
|
|
return [];
|
|
}
|
|
try {
|
|
const show = vscode.workspace.getConfiguration('llmgw').get('showModelsWithoutTools', true);
|
|
return (await listModels(base, key)).map((e) => lib.modelInfo(e, show)).filter(Boolean);
|
|
} catch (err) {
|
|
if (!options.silent) vscode.window.showErrorMessage(String(err.message || err));
|
|
return [];
|
|
}
|
|
},
|
|
|
|
async provideLanguageModelChatResponse(model, messages, options, progress, token) {
|
|
const base = gateway();
|
|
const key = await keyFor(base);
|
|
if (!base || !key) throw new Error('llmgw: not signed in. Run "llmgw: Sign In".');
|
|
const abort = new AbortController();
|
|
const sub = token.onCancellationRequested(() => abort.abort());
|
|
try {
|
|
const res = await call(base, key, '/v1/chat/completions', {
|
|
method: 'POST',
|
|
body: JSON.stringify(lib.requestBody(model, messages, options)),
|
|
signal: abort.signal,
|
|
});
|
|
const answer = new lib.Answer();
|
|
for await (const data of lib.sseData(res.body)) {
|
|
const text = answer.add(data);
|
|
if (text) progress.report(new vscode.LanguageModelTextPart(text));
|
|
}
|
|
for (const c of answer.toolCalls()) {
|
|
progress.report(new vscode.LanguageModelToolCallPart(c.id, c.name, c.input));
|
|
}
|
|
} catch (err) {
|
|
if (token.isCancellationRequested) return;
|
|
throw err;
|
|
} finally {
|
|
sub.dispose();
|
|
}
|
|
},
|
|
|
|
async provideTokenCount(_model, text) {
|
|
if (typeof text === 'string') return lib.tokenGuess(text);
|
|
const parts = (text && text.content) || [];
|
|
return parts.reduce((n, p) => n + lib.tokenGuess(p && typeof p.value === 'string' ? p.value : JSON.stringify(p)), 0);
|
|
},
|
|
};
|
|
|
|
context.subscriptions.push(
|
|
changed,
|
|
vscode.lm.registerLanguageModelChatProvider(VENDOR, provider),
|
|
vscode.window.registerUriHandler({ handleUri }),
|
|
vscode.commands.registerCommand('llmgw.signIn', signIn),
|
|
vscode.commands.registerCommand('llmgw.enterKey', enterKey),
|
|
vscode.commands.registerCommand('llmgw.signOut', signOut),
|
|
vscode.commands.registerCommand('llmgw.refresh', () => changed.fire()),
|
|
vscode.workspace.onDidChangeConfiguration((e) => {
|
|
if (e.affectsConfiguration('llmgw')) changed.fire();
|
|
}),
|
|
);
|
|
|
|
// For another extension, or a test, that has a gateway and a key in hand:
|
|
// the same as the account page's link, without the question, since the
|
|
// caller already runs with every right VS Code gives an extension.
|
|
return {
|
|
async connect(gatewayUrl, key) {
|
|
const base = lib.gatewayBase(gatewayUrl);
|
|
if (!base || !key) throw new Error('llmgw: connect needs a gateway address and a key');
|
|
if (base !== gateway()) {
|
|
await vscode.workspace.getConfiguration('llmgw').update('gatewayUrl', base, vscode.ConfigurationTarget.Global);
|
|
}
|
|
await storeKey(base, String(key).trim());
|
|
},
|
|
};
|
|
}
|
|
|
|
function deactivate() {}
|
|
|
|
module.exports = { activate, deactivate };
|