Files
llmgw/internal/vscodeext/vsix.go
T
Ilker ManapandClaude Opus 5.5 38dfe268c9
CI / test (push) Successful in 8m43s
VS Code gets the gateway's models from an extension the gateway serves
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>
2026-10-24 01:39:50 +02:00

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 }