This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 33181f1e0d38fdd55cddcafc0918a5036e89f3f4 Author: Yang Luo <[email protected]> AuthorDate: Fri Sep 4 14:51:53 2026 +0800 feat(agents): support Continue, Zed, Aider, goose, Crush, Droid and Trae --- README.md | 7 + README_zh.md | 7 + agent/fingerprint_load_test.go | 10 +- agent/fingerprints/aider.json | 8 ++ agent/fingerprints/continue.json | 8 ++ agent/fingerprints/crush.json | 8 ++ agent/fingerprints/droid.json | 8 ++ agent/fingerprints/goose.json | 8 ++ agent/fingerprints/trae.json | 10 ++ agent/fingerprints/zed.json | 10 ++ agentenv/agentenv.go | 15 ++ agentprovider/aider.go | 190 +++++++++++++++++++++++++ agentprovider/continue.go | 271 +++++++++++++++++++++++++++++++++++ agentprovider/droid.go | 239 +++++++++++++++++++++++++++++++ agentprovider/file.go | 9 ++ agentprovider/goose.go | 296 +++++++++++++++++++++++++++++++++++++++ agentprovider/yaml.go | 84 +++++++++++ agentprovider/zed.go | 259 ++++++++++++++++++++++++++++++++++ web/src/components/AgentIcon.tsx | 7 + web/src/lib/agents.ts | 10 ++ web/src/locales/en/data.json | 5 + web/src/locales/zh/data.json | 5 + 22 files changed, 1469 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 78d1f1f..07a7a48 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,13 @@ <a href="https://platform.iflow.cn/en/cli" title="iFlow CLI"><img src="https://www.google.com/s2/favicons?domain=iflow.cn&sz=64" width="30" height="30" alt="iFlow CLI"></a> <a href="https://roocode.com" title="Roo Code"><img src="https://www.google.com/s2/favicons?domain=roocode.com&sz=64" width="30" height="30" alt="Roo Code"></a> <a href="https://github.com/github/copilot-cli" title="Copilot CLI"><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="30" height="30" alt="Copilot CLI"></a> + <a href="https://continue.dev" title="Continue"><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="30" height="30" alt="Continue"></a> + <a href="https://zed.dev" title="Zed"><img src="https://www.google.com/s2/favicons?domain=zed.dev&sz=64" width="30" height="30" alt="Zed"></a> + <a href="https://aider.chat" title="Aider"><img src="https://www.google.com/s2/favicons?domain=aider.chat&sz=64" width="30" height="30" alt="Aider"></a> + <a href="https://github.com/aaif-goose/goose" title="goose"><img src="https://www.google.com/s2/favicons?domain=block.github.io&sz=64" width="30" height="30" alt="goose"></a> + <a href="https://github.com/charmbracelet/crush" title="Crush"><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="30" height="30" alt="Crush"></a> + <a href="https://factory.ai" title="Droid"><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="30" height="30" alt="Droid"></a> + <a href="https://trae.ai" title="Trae"><img src="https://www.google.com/s2/favicons?domain=trae.ai&sz=64" width="30" height="30" alt="Trae"></a> <a href="https://opencode.ai" title="opencode / opencode Desktop"><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="30" height="30" alt="opencode"></a> <a href="https://openagentai.org" title="OpenAgent"><img src="https://www.google.com/s2/favicons?domain=openagentai.org&sz=64" width="30" height="30" alt="OpenAgent"></a> <a href="https://openclaw.ai" title="OpenClaw"><img src="https://www.google.com/s2/favicons?domain=openclaw.ai&sz=64" width="30" height="30" alt="OpenClaw"></a> diff --git a/README_zh.md b/README_zh.md index 697844d..ee65719 100644 --- a/README_zh.md +++ b/README_zh.md @@ -29,6 +29,13 @@ <a href="https://platform.iflow.cn/en/cli" title="iFlow CLI"><img src="https://www.google.com/s2/favicons?domain=iflow.cn&sz=64" width="30" height="30" alt="iFlow CLI"></a> <a href="https://roocode.com" title="Roo Code"><img src="https://www.google.com/s2/favicons?domain=roocode.com&sz=64" width="30" height="30" alt="Roo Code"></a> <a href="https://github.com/github/copilot-cli" title="Copilot CLI"><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="30" height="30" alt="Copilot CLI"></a> + <a href="https://continue.dev" title="Continue"><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="30" height="30" alt="Continue"></a> + <a href="https://zed.dev" title="Zed"><img src="https://www.google.com/s2/favicons?domain=zed.dev&sz=64" width="30" height="30" alt="Zed"></a> + <a href="https://aider.chat" title="Aider"><img src="https://www.google.com/s2/favicons?domain=aider.chat&sz=64" width="30" height="30" alt="Aider"></a> + <a href="https://github.com/aaif-goose/goose" title="goose"><img src="https://www.google.com/s2/favicons?domain=block.github.io&sz=64" width="30" height="30" alt="goose"></a> + <a href="https://github.com/charmbracelet/crush" title="Crush"><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="30" height="30" alt="Crush"></a> + <a href="https://factory.ai" title="Droid"><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="30" height="30" alt="Droid"></a> + <a href="https://trae.ai" title="Trae"><img src="https://www.google.com/s2/favicons?domain=trae.ai&sz=64" width="30" height="30" alt="Trae"></a> <a href="https://opencode.ai" title="opencode / opencode Desktop"><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" width="30" height="30" alt="opencode"></a> <a href="https://openagentai.org" title="OpenAgent"><img src="https://www.google.com/s2/favicons?domain=openagentai.org&sz=64" width="30" height="30" alt="OpenAgent"></a> <a href="https://openclaw.ai" title="OpenClaw"><img src="https://www.google.com/s2/favicons?domain=openclaw.ai&sz=64" width="30" height="30" alt="OpenClaw"></a> diff --git a/agent/fingerprint_load_test.go b/agent/fingerprint_load_test.go index e15bb52..fc77923 100644 --- a/agent/fingerprint_load_test.go +++ b/agent/fingerprint_load_test.go @@ -22,11 +22,11 @@ import ( func TestFingerprintsLoad(t *testing.T) { want := []string{ - "claude-code", "claude-desktop", "cline", "codex-cli", "codex", - "copilot-cli", "cursor-agent", "cursor", "dsh", "gemini-cli", - "hermes-agent", "iflow-cli", "openagent", "openclaw", - "opencode-desktop", "opencode", "qwen-code", "roo-code", - "windsurf", + "aider", "claude-code", "claude-desktop", "cline", "codex-cli", "codex", + "continue", "copilot-cli", "crush", "cursor-agent", "cursor", "droid", + "dsh", "gemini-cli", "goose", "hermes-agent", "iflow-cli", "openagent", + "openclaw", "opencode-desktop", "opencode", "qwen-code", "roo-code", + "trae", "windsurf", "zed", } loaded, err := loadFingerprints(fingerprintFS) diff --git a/agent/fingerprints/aider.json b/agent/fingerprints/aider.json new file mode 100644 index 0000000..2596c8d --- /dev/null +++ b/agent/fingerprints/aider.json @@ -0,0 +1,8 @@ +{ + "id": "aider", + "displayName": "Aider", + "installUrl": "https://aider.chat/docs/install.html", + "execName": "aider", + "homeDirs": [".local/bin"], + "systemPackage": "aider-chat" +} diff --git a/agent/fingerprints/continue.json b/agent/fingerprints/continue.json new file mode 100644 index 0000000..e533939 --- /dev/null +++ b/agent/fingerprints/continue.json @@ -0,0 +1,8 @@ +{ + "id": "continue", + "displayName": "Continue", + "installUrl": "https://docs.continue.dev/getting-started/install", + "execName": "cn", + "stateDir": "continue", + "npmPackage": "@continuedev/cli" +} diff --git a/agent/fingerprints/crush.json b/agent/fingerprints/crush.json new file mode 100644 index 0000000..9d236d9 --- /dev/null +++ b/agent/fingerprints/crush.json @@ -0,0 +1,8 @@ +{ + "id": "crush", + "displayName": "Crush", + "installUrl": "https://github.com/charmbracelet/crush", + "execName": "crush", + "npmPackage": "@charmland/crush", + "homeDirs": [".local/bin"] +} diff --git a/agent/fingerprints/droid.json b/agent/fingerprints/droid.json new file mode 100644 index 0000000..8eba717 --- /dev/null +++ b/agent/fingerprints/droid.json @@ -0,0 +1,8 @@ +{ + "id": "droid", + "displayName": "Droid", + "installUrl": "https://docs.factory.ai/cli/getting-started/quickstart", + "execName": "droid", + "stateDir": "factory", + "homeDirs": [".local/bin"] +} diff --git a/agent/fingerprints/goose.json b/agent/fingerprints/goose.json new file mode 100644 index 0000000..762c373 --- /dev/null +++ b/agent/fingerprints/goose.json @@ -0,0 +1,8 @@ +{ + "id": "goose", + "displayName": "goose", + "installUrl": "https://github.com/aaif-goose/goose", + "execName": "goose", + "homeDirs": [".local/bin"], + "homebrewCasks": ["block-goose-cli"] +} diff --git a/agent/fingerprints/trae.json b/agent/fingerprints/trae.json new file mode 100644 index 0000000..c3e167c --- /dev/null +++ b/agent/fingerprints/trae.json @@ -0,0 +1,10 @@ +{ + "id": "trae", + "displayName": "Trae", + "installUrl": "https://trae.ai/download", + "execName": "Trae", + "desktop": true, + "instanceArg": "--user-data-dir", + "windowsProgramDirs": ["Trae"], + "homebrewCasks": ["trae"] +} diff --git a/agent/fingerprints/zed.json b/agent/fingerprints/zed.json new file mode 100644 index 0000000..379a59f --- /dev/null +++ b/agent/fingerprints/zed.json @@ -0,0 +1,10 @@ +{ + "id": "zed", + "displayName": "Zed", + "installUrl": "https://zed.dev/download", + "execName": "zed", + "desktop": true, + "windowsProgramDirs": ["Zed"], + "homeDirs": [".local/zed.app/libexec"], + "homebrewCasks": ["zed"] +} diff --git a/agentenv/agentenv.go b/agentenv/agentenv.go index 6acceb3..e951380 100644 --- a/agentenv/agentenv.go +++ b/agentenv/agentenv.go @@ -87,6 +87,21 @@ var agentVars = map[string][]variable{ {key: "OPENAI_API_KEY", secret: true}, {key: "OPENAI_MODEL"}, }, + // aider resolves its settings from the command line, then the environment, + // then ~/.aider.conf.yml, so these beat the file a switch writes. + "aider": { + {key: "OPENAI_API_BASE", endpoint: true}, + {key: "OPENAI_API_KEY", secret: true}, + {key: "AIDER_MODEL"}, + }, + // goose reads these for the process it is started in, and they win over + // both config.yaml and the secret store behind it. + "goose": { + {key: "OPENAI_HOST", endpoint: true}, + {key: "OPENAI_API_KEY", secret: true}, + {key: "GOOSE_PROVIDER"}, + {key: "GOOSE_MODEL"}, + }, // dsh resolves the route's key by name, and the environment answers before // the credentials file does. "dsh": { diff --git a/agentprovider/aider.go b/agentprovider/aider.go new file mode 100644 index 0000000..465236a --- /dev/null +++ b/agentprovider/aider.go @@ -0,0 +1,190 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agentprovider + +import ( + "errors" + "path/filepath" + "strings" + + "github.com/apache/casbin-gateway/agenthome" + "github.com/apache/casbin-gateway/internal/yamledit" + "gopkg.in/yaml.v3" +) + +// aiderOpenaiPrefix is how aider is told to reach a model over the OpenAI API +// rather than look the name up among the services it knows. +const aiderOpenaiPrefix = "openai/" + +// aiderLocalKey stands in for a key the endpoint does not have. aider sends one +// either way, and the gateway takes any credential from this host. +const aiderLocalKey = "casbin-gateway" + +// The settings Gateway owns in ~/.aider.conf.yml. +var aiderKeys = []string{"openai-api-base", "openai-api-key", "model"} + +// errAiderNoModel rejects a provider aider could not use: it starts on one +// model named on the command line or in this file, and an endpoint it does not +// know has no catalog to pick one from. +var errAiderNoModel = errors.New("aider needs a model name, so bind a provider that lists at least one model") + +type aiderWriter struct{} + +func init() { + register(aiderWriter{}) +} + +func (aiderWriter) AgentId() string { return "aider" } + +func (aiderWriter) Protocol() string { return "openai" } + +func (w aiderWriter) Plan(target Target, endpoint Endpoint) ([]File, error) { + if endpoint.Model == "" { + return nil, errAiderNoModel + } + path, err := w.configPath(target) + if err != nil { + return nil, err + } + + preview := endpoint + preview.ApiKey = maskSecret(emptyAs(endpoint.ApiKey, aiderLocalKey)) + text, err := yamledit.Render(w.settings(preview)) + if err != nil { + return nil, err + } + return []File{{Path: path, Format: "yaml", Preview: text}}, nil +} + +func (w aiderWriter) Apply(target Target, endpoint Endpoint) (map[string]string, error) { + if endpoint.Model == "" { + return nil, errAiderNoModel + } + path, err := w.configPath(target) + if err != nil { + return nil, err + } + document, config, err := readYAMLMapping(path) + if err != nil { + return nil, err + } + + previous := map[string]string{} + for _, key := range aiderKeys { + if value := yamledit.String(config, key); value != "" { + previous[key] = value + } + } + for key, value := range w.settings(endpoint) { + if err := yamledit.Set(config, value, key); err != nil { + return nil, err + } + } + + changes := &txn{} + if err := saveYAML(changes, path, document, config); err != nil { + changes.abort() + return nil, err + } + if err := changes.commit(); err != nil { + return nil, err + } + return previous, nil +} + +func (w aiderWriter) Restore(target Target, previous map[string]string) error { + path, err := w.configPath(target) + if err != nil { + return err + } + document, config, err := readYAMLMapping(path) + if err != nil { + return err + } + + for _, key := range aiderKeys { + if value, ok := previous[key]; ok { + if err := yamledit.Set(config, value, key); err != nil { + return err + } + continue + } + yamledit.Delete(config, key) + } + + changes := &txn{} + if err := saveYAML(changes, path, document, config); err != nil { + changes.abort() + return err + } + return changes.commit() +} + +func (w aiderWriter) Current(target Target) (string, error) { + config, err := w.configOf(target) + if err != nil { + return "", err + } + return yamledit.String(config, "openai-api-base"), nil +} + +// Builtin is the model aider starts on without Gateway, with the routing prefix +// dropped: the name a person recognizes is the one behind it. +func (w aiderWriter) Builtin(target Target, previous map[string]string) string { + if previous != nil { + return strings.TrimPrefix(previous["model"], aiderOpenaiPrefix) + } + + config, err := w.configOf(target) + if err != nil { + return "" + } + // A model beside an endpoint Gateway wrote is Gateway's, not the one aider + // would pick for itself. + if strings.Contains(yamledit.String(config, "openai-api-base"), "/v1/agents/") { + return "" + } + return strings.TrimPrefix(yamledit.String(config, "model"), aiderOpenaiPrefix) +} + +// settings is what a switch writes at the root of the file. The prefix on the +// model is what sends aider to the endpoint beside it rather than to the +// service whose catalog holds that name. +func (aiderWriter) settings(endpoint Endpoint) map[string]string { + return map[string]string{ + "openai-api-base": endpoint.BaseUrl, + "openai-api-key": emptyAs(endpoint.ApiKey, aiderLocalKey), + "model": aiderOpenaiPrefix + strings.TrimPrefix(endpoint.Model, aiderOpenaiPrefix), + } +} + +func (w aiderWriter) configOf(target Target) (*yaml.Node, error) { + path, err := w.configPath(target) + if err != nil { + return nil, err + } + _, config, err := readYAMLMapping(path) + return config, err +} + +// configPath is the file aider reads for every session, which sits in the home +// directory rather than in a directory of its own. +func (aiderWriter) configPath(target Target) (string, error) { + home, err := agenthome.Resolve(target.Owner) + if err != nil { + return "", err + } + return filepath.Join(home, ".aider.conf.yml"), nil +} diff --git a/agentprovider/continue.go b/agentprovider/continue.go new file mode 100644 index 0000000..48f85e9 --- /dev/null +++ b/agentprovider/continue.go @@ -0,0 +1,271 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agentprovider + +import ( + "errors" + "path/filepath" + + "github.com/apache/casbin-gateway/agenthome" + "github.com/apache/casbin-gateway/internal/yamledit" + "gopkg.in/yaml.v3" +) + +const ( + // continueEntry is the model entry Gateway owns in config.yaml, found by its + // name. Every other model in the file is left alone. + continueEntry = "Casbin Gateway" + // continueLocalKey stands in for a key the endpoint does not have. Continue + // sends one either way, and the gateway takes any credential from this host. + continueLocalKey = "casbin-gateway" + // continueSchema is the config format version Continue reads. A file + // Gateway creates has to name it or Continue refuses to load it. + continueSchema = "v1" +) + +// continueRoles are the jobs the gateway entry is offered for. Autocomplete and +// embeddings are left out: those want a small local model, not a chat endpoint. +var continueRoles = []string{"chat", "edit", "apply"} + +// errContinueNoModel rejects a provider entry Continue could not use: a model +// entry names exactly one model, and an endpoint it does not know has no +// catalog to pick one from. +var errContinueNoModel = errors.New("Continue needs a model name, so bind a provider that lists at least one model") + +type continueWriter struct{} + +func init() { + register(continueWriter{}) +} + +func (continueWriter) AgentId() string { return "continue" } + +func (continueWriter) Protocol() string { return "openai" } + +func (w continueWriter) Plan(target Target, endpoint Endpoint) ([]File, error) { + if endpoint.Model == "" { + return nil, errContinueNoModel + } + path, err := w.configPath(target) + if err != nil { + return nil, err + } + + preview := endpoint + preview.ApiKey = maskSecret(emptyAs(endpoint.ApiKey, continueLocalKey)) + text, err := yamledit.Render(map[string]any{"models": []any{w.model(preview)}}) + if err != nil { + return nil, err + } + return []File{{Path: path, Format: "yaml", Preview: text}}, nil +} + +func (w continueWriter) Apply(target Target, endpoint Endpoint) (map[string]string, error) { + if endpoint.Model == "" { + return nil, errContinueNoModel + } + path, err := w.configPath(target) + if err != nil { + return nil, err + } + document, config, err := readYAMLMapping(path) + if err != nil { + return nil, err + } + + previous := map[string]string{} + if name := w.selected(config); name != "" { + previous["model"] = name + } + + entry, err := yamledit.Node(w.model(endpoint)) + if err != nil { + return nil, err + } + models, err := w.models(config) + if err != nil { + return nil, err + } + // Continue starts on the first model that takes the chat role, so the + // gateway entry replaces the one Gateway wrote before it, or goes first. + if index := indexOfEntry(models, "name", continueEntry); index >= 0 { + models.Content[index] = entry + } else { + models.Content = append([]*yaml.Node{entry}, models.Content...) + } + // A file Gateway creates carries the version Continue reads it as. + if yamledit.Get(config, "schema") == nil { + if err := yamledit.Set(config, continueSchema, "schema"); err != nil { + return nil, err + } + } + + changes := &txn{} + if err := saveYAML(changes, path, document, config); err != nil { + changes.abort() + return nil, err + } + if err := changes.commit(); err != nil { + return nil, err + } + return previous, nil +} + +func (w continueWriter) Restore(target Target, previous map[string]string) error { + path, err := w.configPath(target) + if err != nil { + return err + } + document, config, err := readYAMLMapping(path) + if err != nil { + return err + } + + models := sequenceAt(config, "models") + if index := indexOfEntry(models, "name", continueEntry); index >= 0 { + models.Content = append(models.Content[:index], models.Content[index+1:]...) + } + if yamledit.IsEmpty(models) { + yamledit.Delete(config, "models") + } + // The version is Gateway's only when it wrote the whole file, which is what + // an otherwise empty mapping says. + if len(config.Content) == 2 && yamledit.String(config, "schema") == continueSchema { + yamledit.Delete(config, "schema") + } + + changes := &txn{} + if err := saveYAML(changes, path, document, config); err != nil { + changes.abort() + return err + } + return changes.commit() +} + +// Current is the endpoint behind the model Continue starts on. A model without +// an apiBase is one Continue reaches through a service it knows, which is still +// worth naming: it is not the endpoint Gateway wrote. +func (w continueWriter) Current(target Target) (string, error) { + config, err := w.configOf(target) + if err != nil { + return "", err + } + first := w.firstChat(config) + if first == nil { + return "", nil + } + if base := yamledit.String(first, "apiBase"); base != "" { + return base, nil + } + return yamledit.String(first, "provider"), nil +} + +// Builtin is the model Continue starts on without Gateway. +func (w continueWriter) Builtin(target Target, previous map[string]string) string { + if previous != nil { + return previous["model"] + } + + config, err := w.configOf(target) + if err != nil { + return "" + } + return w.selected(config) +} + +// selected is the name of the model Continue starts on, empty when that is +// Gateway's own entry. +func (w continueWriter) selected(config *yaml.Node) string { + first := w.firstChat(config) + if first == nil { + return "" + } + name := yamledit.String(first, "name") + if name == continueEntry { + return "" + } + return name +} + +// firstChat is the model entry Continue starts a conversation on: the first one +// that takes the chat role, and the first entry at all when none names roles. +func (continueWriter) firstChat(config *yaml.Node) *yaml.Node { + models := sequenceAt(config, "models") + if models == nil { + return nil + } + for _, item := range models.Content { + if item.Kind != yaml.MappingNode { + continue + } + roles := sequenceAt(item, "roles") + if roles == nil { + return item + } + for _, role := range roles.Content { + if role.Value == "chat" { + return item + } + } + } + return nil +} + +// model is the entry Continue loads the endpoint through. Continue ships no +// catalog for a provider it does not know, so the entry carries the whole of +// it: the endpoint, the key and the one model it serves here. +func (continueWriter) model(endpoint Endpoint) map[string]any { + return map[string]any{ + "name": continueEntry, + "provider": "openai", + "model": endpoint.Model, + "apiBase": endpoint.BaseUrl, + "apiKey": emptyAs(endpoint.ApiKey, continueLocalKey), + "roles": continueRoles, + } +} + +// models is the list a switch writes into, created when the file has none. +func (continueWriter) models(config *yaml.Node) (*yaml.Node, error) { + if models := sequenceAt(config, "models"); models != nil { + return models, nil + } + if yamledit.Get(config, "models") != nil { + return nil, errors.New("models must be a YAML list") + } + models := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + if err := yamledit.Set(config, models, "models"); err != nil { + return nil, err + } + return models, nil +} + +func (w continueWriter) configOf(target Target) (*yaml.Node, error) { + path, err := w.configPath(target) + if err != nil { + return nil, err + } + _, config, err := readYAMLMapping(path) + return config, err +} + +// configPath is the one file the IDE extensions and the cn CLI all read. +func (continueWriter) configPath(target Target) (string, error) { + home, err := agenthome.Resolve(target.Owner) + if err != nil { + return "", err + } + return filepath.Join(home, ".continue", "config.yaml"), nil +} diff --git a/agentprovider/droid.go b/agentprovider/droid.go new file mode 100644 index 0000000..e782136 --- /dev/null +++ b/agentprovider/droid.go @@ -0,0 +1,239 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agentprovider + +import ( + "errors" + "path/filepath" + + "github.com/apache/casbin-gateway/agenthome" +) + +const ( + // droidEntry is the custom model Gateway owns in settings.json, found by + // its display name. Every other model in the file is left alone. + droidEntry = "Casbin Gateway" + // droidGenericProvider is how droid is told to speak plain chat completions + // to an endpoint it does not ship. + droidGenericProvider = "generic-chat-completion-api" + // droidLocalKey stands in for a key the endpoint does not have. droid sends + // one either way, and the gateway takes any credential from this host. + droidLocalKey = "casbin-gateway" + // droidMaxOutputTokens is what a custom model is given when the endpoint + // says nothing: droid needs a number, and this is the one its own + // documentation uses. + droidMaxOutputTokens = 16384 +) + +// errDroidNoModel rejects a provider droid could not use: a custom model entry +// names exactly one model, and an endpoint it does not know has no catalog to +// pick one from. +var errDroidNoModel = errors.New("droid needs a model name, so bind a provider that lists at least one model") + +type droidWriter struct{} + +func init() { + register(droidWriter{}) +} + +func (droidWriter) AgentId() string { return "droid" } + +func (droidWriter) Protocol() string { return "openai" } + +func (w droidWriter) Plan(target Target, endpoint Endpoint) ([]File, error) { + if endpoint.Model == "" { + return nil, errDroidNoModel + } + path, err := w.configPath(target) + if err != nil { + return nil, err + } + + preview := endpoint + preview.ApiKey = maskSecret(emptyAs(endpoint.ApiKey, droidLocalKey)) + data, err := encodeJSON(map[string]any{"customModels": []any{w.model(preview)}}) + if err != nil { + return nil, err + } + return []File{{Path: path, Format: "json", Preview: string(data)}}, nil +} + +func (w droidWriter) Apply(target Target, endpoint Endpoint) (map[string]string, error) { + if endpoint.Model == "" { + return nil, errDroidNoModel + } + path, err := w.configPath(target) + if err != nil { + return nil, err + } + settings, _, err := readJSON(path) + if err != nil { + return nil, err + } + + models := w.customModels(settings) + previous := map[string]string{} + if name := w.builtinOf(models); name != "" { + previous["model"] = name + } + + // droid starts on the first custom model, so the gateway entry replaces the + // one Gateway wrote before it, or goes first. + entry := w.model(endpoint) + if index := w.indexOf(models); index >= 0 { + models[index] = entry + } else { + models = append([]any{entry}, models...) + } + settings["customModels"] = models + + return previous, w.save(path, settings) +} + +func (w droidWriter) Restore(target Target, previous map[string]string) error { + path, err := w.configPath(target) + if err != nil { + return err + } + settings, _, err := readJSON(path) + if err != nil { + return err + } + + models := w.customModels(settings) + if index := w.indexOf(models); index >= 0 { + models = append(models[:index], models[index+1:]...) + } + if len(models) == 0 { + delete(settings, "customModels") + } else { + settings["customModels"] = models + } + if len(settings) == 0 { + return removeFile(path) + } + return w.save(path, settings) +} + +// Current is the endpoint behind the model droid starts on. +func (w droidWriter) Current(target Target) (string, error) { + settings, err := w.settingsOf(target) + if err != nil { + return "", err + } + first := w.first(w.customModels(settings)) + if first == nil { + return "", nil + } + return stringAt(first, "baseUrl"), nil +} + +// Builtin is the model droid starts on without Gateway. droid signs in to +// Factory on its own, so a file that lists no custom model has no name to show. +func (w droidWriter) Builtin(target Target, previous map[string]string) string { + if previous != nil { + return previous["model"] + } + + settings, err := w.settingsOf(target) + if err != nil { + return "" + } + return w.builtinOf(w.customModels(settings)) +} + +// builtinOf is the model of the first entry that is not Gateway's own. +func (w droidWriter) builtinOf(models []any) string { + for _, item := range models { + entry, ok := item.(map[string]any) + if !ok || stringAt(entry, "displayName") == droidEntry { + continue + } + return stringAt(entry, "model") + } + return "" +} + +// model is the custom model droid loads the endpoint through. droid ships no +// catalog for an endpoint it does not know, so the entry carries the whole of +// it: the endpoint, the key and the one model it serves here. +func (droidWriter) model(endpoint Endpoint) map[string]any { + return map[string]any{ + "model": endpoint.Model, + "displayName": droidEntry, + "baseUrl": endpoint.BaseUrl, + "apiKey": emptyAs(endpoint.ApiKey, droidLocalKey), + "provider": droidGenericProvider, + "maxOutputTokens": droidMaxOutputTokens, + } +} + +func (droidWriter) customModels(settings map[string]any) []any { + models, _ := settings["customModels"].([]any) + return models +} + +// indexOf is the position of Gateway's own entry, -1 when the file has none. +func (droidWriter) indexOf(models []any) int { + for index, item := range models { + if entry, ok := item.(map[string]any); ok && stringAt(entry, "displayName") == droidEntry { + return index + } + } + return -1 +} + +func (droidWriter) first(models []any) map[string]any { + for _, item := range models { + if entry, ok := item.(map[string]any); ok { + return entry + } + } + return nil +} + +func (w droidWriter) settingsOf(target Target) (map[string]any, error) { + path, err := w.configPath(target) + if err != nil { + return nil, err + } + settings, _, err := readJSON(path) + return settings, err +} + +func (droidWriter) save(path string, settings map[string]any) error { + data, err := encodeJSON(settings) + if err != nil { + return err + } + + changes := &txn{} + if err := changes.write(path, data); err != nil { + changes.abort() + return err + } + return changes.commit() +} + +// configPath is the settings file droid reads. The older config.json beside it +// spells the same keys in snake case and loses to this one, so a switch writes +// only the file that wins. +func (droidWriter) configPath(target Target) (string, error) { + home, err := agenthome.Resolve(target.Owner) + if err != nil { + return "", err + } + return filepath.Join(home, ".factory", "settings.json"), nil +} diff --git a/agentprovider/file.go b/agentprovider/file.go index 94d5f31..0d16270 100644 --- a/agentprovider/file.go +++ b/agentprovider/file.go @@ -41,6 +41,15 @@ func readFile(path string) ([]byte, os.FileMode, bool, error) { return data, info.Mode().Perm(), true, nil } +// removeFile drops a configuration file Gateway is the only reason for. A file +// that is already gone is what the caller wanted. +func removeFile(path string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + func readJSON(path string) (map[string]any, os.FileMode, error) { data, mode, _, err := readFile(path) if err != nil { diff --git a/agentprovider/goose.go b/agentprovider/goose.go new file mode 100644 index 0000000..2282099 --- /dev/null +++ b/agentprovider/goose.go @@ -0,0 +1,296 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agentprovider + +import ( + "errors" + "net/url" + "path/filepath" + "runtime" + "strings" + + "github.com/apache/casbin-gateway/agenthome" + "github.com/apache/casbin-gateway/internal/yamledit" + "gopkg.in/yaml.v3" +) + +const ( + // gooseProvider is the built-in provider goose reaches an OpenAI-compatible + // endpoint through. It is not the endpoint: OPENAI_HOST and OPENAI_BASE_PATH + // below are. + gooseProvider = "openai" + // gooseChatPath is what goose appends to the host for a provider whose base + // path it is not told, and so the tail the gateway's own path needs. + gooseChatPath = "chat/completions" + // gooseLocalKey stands in for a key the endpoint does not have. goose sends + // one either way, and the gateway takes any credential from this host. + gooseLocalKey = "casbin-gateway" +) + +// The config.yaml keys Gateway owns. The API key is not among them: goose +// ignores a provider key placed in config.yaml and reads it from the keyring or +// from secrets.yaml, which is why a switch writes that file too. +const ( + gooseActiveKey = "active_provider" + gooseHostKey = "OPENAI_HOST" + goosePathKey = "OPENAI_BASE_PATH" + gooseModelKey = "GOOSE_MODEL" + gooseSecretKey = "OPENAI_API_KEY" +) + +var gooseKeys = []string{gooseActiveKey, gooseHostKey, goosePathKey, gooseModelKey} + +// errGooseNoModel rejects a provider goose could not use: it starts on one +// model, and an endpoint it does not know has no catalog to pick one from. +var errGooseNoModel = errors.New("goose needs a model name, so bind a provider that lists at least one model") + +type gooseWriter struct{} + +func init() { + register(gooseWriter{}) +} + +func (gooseWriter) AgentId() string { return "goose" } + +func (gooseWriter) Protocol() string { return "openai" } + +func (w gooseWriter) Plan(target Target, endpoint Endpoint) ([]File, error) { + settings, err := w.settings(endpoint) + if err != nil { + return nil, err + } + configPath, secretsPath, err := w.paths(target) + if err != nil { + return nil, err + } + + config, err := yamledit.Render(settings) + if err != nil { + return nil, err + } + secrets, err := yamledit.Render(map[string]string{ + gooseSecretKey: maskSecret(emptyAs(endpoint.ApiKey, gooseLocalKey)), + }) + if err != nil { + return nil, err + } + return []File{ + {Path: configPath, Format: "yaml", Preview: config}, + {Path: secretsPath, Format: "yaml", Preview: secrets}, + }, nil +} + +func (w gooseWriter) Apply(target Target, endpoint Endpoint) (map[string]string, error) { + settings, err := w.settings(endpoint) + if err != nil { + return nil, err + } + configPath, secretsPath, err := w.paths(target) + if err != nil { + return nil, err + } + document, config, err := readYAMLMapping(configPath) + if err != nil { + return nil, err + } + secretsDocument, secrets, err := readYAMLMapping(secretsPath) + if err != nil { + return nil, err + } + + previous := map[string]string{} + for _, key := range gooseKeys { + if value := yamledit.String(config, key); value != "" { + previous[key] = value + } + } + // The provider block carries the model goose starts on in newer versions, + // and the flat GOOSE_MODEL key in the ones before them; both are written so + // the switch lands whichever the installation reads. What is remembered is + // the model of the provider that was active, which is not always the one + // the switch is about to write under. + if model := yamledit.String(config, "providers", w.activeOf(config), "model"); model != "" { + previous["providers.model"] = model + } + if value := yamledit.String(secrets, gooseSecretKey); value != "" { + previous[gooseSecretKey] = value + } + + for key, value := range settings { + if err := yamledit.Set(config, value, key); err != nil { + return nil, err + } + } + for key, value := range map[string]any{"enabled": true, "configured": true, "model": endpoint.Model} { + if err := yamledit.Set(config, value, "providers", gooseProvider, key); err != nil { + return nil, err + } + } + if err := yamledit.Set(secrets, emptyAs(endpoint.ApiKey, gooseLocalKey), gooseSecretKey); err != nil { + return nil, err + } + + changes := &txn{} + if err := saveYAML(changes, configPath, document, config); err != nil { + changes.abort() + return nil, err + } + if err := saveYAML(changes, secretsPath, secretsDocument, secrets); err != nil { + changes.abort() + return nil, err + } + if err := changes.commit(); err != nil { + return nil, err + } + return previous, nil +} + +func (w gooseWriter) Restore(target Target, previous map[string]string) error { + configPath, secretsPath, err := w.paths(target) + if err != nil { + return err + } + document, config, err := readYAMLMapping(configPath) + if err != nil { + return err + } + secretsDocument, secrets, err := readYAMLMapping(secretsPath) + if err != nil { + return err + } + + for _, key := range gooseKeys { + if value, ok := previous[key]; ok { + if err := yamledit.Set(config, value, key); err != nil { + return err + } + continue + } + yamledit.Delete(config, key) + } + // Gateway's own provider block goes away, and the model it displaced goes + // back under whichever provider was active before the switch - which is not + // always the one Gateway wrote under. + active := w.activeOf(config) + yamledit.Delete(config, "providers", gooseProvider) + if model, ok := previous["providers.model"]; ok { + if err := yamledit.Set(config, model, "providers", active, "model"); err != nil { + return err + } + } + if yamledit.IsEmpty(yamledit.Get(config, "providers")) { + yamledit.Delete(config, "providers") + } + if value, ok := previous[gooseSecretKey]; ok { + if err := yamledit.Set(secrets, value, gooseSecretKey); err != nil { + return err + } + } else { + yamledit.Delete(secrets, gooseSecretKey) + } + + changes := &txn{} + if err := saveYAML(changes, configPath, document, config); err != nil { + changes.abort() + return err + } + if err := saveYAML(changes, secretsPath, secretsDocument, secrets); err != nil { + changes.abort() + return err + } + return changes.commit() +} + +// Current is the endpoint the two halves in config.yaml spell out, put back +// together as the one URL the rest of Gateway compares against. +func (w gooseWriter) Current(target Target) (string, error) { + config, err := w.configOf(target) + if err != nil { + return "", err + } + host := yamledit.String(config, gooseHostKey) + if host == "" { + return "", nil + } + path := strings.TrimSuffix(yamledit.String(config, goosePathKey), gooseChatPath) + return strings.TrimSuffix(host, "/") + "/" + strings.Trim(path, "/"), nil +} + +func (w gooseWriter) Builtin(target Target, previous map[string]string) string { + if previous != nil { + return emptyAs(previous["providers.model"], previous[gooseModelKey]) + } + + config, err := w.configOf(target) + if err != nil { + return "" + } + // A model beside an endpoint Gateway wrote is Gateway's, not the one goose + // would pick for itself. + if strings.Contains(yamledit.String(config, goosePathKey), "/agents/") { + return "" + } + return emptyAs(yamledit.String(config, "providers", w.activeOf(config), "model"), yamledit.String(config, gooseModelKey)) +} + +// activeOf is the provider goose starts on, which is the OpenAI one for a file +// that names none: that is goose's own default and the one a switch writes. +func (gooseWriter) activeOf(config *yaml.Node) string { + return emptyAs(yamledit.String(config, gooseActiveKey), gooseProvider) +} + +// settings is what a switch writes at the root of config.yaml. goose reaches an +// endpoint through a host and a path rather than one URL, so the gateway's own +// URL is split into the two it wants. +func (gooseWriter) settings(endpoint Endpoint) (map[string]string, error) { + if endpoint.Model == "" { + return nil, errGooseNoModel + } + parsed, err := url.Parse(endpoint.BaseUrl) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, errors.New("goose needs an absolute base URL to split into a host and a path") + } + return map[string]string{ + gooseActiveKey: gooseProvider, + gooseHostKey: parsed.Scheme + "://" + parsed.Host, + goosePathKey: strings.Trim(parsed.Path, "/") + "/" + gooseChatPath, + gooseModelKey: endpoint.Model, + }, nil +} + +func (w gooseWriter) configOf(target Target) (*yaml.Node, error) { + configPath, _, err := w.paths(target) + if err != nil { + return nil, err + } + _, config, err := readYAMLMapping(configPath) + return config, err +} + +// paths are the two files a switch writes: the settings goose reads, and the +// secrets beside them. A keyring goose can reach answers for the key before +// secrets.yaml does, which the switch leaves alone: the gateway takes whatever +// credential the keyring already holds. +func (gooseWriter) paths(target Target) (string, string, error) { + home, err := agenthome.Resolve(target.Owner) + if err != nil { + return "", "", err + } + dir := filepath.Join(home, ".config", "goose") + if runtime.GOOS == "windows" { + dir = filepath.Join(home, "AppData", "Roaming", "Block", "goose", "config") + } + return filepath.Join(dir, "config.yaml"), filepath.Join(dir, "secrets.yaml"), nil +} diff --git a/agentprovider/yaml.go b/agentprovider/yaml.go new file mode 100644 index 0000000..3866bfd --- /dev/null +++ b/agentprovider/yaml.go @@ -0,0 +1,84 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agentprovider + +import ( + "fmt" + + "github.com/apache/casbin-gateway/internal/yamledit" + "gopkg.in/yaml.v3" +) + +// readYAMLMapping is the root mapping of one agent's YAML configuration, ready +// to be edited in place. A file that has never been written parses to an empty +// document, which is filled rather than refused. +func readYAMLMapping(path string) (*yamledit.Document, *yaml.Node, error) { + data, _, _, err := readFile(path) + if err != nil { + return nil, nil, err + } + document, err := yamledit.Parse(data) + if err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", path, err) + } + mapping, err := document.Mapping() + if err != nil { + return nil, nil, fmt.Errorf("parse %s: %w", path, err) + } + return document, mapping, nil +} + +// saveYAML replaces one file with the rendered document, or removes it once +// nothing is left in it. An emptied root renders as "{}" rather than as +// nothing, and a file holding that is not what the agent had before Gateway +// wrote one. +func saveYAML(changes *txn, path string, document *yamledit.Document, root *yaml.Node) error { + if yamledit.IsEmpty(root) { + return removeFile(path) + } + data, err := document.Bytes() + if err != nil { + return err + } + if len(data) == 0 { + return removeFile(path) + } + return changes.write(path, data) +} + +// sequenceAt is the list at key, nil when the entry is missing or holds +// something else. +func sequenceAt(mapping *yaml.Node, key string) *yaml.Node { + node := yamledit.Get(mapping, key) + if node == nil || node.Kind != yaml.SequenceNode { + return nil + } + return node +} + +// indexOfEntry is the position of the list item whose key holds want, -1 when +// the list has no such item. It is how a writer finds the entry it owns among +// the ones the file's owner wrote. +func indexOfEntry(sequence *yaml.Node, key string, want string) int { + if sequence == nil { + return -1 + } + for index, item := range sequence.Content { + if item.Kind == yaml.MappingNode && yamledit.String(item, key) == want { + return index + } + } + return -1 +} diff --git a/agentprovider/zed.go b/agentprovider/zed.go new file mode 100644 index 0000000..be0124d --- /dev/null +++ b/agentprovider/zed.go @@ -0,0 +1,259 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agentprovider + +import ( + "encoding/json" + "errors" + "fmt" + "path/filepath" + "runtime" + "strings" + + "github.com/apache/casbin-gateway/agenthome" + "github.com/apache/casbin-gateway/internal/jsonc" +) + +const ( + // zedProvider is the OpenAI-compatible provider Gateway owns under + // language_models. Every other provider in the file is left alone. + zedProvider = "casbin-gateway" + // zedDefaultContext is the window a model is assumed to have when the + // endpoint says nothing. Zed requires a number per model. + zedDefaultContext = 128000 + // zedDefaultOutput is the matching output cap. + zedDefaultOutput = 16384 +) + +// zedSelectedKey is the agent's default model, remembered under this name. +const zedSelectedKey = "agent.default_model" + +// errZedNoModel rejects a provider Zed could not use: a provider entry carries +// its own model list, and an endpoint Zed does not know has none of its own. +var errZedNoModel = errors.New("Zed needs a model name, so bind a provider that lists at least one model") + +// zedWriter owns the endpoint and the model list, not the credential: Zed keeps +// a key in the system keychain, and otherwise reads it from the provider id +// upper-snake-cased — CASBIN_GATEWAY_API_KEY. Neither is a store Gateway +// writes, so the key is entered once in Zed itself. Any value does: the gateway +// takes any credential from this host. +type zedWriter struct{} + +func init() { + register(zedWriter{}) +} + +func (zedWriter) AgentId() string { return "zed" } + +func (zedWriter) Protocol() string { return "openai" } + +func (w zedWriter) Plan(target Target, endpoint Endpoint) ([]File, error) { + if endpoint.Model == "" { + return nil, errZedNoModel + } + path, err := w.configPath(target) + if err != nil { + return nil, err + } + + data, err := encodeJSON(map[string]any{ + "language_models": map[string]any{ + "openai_compatible": map[string]any{zedProvider: w.provider(endpoint)}, + }, + "agent": map[string]any{"default_model": w.defaultModel(endpoint)}, + }) + if err != nil { + return nil, err + } + return []File{{Path: path, Format: "json", Preview: string(data)}}, nil +} + +func (w zedWriter) Apply(target Target, endpoint Endpoint) (map[string]string, error) { + if endpoint.Model == "" { + return nil, errZedNoModel + } + path, err := w.configPath(target) + if err != nil { + return nil, err + } + settings, err := w.load(path) + if err != nil { + return nil, err + } + + previous := map[string]string{} + if selected := w.selectedOf(settings); selected != "" { + previous[zedSelectedKey] = selected + } + + ensureNested(settings, "language_models", "openai_compatible")[zedProvider] = w.provider(endpoint) + ensureNested(settings, "agent")["default_model"] = w.defaultModel(endpoint) + + return previous, w.save(path, settings) +} + +func (w zedWriter) Restore(target Target, previous map[string]string) error { + path, err := w.configPath(target) + if err != nil { + return err + } + settings, err := w.load(path) + if err != nil { + return err + } + + if providers := nestedObject(settings, "language_models", "openai_compatible"); providers != nil { + delete(providers, zedProvider) + } + pruneEmpty(settings, "language_models", "openai_compatible") + pruneEmpty(settings, "language_models") + + if selected, ok := previous[zedSelectedKey]; ok { + provider, model, _ := strings.Cut(selected, "/") + ensureNested(settings, "agent")["default_model"] = map[string]any{"provider": provider, "model": model} + } else if agent := objectAt(settings, "agent"); agent != nil { + delete(agent, "default_model") + } + pruneEmpty(settings, "agent") + + if len(settings) == 0 { + return removeFile(path) + } + return w.save(path, settings) +} + +// Current is the endpoint behind the provider Zed's agent starts on. +func (w zedWriter) Current(target Target) (string, error) { + settings, err := w.settingsOf(target) + if err != nil { + return "", err + } + provider, _, _ := strings.Cut(w.selectedOf(settings), "/") + if provider == "" { + return "", nil + } + entry := nestedObject(settings, "language_models", "openai_compatible", provider) + if url := stringAt(entry, "api_url"); url != "" { + return url, nil + } + // A provider with no endpoint of its own is one Zed ships, which is still + // worth naming: it is not the endpoint Gateway wrote. + return provider, nil +} + +// Builtin is the model Zed's agent starts on without Gateway. +func (w zedWriter) Builtin(target Target, previous map[string]string) string { + selected := "" + if previous != nil { + selected = previous[zedSelectedKey] + } else { + settings, err := w.settingsOf(target) + if err != nil { + return "" + } + if selected = w.selectedOf(settings); strings.HasPrefix(selected, zedProvider+"/") { + return "" + } + } + _, model, _ := strings.Cut(selected, "/") + return model +} + +// selectedOf is the agent's default model as "provider/model", empty when the +// file names none. +func (zedWriter) selectedOf(settings map[string]any) string { + selected := nestedObject(settings, "agent", "default_model") + provider, model := stringAt(selected, "provider"), stringAt(selected, "model") + if provider == "" || model == "" { + return "" + } + return provider + "/" + model +} + +// provider is the entry Zed loads the endpoint through. Zed ships no catalog +// for an endpoint it does not know, so the entry carries every model the +// gateway serves and its own picker switches between them. +func (zedWriter) provider(endpoint Endpoint) map[string]any { + models := []any{} + for _, model := range endpoint.catalog() { + models = append(models, map[string]any{ + "name": model, + "max_tokens": zedDefaultContext, + "max_output_tokens": zedDefaultOutput, + }) + } + return map[string]any{"api_url": endpoint.BaseUrl, "available_models": models} +} + +func (zedWriter) defaultModel(endpoint Endpoint) map[string]any { + return map[string]any{"provider": zedProvider, "model": endpoint.Model} +} + +func (w zedWriter) settingsOf(target Target) (map[string]any, error) { + path, err := w.configPath(target) + if err != nil { + return nil, err + } + return w.load(path) +} + +// load reads the settings file. Zed accepts comments and trailing commas in it, +// so a file that has them is read rather than refused; saving writes plain +// JSON, which is what editing the file this way costs. +func (zedWriter) load(path string) (map[string]any, error) { + data, _, _, err := readFile(path) + if err != nil { + return nil, err + } + + settings := map[string]any{} + if strings.TrimSpace(string(data)) == "" { + return settings, nil + } + if err := json.Unmarshal(jsonc.Strip(data), &settings); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + if settings == nil { + return nil, fmt.Errorf("parse %s: the root must be a JSON object", path) + } + return settings, nil +} + +func (zedWriter) save(path string, settings map[string]any) error { + data, err := encodeJSON(settings) + if err != nil { + return err + } + + changes := &txn{} + if err := changes.write(path, data); err != nil { + changes.abort() + return err + } + return changes.commit() +} + +// configPath is the settings file Zed reads. It follows XDG on Unix and sits +// under the roaming profile on Windows. +func (zedWriter) configPath(target Target) (string, error) { + home, err := agenthome.Resolve(target.Owner) + if err != nil { + return "", err + } + if runtime.GOOS == "windows" { + return filepath.Join(home, "AppData", "Roaming", "Zed", "settings.json"), nil + } + return filepath.Join(home, ".config", "zed", "settings.json"), nil +} diff --git a/web/src/components/AgentIcon.tsx b/web/src/components/AgentIcon.tsx index 6fd54d6..f9fb73d 100644 --- a/web/src/components/AgentIcon.tsx +++ b/web/src/components/AgentIcon.tsx @@ -35,6 +35,13 @@ const agentSites: Record<string, string> = { Cline: "cline.bot", "Roo Code": "roocode.com", "Copilot CLI": "github.com", + Continue: "continue.dev", + Zed: "zed.dev", + Aider: "aider.chat", + goose: "block.github.io", + Crush: "charm.sh", + Droid: "factory.ai", + Trae: "trae.ai", }; const agentKey = (agent: unknown) => diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts index ae743d3..446379a 100644 --- a/web/src/lib/agents.ts +++ b/web/src/lib/agents.ts @@ -105,6 +105,16 @@ export function agentSetupNoteKey(agentId: string) { return "agent:iFlow CLI config hint"; case "cline": return "agent:Cline config hint"; + case "continue": + return "agent:Continue config hint"; + case "aider": + return "agent:Aider config hint"; + case "goose": + return "agent:goose config hint"; + case "droid": + return "agent:Droid config hint"; + case "zed": + return "agent:Zed config hint"; default: return ""; } diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index 2cf39db..e600d05 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -37,6 +37,7 @@ "Agent upgraded": "Agent upgraded", "Agents": "Agents", "Agents on this machine": "Agents on this machine", + "Aider config hint": "aider keeps the endpoint in ~/.aider.conf.yml: openai-api-base and openai-api-key, with the model named openai/<model> so aider reaches it over the OpenAI API instead of looking the name up. The command line and the environment are read before this file.", "All agents": "All agents", "All but the ones I pick": "All but the ones I pick", "All event types": "All event types", @@ -73,6 +74,7 @@ "Configuration preview": "Configuration preview", "Configuration restored": "Configuration restored", "Configuration written": "Configuration written", + "Continue config hint": "Continue keeps the endpoint in ~/.continue/config.yaml: a models entry with apiBase and apiKey beside it. The IDE extensions and the cn CLI all read that one file, and Continue starts on the first model that takes the chat role.", "Cooling down": "Cooling down", "Copy path": "Copy path", "Create a directory": "Create a directory", @@ -84,6 +86,7 @@ "Direct mode hint": "The provider's own base URL and key are written into the agent's configuration. Switching to another provider rewrites the file, and the agent has to be restarted to pick it up.", "Direct mode permission hint": "This agent is bound straight to its provider, so its requests never reach Gateway and none of these rules apply. Switch it back to routing through Gateway.", "Drive a browser": "Drive a browser", + "Droid config hint": "droid keeps the endpoint in ~/.factory/settings.json: a customModels entry with baseUrl and apiKey, and provider set to generic-chat-completion-api. The older config.json beside it spells the same keys in snake case and loses to this one.", "Duration": "Duration", "Edit a file": "Edit a file", "Edit a notebook": "Edit a notebook", @@ -281,8 +284,10 @@ "Write confirm hint": "Gateway writes the selected provider into this agent's own configuration file, replacing the file in one step. Restart any session that is already running.", "Write the configuration of {agent}?": "Write the configuration of {agent}?", "Written by Gateway": "Written by Gateway", + "Zed config hint": "Zed keeps the endpoint in its settings.json under language_models.openai_compatible, but not the key: that lives in the system keychain. Enter any value once in Zed's own provider settings, or export CASBIN_GATEWAY_API_KEY — the gateway takes any credential from this host.", "allowed": "allowed", "blocked": "blocked", + "goose config hint": "goose reaches an endpoint through two settings in config.yaml rather than one URL: OPENAI_HOST is the root and OPENAI_BASE_PATH the path below it. goose ignores a provider key placed in config.yaml, so the key goes to secrets.yaml; a key already in the system keyring is used instead, which the gateway accepts either way.", "iFlow CLI config hint": "iFlow CLI keeps the endpoint in ~/.iflow/settings.json itself: baseUrl, apiKey and modelName at the root of the file, with \"selectedAuthType\": \"openai-compatible\" beside them.", "opencode config hint": "opencode reads no base URL from the environment: the endpoint is a \"provider\" entry in ~/.config/opencode/opencode.json, loaded through @ai-sdk/openai-compatible and selected with \"model\": \"<provider>/<model>\".", "switches": "switches", diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index 3b2d477..96e410a 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -37,6 +37,7 @@ "Agent upgraded": "升级完成", "Agents": "智能体", "Agents on this machine": "本机的智能体", + "Aider config hint": "aider 的端点写在 ~/.aider.conf.yml:openai-api-base 和 openai-api-key,模型名写成 openai/<model>,这样 aider 才会走 OpenAI API 而不是去自己的目录里查这个名字。命令行和环境变量会在这个文件之前被读取。", "All agents": "全部 Agent", "All but the ones I pick": "除勾选的以外都允许", "All event types": "全部事件类型", @@ -73,6 +74,7 @@ "Configuration preview": "配置预览", "Configuration restored": "配置已还原", "Configuration written": "配置已写入", + "Continue config hint": "Continue 的端点写在 ~/.continue/config.yaml:models 列表中的一条,旁边是 apiBase 和 apiKey。IDE 插件和 cn CLI 读的都是这一个文件,Continue 从第一个带 chat 角色的模型开始。", "Cooling down": "冷却中", "Copy path": "复制路径", "Create a directory": "新建目录", @@ -84,6 +86,7 @@ "Direct mode hint": "直接把该供应商的 Base URL 和密钥写入 Agent 配置文件。切换供应商会重写文件,且需要重启 Agent 才能生效。", "Direct mode permission hint": "该智能体直连供应商,请求不经过网关,这里的规则不会生效。请把它切回经网关转发。", "Drive a browser": "操作浏览器", + "Droid config hint": "droid 的端点写在 ~/.factory/settings.json:customModels 里的一条,带 baseUrl 和 apiKey,provider 设为 generic-chat-completion-api。旁边那个老的 config.json 用的是 snake case 的同名字段,优先级低于这个文件。", "Duration": "耗时", "Edit a file": "编辑文件", "Edit a notebook": "编辑 Notebook", @@ -281,8 +284,10 @@ "Write confirm hint": "Gateway 会把所选供应商以该 Agent 自己的格式原子地写入其配置文件。已在运行的会话需要重启。", "Write the configuration of {agent}?": "写入 {agent} 的配置?", "Written by Gateway": "已由 Gateway 写入", + "Zed config hint": "Zed 的端点写在 settings.json 的 language_models.openai_compatible 下,但密钥不在那里 —— 它存在系统钥匙串里。在 Zed 自己的 provider 设置里随便填一个值,或者导出 CASBIN_GATEWAY_API_KEY 即可 —— 网关接受本机发来的任何凭据。", "allowed": "项已允许", "blocked": "项被拦截", + "goose config hint": "goose 不是用一个 URL 找端点,而是 config.yaml 里的两项:OPENAI_HOST 是根,OPENAI_BASE_PATH 是它下面的路径。goose 会忽略写在 config.yaml 里的 provider key,所以密钥写进 secrets.yaml;如果系统钥匙串里已经有 key,用的是那个 —— 网关两种都接受。", "iFlow CLI config hint": "iFlow CLI 的端点就写在 ~/.iflow/settings.json 里:文件根部的 baseUrl、apiKey 和 modelName,旁边是 \"selectedAuthType\": \"openai-compatible\"。", "opencode config hint": "opencode 不从环境变量读取 base URL:端点是 ~/.config/opencode/opencode.json 里的一个 \"provider\" 条目,用 @ai-sdk/openai-compatible 加载,再用 \"model\": \"<provider>/<model>\" 选中它。", "switches": "个开关", --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
