VS Code gets the gateway's models from an extension the gateway serves
CI / test (push) Successful in 8m43s
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:
1 parent
fea5774fe5
commit
38dfe268c9
17 files changed
+1412
-9
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user