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>
216 lines
6.6 KiB
Go
216 lines
6.6 KiB
Go
// 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 }
|