diff --git a/docs/reference.md b/docs/reference.md index e00772df..1b12a8d8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -94,7 +94,7 @@ line; agents connected to magpie lose it when it quits. | Zed | `~/.config/zed/settings.json` (`$XDG_CONFIG_HOME` on Linux, `%APPDATA%\Zed` on Windows) | model (a `magpie` OpenAI-compatible provider; its catalog in Zed's picker) | | VS Code (Chat) | `~/Library/Application Support/Code/User/settings.json` + `chatLanguageModels.json` (`~/.config/Code/User` on Linux, `%APPDATA%\Code\User` on Windows) | model (`chat.defaultModel`; a `magpie` Custom Endpoint group, its catalog in Chat's model picker; VS Code 1.122+, no Copilot sign-in or key needed). Each profile's own pair under `User/profiles//` (listed in `globalStorage/storage.json`) gets the same, unless the profile uses the default's | | VS Code Insiders (Chat) | the same files under `Code - Insiders/User` in place of `Code/User` | as VS Code, a row of its own; its models send the token `magpie-vscode-insiders`, since its chat's User-Agent is VS Code's | -| VSCodium (Chat) | the same files under `VSCodium/User` in place of `Code/User` | as VS Code, a row of its own; its models send the token `magpie-vscodium`, so Usage counts them as VSCodium; its Chat features must be turned on (`chat.disableAIFeatures=false`, and `defaultChatAgent` plus `trustedExtensionAuthAccess` for GitHub.copilot-chat in its product.json) | +| VSCodium (Chat) | the same files under `VSCodium/User` in place of `Code/User` | as VS Code, a row of its own; its models send the token `magpie-vscodium`, so Usage counts them as VSCodium; its Chat features must be turned on (`chat.disableAIFeatures=false`, and `defaultChatAgent` plus `trustedExtensionAuthAccess` for GitHub.copilot-chat in its product.json). With the Marketplace's GitHub Copilot Chat 0.48.1, which has no Custom Endpoint provider, the group is written for its OpenAI Compatible provider (`customoai`, the token sent as `x-api-key`), which lists models only while Copilot Chat is signed in to GitHub with a personal Copilot plan | | JetBrains Air | `acp.json` in `~/Library/Application Support/JetBrains/Air` (`~/.config/JetBrains/Air` on Linux, `%APPDATA%\JetBrains\Air` on Windows) + `magpie-opencode.json` beside it | model (a `Magpie` ACP agent: OpenCode's `opencode acp` on magpie's provider alone, its models and routing groups in Air's model menu; needs OpenCode installed) | | Copilot CLI | `~/.copilot/settings.json` | model | | Crush | `~/.config/crush/crush.json` | large, small | diff --git a/docs/subsystems/agent-wiring.md b/docs/subsystems/agent-wiring.md index 0a349f65..429cabe2 100644 --- a/docs/subsystems/agent-wiring.md +++ b/docs/subsystems/agent-wiring.md @@ -39,6 +39,7 @@ something else shows up as drift. - A value an agent spells as its own provider's model is never taken for one of magpie's, even when magpie has a provider of the same name (`Spelled`, #835). - Listing never starts a stopped WSL distro. A stopped distro's agent shows what was last seen there (`asleep`, `wsl.json`); setting one of its fields writes its files, which starts the distro. - Codex's sign-in (`login`) is ChatGPT (`""`, the default), Always ChatGPT (`chatgpt`) or magpie API (`api`). With ChatGPT, magpie's models join Codex's own beside the sign-in (`openai_base_url`), and while the Codex app holds the ChatGPT account Codex is signed in to (`codexUsedUp`, `provider.CodexUsedUp`: OpenAI no longer allows the account and it has no credits left, a workspace still within its overage excepted), magpie is made Codex's provider (`codex.out`): the Codex app sends nothing at all on an account it holds, a magpie model's turn included (#540), and as a provider of its own magpie is past that. A window at 100% with credits left doesn't count, and Codex stays signed in. Sync puts it back beside the sign-in once the account has room. Always ChatGPT never does that: Codex stays in its ChatGPT state, and the Codex app may send nothing till the account has room, while Codex CLI's turns still reach magpie by the base URL, and its routing and other accounts serve them. +- An agent's config is written in the form the installed version reads, taken from what that version declares, not the latest one. VSCodium has no Copilot Chat of its own, and the Marketplace's last one (0.48.1) has no Custom Endpoint provider, only OpenAI Compatible (`customoai`, #1014). magpie reads the provider list in the package.json of the `github.copilot-chat` installed under `~/.vscode-oss/extensions` (`vscodeChatOf`). It writes its group for `customendpoint` when that is declared, for `customoai` when only that is, with each model's window as `maxInputTokens` + `maxOutputTokens` and the token as `x-api-key` (that version drops an Authorization in `requestHeaders`). A missing or unreadable extension keeps `customendpoint`. Sync rewrites a group written for the other provider, and Check reports it, as it does a Copilot Chat declaring neither. That Copilot Chat registers its providers only while signed in to GitHub with a personal Copilot plan, which the Notice says. - Tests never touch the real machine. `TestMain` uses `testenv`, which points HOME, USERPROFILE, the XDG folders and APPDATA into a temporary folder, clears `agentenv.Vars` and puts failing stand-ins for keychain tools and agent CLIs on PATH. Agent-specific side effects (Aside, the Zed keychain, Codex's usage check) are also stubbed in `TestMain`. ## Verification diff --git a/internal/agent/vscode.go b/internal/agent/vscode.go index 4cb8137e..d17003d0 100644 --- a/internal/agent/vscode.go +++ b/internal/agent/vscode.go @@ -16,6 +16,17 @@ package agent // user is asked for none. The model a new chat starts on is the // chat.defaultModel setting (a model id: the catalog id here); picked in // Chat's model picker, a model is kept in VS Code's storage, not here. +// +// VSCodium has no Copilot Chat of its own: its users install the +// Marketplace's, whose last release (0.48.1) has no Custom Endpoint +// provider but an OpenAI Compatible one, customoai (xybio, #1014: only Auto +// showed). That one takes the same group in the same file, with each +// model's window as maxInputTokens beside maxOutputTokens, and drops an +// Authorization in requestHeaders, so magpie's token goes as x-api-key +// there (the gateway takes a key from either). It registers its providers +// only while Copilot Chat is signed in to GitHub with a personal Copilot +// plan; the Notice says so. Which of the two magpie writes is the one the +// installed extension's package.json declares (vscodeChatOf). import ( "encoding/json" @@ -23,6 +34,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "github.com/tidwall/gjson" @@ -40,8 +52,36 @@ const vscodeDefault = `chat\.defaultModel` // can't change. const vscodeUA = "githubcopilotchat" -// vscodeGroup is how magpie's group is found among the user's. -var vscodeGroup = map[string]string{"vendor": "customendpoint", "name": magpieID} +// The providers magpie's group can be written for: VS Code's Custom +// Endpoint, and the OpenAI Compatible one of the Copilot Chat VSCodium +// installs. +const ( + vscodeEndpoint = "customendpoint" + vscodeOAI = "customoai" +) + +// vscodeVendors are both, the one magpie writes when it can't tell first. +var vscodeVendors = []string{vscodeEndpoint, vscodeOAI} + +// vscodeGroupOf is how magpie's group for a provider is found among the +// user's. +func vscodeGroupOf(vendor string) map[string]string { + return map[string]string{"vendor": vendor, "name": magpieID} +} + +// vscodeGroup is magpie's Custom Endpoint group. +var vscodeGroup = vscodeGroupOf(vscodeEndpoint) + +// vscodeOurs is magpie's group in a chatLanguageModels.json, whichever +// provider it was written for, and that provider. +func vscodeOurs(lm string) (group, vendor string, ok bool) { + for _, v := range vscodeVendors { + if g, ok := edit.GetJSONItem(lm, vscodeGroupOf(v)); ok { + return g, v, true + } + } + return "", "", false +} // vscodeKind is one build of VS Code, each with its own User folder, its // own app and its own row: VS Code, and VS Code Insiders beside it (wani on @@ -53,12 +93,16 @@ type vscodeKind struct { // Insiders' chat names itself the same, so it is told by its token // instead (gateway.TokenFor), which its models send. ua []string + // exts, for a build without a Copilot Chat of its own, is the folder + // in home its extensions are installed in; extDir is where that is + // (vscodeOf), "" for a build whose chat is built in. + exts, extDir string } var ( vscodeStable = vscodeKind{id: "vscode", name: "VS Code", folder: "Code", bin: "code", aliases: []string{"vs-code", "copilot-chat", "vscode-chat"}, ua: []string{vscodeUA}} vscodeInsiders = vscodeKind{id: "vscode-insiders", name: "VS Code Insiders", folder: "Code - Insiders", bin: "code-insiders", aliases: []string{"vs-code-insiders", "code-insiders"}} - vscodiumKind = vscodeKind{id: "vscodium", name: "VSCodium", folder: "VSCodium", bin: "codium", aliases: []string{"codium", "vscodium-chat"}} + vscodiumKind = vscodeKind{id: "vscodium", name: "VSCodium", folder: "VSCodium", bin: "codium", aliases: []string{"codium", "vscodium-chat"}, exts: ".vscode-oss"} ) // token is the bearer token its models send: Stable's the gateway's own, @@ -88,6 +132,9 @@ func vscodeOf(k vscodeKind, home, cfg string) *Agent { cfg = filepath.Join(home, "AppData", "Roaming") } } + if k.exts != "" { + k.extDir = filepath.Join(home, k.exts, "extensions") + } return vscodeKindAt(k, filepath.Join(cfg, k.folder, "User")) } @@ -99,7 +146,7 @@ func vscodeKindAt(k vscodeKind, dir string) *Agent { key := func(p string) string { return k.id + ":" + p + ":model" } getAt := func(p string) string { v, _ := edit.GetJSON(p, vscodeDefault); return v } get := func() string { return getAt(path) } - ours := func() (string, bool) { return edit.GetJSONItem(models, vscodeGroup) } + ours := func() (string, bool) { g, _, ok := vscodeOurs(models); return g, ok } joined := func() bool { _, ok := ours(); return ok } // inGroup: a model id is one of those magpie's group lists inGroup := func(id string) bool { @@ -135,14 +182,24 @@ func vscodeKindAt(k vscodeKind, dir string) *Agent { s, m := vscodeProfiles(dir) return edit.Atomically(func() error { return fn(s, m) }, append(s, m...)...) } - // setGroup writes magpie's group in the default's and each profile's + // setGroup writes magpie's group in the default's and each profile's, + // for the provider the chat installed has, in place of one written + // for the other setGroup := func(lms []string) error { - v := vscodeGroupJSON(k) + vendor := k.chat().vendor + v := vscodeGroupJSON(k, vendor) for _, lm := range append([]string{models}, lms...) { - if cur, ok := edit.GetJSONItem(lm, vscodeGroup); ok && sameJSON(cur, v) { + for _, other := range vscodeVendors { + if other != vendor { + if err := edit.DelJSONItem(lm, vscodeGroupOf(other)); err != nil { + return err + } + } + } + if cur, ok := edit.GetJSONItem(lm, vscodeGroupOf(vendor)); ok && sameJSON(cur, v) { continue } - if err := edit.SetJSONItem(lm, vscodeGroup, v); err != nil { + if err := edit.SetJSONItem(lm, vscodeGroupOf(vendor), v); err != nil { return err } } @@ -164,8 +221,8 @@ func vscodeKindAt(k vscodeKind, dir string) *Agent { }, Notice: func() string { if joined() { - if k.id == "vscodium" { - return "VSCodium's Chat features must be enabled (chat.disableAIFeatures=false) and its product.json must include defaultChatAgent and trustedExtensionAuthAccess for GitHub.copilot-chat. Then run Developer: Reload Window in VSCodium." + if k.exts != "" { + return k.chat().notice(k.name) } return "magpie's models are in " + k.name + "'s Chat model picker, under magpie, in each of its profiles (VS Code 1.122 or later). If they don't show, run Developer: Reload Window in " + k.name + "." } @@ -185,8 +242,10 @@ func vscodeKindAt(k vscodeKind, dir string) *Agent { forget(key(p)) } for _, lm := range append([]string{models}, lms...) { - if err := edit.DelJSONItem(lm, vscodeGroup); err != nil { - return err + for _, v := range vscodeVendors { + if err := edit.DelJSONItem(lm, vscodeGroupOf(v)); err != nil { + return err + } } } return nil @@ -200,14 +259,23 @@ func vscodeKindAt(k vscodeKind, dir string) *Agent { return profiles(func(_, lms []string) error { return setGroup(lms) }) }, Check: func() string { - g, ok := ours() + g, vendor, ok := vscodeOurs(models) if !ok || !gjson.Get(g, "models.0").Exists() { return "" } + if c := k.chat(); c.unsupported { + return c.notice(k.name) + } else if c.vendor != vendor { + return k.name + "'s Copilot Chat " + c.version + " lists a group for " + c.vendor + " and magpie's is for " + vendor + "; magpie writes it again when it next starts" + } + header, want := "requestHeaders.Authorization", "Bearer "+k.token() + if vendor == vscodeOAI { + header, want = "requestHeaders.x-api-key", k.token() + } return wiringOff(k.name, models, func(k string) (string, bool) { r := gjson.Get(g, "models.0."+k) return r.String(), r.Exists() - }, "url", vscodeURL(), "requestHeaders.Authorization", "Bearer "+k.token()) + }, "url", vscodeURL(), header, want) }, Fields: []Field{{ Key: "model", Label: "model", Get: model, @@ -337,9 +405,10 @@ func vscodeProfiles(dir string) (settings, models []string) { // url that ends in /chat/completions is asked as it is. func vscodeURL() string { return gatewayV1() + "/chat/completions" } -// vscodeGroupJSON is magpie's group in chatLanguageModels.json: the catalog -// as the chat of the VS Code k is shown it. -func vscodeGroupJSON(k vscodeKind) map[string]any { +// vscodeGroupJSON is magpie's group in chatLanguageModels.json for a +// provider (vscodeEndpoint or vscodeOAI): the catalog as the chat of the VS +// Code k is shown it. +func vscodeGroupJSON(k vscodeKind, vendor string) map[string]any { list := []any{} for _, m := range magpieModels(k.id) { context := m.Context @@ -360,11 +429,145 @@ func vscodeGroupJSON(k vscodeKind) map[string]any { "contextWindow": context, "maxOutputTokens": output, "requestHeaders": map[string]string{"Authorization": "Bearer " + k.token()}, } + if vendor == vscodeOAI { + // its window is maxInputTokens plus maxOutputTokens, and an + // Authorization of the model's own is dropped + delete(entry, "contextWindow") + entry["maxInputTokens"] = context - output + entry["requestHeaders"] = map[string]string{"x-api-key": k.token()} + } if len(m.Efforts) > 0 { entry["supportsReasoningEffort"] = m.Efforts entry["reasoningEffortFormat"] = "chat-completions" } list = append(list, entry) } - return map[string]any{"name": magpieID, "vendor": "customendpoint", "apiType": "chat-completions", "models": list} + if vendor == vscodeOAI { + return map[string]any{"name": magpieID, "vendor": vscodeOAI, "models": list} + } + return map[string]any{"name": magpieID, "vendor": vscodeEndpoint, "apiType": "chat-completions", "models": list} +} + +// vscodeChatID is Copilot Chat's extension id. +const vscodeChatID = "github.copilot-chat" + +// vscodeChat is the Copilot Chat a VS Code build runs, as far as magpie's +// group goes: the provider the group is written for, the version found, +// none (no Copilot Chat installed) and unsupported (one that declares +// neither provider). +type vscodeChat struct { + vendor, version string + none, unsupported bool +} + +// chat is the Copilot Chat k runs: a build with one built in has the +// Custom Endpoint provider; one without has the extension installed in its +// extensions folder, whose package.json says which it registers. A folder +// or file that can't be read is not taken as "none": the Custom Endpoint +// group stays as magpie always wrote it. +func (k vscodeKind) chat() vscodeChat { + if k.exts == "" { + return vscodeChat{vendor: vscodeEndpoint} + } + return vscodeChatOf(k.extDir) +} + +func vscodeChatOf(dir string) vscodeChat { + at, version, found := vscodeChatInstalled(dir) + if !found { + return vscodeChat{vendor: vscodeEndpoint, none: true} + } + b, err := os.ReadFile(filepath.Join(at, "package.json")) + if err != nil || !gjson.ValidBytes(b) { + return vscodeChat{vendor: vscodeEndpoint, version: version} + } + if version == "" { + version = gjson.GetBytes(b, "version").String() + } + has := func(vendor string) bool { + return gjson.GetBytes(b, `contributes.languageModelChatProviders.#(vendor=="`+vendor+`")`).Exists() + } + switch { + case has(vscodeEndpoint): + return vscodeChat{vendor: vscodeEndpoint, version: version} + case has(vscodeOAI): + return vscodeChat{vendor: vscodeOAI, version: version} + } + return vscodeChat{vendor: vscodeEndpoint, version: version, unsupported: true} +} + +// vscodeChatInstalled is the folder of the Copilot Chat installed in an +// extensions folder: the one its extensions.json lists, else the highest +// version of the github.copilot-chat- folders not marked +// obsolete. found is false only when the folder was read and has none. +func vscodeChatInstalled(dir string) (at, version string, found bool) { + if b, err := os.ReadFile(filepath.Join(dir, "extensions.json")); err == nil && gjson.ValidBytes(b) { + for _, e := range gjson.ParseBytes(b).Array() { + if !strings.EqualFold(e.Get("identifier.id").String(), vscodeChatID) { + continue + } + if rel := e.Get("relativeLocation").String(); rel != "" { + return filepath.Join(dir, rel), e.Get("version").String(), true + } + if p := e.Get("location.fsPath").String(); p != "" { + return p, e.Get("version").String(), true + } + } + } + ents, err := os.ReadDir(dir) + if err != nil { + return "", "", !os.IsNotExist(err) + } + var obsolete map[string]bool + if b, err := os.ReadFile(filepath.Join(dir, ".obsolete")); err == nil { + _ = json.Unmarshal(b, &obsolete) + } + for _, e := range ents { + v, ok := strings.CutPrefix(strings.ToLower(e.Name()), vscodeChatID+"-") + if !e.IsDir() || !ok || obsolete[e.Name()] { + continue + } + // a platform build is github.copilot-chat-- + if i := strings.IndexByte(v, '-'); i > 0 { + v = v[:i] + } + if at == "" || vscodeNewer(v, version) { + at, version = filepath.Join(dir, e.Name()), v + } + } + return at, version, at != "" +} + +// notice is what a build without its own chat is told with magpie's group +// written: what its Copilot Chat needs to list it. +func (c vscodeChat) notice(name string) string { + setup := name + "'s Chat features must be enabled (chat.disableAIFeatures=false) and its product.json must include defaultChatAgent and trustedExtensionAuthAccess for GitHub.copilot-chat. Then run Developer: Reload Window in " + name + "." + switch { + case c.none: + return "Install GitHub Copilot Chat in " + name + " to see magpie's models in its Chat. " + setup + case c.unsupported: + return name + "'s GitHub Copilot Chat " + c.version + " has neither a Custom Endpoint nor an OpenAI Compatible provider, so it can't list magpie's models." + case c.vendor == vscodeOAI: + return "magpie's models are in " + name + "'s Chat model picker under magpie, from GitHub Copilot Chat " + c.version + "'s OpenAI Compatible provider, which it offers only while it is signed in to GitHub with a personal Copilot plan. " + setup + } + return setup +} + +// vscodeNewer is whether extension version a is later than b, compared +// number by number. +func vscodeNewer(a, b string) bool { + as, bs := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(as) || i < len(bs); i++ { + var x, y int + if i < len(as) { + x, _ = strconv.Atoi(as[i]) + } + if i < len(bs) { + y, _ = strconv.Atoi(bs[i]) + } + if x != y { + return x > y + } + } + return false } diff --git a/internal/agent/vscode_test.go b/internal/agent/vscode_test.go index 01af1510..8c62c3f5 100644 --- a/internal/agent/vscode_test.go +++ b/internal/agent/vscode_test.go @@ -347,7 +347,7 @@ func TestVSCodiumModelsUseTheirOwnVisibility(t *testing.T) { t.Fatal(err) } has := func(k vscodeKind, id string) bool { - b, err := json.Marshal(vscodeGroupJSON(k)) + b, err := json.Marshal(vscodeGroupJSON(k, vscodeEndpoint)) if err != nil { t.Fatal(err) } @@ -360,3 +360,143 @@ func TestVSCodiumModelsUseTheirOwnVisibility(t *testing.T) { t.Fatal("VS Code did not retain its own hidden-model list") } } + +// copilotChat0481Providers is contributes.languageModelChatProviders of +// the Marketplace's GitHub Copilot Chat 0.48.1, the last one VSCodium can +// install (its configuration schemas left out): an OpenAI Compatible +// provider, no Custom Endpoint. +const copilotChat0481Providers = `[{"vendor": "copilot", "displayName": "Copilot"}, {"vendor": "copilotcli", "displayName": "Copilot CLI", "when": "false"}, {"vendor": "claude-code", "displayName": "Claude Code", "when": "false"}, {"vendor": "anthropic", "displayName": "Anthropic"}, {"vendor": "xai", "displayName": "xAI"}, {"vendor": "gemini", "displayName": "Google"}, {"vendor": "openrouter", "displayName": "OpenRouter"}, {"vendor": "openai", "displayName": "OpenAI"}, {"vendor": "ollama", "displayName": "Ollama"}, {"vendor": "customoai", "when": "productQualityType != 'stable'", "displayName": "OpenAI Compatible"}, {"vendor": "azure", "displayName": "Azure"}]` + +// installCopilotChat installs a Copilot Chat of a version declaring +// providers in VSCodium's extensions folder under home, listed in +// extensions.json as VSCodium lists it. +func installCopilotChat(t *testing.T, home, version, providers string) { + t.Helper() + exts := filepath.Join(home, ".vscode-oss", "extensions") + folder := "github.copilot-chat-" + version + writeFile(t, filepath.Join(exts, folder, "package.json"), `{"name":"copilot-chat","publisher":"GitHub","version":"`+version+`","contributes":{"languageModelChatProviders":`+providers+`}}`) + writeFile(t, filepath.Join(exts, "extensions.json"), `[{"identifier":{"id":"github.copilot-chat","uuid":"7ec7d6e6-b89e-4cc5-a59b-d6c4d238246f"},"version":"`+version+`","location":{"$mid":1,"path":"/x","scheme":"file"},"relativeLocation":"`+folder+`","metadata":{"source":"gallery"}}]`) +} + +func TestVSCodiumCopilotChatOpenAICompatible(t *testing.T) { + home := syncHome(t) + if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m"}}); err != nil { + t.Fatal(err) + } + installCopilotChat(t, home, "0.48.1", copilotChat0481Providers) + c, err := Find("vscodium") + if err != nil { + t.Fatal(err) + } + lm := filepath.Join(c.Dir, "chatLanguageModels.json") + // the user's own groups + writeFile(t, lm, `[{"name":"mine","vendor":"customoai","models":[{"id":"x","name":"x","url":"http://h/v1","toolCalling":true,"vision":false,"maxInputTokens":1000,"maxOutputTokens":100}]},{"name":"Theirs","vendor":"customendpoint","models":[]}]`+"\n") + writeFile(t, c.Path, "{}\n") + if err := c.Connect(); err != nil { + t.Fatal(err) + } + g, ok := edit.GetJSONItem(lm, vscodeGroupOf(vscodeOAI)) + if !ok { + t.Fatalf("no customoai group:\n%s", readFile(lm)) + } + m := gjson.Get(g, `models.#(id=="relay/m")`) + if m.Get("url").String() != vscodeURL() || m.Get("requestHeaders.x-api-key").String() != "magpie-vscodium" || + m.Get("requestHeaders.Authorization").Exists() || m.Get("contextWindow").Exists() || gjson.Get(g, "apiKey").Exists() || + m.Get("maxInputTokens").Int() <= 0 || m.Get("maxOutputTokens").Int() <= 0 || !m.Get("toolCalling").Bool() { + t.Fatalf("model: %s", m.Raw) + } + if _, ok := edit.GetJSONItem(lm, vscodeGroup); ok { + t.Fatalf("Custom Endpoint group kept:\n%s", readFile(lm)) + } + if _, ok := edit.GetJSONItem(lm, map[string]string{"name": "mine", "vendor": "customoai"}); !ok { + t.Fatalf("user's group lost:\n%s", readFile(lm)) + } + if _, ok := edit.GetJSONItem(lm, map[string]string{"name": "Theirs", "vendor": "customendpoint"}); !ok { + t.Fatalf("user's group lost:\n%s", readFile(lm)) + } + if !c.Wired() || c.Check() != "" { + t.Fatalf("wired %v, check %q", c.Wired(), c.Check()) + } + if n := c.Notice(); !strings.Contains(n, "OpenAI Compatible") || !strings.Contains(n, "personal Copilot plan") { + t.Fatalf("notice: %q", n) + } + if got := usage.AgentOf("vscodium"); got != "vscodium" { + t.Fatalf("token: %q", got) + } + + // the Custom Endpoint group an older magpie wrote: Sync, as at magpie's + // start, writes it for customoai + if err := edit.DelJSONItem(lm, vscodeGroupOf(vscodeOAI)); err != nil { + t.Fatal(err) + } + if err := edit.SetJSONItem(lm, vscodeGroup, vscodeGroupJSON(vscodiumKind, vscodeEndpoint)); err != nil { + t.Fatal(err) + } + if c.Check() == "" { + t.Fatal("Check missed a Custom Endpoint group on 0.48.1") + } + if err := c.Sync(); err != nil { + t.Fatal(err) + } + if _, ok := edit.GetJSONItem(lm, vscodeGroup); ok || c.Check() != "" { + t.Fatalf("not rewritten (%q):\n%s", c.Check(), readFile(lm)) + } + if _, ok := edit.GetJSONItem(lm, vscodeGroupOf(vscodeOAI)); !ok { + t.Fatalf("no customoai group after Sync:\n%s", readFile(lm)) + } + + // a Copilot Chat with a Custom Endpoint provider: Sync writes that + installCopilotChat(t, home, "0.50.0", `[{"vendor":"copilot"},{"vendor":"customendpoint"},{"vendor":"customoai"}]`) + if c.Check() == "" { + t.Fatal("Check missed the group written for the other provider") + } + if err := c.Sync(); err != nil { + t.Fatal(err) + } + if _, ok := edit.GetJSONItem(lm, vscodeGroupOf(vscodeOAI)); ok { + t.Fatalf("customoai group kept:\n%s", readFile(lm)) + } + g, ok = edit.GetJSONItem(lm, vscodeGroup) + if !ok || gjson.Get(g, "models.0.requestHeaders.Authorization").String() != "Bearer magpie-vscodium" || c.Check() != "" { + t.Fatalf("custom endpoint: %s %q", g, c.Check()) + } + + // neither provider: said so + installCopilotChat(t, home, "0.51.0", `[{"vendor":"copilot"}]`) + if ch := c.Check(); !strings.Contains(ch, "neither") { + t.Fatalf("check: %q", ch) + } + if n := c.Notice(); !strings.Contains(n, "neither") { + t.Fatalf("notice: %q", n) + } + + if err := c.Disconnect(); err != nil { + t.Fatal(err) + } + for _, v := range vscodeVendors { + if _, ok := edit.GetJSONItem(lm, vscodeGroupOf(v)); ok { + t.Fatalf("disconnected, %s group kept:\n%s", v, readFile(lm)) + } + } + if _, ok := edit.GetJSONItem(lm, map[string]string{"name": "mine", "vendor": "customoai"}); !ok { + t.Fatalf("user's group lost on disconnect:\n%s", readFile(lm)) + } +} + +func TestVSCodeChatInstalledWithoutExtensionsJSON(t *testing.T) { + dir := t.TempDir() + if c := vscodeChatOf(filepath.Join(dir, "missing")); !c.none || c.vendor != vscodeEndpoint { + t.Fatalf("missing: %+v", c) + } + writeFile(t, filepath.Join(dir, "github.copilot-chat-0.9.0", "package.json"), `{"contributes":{"languageModelChatProviders":[{"vendor":"customendpoint"}]}}`) + writeFile(t, filepath.Join(dir, "github.copilot-chat-0.48.1", "package.json"), `{"contributes":{"languageModelChatProviders":`+copilotChat0481Providers+`}}`) + writeFile(t, filepath.Join(dir, "github.copilot-chat-0.50.0", "package.json"), `{"contributes":{"languageModelChatProviders":[{"vendor":"customendpoint"}]}}`) + writeFile(t, filepath.Join(dir, ".obsolete"), `{"github.copilot-chat-0.50.0":true}`) + if c := vscodeChatOf(dir); c.vendor != vscodeOAI || c.version != "0.48.1" { + t.Fatalf("got %+v", c) + } + writeFile(t, filepath.Join(dir, "github.copilot-chat-0.48.1", "package.json"), `{not json`) + if c := vscodeChatOf(dir); c.vendor != vscodeEndpoint || c.none || c.unsupported { + t.Fatalf("unreadable: %+v", c) + } +}