agent: Copilot in JetBrains IDEs takes magpie's models as a Bring Your Own Key custom endpoint provider, written into the Copilot language server's byok.json beside the user's own (KamToHung on Discord)
This commit is contained in:
1 parent
a2a7fb1123
commit
d2241a5798
7 files changed
+486
-2
No files matched your search
@@ -295,7 +295,7 @@ Find them under Plugins › Discover › Gateway middleware. See [Gateway middle
|
||||
<table>
|
||||
<tr><td>
|
||||
|
||||
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
|
||||
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
+1
-1
@@ -295,7 +295,7 @@ magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}
|
||||
<table>
|
||||
<tr><td>
|
||||
|
||||
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
|
||||
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
@@ -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/<id>/` (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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 <name> is three keys there, as the server's own
|
||||
// copilot/byok/saveCustomProviderConfig and saveModel write them:
|
||||
//
|
||||
// "<name>-provider-config": {"groupName": <name>, "apiType": "chatCompletions"}
|
||||
// "<name>-api-key": "<key, in plain text>"
|
||||
// "<name>-models-config": {"<model id>": {"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/<server version>" 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user