diff --git a/README.md b/README.md
index 315371a5..e3ed7e76 100644
--- a/README.md
+++ b/README.md
@@ -295,7 +295,7 @@ Find them under Plugins › Discover › Gateway middleware. See [Gateway middle
|
-Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy
+Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy
|
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 5d428aa5..fc916d3c 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -295,7 +295,7 @@ magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}
|
-Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy
+Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy
|
diff --git a/docs/reference.md b/docs/reference.md
index cf8fe62a..31859dc8 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -95,6 +95,7 @@ line; agents connected to magpie lose it when it quits.
| 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). 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 |
+| Copilot (JetBrains) | `byok.json` in `~/.config/github-copilot` (`$XDG_CONFIG_HOME/github-copilot` when set, `%USERPROFILE%\AppData\Local\github-copilot` on Windows), the Copilot language server's own file, which Xcode's and Eclipse's Copilot read too | provider (a `magpie` custom endpoint provider beside the user's: every magpie model at the gateway's `/v1/chat/completions`, sent with `magpie-copilot-jetbrains`; a model hidden in Manage Models stays hidden through a sync; off removes magpie's three keys alone). Found by the `github-copilot-intellij` plugin in a JetBrains IDE or Android Studio. Listed after the IDE restarts, and only while Copilot is signed in to GitHub on a plan with Bring Your Own Key (Free, Pro, Pro+; Business and Enterprise when the organisation allows it); the model is picked in Copilot Chat |
| 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 953f072c..2febccfa 100644
--- a/docs/subsystems/agent-wiring.md
+++ b/docs/subsystems/agent-wiring.md
@@ -35,6 +35,7 @@ something else shows up as drift.
- magpie only removes what it wrote. Anything else in an agent's config stays as it was.
- JetBrains Air's `magpie-opencode.json` keeps user-added fields in `provider.magpie`, including nested options, on sync and model picks (`air` in [`air.go`](../../internal/agent/air.go), using `theirsKept` in [`sync.go`](../../internal/agent/sync.go)). The gateway fields and complete model list are still replaced, so removed models leave the list.
+- Copilot in JetBrains IDEs keeps its Bring Your Own Key providers in the Copilot language server's `byok.json`, shared with Xcode's and Eclipse's Copilot (`copilotJetBrains` in [`copilotjetbrains.go`](../../internal/agent/copilotjetbrains.go)). magpie writes its own three keys there, `magpie-provider-config`, `magpie-api-key` and `magpie-models-config`, in the bytes the server itself writes, and only when they differ; the user's providers and keys stay. A magpie model the user hid in Manage Models (`isRegistered: false`) stays hidden through a sync. A new file is made 0600 in a 0700 folder, as the server makes it.
- Ante's `catalog.json` keeps user-added fields in `providers.magpie` the same way (`ante` in [`ante.go`](../../internal/agent/ante.go)); its `auth`, `http_headers`, `extra_body` and `stream_idle_timeout_secs` are what a gateway shared on the local network or reached from WSL needs to send a key. Ante starts a session on the `provider` and `model` of `settings.json`, not on what its catalog holds (#1210), so magpie sets that pair and puts back what it found there; a pick the user makes in Ante while magpie is wired is theirs and stands.
- JSON/JSONC config edits and their previews reject malformed input rather than editing a partially parsed document. Object edits and VS Code's model-group array edits leave the original file untouched on a parse error; valid comments and trailing commas remain supported (`internal/edit`).
- Codex signed in to ChatGPT stays beside its sign-in (`openai_base_url`) unless the user picks magpie API. magpie becomes its provider only while the Codex app holds the active account, which it sends nothing for: `/wham/usage` says it isn't allowed, and it has no credits and isn't a workspace still within its overage (`codexHeld`, read by `provider.CodexUsedUp`). Where the app goes by what usage can't show (an experiment's gate, a reserve, a Team plan's overage under the reserve experiment), it counts as held: a wrong move costs ChatGPT extras, a missed one every turn. A saved reading kept through a failed read stops saying held once its used-up window has started again; a hold with no window used up stays till a fresh read. The app then loses its ChatGPT state (durable threads, remote control), so a window at 100% alone, which credits get past, doesn't move it (`codex.out` marks the move, and Sync undoes it once the account has room).
diff --git a/internal/agent/agents.go b/internal/agent/agents.go
index c55c0658..79cfeee2 100644
--- a/internal/agent/agents.go
+++ b/internal/agent/agents.go
@@ -84,6 +84,7 @@ func All() []*Agent {
vscode(home, cfg),
vscodeInsidersAgent(home, cfg),
vscodium(home, cfg),
+ copilotJetBrains(home),
air(home, cfg),
copilot(home),
crush(home, cfg),
diff --git a/internal/agent/copilotjetbrains.go b/internal/agent/copilotjetbrains.go
new file mode 100644
index 00000000..bc948b1a
--- /dev/null
+++ b/internal/agent/copilotjetbrains.go
@@ -0,0 +1,246 @@
+package agent
+
+// GitHub Copilot in JetBrains IDEs (IntelliJ IDEA, PyCharm, GoLand, …, and
+// Android Studio; the github-copilot-intellij plugin) takes models of other
+// vendors through Bring Your Own Key: Copilot Chat's model picker, Manage
+// Models…, Add Custom Endpoint Provider (KamToHung on Discord). The plugin
+// keeps none of it itself: it asks the Copilot language server it bundles
+// (copilot-language-server, the npm @github/copilot-language-server), which
+// keeps every provider in one JSON object, byok.json in
+// $XDG_CONFIG_HOME/github-copilot when that is set (and absolute),
+// %USERPROFILE%\AppData\Local\github-copilot on Windows and
+// ~/.config/github-copilot elsewhere, read again at every use. A custom
+// endpoint provider is three keys there, as the server's own
+// copilot/byok/saveCustomProviderConfig and saveModel write them:
+//
+// "-provider-config": {"groupName": , "apiType": "chatCompletions"}
+// "-api-key": ""
+// "-models-config": {"": {"deploymentUrl": …, "isRegistered": true,
+// "isCustomModel": true, "modelCapabilities": {"name": …, "maxInputTokens": …,
+// "maxOutputTokens": …, "toolCalling": true, "vision": …}}}
+//
+// So magpie adds the provider "magpie" there: its catalog, each model at the
+// gateway's /v1/chat/completions, sent with the key as a Bearer token (the
+// key is the file's, not the IDE's password safe). Tool calling is on, as
+// Copilot's agent mode lists only models that call tools; a model's
+// maxInputTokens is the window the server fills, its output kept within it.
+// The user's other providers and keys stay as they are.
+//
+// The IDE lists the server's providers when Copilot signs in, so the models
+// show after the IDE restarts. The model is picked in Copilot Chat, kept in
+// the IDE's project files, so what magpie sets is whether its models are in
+// that picker. BYOK needs a GitHub sign-in with Copilot: the server offers
+// it to a personal plan (Free, Pro, Pro+) and to Business or Enterprise
+// where the organisation's policy allows it. Its requests say
+// "GithubCopilot/" as every client of that server does, so
+// they are told apart by their key. Xcode's and Eclipse's Copilot run the
+// same server and read the same file: they list magpie's models too.
+//
+// Checked with the language server 1.545.1 bundled in plugin 1.18.0, under
+// a sandbox HOME signed in to a real Copilot account: the file magpie
+// writes is listed by copilot/byok/listCustomProviderConfigs and
+// listModels, and a Chat turn on one of its models reached the gateway with
+// magpie's key. Not with a JetBrains IDE's own window.
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+
+ "github.com/tidwall/gjson"
+ "github.com/yetone/magpie/internal/appdir"
+ "github.com/yetone/magpie/internal/edit"
+ "github.com/yetone/magpie/internal/gateway"
+)
+
+// copilotJBID is the agent's id, and what its token names.
+const copilotJBID = "copilot-jetbrains"
+
+// The keys of magpie's provider in byok.json.
+const (
+ copilotJBConfig = magpieID + "-provider-config"
+ copilotJBKey = magpieID + "-api-key"
+ copilotJBModels = magpieID + "-models-config"
+)
+
+// copilotJBPlugin is the folder the plugin is installed in, under an IDE's
+// plugins folder.
+const copilotJBPlugin = "github-copilot-intellij"
+
+func copilotJetBrains(home string) *Agent {
+ return copilotJetBrainsAt(copilotBYOKPath(home), func() bool { return copilotJBInstalled(home) })
+}
+
+// copilotBYOKPath is byok.json where the language server keeps it
+// (getXdgConfigPath).
+func copilotBYOKPath(home string) string {
+ if x := appdir.Getenv("XDG_CONFIG_HOME"); x != "" && filepath.IsAbs(x) {
+ return filepath.Join(x, "github-copilot", "byok.json")
+ }
+ if runtime.GOOS == "windows" {
+ // the server's os.homedir(), USERPROFILE, which home is too
+ return filepath.Join(home, "AppData", "Local", "github-copilot", "byok.json")
+ }
+ return filepath.Join(home, ".config", "github-copilot", "byok.json")
+}
+
+// copilotJBInstalled reports whether some JetBrains IDE, or Android Studio,
+// has the Copilot plugin installed: in the plugins folder of an IDE's
+// config folder on macOS and Windows, in the IDE's data folder itself on
+// Linux.
+func copilotJBInstalled(home string) bool {
+ var base, sub string
+ switch runtime.GOOS {
+ case "darwin":
+ base, sub = filepath.Join(home, "Library", "Application Support"), "plugins"
+ case "windows":
+ if base = appdir.Getenv("APPDATA"); base == "" {
+ base = filepath.Join(home, "AppData", "Roaming")
+ }
+ sub = "plugins"
+ default:
+ if base = appdir.Getenv("XDG_DATA_HOME"); base == "" {
+ base = filepath.Join(home, ".local", "share")
+ }
+ }
+ for _, vendor := range []string{"JetBrains", "Google"} {
+ ms, _ := filepath.Glob(filepath.Join(base, vendor, "*", sub, copilotJBPlugin))
+ for _, m := range ms {
+ if isDir(m) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func copilotJetBrainsAt(path string, installed func() bool) *Agent {
+ wired := func() bool { _, ok := edit.GetJSON(path, copilotJBConfig); return ok }
+ return atomic(&Agent{
+ ID: copilotJBID, Name: "Copilot (JetBrains)", Icon: "githubcopilot",
+ Aliases: []string{"jetbrains", "jetbrains-copilot", "copilot-idea", "copilot-intellij", "intellij", "idea"},
+ Dir: filepath.Dir(path), Path: path,
+ // the folder is every Copilot's (VS Code's sign-in too): the
+ // plugin installed in an IDE, or magpie's provider written, says
+ // it is here
+ detect: func() bool { return installed() || wired() },
+ Notice: func() string {
+ if !wired() {
+ return ""
+ }
+ return "magpie's models are in Copilot Chat's model picker in your JetBrains IDE, under magpie, once the IDE restarts. They need Copilot signed in to GitHub on a plan with Bring Your Own Key: Copilot Free, Pro and Pro+ have it, Business and Enterprise when the organisation's policy allows it."
+ },
+ Sync: func() error {
+ if !wired() {
+ return nil
+ }
+ return copilotJBWrite(path)
+ },
+ Check: func() string {
+ if !wired() {
+ return ""
+ }
+ // every model at the gateway: the first that isn't, if any (none
+ // listed while the catalog is empty)
+ raw, _ := edit.GetJSON(path, copilotJBModels)
+ url := vscodeURL()
+ for _, m := range gjson.Parse(raw).Map() {
+ if url = m.Get("deploymentUrl").String(); url != vscodeURL() {
+ break
+ }
+ }
+ return wiringOff("Copilot (JetBrains)", path, func(k string) (string, bool) {
+ if k == "deploymentUrl" {
+ return url, url != ""
+ }
+ return edit.GetJSON(path, k)
+ }, "deploymentUrl", vscodeURL(), copilotJBKey, gateway.TokenFor(copilotJBID))
+ },
+ Fields: []Field{{
+ Key: "provider", Label: "provider",
+ Get: func() string {
+ if wired() {
+ return magpieID
+ }
+ return ""
+ },
+ Set: func(v string) error {
+ if v == "" {
+ return edit.DelJSON(path, copilotJBConfig, copilotJBKey, copilotJBModels)
+ }
+ return copilotJBWrite(path)
+ },
+ Options: func(map[string]string) []Option {
+ return []Option{{Value: magpieID, Label: "magpie", Icon: "magpie", Note: "every magpie model in Copilot Chat's model picker"}}
+ },
+ }},
+ }, path)
+}
+
+// copilotJBWrite writes magpie's provider into byok.json, made private as
+// the language server makes it when there is none. A model of magpie's the
+// user hid in the IDE's Manage Models stays hidden.
+func copilotJBWrite(path string) error {
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return err
+ }
+ if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil {
+ return err
+ }
+ }
+ hidden := map[string]bool{}
+ if raw, ok := edit.GetJSON(path, copilotJBModels); ok {
+ for id, m := range gjson.Parse(raw).Map() {
+ if r := m.Get("isRegistered"); r.Exists() && !r.Bool() {
+ hidden[id] = true
+ }
+ }
+ }
+ models := copilotJBModelsJSON(hidden)
+ kvs := []edit.KV{
+ {Path: copilotJBConfig, Value: map[string]string{"groupName": magpieID, "apiType": "chatCompletions"}},
+ {Path: copilotJBKey, Value: gateway.TokenFor(copilotJBID)},
+ {Path: copilotJBModels, Value: models},
+ }
+ // written only where it differs, so a sync with nothing new leaves the
+ // file as the server last wrote it
+ var set []edit.KV
+ for _, kv := range kvs {
+ if cur, ok := edit.GetJSON(path, kv.Path); !ok || !sameJSON(cur, kv.Value) {
+ set = append(set, kv)
+ }
+ }
+ if len(set) == 0 {
+ return nil
+ }
+ return edit.SetJSON(path, set...)
+}
+
+// copilotJBModelsJSON is magpie's models-config: the catalog as Copilot
+// (JetBrains) is shown it, keyed by model id.
+func copilotJBModelsJSON(hidden map[string]bool) map[string]any {
+ out := map[string]any{}
+ for _, m := range magpieModels(copilotJBID) {
+ context := m.Context
+ if context == 0 {
+ context = 128000 // the server's own default for a custom endpoint
+ }
+ // the output is reserved out of the window the prompt fills
+ output := maxTokens(m)
+ if output <= 0 {
+ output = 16000
+ }
+ output = min(output, context/4)
+ out[m.ID] = map[string]any{
+ "deploymentUrl": vscodeURL(),
+ "isRegistered": !hidden[m.ID],
+ "isCustomModel": true,
+ "modelCapabilities": map[string]any{
+ "name": m.Name, "maxInputTokens": context, "maxOutputTokens": output,
+ "toolCalling": true, "vision": m.Images,
+ },
+ }
+ }
+ return out
+}
diff --git a/internal/agent/copilotjetbrains_test.go b/internal/agent/copilotjetbrains_test.go
new file mode 100644
index 00000000..85783b84
--- /dev/null
+++ b/internal/agent/copilotjetbrains_test.go
@@ -0,0 +1,235 @@
+package agent
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "runtime"
+ "slices"
+ "testing"
+
+ "github.com/yetone/magpie/internal/gateway"
+)
+
+// copilotJBUser is byok.json as the Copilot language server 1.545.1 (in the
+// JetBrains plugin 1.18.0) wrote it for copilot/byok/saveCustomProviderConfig
+// and saveModel of a custom endpoint provider of the user's: its bytes as
+// they were, one line and a newline.
+const copilotJBUser = `{"refprov-api-key":"refkey","refprov-provider-config":{"groupName":"refprov","apiType":"chatCompletions"},"refprov-models-config":{"r1":{"deploymentUrl":"http://127.0.0.1:3519/v1/chat/completions","isRegistered":true,"isCustomModel":true,"modelCapabilities":{"name":"Ref One","maxInputTokens":100000,"maxOutputTokens":8000,"toolCalling":true,"vision":false}}}}
+`
+
+// copilotJBModel is a model of a custom endpoint provider as the server
+// keeps one; nothing else taken.
+type copilotJBModel struct {
+ DeploymentURL string `json:"deploymentUrl"`
+ IsRegistered *bool `json:"isRegistered"`
+ IsCustomModel *bool `json:"isCustomModel"`
+ ModelCapabilities struct {
+ Name string `json:"name"`
+ MaxInputTokens int `json:"maxInputTokens"`
+ MaxOutputTokens int `json:"maxOutputTokens"`
+ ToolCalling bool `json:"toolCalling"`
+ Vision *bool `json:"vision"`
+ } `json:"modelCapabilities"`
+}
+
+func strictJSON(t *testing.T, raw []byte, v any) {
+ t.Helper()
+ d := json.NewDecoder(bytes.NewReader(raw))
+ d.DisallowUnknownFields()
+ if err := d.Decode(v); err != nil {
+ t.Fatalf("not the server's format: %v\n%s", err, raw)
+ }
+}
+
+// Copilot in JetBrains IDEs takes magpie as a custom endpoint provider in
+// the language server's byok.json, written as the server writes its own,
+// beside the user's providers and keys, which stay; a model hidden in the
+// IDE stays hidden through a sync; off takes out magpie's keys alone.
+func TestCopilotJetBrains(t *testing.T) {
+ home := syncHome(t)
+ path := filepath.Join(home, ".config", "github-copilot", "byok.json")
+ os.MkdirAll(filepath.Dir(path), 0o700)
+ os.WriteFile(path, []byte(copilotJBUser), 0o600)
+
+ if !slices.ContainsFunc(All(), func(x *Agent) bool { return x.ID == copilotJBID }) {
+ t.Fatal("Copilot (JetBrains) is not among the agents")
+ }
+ a := copilotJetBrainsAt(path, func() bool { return false })
+ f := a.Field("provider")
+ if f == nil || f.Get() != "" || a.Wired() || a.Detected() {
+ t.Fatalf("before: %v %v %v", f, a.Wired(), a.Detected())
+ }
+ if err := a.Apply("provider", magpieID); err != nil {
+ t.Fatal(err)
+ }
+ if f.Get() != magpieID || !a.Wired() || !a.Detected() {
+ t.Fatalf("after: %q %v %v", f.Get(), a.Wired(), a.Detected())
+ }
+ if d := a.Check(); d != "" {
+ t.Fatalf("check: %s", d)
+ }
+ if st, _ := os.Stat(path); runtime.GOOS != "windows" && st.Mode().Perm() != 0o600 {
+ t.Errorf("mode %v", st.Mode().Perm())
+ }
+
+ file := map[string]json.RawMessage{}
+ user := map[string]json.RawMessage{}
+ if err := json.Unmarshal([]byte(readFile(path)), &file); err != nil {
+ t.Fatalf("%v\n%s", err, readFile(path))
+ }
+ json.Unmarshal([]byte(copilotJBUser), &user)
+ for k, v := range user {
+ if !sameJSON(string(file[k]), v) {
+ t.Errorf("the user's %s changed: %s", k, file[k])
+ }
+ }
+
+ var cfg struct {
+ GroupName string `json:"groupName"`
+ APIType string `json:"apiType"`
+ }
+ strictJSON(t, file["magpie-provider-config"], &cfg)
+ if cfg.GroupName != magpieID || cfg.APIType != "chatCompletions" {
+ t.Errorf("provider config: %s", file["magpie-provider-config"])
+ }
+ var key string
+ json.Unmarshal(file["magpie-api-key"], &key)
+ if key != gateway.TokenFor(copilotJBID) {
+ t.Errorf("key %q", key)
+ }
+ var models map[string]copilotJBModel
+ strictJSON(t, file["magpie-models-config"], &models)
+ m, ok := models["relay/glm-4.6"]
+ if !ok {
+ t.Fatalf("relay/glm-4.6 not listed: %s", file["magpie-models-config"])
+ }
+ c := m.ModelCapabilities
+ if m.DeploymentURL != gatewayV1()+"/chat/completions" || m.IsRegistered == nil || !*m.IsRegistered ||
+ m.IsCustomModel == nil || !*m.IsCustomModel || !c.ToolCalling || c.Vision == nil || c.Name == "" ||
+ c.MaxInputTokens != 204800 || c.MaxOutputTokens <= 0 || c.MaxOutputTokens > c.MaxInputTokens/4 {
+ t.Errorf("model: %+v", m)
+ }
+
+ // hidden in the IDE's Manage Models, it stays hidden through a sync
+ var all map[string]map[string]any
+ json.Unmarshal(file["magpie-models-config"], &all)
+ all["relay/glm-4.6"]["isRegistered"] = false
+ file["magpie-models-config"], _ = json.Marshal(all)
+ b, _ := json.Marshal(file)
+ os.WriteFile(path, append(b, '\n'), 0o600)
+ if err := a.Sync(); err != nil {
+ t.Fatal(err)
+ }
+ json.Unmarshal([]byte(readFile(path)), &file)
+ models = nil
+ strictJSON(t, file["magpie-models-config"], &models)
+ if r := models["relay/glm-4.6"].IsRegistered; r == nil || *r {
+ t.Errorf("a hidden model was shown again: %s", file["magpie-models-config"])
+ }
+
+ // the key changed elsewhere: told
+ if err := os.WriteFile(path, bytes.Replace([]byte(readFile(path)), []byte(gateway.TokenFor(copilotJBID)), []byte("other"), 1), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if d := a.Check(); d == "" {
+ t.Error("a changed key isn't told")
+ }
+
+ if err := a.Apply("provider", ""); err != nil {
+ t.Fatal(err)
+ }
+ if f.Get() != "" || a.Wired() {
+ t.Fatalf("after off: %s", readFile(path))
+ }
+ file = map[string]json.RawMessage{}
+ json.Unmarshal([]byte(readFile(path)), &file)
+ if len(file) != len(user) {
+ t.Errorf("after off: %s", readFile(path))
+ }
+ for k, v := range user {
+ if !sameJSON(string(file[k]), v) {
+ t.Errorf("the user's %s changed: %s", k, file[k])
+ }
+ }
+ // off, a sync leaves it off
+ if err := a.Sync(); err != nil || a.Wired() {
+ t.Fatalf("sync after off: %v %s", err, readFile(path))
+ }
+}
+
+// With no byok.json yet (Copilot never given a key), magpie's makes it as
+// the server does: private, in a private folder.
+func TestCopilotJetBrainsNewFile(t *testing.T) {
+ home := syncHome(t)
+ path := filepath.Join(home, ".config", "github-copilot", "byok.json")
+ a := copilotJetBrainsAt(path, func() bool { return true })
+ if err := a.Apply("provider", magpieID); err != nil {
+ t.Fatal(err)
+ }
+ var file map[string]json.RawMessage
+ if err := json.Unmarshal([]byte(readFile(path)), &file); err != nil || len(file) != 3 {
+ t.Fatalf("%v: %s", err, readFile(path))
+ }
+ if runtime.GOOS == "windows" {
+ return
+ }
+ if st, _ := os.Stat(path); st.Mode().Perm() != 0o600 {
+ t.Errorf("file mode %v", st.Mode().Perm())
+ }
+ if st, _ := os.Stat(filepath.Dir(path)); st.Mode().Perm() != 0o700 {
+ t.Errorf("folder mode %v", st.Mode().Perm())
+ }
+}
+
+// byok.json is where the server keeps it: under an absolute
+// XDG_CONFIG_HOME, else ~/.config/github-copilot, or
+// %USERPROFILE%\AppData\Local\github-copilot on Windows.
+func TestCopilotBYOKPath(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("USERPROFILE", home)
+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
+ if got, want := copilotBYOKPath(home), filepath.Join(home, "xdg", "github-copilot", "byok.json"); got != want {
+ t.Errorf("XDG: %s, want %s", got, want)
+ }
+ t.Setenv("XDG_CONFIG_HOME", "relative/xdg")
+ want := filepath.Join(home, ".config", "github-copilot", "byok.json")
+ if runtime.GOOS == "windows" {
+ want = filepath.Join(home, "AppData", "Local", "github-copilot", "byok.json")
+ }
+ if got := copilotBYOKPath(home); got != want {
+ t.Errorf("default: %s, want %s", got, want)
+ }
+}
+
+// The agent is found by the plugin installed in a JetBrains IDE (or
+// Android Studio), not by ~/.config/github-copilot, which every Copilot
+// signed in on the machine has.
+func TestCopilotJetBrainsDetected(t *testing.T) {
+ home := syncHome(t)
+ t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))
+ t.Setenv("XDG_DATA_HOME", "")
+ os.MkdirAll(filepath.Join(home, ".config", "github-copilot"), 0o700)
+ os.WriteFile(filepath.Join(home, ".config", "github-copilot", "apps.json"), []byte("{}"), 0o600)
+ if copilotJetBrains(home).Detected() {
+ t.Fatal("found with no IDE")
+ }
+ var ide string
+ switch runtime.GOOS {
+ case "darwin":
+ ide = filepath.Join(home, "Library", "Application Support", "JetBrains", "IntelliJIdea2026.1", "plugins")
+ case "windows":
+ ide = filepath.Join(home, "AppData", "Roaming", "JetBrains", "IntelliJIdea2026.1", "plugins")
+ default:
+ ide = filepath.Join(home, ".local", "share", "JetBrains", "IntelliJIdea2026.1")
+ }
+ os.MkdirAll(ide, 0o755)
+ if copilotJetBrains(home).Detected() {
+ t.Fatal("found with an IDE without the plugin")
+ }
+ os.MkdirAll(filepath.Join(ide, copilotJBPlugin), 0o755)
+ if !copilotJetBrains(home).Detected() {
+ t.Fatal("not found with the plugin installed")
+ }
+}