diff --git a/CHANGELOG.md b/CHANGELOG.md
index 240adf5..87c4259 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,18 @@ APIs change between minors without a deprecation period.
### Added
+- **llmgw for VS Code.** An extension of llmgw's own, served by every gateway
+ at `/setup/vscode/llmgw.vsix` with that gateway's address in it, made from
+ the binary itself: plain JavaScript, no dependencies, no build, and so no
+ marketplace needed at a site with no internet. It offers the gateway's chat
+ models to VS Code's language-model API — the chat's model picker, agent
+ mode, and every extension that asks VS Code for a model — with the sizes
+ and the tool and vision capabilities the catalog says, streamed, with tool
+ calls and their results both ways. **VS Code** is a tool on the account
+ page's **Connect your editor**: a key, the one-line install, and an **Open
+ in VS Code** link that hands the key to the extension, which keeps it in
+ VS Code's secret storage. **llmgw: Sign In** opens that page with a state
+ of its own; a link without it is confirmed first, the gateway named.
- **An install guide with every screen, at `/install`.** From three bare
Ubuntu machines to a first answer: what each machine needs and how to
check it, the release fetched and checked, the setup screens one by one,
diff --git a/docs/onboarding.md b/docs/onboarding.md
index 11c7fb1..f4c3fc4 100644
--- a/docs/onboarding.md
+++ b/docs/onboarding.md
@@ -54,10 +54,20 @@ on the page that creates it and never again.
### Connecting an editor
-The account page's **Connect your editor** makes a key for Continue, Codex or
-Cline and shows what the tool needs with that key, this gateway's address and a
-model already in it:
+The account page's **Connect your editor** makes a key for VS Code, Continue,
+Codex or Cline and shows what the tool needs with that key, this gateway's
+address and a model already in it:
+- **VS Code** — llmgw's own extension, downloaded from the gateway at
+ `/setup/vscode/llmgw.vsix` with this gateway's address already in it. It
+ offers every chat model the key may use to VS Code itself: the chat's model
+ picker, agent mode, and any extension that asks VS Code for a language model
+ (Cline's "VS Code LM API" provider among them), with no setup of their own.
+ **llmgw: Sign In** in VS Code opens this page with VS Code chosen; the page's
+ **Open in VS Code** link hands the key back, and the extension keeps it in
+ VS Code's secret storage. A link the extension did not ask for is confirmed
+ first, with the gateway's address shown, since it decides where code is sent.
+ **llmgw: Enter API Key** takes a pasted one instead.
- **Continue** — an assistant file of its own, `~/.continue/assistants/llmgw.yaml`,
so their `config.yaml` is not touched.
- **Codex** — a provider and a profile in `~/.codex/config.toml`, run with
diff --git a/internal/vscodeext/e2e/pack/main.go b/internal/vscodeext/e2e/pack/main.go
new file mode 100644
index 0000000..eb3433f
--- /dev/null
+++ b/internal/vscodeext/e2e/pack/main.go
@@ -0,0 +1,60 @@
+// Command pack writes the VS Code extension as a gateway would serve it: the
+// .vsix, and the same files unpacked, for VS Code's --extensionDevelopmentPath.
+package main
+
+import (
+ "archive/zip"
+ "bytes"
+ "flag"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "gitea.mynodes.xyz/ilker/llmgw/internal/vscodeext"
+)
+
+func main() {
+ out := flag.String("out", "llmgw-vsix", "directory to write llmgw.vsix and its unpacked files into")
+ version := flag.String("version", "v0.0.1", "llmgw version the extension says it is")
+ gateway := flag.String("gateway", "", "gateway address the extension's setting starts at")
+ flag.Parse()
+
+ b, err := vscodeext.Build(*version, *gateway)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := os.MkdirAll(*out, 0o755); err != nil {
+ log.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(*out, vscodeext.FileName), b, 0o644); err != nil {
+ log.Fatal(err)
+ }
+ zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
+ if err != nil {
+ log.Fatal(err)
+ }
+ for _, f := range zr.File {
+ if strings.Contains(f.Name, "..") {
+ log.Fatalf("unsafe name %q", f.Name)
+ }
+ dst := filepath.Join(*out, filepath.FromSlash(f.Name))
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ log.Fatal(err)
+ }
+ rc, err := f.Open()
+ if err != nil {
+ log.Fatal(err)
+ }
+ var body bytes.Buffer
+ _, err = body.ReadFrom(rc)
+ rc.Close()
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := os.WriteFile(dst, body.Bytes(), 0o644); err != nil {
+ log.Fatal(err)
+ }
+ }
+ log.Printf("wrote %s and its files under %s", vscodeext.FileName, *out)
+}
diff --git a/internal/vscodeext/e2e/run.js b/internal/vscodeext/e2e/run.js
new file mode 100644
index 0000000..745bf30
--- /dev/null
+++ b/internal/vscodeext/e2e/run.js
@@ -0,0 +1,101 @@
+'use strict';
+
+// The extension in a real VS Code, against a real gateway: VS Code's own
+// language-model API lists the gateway's models, gets an answer from one,
+// and has one call a tool and read its result back.
+//
+// go run ./internal/vscodeext/e2e/pack -out /tmp/llmgw-vsix # unpacked extension
+// LLMGW_TEST_GATEWAY=https://gw LLMGW_TEST_KEY=sk-llmgw-… \
+// LLMGW_TEST_CHAT=qwen2.5-0.5b-instruct LLMGW_TEST_TOOLS=qwen3-coder-30b \
+// LLMGW_TEST_OUT=/tmp/e2e.json \
+// code --user-data-dir /tmp/vsc-data --extensions-dir /tmp/vsc-ext \
+// --extensionDevelopmentPath=/tmp/llmgw-vsix/extension \
+// --extensionTestsPath=$PWD/internal/vscodeext/e2e/run.js
+//
+// It writes what happened to LLMGW_TEST_OUT and exits non-zero on failure.
+
+const vscode = require('vscode');
+const fs = require('fs');
+
+const env = (k) => {
+ const v = process.env[k];
+ if (!v) throw new Error(`${k} is not set`);
+ return v;
+};
+
+async function collect(response) {
+ const out = { text: '', calls: [] };
+ for await (const part of response.stream) {
+ if (part instanceof vscode.LanguageModelTextPart) out.text += part.value;
+ else if (part instanceof vscode.LanguageModelToolCallPart) out.calls.push(part);
+ }
+ return out;
+}
+
+function within(ms, what, p) {
+ return Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what}: no answer in ${ms / 1000}s`)), ms))]);
+}
+
+async function run() {
+ const report = { steps: [] };
+ const out = process.env.LLMGW_TEST_OUT;
+ // Written after every step, so a run that hangs still says how far it got.
+ const step = (name, detail) => {
+ report.steps.push({ name, at: new Date().toISOString(), ...detail });
+ if (out) fs.writeFileSync(out, JSON.stringify(report, null, 2));
+ };
+ const send = (model, msgs, opts) => model.sendRequest(msgs, { justification: 'llmgw end-to-end test', ...opts }, new vscode.CancellationTokenSource().token);
+ try {
+ const ext = vscode.extensions.getExtension('llmgw.llmgw');
+ if (!ext) throw new Error('the llmgw extension is not loaded');
+ const api = await ext.activate();
+ await api.connect(env('LLMGW_TEST_GATEWAY'), env('LLMGW_TEST_KEY'));
+ step('connected', { gateway: env('LLMGW_TEST_GATEWAY') });
+
+ let models = [];
+ for (let i = 0; i < 20 && !models.length; i++) {
+ models = await vscode.lm.selectChatModels({ vendor: 'llmgw' });
+ if (!models.length) await new Promise((r) => setTimeout(r, 500));
+ }
+ step('models', { count: models.length, ids: models.map((m) => m.id) });
+ if (!models.length) throw new Error('VS Code lists no llmgw models');
+
+ const chat = models.find((m) => m.id === env('LLMGW_TEST_CHAT'));
+ if (!chat) throw new Error(`${env('LLMGW_TEST_CHAT')} is not among the models`);
+ step('asking', { model: chat.id });
+ const hello = await within(180000, 'chat', send(chat, [vscode.LanguageModelChatMessage.User('Reply with the single word: hello')], {}).then(collect));
+ step('chat', { model: chat.id, text: hello.text, maxInputTokens: chat.maxInputTokens });
+ if (!hello.text.trim()) throw new Error('the chat model answered nothing');
+
+ const toolModel = models.find((m) => m.id === env('LLMGW_TEST_TOOLS'));
+ if (!toolModel) throw new Error(`${env('LLMGW_TEST_TOOLS')} is not among the models`);
+ const tools = [{
+ name: 'get_build_number',
+ description: 'Returns the current build number of the project.',
+ inputSchema: { type: 'object', properties: { project: { type: 'string' } }, required: ['project'] },
+ }];
+ const ask = vscode.LanguageModelChatMessage.User('What is the current build number of the project "llmgw"? Use the tool.');
+ step('asking', { model: toolModel.id });
+ const first = await within(300000, 'tool call', send(toolModel, [ask], { tools }).then(collect));
+ step('tool call', { model: toolModel.id, calls: first.calls.map((c) => ({ name: c.name, input: c.input })), text: first.text });
+ if (!first.calls.length) throw new Error('the tool model made no tool call');
+
+ const c = first.calls[0];
+ const second = await within(300000, 'tool result', send(toolModel, [
+ ask,
+ vscode.LanguageModelChatMessage.Assistant([new vscode.LanguageModelToolCallPart(c.callId, c.name, c.input)]),
+ vscode.LanguageModelChatMessage.User([new vscode.LanguageModelToolResultPart(c.callId, [new vscode.LanguageModelTextPart('4711')])]),
+ ], { tools }).then(collect));
+ step('tool result', { text: second.text });
+ if (!second.text.includes('4711')) throw new Error('the answer after the tool result does not use it');
+ report.ok = true;
+ } catch (err) {
+ report.ok = false;
+ report.error = String(err && err.stack ? err.stack : err);
+ }
+ if (out) fs.writeFileSync(out, JSON.stringify(report, null, 2));
+ else console.log(JSON.stringify(report, null, 2));
+ if (!report.ok) throw new Error(report.error);
+}
+
+module.exports = { run };
diff --git a/internal/vscodeext/ext/README.md b/internal/vscodeext/ext/README.md
new file mode 100644
index 0000000..9eb82cb
--- /dev/null
+++ b/internal/vscodeext/ext/README.md
@@ -0,0 +1,28 @@
+# llmgw for VS Code
+
+The models your llmgw gateway serves, in VS Code: in the chat's model
+picker, in agent mode, and in any extension that asks VS Code for a language
+model. Nothing goes anywhere but the gateway you connect it to.
+
+## Connecting
+
+1. **llmgw: Sign In** from the command palette. It opens your gateway's
+ account page; sign in there and choose **Make a key and the
+ configuration** with *VS Code* as the tool. The page hands the key back to
+ VS Code.
+2. Or **llmgw: Enter API Key** and paste a key from that page.
+
+The gateway's address is the `llmgw.gatewayUrl` setting. An extension
+downloaded from a gateway has that gateway's address in it already.
+
+The key is kept in VS Code's secret storage. **llmgw: Sign Out** forgets it;
+revoke it on the account page to stop it working.
+
+## Which models
+
+Every chat model the key may use. Agent mode acts only through tool calls,
+so it needs a model the gateway's catalog says makes them; set
+`llmgw.showModelsWithoutTools` to `false` to see only those.
+
+Completion as you type is not part of what VS Code lets a model provider
+offer; for that, use an extension such as Continue against the same gateway.
diff --git a/internal/vscodeext/ext/extension.js b/internal/vscodeext/ext/extension.js
new file mode 100644
index 0000000..b1141da
--- /dev/null
+++ b/internal/vscodeext/ext/extension.js
@@ -0,0 +1,253 @@
+'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);
+ const 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;
+ }
+ 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 };
diff --git a/internal/vscodeext/ext/lib.js b/internal/vscodeext/ext/lib.js
new file mode 100644
index 0000000..93343f2
--- /dev/null
+++ b/internal/vscodeext/ext/lib.js
@@ -0,0 +1,269 @@
+'use strict';
+
+// What the extension does that does not need VS Code: reading the gateway's
+// model list, turning VS Code's messages into chat-completion messages, and
+// reading the streamed answer back. Kept apart so it can be tested with plain
+// Node, against a gateway, without an editor running.
+
+const USER = 1;
+const ASSISTANT = 2;
+// Not in every VS Code that runs this: a system message, where it exists.
+const SYSTEM = 3;
+
+// gatewayBase is the address the person typed, as the API is under it: no
+// trailing slash, and no /v1, which people copy from client guides.
+function gatewayBase(raw) {
+ let s = String(raw || '').trim();
+ if (!s) return '';
+ // A bare host is https; any other scheme than http(s) is not a gateway.
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) {
+ if (!/^https?:\/\//i.test(s)) return '';
+ } else {
+ s = 'https://' + s;
+ }
+ let u;
+ try {
+ u = new URL(s);
+ } catch {
+ return '';
+ }
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return '';
+ let path = u.pathname.replace(/\/+$/, '');
+ if (path.endsWith('/v1')) path = path.slice(0, -3);
+ return u.origin + path;
+}
+
+// modelInfo is what VS Code is told of one entry of /v1/models, or null for
+// one that does not chat: an embedding model, or one that makes pictures,
+// meshes or anything else through a workflow.
+//
+// The sizes are the catalog's when it gives them. VS Code trims a
+// conversation to maxInputTokens, and a request over what a replica holds is
+// refused by the gateway, so guessing high is worse than guessing low.
+function modelInfo(entry, showWithoutTools) {
+ if (!entry || typeof entry.id !== 'string' || !entry.id) return null;
+ const caps = entry.capabilities || {};
+ if (caps.embeddings || caps.images || caps.meshes || caps.workflows) return null;
+ const tools = !!caps.tools;
+ if (!tools && !showWithoutTools) return null;
+ const context = Number(entry.context_length) > 0 ? Number(entry.context_length) : 8192;
+ let output = Number(entry.max_output_tokens) > 0 ? Number(entry.max_output_tokens) : 0;
+ output = Math.min(output || 4096, Math.max(256, Math.floor(context / 4)));
+ const detail = entry.alias_of ? `llmgw · ${entry.alias_of}` : 'llmgw';
+ return {
+ id: entry.id,
+ name: entry.id,
+ family: entry.alias_of || entry.id,
+ version: 'llmgw',
+ detail,
+ tooltip: entry.description || undefined,
+ maxInputTokens: Math.max(256, context - output),
+ maxOutputTokens: output,
+ capabilities: { toolCalling: tools, imageInput: !!caps.vision },
+ };
+}
+
+function isToolCall(p) {
+ return p && typeof p.callId === 'string' && typeof p.name === 'string' && 'input' in p;
+}
+function isToolResult(p) {
+ return p && typeof p.callId === 'string' && Array.isArray(p.content);
+}
+function isText(p) {
+ return p && typeof p.value === 'string' && !isToolCall(p) && !isToolResult(p);
+}
+function isImage(p) {
+ return p && typeof p.mimeType === 'string' && p.mimeType.startsWith('image/') && p.data instanceof Uint8Array;
+}
+
+// resultText flattens a tool result to the text a chat-completion tool
+// message carries. Parts that are not text (prompt-tsx trees) go as JSON.
+function resultText(content) {
+ return content
+ .map((c) => (isText(c) ? c.value : typeof c === 'string' ? c : JSON.stringify(c)))
+ .join('');
+}
+
+// chatMessages turns VS Code's request messages into chat-completion ones.
+//
+// One VS Code message can hold several kinds of part; the API they are going
+// to wants each in its own message: an assistant's tool calls in one, each
+// tool result in a tool message straight after it, and only then the text a
+// person wrote beside the results.
+function chatMessages(messages, vision) {
+ const out = [];
+ for (const m of messages) {
+ const parts = Array.isArray(m.content) ? m.content : [];
+ if (m.role === ASSISTANT) {
+ const text = parts.filter(isText).map((p) => p.value).join('');
+ const calls = parts.filter(isToolCall).map((p) => ({
+ id: p.callId,
+ type: 'function',
+ function: { name: p.name, arguments: JSON.stringify(p.input ?? {}) },
+ }));
+ const msg = { role: 'assistant', content: text || (calls.length ? null : '') };
+ if (calls.length) msg.tool_calls = calls;
+ out.push(msg);
+ continue;
+ }
+ for (const p of parts.filter(isToolResult)) {
+ out.push({ role: 'tool', tool_call_id: p.callId, content: resultText(p.content) });
+ }
+ const texts = parts.filter(isText).map((p) => p.value);
+ const images = vision ? parts.filter(isImage) : [];
+ if (!texts.length && !images.length) continue;
+ const role = m.role === SYSTEM ? 'system' : 'user';
+ if (!images.length) {
+ out.push({ role, content: texts.join('') });
+ continue;
+ }
+ const content = [];
+ if (texts.length) content.push({ type: 'text', text: texts.join('') });
+ for (const img of images) {
+ const b64 = Buffer.from(img.data).toString('base64');
+ content.push({ type: 'image_url', image_url: { url: `data:${img.mimeType};base64,${b64}` } });
+ }
+ out.push({ role, content });
+ }
+ return out;
+}
+
+// chatTools turns VS Code's tool list into chat-completion functions. A tool
+// without a schema still needs one: an object that takes nothing.
+function chatTools(tools) {
+ return (tools || []).map((t) => ({
+ type: 'function',
+ function: {
+ name: t.name,
+ description: t.description || '',
+ parameters: t.inputSchema && Object.keys(t.inputSchema).length ? t.inputSchema : { type: 'object', properties: {} },
+ },
+ }));
+}
+
+// requestBody is the whole chat-completion request. toolMode 2 is VS Code's
+// "Required": the model must call one of the tools.
+function requestBody(model, messages, options) {
+ const body = {
+ model: model.id,
+ messages: chatMessages(messages, !!(model.capabilities && model.capabilities.imageInput)),
+ stream: true,
+ max_tokens: model.maxOutputTokens,
+ };
+ const tools = chatTools(options && options.tools);
+ if (tools.length) {
+ body.tools = tools;
+ if (options.toolMode === 2) body.tool_choice = 'required';
+ }
+ const mo = (options && options.modelOptions) || {};
+ for (const k of ['temperature', 'top_p', 'stop', 'seed']) {
+ if (mo[k] !== undefined) body[k] = mo[k];
+ }
+ return body;
+}
+
+// sseData yields the data of each server-sent event in a byte stream, as the
+// chat-completion stream sends them: "data: {...}" lines, ending "[DONE]".
+async function* sseData(stream) {
+ const decoder = new TextDecoder();
+ let buf = '';
+ for await (const chunk of stream) {
+ buf += decoder.decode(chunk, { stream: true });
+ let i;
+ while ((i = buf.indexOf('\n')) >= 0) {
+ const line = buf.slice(0, i).replace(/\r$/, '');
+ buf = buf.slice(i + 1);
+ if (line.startsWith('data:')) yield line.slice(5).trimStart();
+ }
+ }
+ buf += decoder.decode();
+ if (buf.startsWith('data:')) yield buf.slice(5).trimStart();
+}
+
+// Answer collects one streamed answer: its text as it comes, and its tool
+// calls, whose arguments arrive in pieces and are only whole at the end.
+class Answer {
+ constructor() {
+ this.calls = new Map();
+ this.usage = null;
+ }
+
+ // add takes one event's data and returns the text it carries, if any.
+ add(data) {
+ if (data === '[DONE]') return '';
+ let ev;
+ try {
+ ev = JSON.parse(data);
+ } catch {
+ return '';
+ }
+ if (ev.error) {
+ const msg = typeof ev.error === 'string' ? ev.error : ev.error.message || JSON.stringify(ev.error);
+ throw new Error(msg);
+ }
+ if (ev.usage) this.usage = ev.usage;
+ let text = '';
+ for (const choice of ev.choices || []) {
+ const d = choice.delta || {};
+ if (typeof d.content === 'string') text += d.content;
+ for (const tc of d.tool_calls || []) {
+ const key = tc.index ?? tc.id ?? this.calls.size;
+ const c = this.calls.get(key) || { id: '', name: '', args: '' };
+ if (tc.id) c.id = tc.id;
+ if (tc.function && tc.function.name) c.name += tc.function.name;
+ if (tc.function && typeof tc.function.arguments === 'string') c.args += tc.function.arguments;
+ this.calls.set(key, c);
+ }
+ }
+ return text;
+ }
+
+ // toolCalls is every call the answer made, its arguments parsed. A call
+ // whose arguments are not JSON goes with them as text, under "input", so
+ // the tool sees what the model wrote rather than nothing.
+ toolCalls() {
+ const out = [];
+ let n = 0;
+ for (const c of this.calls.values()) {
+ n++;
+ if (!c.name) continue;
+ let input = {};
+ if (c.args.trim()) {
+ try {
+ input = JSON.parse(c.args);
+ } catch {
+ input = { input: c.args };
+ }
+ }
+ out.push({ id: c.id || `call_${n}`, name: c.name, input });
+ }
+ return out;
+ }
+}
+
+// errorMessage reads what a gateway said when it refused a request.
+function errorMessage(status, bodyText) {
+ let msg = '';
+ try {
+ const j = JSON.parse(bodyText);
+ const e = j.error;
+ msg = (typeof e === 'string' ? e : e && e.message) || j.message || '';
+ } catch {
+ msg = bodyText.trim().slice(0, 300);
+ }
+ if (status === 401) return 'llmgw: the gateway did not accept the key. Run "llmgw: Sign In" to make a new one.';
+ if (status === 403) return `llmgw: this key may not use that model${msg ? ` (${msg})` : ''}.`;
+ if (status === 429) return `llmgw: a limit was reached${msg ? `: ${msg}` : ''}.`;
+ return `llmgw: the gateway answered ${status}${msg ? `: ${msg}` : ''}.`;
+}
+
+// tokenGuess is how many tokens a text is, near enough for VS Code to decide
+// what fits: about four characters to a token in English and in code.
+function tokenGuess(text) {
+ return Math.ceil(String(text || '').length / 4);
+}
+
+module.exports = {
+ USER, ASSISTANT, SYSTEM,
+ gatewayBase, modelInfo, chatMessages, chatTools, requestBody, sseData, Answer, errorMessage, tokenGuess,
+};
diff --git a/internal/vscodeext/ext/lib_test.js b/internal/vscodeext/ext/lib_test.js
new file mode 100644
index 0000000..b61c029
--- /dev/null
+++ b/internal/vscodeext/ext/lib_test.js
@@ -0,0 +1,123 @@
+'use strict';
+
+// node --test internal/vscodeext/ext/lib_test.js
+// (VS Code's own runtime works too:
+// ELECTRON_RUN_AS_NODE=1 "/Applications/Visual Studio Code.app/Contents/MacOS/Electron" --test …)
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const lib = require('./lib');
+
+// VS Code's parts, as the provider receives them: classes in VS Code,
+// told apart here by their fields.
+const text = (value) => ({ value });
+const call = (callId, name, input) => ({ callId, name, input });
+const result = (callId, ...values) => ({ callId, content: values.map(text) });
+
+test('gatewayBase takes what people paste', () => {
+ assert.equal(lib.gatewayBase('https://llm.example.com/'), 'https://llm.example.com');
+ assert.equal(lib.gatewayBase('https://llm.example.com/v1'), 'https://llm.example.com');
+ assert.equal(lib.gatewayBase('llm.example.com'), 'https://llm.example.com');
+ assert.equal(lib.gatewayBase('http://10.0.0.140:8080/v1/'), 'http://10.0.0.140:8080');
+ assert.equal(lib.gatewayBase('ftp://x'), '');
+ assert.equal(lib.gatewayBase(''), '');
+});
+
+test('modelInfo keeps chat models and says what the catalog says', () => {
+ const m = lib.modelInfo({
+ id: 'coder', alias_of: 'qwen3-coder-30b', context_length: 32768, max_output_tokens: 8192,
+ capabilities: { tools: true, vision: false },
+ }, true);
+ assert.equal(m.id, 'coder');
+ assert.equal(m.family, 'qwen3-coder-30b');
+ assert.equal(m.maxOutputTokens, 8192);
+ assert.equal(m.maxInputTokens, 32768 - 8192);
+ assert.deepEqual(m.capabilities, { toolCalling: true, imageInput: false });
+
+ assert.equal(lib.modelInfo({ id: 'bge-m3', capabilities: { embeddings: true } }, true), null);
+ assert.equal(lib.modelInfo({ id: 'flux', capabilities: { images: true } }, true), null);
+ assert.equal(lib.modelInfo({ id: 'sf3d', capabilities: { meshes: true, workflows: true } }, true), null);
+ assert.equal(lib.modelInfo({ id: 'plain', capabilities: {} }, false), null);
+
+ const small = lib.modelInfo({ id: 'small', context_length: 4096, capabilities: {} }, true);
+ assert.equal(small.maxOutputTokens, 1024, 'a quarter of a small context, not 4096 of it');
+});
+
+test('chatMessages puts tool calls and results where chat completions want them', () => {
+ const msgs = lib.chatMessages([
+ { role: lib.USER, content: [text('Read main.go')] },
+ { role: lib.ASSISTANT, content: [text('Reading it.'), call('c1', 'read_file', { path: 'main.go' })] },
+ { role: lib.USER, content: [result('c1', 'package main'), text('Now summarise it.')] },
+ ], false);
+ assert.deepEqual(msgs, [
+ { role: 'user', content: 'Read main.go' },
+ {
+ role: 'assistant', content: 'Reading it.',
+ tool_calls: [{ id: 'c1', type: 'function', function: { name: 'read_file', arguments: '{"path":"main.go"}' } }],
+ },
+ { role: 'tool', tool_call_id: 'c1', content: 'package main' },
+ { role: 'user', content: 'Now summarise it.' },
+ ]);
+});
+
+test('an assistant message of calls alone has null content', () => {
+ const [m] = lib.chatMessages([{ role: lib.ASSISTANT, content: [call('c1', 'ls', {})] }], false);
+ assert.equal(m.content, null);
+ assert.equal(m.tool_calls.length, 1);
+});
+
+test('images go as data URLs only to a model that takes them', () => {
+ const img = { mimeType: 'image/png', data: new Uint8Array([1, 2, 3]) };
+ const [withVision] = lib.chatMessages([{ role: lib.USER, content: [text('what is this'), img] }], true);
+ assert.deepEqual(withVision.content[1], { type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } });
+ const [without] = lib.chatMessages([{ role: lib.USER, content: [text('what is this'), img] }], false);
+ assert.equal(without.content, 'what is this');
+});
+
+test('requestBody asks for tools as functions, and requires one when VS Code does', () => {
+ const model = { id: 'coder', maxOutputTokens: 2048, capabilities: { imageInput: false } };
+ const body = lib.requestBody(model, [{ role: lib.USER, content: [text('hi')] }], {
+ tools: [{ name: 'ls', description: 'list', inputSchema: undefined }], toolMode: 2, modelOptions: { temperature: 0.2 },
+ });
+ assert.equal(body.stream, true);
+ assert.equal(body.max_tokens, 2048);
+ assert.equal(body.tool_choice, 'required');
+ assert.equal(body.temperature, 0.2);
+ assert.deepEqual(body.tools[0].function.parameters, { type: 'object', properties: {} });
+});
+
+async function* chunks(...parts) {
+ for (const p of parts) yield new TextEncoder().encode(p);
+}
+
+test('sseData reads events split anywhere', async () => {
+ const got = [];
+ for await (const d of lib.sseData(chunks('data: {"a":', '1}\r\n\ndata: [DO', 'NE]\n'))) got.push(d);
+ assert.deepEqual(got, ['{"a":1}', '[DONE]']);
+});
+
+test('Answer joins text and tool-call arguments that arrive in pieces', () => {
+ const a = new lib.Answer();
+ const ev = (delta) => JSON.stringify({ choices: [{ delta }] });
+ assert.equal(a.add(ev({ content: 'Hel' })), 'Hel');
+ assert.equal(a.add(ev({ content: 'lo' })), 'lo');
+ a.add(ev({ tool_calls: [{ index: 0, id: 'call_a', function: { name: 'read_file', arguments: '{"pa' } }] }));
+ a.add(ev({ tool_calls: [{ index: 0, function: { arguments: 'th":"x"}' } }] }));
+ a.add(ev({ tool_calls: [{ index: 1, id: 'call_b', function: { name: 'ls', arguments: '' } }] }));
+ assert.equal(a.add('[DONE]'), '');
+ assert.deepEqual(a.toolCalls(), [
+ { id: 'call_a', name: 'read_file', input: { path: 'x' } },
+ { id: 'call_b', name: 'ls', input: {} },
+ ]);
+});
+
+test('Answer passes on an error the stream carries', () => {
+ const a = new lib.Answer();
+ assert.throws(() => a.add(JSON.stringify({ error: { message: 'budget spent' } })), /budget spent/);
+});
+
+test('errorMessage says what to do about a refused key', () => {
+ assert.match(lib.errorMessage(401, '{"error":{"message":"invalid key"}}'), /Sign In/);
+ assert.match(lib.errorMessage(429, '{"error":{"message":"rate limit"}}'), /rate limit/);
+ assert.match(lib.errorMessage(500, 'boom'), /500: boom/);
+});
diff --git a/internal/vscodeext/ext/package.json b/internal/vscodeext/ext/package.json
new file mode 100644
index 0000000..32f6b7d
--- /dev/null
+++ b/internal/vscodeext/ext/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "llmgw",
+ "publisher": "llmgw",
+ "displayName": "llmgw",
+ "description": "The models your llmgw gateway serves, in VS Code's chat and in every extension that asks VS Code for a language model.",
+ "version": "0.0.0",
+ "license": "UNLICENSED",
+ "engines": {
+ "vscode": "^1.104.0"
+ },
+ "categories": ["AI", "Chat"],
+ "keywords": ["llmgw", "llm", "gateway", "self-hosted", "on-premises"],
+ "main": "./extension.js",
+ "extensionKind": ["ui", "workspace"],
+ "activationEvents": ["onStartupFinished", "onUri"],
+ "capabilities": {
+ "untrustedWorkspaces": { "supported": true },
+ "virtualWorkspaces": true
+ },
+ "contributes": {
+ "languageModelChatProviders": [
+ { "vendor": "llmgw", "displayName": "llmgw" }
+ ],
+ "commands": [
+ { "command": "llmgw.signIn", "title": "Sign In", "category": "llmgw" },
+ { "command": "llmgw.enterKey", "title": "Enter API Key", "category": "llmgw" },
+ { "command": "llmgw.signOut", "title": "Sign Out", "category": "llmgw" },
+ { "command": "llmgw.refresh", "title": "Refresh Models", "category": "llmgw" }
+ ],
+ "configuration": {
+ "title": "llmgw",
+ "properties": {
+ "llmgw.gatewayUrl": {
+ "type": "string",
+ "default": "",
+ "markdownDescription": "The gateway's address, such as `https://llm.example.com`: the one the people running it gave you, without `/v1`."
+ },
+ "llmgw.showModelsWithoutTools": {
+ "type": "boolean",
+ "default": true,
+ "markdownDescription": "Offer models whose tool calls the gateway does not vouch for. They answer in chat, but an agent needs a model that makes tool calls to read a file or run a command."
+ }
+ }
+ }
+ }
+}
diff --git a/internal/vscodeext/vsix.go b/internal/vscodeext/vsix.go
new file mode 100644
index 0000000..1930ad3
--- /dev/null
+++ b/internal/vscodeext/vsix.go
@@ -0,0 +1,215 @@
+// Package vscodeext is llmgw's extension for VS Code, and the .vsix a
+// gateway serves it as.
+//
+// The extension is plain JavaScript with no dependencies, so it needs no
+// build: the files under ext/ are what VS Code runs. The package is made
+// when it is asked for, from those files, with the gateway's version and
+// address written into its package.json — so an extension downloaded from a
+// gateway already knows where it came from, and a site with no internet
+// gets it from the same place it gets everything else.
+package vscodeext
+
+import (
+ "archive/zip"
+ "bytes"
+ "embed"
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "io/fs"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+)
+
+//go:embed ext
+var files embed.FS
+
+// Identity is what VS Code calls the extension: publisher.name. The
+// account page's link back is vscode://llmgw.llmgw/connect.
+const (
+ Publisher = "llmgw"
+ Name = "llmgw"
+ ID = Publisher + "." + Name
+)
+
+// FileName is what the package is saved as.
+const FileName = "llmgw.vsix"
+
+var semverCore = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)`)
+
+// Version is the extension version for an llmgw version: its major, minor
+// and patch, since VS Code takes nothing more. A build with no release in
+// its version is 0.0.0, which any released one replaces.
+func Version(llmgw string) string {
+ m := semverCore.FindStringSubmatch(llmgw)
+ if m == nil {
+ return "0.0.0"
+ }
+ return m[1] + "." + m[2] + "." + m[3]
+}
+
+// manifest reads ext/package.json and writes into it the version and the
+// gateway the setting starts at.
+func manifest(version, gateway string) (map[string]any, []byte, error) {
+ raw, err := files.ReadFile("ext/package.json")
+ if err != nil {
+ return nil, nil, err
+ }
+ var pkg map[string]any
+ if err := json.Unmarshal(raw, &pkg); err != nil {
+ return nil, nil, fmt.Errorf("ext/package.json: %w", err)
+ }
+ pkg["version"] = version
+ props, ok := dig(pkg, "contributes", "configuration", "properties", "llmgw.gatewayUrl")
+ if !ok {
+ return nil, nil, fmt.Errorf("ext/package.json: no llmgw.gatewayUrl setting")
+ }
+ props["default"] = gateway
+ out, err := json.MarshalIndent(pkg, "", " ")
+ if err != nil {
+ return nil, nil, err
+ }
+ return pkg, append(out, '\n'), nil
+}
+
+func dig(m map[string]any, path ...string) (map[string]any, bool) {
+ for _, k := range path {
+ next, ok := m[k].(map[string]any)
+ if !ok {
+ return nil, false
+ }
+ m = next
+ }
+ return m, true
+}
+
+// Build makes the .vsix: the extension's files under extension/, and the
+// two files VS Code reads to install it.
+func Build(llmgwVersion, gateway string) ([]byte, error) {
+ version := Version(llmgwVersion)
+ pkg, pkgJSON, err := manifest(version, gateway)
+ if err != nil {
+ return nil, err
+ }
+
+ var names []string
+ err = fs.WalkDir(files, "ext", func(p string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return err
+ }
+ // Tests stay in the source tree; VS Code has no use for them.
+ if strings.HasSuffix(p, "_test.js") {
+ return nil
+ }
+ names = append(names, p)
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ slices.Sort(names)
+
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ // One time for every entry, so the same files make the same bytes.
+ stamp := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ add := func(name string, body []byte) error {
+ w, err := zw.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Deflate, Modified: stamp})
+ if err != nil {
+ return err
+ }
+ _, err = w.Write(body)
+ return err
+ }
+ if err := add("[Content_Types].xml", []byte(contentTypes)); err != nil {
+ return nil, err
+ }
+ vsixManifest, err := vsixManifest(pkg, version)
+ if err != nil {
+ return nil, err
+ }
+ if err := add("extension.vsixmanifest", vsixManifest); err != nil {
+ return nil, err
+ }
+ for _, p := range names {
+ body := pkgJSON
+ if p != "ext/package.json" {
+ if body, err = files.ReadFile(p); err != nil {
+ return nil, err
+ }
+ }
+ if err := add("extension/"+strings.TrimPrefix(p, "ext/"), body); err != nil {
+ return nil, err
+ }
+ }
+ if err := zw.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+const contentTypes = `
+
+
+
+
+
+
+`
+
+// vsixManifest is extension.vsixmanifest, said from package.json the way
+// vsce says it, so VS Code reads the same identity from both.
+func vsixManifest(pkg map[string]any, version string) ([]byte, error) {
+ str := func(k string) string { s, _ := pkg[k].(string); return s }
+ list := func(k string) string {
+ var out []string
+ for _, v := range asSlice(pkg[k]) {
+ if s, ok := v.(string); ok {
+ out = append(out, s)
+ }
+ }
+ return strings.Join(out, ",")
+ }
+ engine, _ := dig(pkg, "engines")
+ vscode, _ := engine["vscode"].(string)
+
+ esc := func(s string) string {
+ var b bytes.Buffer
+ _ = xml.EscapeText(&b, []byte(s))
+ return b.String()
+ }
+ var b strings.Builder
+ fmt.Fprintf(&b, `
+
+
+
+ %s
+ %s
+ %s
+ %s
+ Public
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`, esc(str("name")), esc(version), esc(str("publisher")), esc(str("displayName")), esc(str("description")),
+ esc(list("keywords")), esc(list("categories")), esc(vscode), esc(list("extensionKind")))
+ return []byte(b.String()), nil
+}
+
+func asSlice(v any) []any { s, _ := v.([]any); return s }
diff --git a/internal/vscodeext/vsix_test.go b/internal/vscodeext/vsix_test.go
new file mode 100644
index 0000000..4af6c08
--- /dev/null
+++ b/internal/vscodeext/vsix_test.go
@@ -0,0 +1,150 @@
+package vscodeext
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/json"
+ "encoding/xml"
+ "io"
+ "slices"
+ "testing"
+)
+
+func TestVersion(t *testing.T) {
+ for in, want := range map[string]string{
+ "v0.14.9": "0.14.9",
+ "v0.14.9-11-g4eb49d0-dev": "0.14.9",
+ "1.2.3": "1.2.3",
+ "dev": "0.0.0",
+ "": "0.0.0",
+ } {
+ if got := Version(in); got != want {
+ t.Errorf("Version(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func unzip(t *testing.T, b []byte) map[string][]byte {
+ t.Helper()
+ zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := map[string][]byte{}
+ for _, f := range zr.File {
+ rc, err := f.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := io.ReadAll(rc)
+ rc.Close()
+ out[f.Name] = body
+ }
+ return out
+}
+
+func TestBuild(t *testing.T) {
+ b, err := Build("v0.15.2-3-gabc", "https://llm.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := unzip(t, b)
+ for _, name := range []string{
+ "[Content_Types].xml", "extension.vsixmanifest",
+ "extension/package.json", "extension/extension.js", "extension/lib.js", "extension/README.md",
+ } {
+ if _, ok := got[name]; !ok {
+ t.Errorf("%s missing from the package", name)
+ }
+ }
+ for name := range got {
+ if bytes.HasSuffix([]byte(name), []byte("_test.js")) {
+ t.Errorf("%s is a test and should stay out of the package", name)
+ }
+ }
+
+ var pkg map[string]any
+ if err := json.Unmarshal(got["extension/package.json"], &pkg); err != nil {
+ t.Fatal(err)
+ }
+ if pkg["version"] != "0.15.2" {
+ t.Errorf("version %v, want 0.15.2", pkg["version"])
+ }
+ setting, _ := dig(pkg, "contributes", "configuration", "properties", "llmgw.gatewayUrl")
+ if setting["default"] != "https://llm.example.com" {
+ t.Errorf("gatewayUrl default %v, want the serving gateway", setting["default"])
+ }
+
+ var m struct {
+ Metadata struct {
+ Identity struct {
+ ID string `xml:"Id,attr"`
+ Version string `xml:"Version,attr"`
+ Publisher string `xml:"Publisher,attr"`
+ }
+ Properties struct {
+ Property []struct {
+ ID string `xml:"Id,attr"`
+ Value string `xml:"Value,attr"`
+ }
+ }
+ }
+ Assets struct {
+ Asset []struct {
+ Type string `xml:"Type,attr"`
+ Path string `xml:"Path,attr"`
+ }
+ }
+ }
+ if err := xml.Unmarshal(got["extension.vsixmanifest"], &m); err != nil {
+ t.Fatalf("extension.vsixmanifest: %v", err)
+ }
+ id := m.Metadata.Identity
+ if id.Publisher+"."+id.ID != ID || id.Version != "0.15.2" {
+ t.Errorf("identity %s.%s@%s, want %s@0.15.2", id.Publisher, id.ID, id.Version, ID)
+ }
+ props := map[string]string{}
+ for _, p := range m.Metadata.Properties.Property {
+ props[p.ID] = p.Value
+ }
+ if props["Microsoft.VisualStudio.Code.Engine"] != "^1.104.0" {
+ t.Errorf("engine %q", props["Microsoft.VisualStudio.Code.Engine"])
+ }
+ if props["Microsoft.VisualStudio.Code.ExtensionKind"] != "ui,workspace" {
+ t.Errorf("extension kind %q", props["Microsoft.VisualStudio.Code.ExtensionKind"])
+ }
+ if !slices.ContainsFunc(m.Assets.Asset, func(a struct {
+ Type string `xml:"Type,attr"`
+ Path string `xml:"Path,attr"`
+ }) bool {
+ return a.Type == "Microsoft.VisualStudio.Code.Manifest" && a.Path == "extension/package.json"
+ }) {
+ t.Error("no manifest asset pointing at extension/package.json")
+ }
+}
+
+func TestBuildIsReproducible(t *testing.T) {
+ a, err := Build("v1.0.0", "https://a")
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _ := Build("v1.0.0", "https://a")
+ if !bytes.Equal(a, b) {
+ t.Error("the same inputs made different packages")
+ }
+}
+
+// The address goes into JSON and XML; neither may let it out of its string.
+func TestBuildEscapesGateway(t *testing.T) {
+ b, err := Build("v1.0.0", `https://x"},"main":"evil.js`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var pkg map[string]any
+ if err := json.Unmarshal(unzip(t, b)["extension/package.json"], &pkg); err != nil {
+ t.Fatal(err)
+ }
+ if pkg["main"] != "./extension.js" {
+ t.Errorf("main = %v: the gateway address escaped its string", pkg["main"])
+ }
+}
diff --git a/internal/web/account.go b/internal/web/account.go
index d9c2c28..c2686be 100644
--- a/internal/web/account.go
+++ b/internal/web/account.go
@@ -18,6 +18,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/clientip"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
+ "gitea.mynodes.xyz/ilker/llmgw/internal/vscodeext"
)
// The errors from the account service and the store that these pages show a
@@ -203,6 +204,7 @@ func (p *AccountPages) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /profile/licences", p.selfAudited(p.setLicences))
mux.HandleFunc("POST /profile/connect", p.selfAudited(p.connect))
mux.HandleFunc("GET /setup/{tool}", p.setupScript)
+ mux.HandleFunc("GET /setup/vscode/"+vscodeext.FileName, p.vsix)
}
// --- signup ---
@@ -396,9 +398,12 @@ type profileData struct {
// Connect is an editor just connected: its key and configuration, shown
// once, like any new key.
Connect *connectView
- // Tools and Models are what the connect form offers.
+ // Tools and Models are what the connect form offers; Tool is the one it
+ // opens on, and State what VS Code's sign-in asked to have carried back.
Tools []struct{ Key, Name string }
Models []string
+ Tool string
+ State string
Error string
// Licences is the choice of licences not to use; nil when not offered.
@@ -426,7 +431,16 @@ func (p *AccountPages) profile(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
- p.renderProfile(w, r, sess, user, "", "")
+ // VS Code's sign-in opens /profile?tool=vscode&state=…: the form opens
+ // on that tool, with the state ready to go back in the link.
+ q := r.URL.Query()
+ d := profileData{State: signInState(q.Get("state"))}
+ for _, t := range editorTools {
+ if t.Key == q.Get("tool") {
+ d.Tool = t.Key
+ }
+ }
+ p.render(w, r, sess, user, d)
}
func (p *AccountPages) createKey(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/web/connect.go b/internal/web/connect.go
index f633394..cfcbafd 100644
--- a/internal/web/connect.go
+++ b/internal/web/connect.go
@@ -2,12 +2,15 @@ package web
import (
"fmt"
+ "html/template"
"net/http"
+ "net/url"
"regexp"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
+ "gitea.mynodes.xyz/ilker/llmgw/internal/vscodeext"
)
// Connecting an editor: a key made for it, and the configuration it needs
@@ -21,7 +24,7 @@ import (
// editorTools are the tools the page knows how to configure.
var editorTools = []struct{ Key, Name string }{
- {"continue", "Continue"}, {"codex", "Codex CLI"}, {"cline", "Cline"},
+ {"vscode", "VS Code"}, {"continue", "Continue"}, {"codex", "Codex CLI"}, {"cline", "Cline"},
}
// connectView is what the page shows after a key was made for a tool.
@@ -36,6 +39,9 @@ type connectView struct {
// Command writes the files; Run is how to start the tool on the gateway.
Command, Run string
Notes []string
+ // Link hands the key to a tool that takes it through a link of its own:
+ // llmgw's VS Code extension, at vscode://llmgw.llmgw/connect.
+ Link template.URL
}
type connectFile struct{ Path, Name, Body string }
@@ -141,6 +147,16 @@ func connectIn(l *Lang, tool, base, model, key string, f ModelFacts) connectView
v.Command = fmt.Sprintf("curl -fsSL '%s' | sh", script)
v.Run = fmt.Sprintf("LLMGW_API_KEY='%s' codex --profile llmgw", key)
v.Notes = []string{l.T("connect.note.codex_blocks"), l.T("connect.note.codex_env")}
+ case "vscode":
+ download := base + "/setup/vscode/" + vscodeext.FileName
+ v.Command = fmt.Sprintf("curl -fsSLo %[1]s '%[2]s' && code --install-extension %[1]s", vscodeext.FileName, download)
+ v.Link = vscodeLink(base, key, "")
+ v.Fields = []connectField{
+ {l.T("connect.field.gateway"), base},
+ {l.T("connect.field.key"), key},
+ }
+ v.Run = l.T("connect.run.vscode")
+ v.Notes = []string{l.T("connect.note.vscode_agent")}
case "cline":
// The values are what Cline's own screen offers and takes, which is
// English whatever language this page is in.
@@ -203,9 +219,54 @@ func (p *AccountPages) connect(w http.ResponseWriter, r *http.Request) {
}
p.log.Info("a user connected an editor", "user", user.ID, "tool", tool, "key", k.ID)
c := connectIn(l, tool, baseURL(r), model, secret, p.facts(model))
+ if tool == "vscode" {
+ // The extension's sign-in sent a state of its own; the link carries
+ // it back so the extension knows this is the page it opened.
+ c.Link = vscodeLink(baseURL(r), secret, signInState(r.FormValue("state")))
+ }
p.render(w, r, sess, user, profileData{Connect: &c})
}
+// vscodeLink is the link that hands VS Code's llmgw extension a gateway and
+// a key. It is opened by the person's own browser and handled by their own
+// editor; the key is on the page beside it already.
+func vscodeLink(base, key, state string) template.URL {
+ q := url.Values{"gateway": {base}, "key": {key}}
+ if state != "" {
+ q.Set("state", state)
+ }
+ return template.URL("vscode://" + vscodeext.ID + "/connect?" + q.Encode())
+}
+
+// stateShape is what the extension's sign-in state looks like: random,
+// base64url. Anything else is dropped rather than carried into a link.
+var stateShape = regexp.MustCompile(`^[A-Za-z0-9_-]{16,64}$`)
+
+func signInState(s string) string {
+ if stateShape.MatchString(s) {
+ return s
+ }
+ return ""
+}
+
+// vsix serves the VS Code extension, made for this gateway: its address is
+// the extension's setting to start with. Served without a session, like the
+// setup scripts — it holds no secret, only the address the person typed.
+func (p *AccountPages) vsix(w http.ResponseWriter, r *http.Request) {
+ b, err := vscodeext.Build(p.version, baseURL(r))
+ if err != nil {
+ p.log.Error("could not make the VS Code extension", "err", err)
+ http.Error(w, "could not make the extension", http.StatusInternalServerError)
+ return
+ }
+ // Never from a cache, for the same reason as the scripts: it names the
+ // address the request came to.
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Content-Type", "application/vsix")
+ w.Header().Set("Content-Disposition", `attachment; filename="`+vscodeext.FileName+`"`)
+ _, _ = w.Write(b)
+}
+
// setupScript is the script the one-line command runs: it writes the tool's
// configuration with the key from the environment, keeping a copy of what
// was there. Served without a session — it holds nothing a stranger could
diff --git a/internal/web/connect_test.go b/internal/web/connect_test.go
index 8c58342..d904e93 100644
--- a/internal/web/connect_test.go
+++ b/internal/web/connect_test.go
@@ -143,3 +143,48 @@ func TestContinueNeverAsksForMoreThanTheGatewayAllows(t *testing.T) {
t.Errorf("not capped at max_output:\n%s", y)
}
}
+
+func TestVSCodeGetsTheExtensionAndALinkBack(t *testing.T) {
+ c := connectFor("vscode", "https://gw.example", "m", "sk-llmgw-abc", ModelFacts{})
+ if !strings.Contains(c.Command, "https://gw.example/setup/vscode/llmgw.vsix") || !strings.Contains(c.Command, "code --install-extension llmgw.vsix") {
+ t.Errorf("command = %s", c.Command)
+ }
+ if strings.Contains(c.Command, "sk-llmgw-abc") {
+ t.Errorf("the key is in the download command: %s", c.Command)
+ }
+ link := string(c.Link)
+ if !strings.HasPrefix(link, "vscode://llmgw.llmgw/connect?") || !strings.Contains(link, "gateway=https%3A%2F%2Fgw.example") ||
+ !strings.Contains(link, "key=sk-llmgw-abc") || strings.Contains(link, "state=") {
+ t.Errorf("link = %s", link)
+ }
+ if len(c.Fields) != 2 || c.Fields[0].Value != "https://gw.example" {
+ t.Errorf("fields = %+v", c.Fields)
+ }
+}
+
+func TestOnlyAStateShapedLikeOneGoesBackInTheLink(t *testing.T) {
+ if got := signInState("abcDEF0123456789_-xy"); got != "abcDEF0123456789_-xy" {
+ t.Errorf("a good state was dropped: %q", got)
+ }
+ for _, bad := range []string{"", "short", "has space in it here", `">