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", `">