VS Code gets the gateway's models from an extension the gateway serves
CI / test (push) Successful in 8m43s

llmgw's own extension for VS Code, plain JavaScript with no dependencies,
packed into a .vsix by the binary at /setup/vscode/llmgw.vsix with the
serving gateway's address in it, so it needs no build, no Node and no
marketplace. It registers llmgw as a language-model provider: the chat's
model picker, agent mode and any extension that asks VS Code for a model
get every chat model the key may use, sized and with tool and vision
capabilities as the catalog says, streamed, tool calls both ways.

VS Code is a tool on the account page's Connect your editor: a key, the
install command, and an Open in VS Code link that hands the key to the
extension's secret storage. The extension's Sign In opens the page with a
state of its own; a link without it is confirmed, the gateway named, since
it decides where code is sent.

Tested in VS Code 1.138 against the PoC: 27 models listed, an answer from
qwen2.5-0.5b-instruct, and qwen3-coder-30b calling a tool and using its
result (internal/vscodeext/e2e).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-24 01:39:50 +02:00
1 parent fea5774fe5
commit 38dfe268c9
17 files changed
+1412 -9

No files matched your search

+12
View File
@@ -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,
+13 -3
View File
@@ -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
+60
View File
@@ -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)
}
+101
View File
@@ -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 };
+28
View File
@@ -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.
+253
View File
@@ -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 };
+269
View File
@@ -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,
};
+123
View File
@@ -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/);
});
+46
View File
@@ -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."
}
}
}
}
}
+215
View File
@@ -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 = `<?xml version="1.0" encoding="utf-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension=".json" ContentType="application/json"/>
<Default Extension=".js" ContentType="application/javascript"/>
<Default Extension=".md" ContentType="text/markdown"/>
<Default Extension=".vsixmanifest" ContentType="text/xml"/>
</Types>
`
// 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, `<?xml version="1.0" encoding="utf-8"?>
<PackageManifest Version="2.0.0" xmlns="http://schemas.microsoft.com/developer/vsx-schema/2011" xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011">
<Metadata>
<Identity Language="en-US" Id="%s" Version="%s" Publisher="%s"/>
<DisplayName>%s</DisplayName>
<Description xml:space="preserve">%s</Description>
<Tags>%s</Tags>
<Categories>%s</Categories>
<GalleryFlags>Public</GalleryFlags>
<Properties>
<Property Id="Microsoft.VisualStudio.Code.Engine" Value="%s"/>
<Property Id="Microsoft.VisualStudio.Code.ExtensionDependencies" Value=""/>
<Property Id="Microsoft.VisualStudio.Code.ExtensionPack" Value=""/>
<Property Id="Microsoft.VisualStudio.Code.ExtensionKind" Value="%s"/>
<Property Id="Microsoft.VisualStudio.Code.LocalizedLanguages" Value=""/>
</Properties>
</Metadata>
<Installation>
<InstallationTarget Id="Microsoft.VisualStudio.Code"/>
</Installation>
<Dependencies/>
<Assets>
<Asset Type="Microsoft.VisualStudio.Code.Manifest" Path="extension/package.json" Addressable="true"/>
<Asset Type="Microsoft.VisualStudio.Services.Content.Details" Path="extension/README.md" Addressable="true"/>
</Assets>
</PackageManifest>
`, 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 }
+150
View File
@@ -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"])
}
}
+16 -2
View File
@@ -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) {
+62 -1
View File
@@ -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
+45
View File
@@ -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", `"><script>`, strings.Repeat("a", 65)} {
if got := signInState(bad); got != "" {
t.Errorf("signInState(%q) = %q", bad, got)
}
}
if l := string(vscodeLink("https://gw", "k", signInState("abcdefghijklmnop"))); !strings.Contains(l, "state=abcdefghijklmnop") {
t.Errorf("link = %s", l)
}
}
func TestTheExtensionIsServedForThisGateway(t *testing.T) {
p := &AccountPages{version: "v0.15.0"}
w := httptest.NewRecorder()
p.vsix(w, httptest.NewRequest("GET", "http://gw.example/setup/vscode/llmgw.vsix", nil))
if w.Code != http.StatusOK || w.Header().Get("Cache-Control") != "no-store" ||
!strings.Contains(w.Header().Get("Content-Disposition"), "llmgw.vsix") {
t.Fatalf("%d %v", w.Code, w.Header())
}
if !strings.HasPrefix(w.Body.String(), "PK") {
t.Error("not a zip")
}
}
+5
View File
@@ -75,6 +75,11 @@
"connect.note.codex_blocks": "The command adds the two blocks above to ~/.codex/config.toml, replacing any it added before, and keeps a copy of the file as it was.",
"connect.note.codex_env": "Put export LLMGW_API_KEY=… in your shell's profile to leave it out of the command line.",
"connect.run.cline": "Cline keeps these in VS Code's own secret storage, so they are set in its settings: open Cline, the gear, and fill them in.",
"connect.command_note_vscode": "Downloads llmgw's extension for VS Code, made for this gateway, and installs it. Or download it from the link in the command and use Extensions › … › Install from VSIX.",
"connect.field.gateway": "Gateway",
"connect.open_vscode": "Open in VS Code",
"connect.run.vscode": "The link hands VS Code the key. Or run \"llmgw: Enter API Key\" in VS Code and paste the key above. The gateway's models are then in the chat's model picker, under llmgw.",
"connect.note.vscode_agent": "Agent mode acts only through tool calls: choose a model the catalog says makes them. Completion as you type is not part of it; use Continue for that.",
"connect.err.choose": "choose a tool and a model",
"profile.create_key": "Create a key",
+5
View File
@@ -75,6 +75,11 @@
"connect.note.codex_blocks": "Komut, yukarıdaki iki bloğu ~/.codex/config.toml dosyasına ekler, daha önce eklediklerinin yerine koyar ve dosyanın önceki hâlinin bir kopyasını saklar.",
"connect.note.codex_env": "Anahtarı komut satırına yazmamak için export LLMGW_API_KEY=… satırını kabuğunuzun profil dosyasına ekleyin.",
"connect.run.cline": "Cline bu ayarları VS Code'un kendi gizli deposunda tutar, bu yüzden kendi ayarlar ekranından girilir: Cline'ı açın, dişli simgesine tıklayın ve alanları doldurun.",
"connect.command_note_vscode": "llmgw'nin bu ağ geçidi için hazırlanmış VS Code eklentisini indirir ve kurar. Ya da komuttaki bağlantıdan indirip Uzantılar › … › VSIX'ten Yükle ile kurun.",
"connect.field.gateway": "Ağ geçidi",
"connect.open_vscode": "VS Code'da aç",
"connect.run.vscode": "Bağlantı anahtarı VS Code'a verir. Ya da VS Code'da \"llmgw: Enter API Key\" komutunu çalıştırıp yukarıdaki anahtarı yapıştırın. Ağ geçidinin modelleri ardından sohbetin model seçicisinde, llmgw başlığı altında olur.",
"connect.note.vscode_agent": "Ajan modu yalnızca araç çağrılarıyla iş yapar: kataloğun araç çağrısı yaptığını söylediği bir model seçin. Yazarken tamamlama bunun parçası değildir; onun için Continue'yu kullanın.",
"connect.err.choose": "bir araç ve bir model seçin",
"profile.create_key": "Anahtar oluşturun",
+9 -3
View File
@@ -80,7 +80,7 @@
<p>{{th "connect.shown_once"}}</p>
{{if .Command}}
<h3>{{t "connect.one_command"}}</h3>
<p class="note">{{if eq .Tool "codex"}}{{t "connect.command_note_codex"}}{{else}}{{t "connect.command_note"}}{{end}}</p>
<p class="note">{{if eq .Tool "codex"}}{{t "connect.command_note_codex"}}{{else if eq .Tool "vscode"}}{{t "connect.command_note_vscode"}}{{else}}{{t "connect.command_note"}}{{end}}</p>
<div class="copyable"><pre>{{.Command}}</pre><button type="button" class="link copy">{{t "common.copy"}}</button></div>
{{end}}
{{range .Files}}
@@ -94,20 +94,26 @@
<tr><th>{{.Label}}</th><td><code>{{.Value}}</code></td><td><button type="button" class="link copy" data-value="{{.Value}}">{{t "common.copy"}}</button></td></tr>{{end}}
</table>
{{end}}
{{if .Link}}
<h3>{{t "connect.then"}}</h3>
<p><a href="{{.Link}}"><strong>{{t "connect.open_vscode"}}</strong></a></p>
{{else}}
<h3>{{t "connect.then"}}</h3>
{{end}}
<p>{{if eq .Tool "codex"}}<code>{{.Run}}</code>{{else}}{{.Run}}{{end}}</p>
{{range .Notes}}<p class="note">{{.}}</p>{{end}}
</section>
{{end}}
<h2>{{t "connect.heading"}}</h2>
<h2 id="connect">{{t "connect.heading"}}</h2>
<p class="note">{{t "connect.intro"}}</p>
<p class="note">{{th "connect.own_code"}}</p>
<form method="post" action="/profile/connect#connected" class="connect-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
{{if .State}}<input type="hidden" name="state" value="{{.State}}">{{end}}
<div class="field-row">
<label>{{t "connect.tool"}}
<select name="tool">{{range .Tools}}<option value="{{.Key}}">{{.Name}}</option>{{end}}</select></label>
<select name="tool">{{range .Tools}}<option value="{{.Key}}"{{if eq .Key $.Tool}} selected{{end}}>{{.Name}}</option>{{end}}</select></label>
<label>{{t "connect.model"}}
{{if .Models}}<select name="model">{{range .Models}}<option value="{{.}}">{{.}}</option>{{end}}</select>
{{else}}<input type="text" name="model" placeholder="{{t "connect.model_placeholder"}}" required>{{end}}</label>