Files
yetoneandClaude Opus 5.5 a370877ca4 agents: VS Code's built-in chat (Copilot Chat) — switched on, magpie's models join Chat's model picker as a Custom Endpoint group in chatLanguageModels.json, with no key to enter and no Copilot sign-in needed (wani on Discord: 可以添加vscode自带的agent的配置吗)
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>
2026-10-04 08:23:38 +08:00

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)
}
}