VS Code Stable takes other vendors' models through its Custom Endpoint provider since 1.122 (customoai, the older OpenAI Compatible one, is Insiders only), signed in to GitHub or not. The provider groups live in chatLanguageModels.json beside settings.json in the default profile's User folder (~/Library/Application Support/Code/User, ~/.config/Code/User, %APPDATA%\Code\User), a JSONC array VS Code watches. magpie adds {"vendor":"customendpoint","name":"magpie","apiType":"chat-completions","models":[...]} with each catalog model at the gateway's /v1/chat/completions, toolCalling true (agent mode lists only tool-calling models), vision, contextWindow and maxOutputTokens (capped at a quarter of the window, as VS Code keeps the output budget out of the prompt's), and the model's efforts as supportsReasoningEffort. A group's apiKey is a secret property that VS Code reads from its secret storage only (languageModels.ts _resolveConfiguration), so it can't be written to the file; each model instead sends Authorization: Bearer magpie in requestHeaders, which CustomEndpointOAIEndpoint lets through and which stops it sending its own key header. The model a new chat starts on is chat.defaultModel in settings.json (matched by model id, so the catalog id); its old value is stashed and put back. The group is the agent's Joined: picking one of VS Code's own (auto) keeps magpie's models listed, and switching off (Unwire) takes the group out and puts chat.defaultModel back. Sync rewrites the group as the catalog changes, Check says when its url or header no longer reach the gateway, the Notice says where the models are and to reload the window if they don't show, and requests are credited to it by GitHubCopilotChat/<version>, a User-Agent a model's headers can't change.
internal/edit: SetJSONItem/GetJSONItem/DelJSONItem edit one element of a JSONC file's top-level array, found by its members, keeping comments, trailing commas and the other elements; SetJSON/DelJSON take a backslash-escaped dot as part of a key, as gjson does, for VS Code's flat "chat.defaultModel".
TestVSCode (connect keeps the user's settings and groups with their comments, the group's url/header/toolCalling/window and no apiKey or discovery url, Sync, Check, an own model keeping the group, disconnect back to the user's last own model), TestVSCodeRoundTrip (on and off leaves both files byte for byte; no files at all) and TestVSCodeUA; TestJSONItem, TestDelJSONItemAmongOthers and TestJSONEscapedDot. go vet ./internal/..., go build -tags nogui ./..., GOOS=windows and GOOS=linux builds pass; go test of internal/agent, edit, usage and gateway (each sandboxes HOME in TestMain) and internal/gui's page and agent connect tests pass; the full go test under a temp HOME was not run (the sandbox refused the HOME override). No GUI code changed (the generic connected wording fits), so no webkit test. VS Code itself is not installed here: not tried against a real VS Code.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
4.7 KiB
Go
143 lines
4.7 KiB
Go
package edit
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/tidwall/jsonc"
|
|
)
|
|
|
|
// a top-level array of groups, as VS Code's chatLanguageModels.json: one
|
|
// group found by its members, set, replaced and taken out with the rest of
|
|
// the file as it was
|
|
func TestJSONItem(t *testing.T) {
|
|
where := map[string]string{"vendor": "customendpoint", "name": "magpie"}
|
|
mine := map[string]any{"name": "magpie", "vendor": "customendpoint", "models": []any{map[string]any{"id": "a/b"}}}
|
|
mine2 := map[string]any{"name": "magpie", "vendor": "customendpoint", "models": []any{map[string]any{"id": "c/d"}}}
|
|
other := "{\n\t\t\"name\": \"Ollama, local\", // mine\n\t\t\"vendor\": \"ollama\"\n\t}"
|
|
cases := []struct{ name, in string }{
|
|
{"missing", ""},
|
|
{"empty", "[]\n"},
|
|
{"one", "// my groups\n[\n\t" + other + "\n]\n"},
|
|
{"trailing comma", "[\n\t" + other + ", // last, with a comma\n]\n"},
|
|
{"compact", `[{"name":"x","vendor":"openai"}]`},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "chatLanguageModels.json")
|
|
if c.in != "" {
|
|
os.WriteFile(path, []byte(c.in), 0o644)
|
|
}
|
|
valid := func(when string) []map[string]any {
|
|
t.Helper()
|
|
b, _ := os.ReadFile(path)
|
|
var got []map[string]any
|
|
if err := json.Unmarshal(jsonc.ToJSON(b), &got); err != nil {
|
|
t.Fatalf("%s: not valid JSONC: %v\n%s", when, err, b)
|
|
}
|
|
for _, keep := range []string{"// my groups", "// mine", "// last, with a comma"} {
|
|
if strings.Contains(c.in, keep) && !strings.Contains(string(b), keep) {
|
|
t.Fatalf("%s: comment %q lost:\n%s", when, keep, b)
|
|
}
|
|
}
|
|
return got
|
|
}
|
|
before := 0
|
|
if c.in != "" {
|
|
before = len(valid("before"))
|
|
}
|
|
if err := SetJSONItem(path, where, mine); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := valid("set")
|
|
if len(got) != before+1 || got[len(got)-1]["name"] != "magpie" {
|
|
t.Fatalf("set: %v", got)
|
|
}
|
|
if v, ok := GetJSONItem(path, where); !ok || !strings.Contains(v, `"a/b"`) {
|
|
t.Fatalf("get: %q %v", v, ok)
|
|
}
|
|
if err := SetJSONItem(path, where, mine2); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got = valid("replace")
|
|
if len(got) != before+1 {
|
|
t.Fatalf("replace added one: %v", got)
|
|
}
|
|
if v, _ := GetJSONItem(path, where); !strings.Contains(v, `"c/d"`) || strings.Contains(v, `"a/b"`) {
|
|
t.Fatalf("replace: %q", v)
|
|
}
|
|
if err := DelJSONItem(path, where); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got = valid("delete")
|
|
if len(got) != before {
|
|
t.Fatalf("delete: %v", got)
|
|
}
|
|
if _, ok := GetJSONItem(path, where); ok {
|
|
t.Fatal("still there")
|
|
}
|
|
if c.in != "" && c.in != "[]\n" {
|
|
b, _ := os.ReadFile(path)
|
|
bare := func(s string) string { return strings.Join(strings.Fields(strings.ReplaceAll(s, ",", "")), "") }
|
|
if bare(string(b)) != bare(c.in) {
|
|
t.Fatalf("not as it was:\n%s\nwas:\n%s", b, c.in)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// taken out from between others, and from the front, the commas stay right
|
|
func TestDelJSONItemAmongOthers(t *testing.T) {
|
|
where := map[string]string{"name": "magpie"}
|
|
for _, in := range []string{
|
|
"[\n {\"name\": \"a\"},\n {\"name\": \"magpie\"},\n {\"name\": \"b\"}\n]\n",
|
|
"[\n {\"name\": \"magpie\"},\n {\"name\": \"a\"},\n {\"name\": \"b\"}\n]\n",
|
|
"[\n {\"name\": \"a\"},\n {\"name\": \"b\"},\n {\"name\": \"magpie\"}\n]\n",
|
|
`[{"name":"a"},{"name":"magpie"},{"name":"b"}]`,
|
|
} {
|
|
path := filepath.Join(t.TempDir(), "x.json")
|
|
os.WriteFile(path, []byte(in), 0o644)
|
|
if err := DelJSONItem(path, where); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := os.ReadFile(path)
|
|
var got []map[string]string
|
|
if err := json.Unmarshal(b, &got); err != nil || len(got) != 2 {
|
|
t.Fatalf("%v %v\n%s", err, got, b)
|
|
}
|
|
}
|
|
}
|
|
|
|
// a key with a dot in it, as VS Code's settings.json keeps chat.defaultModel
|
|
func TestJSONEscapedDot(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "settings.json")
|
|
os.WriteFile(path, []byte("{\n\t// mine\n\t\"editor.fontSize\": 14\n}\n"), 0o644)
|
|
if err := SetJSON(path, KV{Path: `chat\.defaultModel`, Value: "a/b"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := os.ReadFile(path)
|
|
if !strings.Contains(string(b), `"chat.defaultModel": "a/b"`) || !strings.Contains(string(b), "// mine") {
|
|
t.Fatalf("set:\n%s", b)
|
|
}
|
|
if v, _ := GetJSON(path, `chat\.defaultModel`); v != "a/b" {
|
|
t.Fatalf("get %q", v)
|
|
}
|
|
if err := SetJSON(path, KV{Path: `chat\.defaultModel`, Value: "c/d"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v, _ := GetJSON(path, `chat\.defaultModel`); v != "c/d" {
|
|
t.Fatalf("replace %q", v)
|
|
}
|
|
if err := DelJSON(path, `chat\.defaultModel`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ = os.ReadFile(path)
|
|
if string(b) != "{\n\t// mine\n\t\"editor.fontSize\": 14\n}\n" {
|
|
t.Fatalf("delete:\n%q", b)
|
|
}
|
|
}
|