Files
magpie/internal/edit/jsonarray.go
T
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

221 lines
6.0 KiB
Go

package edit
import (
"bytes"
"encoding/json"
"fmt"
"github.com/tidwall/gjson"
"github.com/tidwall/jsonc"
)
// A JSON/JSONC file whose top level is an array of objects (VS Code's
// chatLanguageModels.json, a list of model provider groups): one element is
// found by the string members it has (where), and read, put in place or
// taken out with the rest of the file — comments, trailing commas and the
// other elements — as it was.
// GetJSONItem returns the raw JSON of the first element of the file's
// top-level array that has every member of where.
func GetJSONItem(path string, where map[string]string) (string, bool) {
raw, err := Read(path)
if err != nil || len(bytes.TrimSpace(raw)) == 0 {
return "", false
}
r, ok := findItem(jsonc.ToJSONInPlace(raw), where)
if !ok {
return "", false
}
return r.Raw, true
}
// SetJSONItem replaces the first element that has every member of where
// with value, or adds value at the end of the array when none does. A
// missing or empty file is made an array of value alone.
func SetJSONItem(path string, where map[string]string, value any) error {
raw, err := Read(path)
if err != nil {
return err
}
if len(bytes.TrimSpace(raw)) == 0 {
v, _ := json.MarshalIndent(value, "\t", "\t")
return WriteAtomic(path, []byte("[\n\t"+string(v)+"\n]\n"))
}
stripped := jsonc.ToJSONInPlace(append([]byte(nil), raw...))
root, open, close := topArray(stripped)
if close < 0 {
return fmt.Errorf("%s: top level is not a JSON array", path)
}
if r, ok := findItem(stripped, where); ok {
out, _ := splice(raw, r.Index, len(r.Raw), marshalAt(raw, r.Index, value))
return WriteAtomic(path, out)
}
items := root.Array()
if len(items) == 0 {
v, _ := json.MarshalIndent(value, "\t", "\t")
out, _ := splice(raw, open, close-open+1, []byte("[\n\t"+string(v)+"\n]"))
return WriteAtomic(path, out)
}
last := items[len(items)-1]
at := last.Index + len(last.Raw)
// a trailing comma after the last element (blanked in stripped) stays,
// the new element after it
sep := ","
if j := nextToken(raw, at); j < close && raw[j] == ',' {
at, sep = j+1, ""
}
if bytes.IndexByte(raw, '\n') < 0 {
v, _ := json.Marshal(value)
out, _ := splice(raw, at, 0, []byte(sep+string(v)))
return WriteAtomic(path, out)
}
indent := lineIndent(raw, last.Index)
v := marshalAt(raw, last.Index, value)
out, _ := splice(raw, at, 0, []byte(sep+"\n"+indent+string(v)))
return WriteAtomic(path, out)
}
// DelJSONItem removes every element of the file's top-level array that has
// every member of where. A file without one is left as it is.
func DelJSONItem(path string, where map[string]string) error {
raw, err := Read(path)
if err != nil || len(bytes.TrimSpace(raw)) == 0 {
return err
}
changed := false
for {
stripped := jsonc.ToJSONInPlace(append([]byte(nil), raw...))
r, ok := findItem(stripped, where)
if !ok {
break
}
raw, changed = delItem(raw, stripped, r), true
}
if !changed {
return nil
}
return WriteAtomic(path, raw)
}
// topArray is stripped's top-level array, with the offsets of its brackets
// (close -1 when the top level is no array); its elements' Index are their
// offsets in stripped.
func topArray(stripped []byte) (gjson.Result, int, int) {
open := 0
for open < len(stripped) && isSpace(stripped[open]) {
open++
}
close := len(stripped) - 1
for close > open && isSpace(stripped[close]) {
close--
}
if open >= len(stripped) || stripped[open] != '[' || stripped[close] != ']' {
return gjson.Result{}, 0, -1
}
root := gjson.ParseBytes(stripped[open : close+1])
if !root.IsArray() {
return gjson.Result{}, 0, -1
}
root.Index = open
return root, open, close
}
func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
// nextToken is the offset of the first byte at or after i in raw that is
// neither white space nor in a comment: between two tokens, where a
// trailing comma jsonc blanks would be.
func nextToken(raw []byte, i int) int {
for i < len(raw) {
switch {
case isSpace(raw[i]):
i++
case bytes.HasPrefix(raw[i:], []byte("//")):
if n := bytes.IndexByte(raw[i:], '\n'); n >= 0 {
i += n + 1
} else {
return len(raw)
}
case bytes.HasPrefix(raw[i:], []byte("/*")):
if n := bytes.Index(raw[i+2:], []byte("*/")); n >= 0 {
i += n + 4
} else {
return len(raw)
}
default:
return i
}
}
return i
}
// findItem is the first element of stripped's top-level array that has
// every member of where, its Index its offset in stripped.
func findItem(stripped []byte, where map[string]string) (gjson.Result, bool) {
root, _, close := topArray(stripped)
if close < 0 {
return gjson.Result{}, false
}
for _, it := range root.Array() {
if !it.IsObject() || it.Index == 0 {
continue
}
match := true
for k, want := range where {
if v := it.Get(gjson.Escape(k)); !v.Exists() || v.String() != want {
match = false
break
}
}
if match {
return it, true
}
}
return gjson.Result{}, false
}
// delItem cuts the element r out of raw with the comma that went with it:
// the one after it, or for the last element the one before; on lines of
// its own, its lines go too.
func delItem(raw, stripped []byte, r gjson.Result) []byte {
a, b := r.Index, r.Index+len(r.Raw)
ls := bytes.LastIndexByte(raw[:a], '\n') + 1
alone := len(bytes.TrimSpace(stripped[ls:a])) == 0
j := b
for j < len(stripped) && isSpace(stripped[j]) {
j++
}
from, to := a, b
if j < len(stripped) && stripped[j] == ',' {
// not the last element: through the comma after it
to = j + 1
if alone {
from = ls
if to < len(raw) && raw[to] == '\n' {
to++
}
}
out, _ := splice(raw, from, to-from, nil)
return out
}
// the last element: its trailing comma too, then the comma before it
if t := nextToken(raw, b); t < len(raw) && raw[t] == ',' {
to = t + 1
}
if alone {
from = ls
if to < len(raw) && raw[to] == '\n' {
to++
}
}
out, _ := splice(raw, from, to-from, nil)
k := a
for k > 0 && isSpace(stripped[k-1]) {
k--
}
if k > 0 && stripped[k-1] == ',' {
out, _ = splice(out, k-1, 1, nil)
}
return out
}