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
The following commit(s) were added to refs/heads/master by this push:
new 9418240 feat(agents): support Pi, Kimi Code CLI and CodeBuddy Code
9418240 is described below
commit 941824042a3d0822809089f120d87dc90e962b42
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 16:53:42 2026 +0800
feat(agents): support Pi, Kimi Code CLI and CodeBuddy Code
---
agent/fingerprint_load_test.go | 10 +-
agent/fingerprints/codebuddy.json | 9 +
agent/fingerprints/kimi-code.json | 10 +
agent/fingerprints/pi.json | 9 +
agentconfig/layout.go | 30 +++
agentenv/agentenv.go | 7 +
agentprovider/codebuddy.go | 378 ++++++++++++++++++++++++++++++++++++++
agentprovider/kimi.go | 263 ++++++++++++++++++++++++++
agentprovider/pi.go | 284 ++++++++++++++++++++++++++++
agentprovider/toml.go | 53 +++++-
web/src/components/AgentIcon.tsx | 3 +
web/src/lib/agents.ts | 6 +
web/src/locales/en/data.json | 3 +
web/src/locales/zh/data.json | 3 +
14 files changed, 1061 insertions(+), 7 deletions(-)
diff --git a/agent/fingerprint_load_test.go b/agent/fingerprint_load_test.go
index fc77923..e116f05 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{
- "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",
+ "aider", "claude-code", "claude-desktop", "cline", "codebuddy",
+ "codex-cli", "codex", "continue", "copilot-cli", "crush",
"cursor-agent",
+ "cursor", "droid", "dsh", "gemini-cli", "goose", "hermes-agent",
+ "iflow-cli", "kimi-code", "openagent", "openclaw",
"opencode-desktop",
+ "opencode", "pi", "qwen-code", "roo-code", "trae", "windsurf",
"zed",
}
loaded, err := loadFingerprints(fingerprintFS)
diff --git a/agent/fingerprints/codebuddy.json
b/agent/fingerprints/codebuddy.json
new file mode 100644
index 0000000..48949f9
--- /dev/null
+++ b/agent/fingerprints/codebuddy.json
@@ -0,0 +1,9 @@
+{
+ "id": "codebuddy",
+ "displayName": "CodeBuddy Code",
+ "installUrl": "https://www.codebuddy.ai/docs/cli/installation",
+ "execName": "codebuddy",
+ "instanceEnv": "CODEBUDDY_CONFIG_DIR",
+ "stateDir": "codebuddy",
+ "npmPackage": "@tencent-ai/codebuddy-code"
+}
diff --git a/agent/fingerprints/kimi-code.json
b/agent/fingerprints/kimi-code.json
new file mode 100644
index 0000000..dc9cf66
--- /dev/null
+++ b/agent/fingerprints/kimi-code.json
@@ -0,0 +1,10 @@
+{
+ "id": "kimi-code",
+ "displayName": "Kimi Code CLI",
+ "installUrl":
"https://moonshotai.github.io/kimi-code/en/guides/getting-started",
+ "execName": "kimi",
+ "instanceEnv": "KIMI_CODE_HOME",
+ "stateDir": "kimi-code",
+ "npmPackage": "@moonshot-ai/kimi-code",
+ "homeDirs": [".kimi-code/bin"]
+}
diff --git a/agent/fingerprints/pi.json b/agent/fingerprints/pi.json
new file mode 100644
index 0000000..fa713a7
--- /dev/null
+++ b/agent/fingerprints/pi.json
@@ -0,0 +1,9 @@
+{
+ "id": "pi",
+ "displayName": "Pi",
+ "installUrl": "https://pi.dev/docs/latest/quickstart",
+ "execName": "pi",
+ "instanceEnv": "PI_CODING_AGENT_DIR",
+ "stateDir": "pi",
+ "npmPackage": "@earendil-works/pi-coding-agent"
+}
diff --git a/agentconfig/layout.go b/agentconfig/layout.go
index 1ba0465..944790a 100644
--- a/agentconfig/layout.go
+++ b/agentconfig/layout.go
@@ -241,6 +241,36 @@ var layouts = map[string]layout{
},
"opencode": opencodeLayout,
"opencode-desktop": opencodeLayout,
+ // pi keeps everything under ~/.pi/agent and reads the shared ~/.agents
+ // skills beside its own. It ships no MCP client of its own: a server is
+ // reached through an extension, which is not a file Gateway can write.
+ "pi": {
+ skills: &skillLayout{sources: []skillSource{
+ userSkills(".pi", "agent", "skills"),
+ userSkills(".agents", "skills"),
+ }},
+ prompt: promptFile("AGENTS.md", ".pi", "agent"),
+ },
+ // Kimi Code keeps its own skills beside its config and reads the shared
+ // ~/.agents skills too, which stay in the home rather than moving with
+ // KIMI_CODE_HOME so the other agents keep reading them.
+ "kimi-code": {
+ skills: &skillLayout{sources: []skillSource{
+ userSkills(".kimi-code", "skills"),
+ userSkills(".agents", "skills"),
+ pluginSkills(".kimi-code", "plugins"),
+ }},
+ mcp: &mcpLayout{file: under(".kimi-code", "mcp.json"),
store: &jsonStore{paths: [][]string{{"mcpServers"}}}},
+ prompt: promptFile("AGENTS.md", ".kimi-code"),
+ },
+ "codebuddy": {
+ skills: &skillLayout{sources: []skillSource{
+ userSkills(".codebuddy", "skills"),
+ pluginSkills(".codebuddy", "plugins"),
+ }},
+ mcp: &mcpLayout{file: under(".codebuddy", ".mcp.json"),
store: &jsonStore{paths: [][]string{{"mcpServers"}}}},
+ prompt: promptFile("CODEBUDDY.md", ".codebuddy"),
+ },
}
var codexLayout = layout{
diff --git a/agentenv/agentenv.go b/agentenv/agentenv.go
index e951380..1800b0d 100644
--- a/agentenv/agentenv.go
+++ b/agentenv/agentenv.go
@@ -107,6 +107,13 @@ var agentVars = map[string][]variable{
"dsh": {
{key: "CASBIN_GATEWAY_API_KEY", secret: true},
},
+ // CodeBuddy reads the model from the environment before settings.json,
so
+ // this one wins over the selection a switch writes. The endpoint is not
+ // listed with it: a custom model entry carries its own url, which
+ // CODEBUDDY_BASE_URL does not reach.
+ "codebuddy": {
+ {key: "CODEBUDDY_MODEL"},
+ },
}
// value is one variable found set, with where it was found.
diff --git a/agentprovider/codebuddy.go b/agentprovider/codebuddy.go
new file mode 100644
index 0000000..4bb6f55
--- /dev/null
+++ b/agentprovider/codebuddy.go
@@ -0,0 +1,378 @@
+// 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"
+ "path/filepath"
+ "strings"
+
+ "github.com/apache/casbin-gateway/agenthome"
+)
+
+const (
+ // codebuddyVendor marks the entries in models.json Gateway owns.
CodeBuddy
+ // keys its models by id rather than by provider, so this is what tells
our
+ // entries apart from the ones someone added by hand.
+ codebuddyVendor = "Casbin Gateway"
+ // codebuddyChatPath completes the endpoint. CodeBuddy sends a custom
model
+ // to the url as written, so it carries the whole path rather than the
root
+ // the other agents take.
+ codebuddyChatPath = "/chat/completions"
+ // codebuddyLocalKey stands in for a key the endpoint does not have. The
+ // CLI sends one either way, and the gateway takes any credential from
this
+ // host.
+ codebuddyLocalKey = "casbin-gateway"
+)
+
+// The keys Gateway owns, remembered under these names.
+const (
+ codebuddyModelKey = "settings.model"
+ // codebuddyDisplacedKey holds the models.json entries a switch
overwrote,
+ // as the file stored them: an entry Gateway writes takes the id of a
model
+ // someone configured themselves, and that one comes back on restore.
+ codebuddyDisplacedKey = "models.displaced"
+)
+
+// errCodebuddyNoModel rejects a model list CodeBuddy could not use: entries in
+// models.json are keyed by the model id, so there has to be one.
+var errCodebuddyNoModel = errors.New("CodeBuddy needs a model name, so bind a
provider that lists at least one model")
+
+type codebuddyWriter struct{}
+
+func init() {
+ register(codebuddyWriter{})
+}
+
+func (codebuddyWriter) AgentId() string { return "codebuddy" }
+
+func (codebuddyWriter) Protocol() string { return "openai" }
+
+func (w codebuddyWriter) Plan(target Target, endpoint Endpoint) ([]File,
error) {
+ if endpoint.Model == "" {
+ return nil, errCodebuddyNoModel
+ }
+ modelsPath, settingsPath, err := w.paths(target)
+ if err != nil {
+ return nil, err
+ }
+
+ preview := endpoint
+ preview.ApiKey = maskSecret(emptyAs(endpoint.ApiKey, codebuddyLocalKey))
+ models, err := encodeJSON(map[string]any{"models": w.entries(preview)})
+ if err != nil {
+ return nil, err
+ }
+ settings, err := encodeJSON(map[string]any{"model": endpoint.Model})
+ if err != nil {
+ return nil, err
+ }
+ return []File{
+ {Path: modelsPath, Format: "json", Preview: string(models)},
+ {Path: settingsPath, Format: "json", Preview: string(settings)},
+ }, nil
+}
+
+func (w codebuddyWriter) Apply(target Target, endpoint Endpoint)
(map[string]string, error) {
+ if endpoint.Model == "" {
+ return nil, errCodebuddyNoModel
+ }
+ modelsPath, settingsPath, err := w.paths(target)
+ if err != nil {
+ return nil, err
+ }
+ models, _, err := readJSON(modelsPath)
+ if err != nil {
+ return nil, err
+ }
+ settings, _, err := readJSON(settingsPath)
+ if err != nil {
+ return nil, err
+ }
+
+ previous := map[string]string{}
+ if selected := stringAt(settings, "model"); selected != "" &&
!w.owns(models, selected) {
+ previous[codebuddyModelKey] = selected
+ }
+
+ entries := w.entries(endpoint)
+ ids := w.idsOf(entries)
+ kept, displaced := w.split(codebuddyList(models, "models"), ids)
+ if len(displaced) > 0 {
+ text, err := json.Marshal(displaced)
+ if err != nil {
+ return nil, err
+ }
+ previous[codebuddyDisplacedKey] = string(text)
+ }
+
+ models["models"] = append(kept, entries...)
+ // An absent availableModels shows every model there is, so adding one
+ // holding only Gateway's would hide the rest.
+ if listed := codebuddyList(models, "availableModels"); listed != nil {
+ models["availableModels"] = codebuddyAdd(listed, ids)
+ }
+ settings["model"] = endpoint.Model
+
+ return previous, w.save(modelsPath, models, settingsPath, settings)
+}
+
+func (w codebuddyWriter) Restore(target Target, previous map[string]string)
error {
+ modelsPath, settingsPath, err := w.paths(target)
+ if err != nil {
+ return err
+ }
+ models, _, err := readJSON(modelsPath)
+ if err != nil {
+ return err
+ }
+ settings, _, err := readJSON(settingsPath)
+ if err != nil {
+ return err
+ }
+
+ displaced := []any{}
+ if text, ok := previous[codebuddyDisplacedKey]; ok {
+ if err := json.Unmarshal([]byte(text), &displaced); err != nil {
+ return err
+ }
+ }
+
+ kept, dropped := w.split(codebuddyList(models, "models"), nil)
+ models["models"] = append(kept, displaced...)
+ // A displaced entry took back the id Gateway's had, so that id stays
+ // listed; the rest of Gateway's go.
+ if listed := codebuddyList(models, "availableModels"); listed != nil {
+ models["availableModels"] = codebuddyDrop(listed,
w.idsOf(dropped), w.idsOf(displaced))
+ }
+ if len(codebuddyList(models, "models")) == 0 {
+ delete(models, "models")
+ delete(models, "availableModels")
+ }
+
+ if value, ok := previous[codebuddyModelKey]; ok {
+ settings["model"] = value
+ } else {
+ delete(settings, "model")
+ }
+ return w.save(modelsPath, models, settingsPath, settings)
+}
+
+func (w codebuddyWriter) Current(target Target) (string, error) {
+ models, settings, err := w.read(target)
+ if err != nil {
+ return "", err
+ }
+
+ selected := stringAt(settings, "model")
+ if selected == "" {
+ return "", nil
+ }
+ for _, entry := range codebuddyList(models, "models") {
+ model, _ := entry.(map[string]any)
+ if model != nil && stringAt(model, "id") == selected {
+ return strings.TrimSuffix(stringAt(model, "url"),
codebuddyChatPath), nil
+ }
+ }
+ // A model with no entry of its own is one CodeBuddy serves itself,
which is
+ // still worth naming: it is not the endpoint Gateway wrote.
+ return selected, nil
+}
+
+// Builtin is the model CodeBuddy starts on without Gateway: the one settings
+// name, or the account it signs in to when they name none.
+func (w codebuddyWriter) Builtin(target Target, previous map[string]string)
string {
+ if previous != nil {
+ return emptyAs(previous[codebuddyModelKey], codebuddyBuiltin)
+ }
+
+ models, settings, err := w.read(target)
+ if err != nil {
+ return codebuddyBuiltin
+ }
+ selected := stringAt(settings, "model")
+ if selected == "" || w.owns(models, selected) {
+ return codebuddyBuiltin
+ }
+ return selected
+}
+
+// codebuddyBuiltin is what CodeBuddy talks to with no model entry of its own:
+// the CodeBuddy account, whose model the CLI picks by itself.
+const codebuddyBuiltin = "CodeBuddy"
+
+// entries is the catalog as models.json stores it. CodeBuddy carries the list
+// itself, so all of it is written and its own picker can switch between the
+// models without Gateway writing the file again.
+func (codebuddyWriter) entries(endpoint Endpoint) []any {
+ entries := []any{}
+ for _, model := range endpoint.catalog() {
+ entries = append(entries, map[string]any{
+ "id": model,
+ "name": model,
+ "vendor": codebuddyVendor,
+ "url": endpoint.BaseUrl +
codebuddyChatPath,
+ "apiKey": emptyAs(endpoint.ApiKey,
codebuddyLocalKey),
+ "supportsToolCall": true,
+ })
+ }
+ return entries
+}
+
+// split separates the entries Gateway is about to own from the ones it leaves
+// alone: its own from a previous switch, and any carrying an id it is about to
+// write. The second return is what was taken over, which is what comes back on
+// restore.
+func (codebuddyWriter) split(entries []any, ids []string) ([]any, []any) {
+ taken := map[string]bool{}
+ for _, id := range ids {
+ taken[id] = true
+ }
+
+ kept := []any{}
+ removed := []any{}
+ for _, entry := range entries {
+ model, _ := entry.(map[string]any)
+ if model != nil && (stringAt(model, "vendor") ==
codebuddyVendor || taken[stringAt(model, "id")]) {
+ removed = append(removed, entry)
+ continue
+ }
+ kept = append(kept, entry)
+ }
+ return kept, removed
+}
+
+func (codebuddyWriter) idsOf(entries []any) []string {
+ ids := []string{}
+ for _, entry := range entries {
+ if model, _ := entry.(map[string]any); model != nil {
+ if id := stringAt(model, "id"); id != "" {
+ ids = append(ids, id)
+ }
+ }
+ }
+ return ids
+}
+
+// owns reports a model id that names one of Gateway's own entries.
+func (codebuddyWriter) owns(models map[string]any, id string) bool {
+ for _, entry := range codebuddyList(models, "models") {
+ model, _ := entry.(map[string]any)
+ if model != nil && stringAt(model, "id") == id {
+ return stringAt(model, "vendor") == codebuddyVendor
+ }
+ }
+ return false
+}
+
+func (w codebuddyWriter) save(modelsPath string, models map[string]any,
settingsPath string, settings map[string]any) error {
+ changes := &txn{}
+ if err := w.stage(changes, modelsPath, models); err != nil {
+ changes.abort()
+ return err
+ }
+ if err := w.stage(changes, settingsPath, settings); err != nil {
+ changes.abort()
+ return err
+ }
+ return changes.commit()
+}
+
+// stage queues one file, removing it instead once Gateway's entries were the
+// only thing in it: an empty file is not what CodeBuddy had before.
+func (codebuddyWriter) stage(changes *txn, path string, config map[string]any)
error {
+ if len(config) == 0 {
+ return removeFile(path)
+ }
+ data, err := encodeJSON(config)
+ if err != nil {
+ return err
+ }
+ return changes.write(path, data)
+}
+
+func (w codebuddyWriter) read(target Target) (map[string]any, map[string]any,
error) {
+ modelsPath, settingsPath, err := w.paths(target)
+ if err != nil {
+ return nil, nil, err
+ }
+ models, _, err := readJSON(modelsPath)
+ if err != nil {
+ return nil, nil, err
+ }
+ settings, _, err := readJSON(settingsPath)
+ if err != nil {
+ return nil, nil, err
+ }
+ return models, settings, nil
+}
+
+// paths are the two files a switch writes: the model catalog, and the settings
+// naming the one CodeBuddy starts on.
+func (codebuddyWriter) paths(target Target) (string, string, error) {
+ home, err := agenthome.Resolve(target.Owner)
+ if err != nil {
+ return "", "", err
+ }
+ dir := filepath.Join(home, ".codebuddy")
+ return filepath.Join(dir, "models.json"), filepath.Join(dir,
"settings.json"), nil
+}
+
+// codebuddyList is the array at key, nil when the key is missing or holds
+// something else. The difference matters for availableModels, where a missing
+// list means every model rather than none.
+func codebuddyList(config map[string]any, key string) []any {
+ value, ok := config[key].([]any)
+ if !ok {
+ return nil
+ }
+ return value
+}
+
+func codebuddyAdd(listed []any, ids []string) []any {
+ seen := map[string]bool{}
+ for _, value := range listed {
+ if text, ok := value.(string); ok {
+ seen[text] = true
+ }
+ }
+ for _, id := range ids {
+ if !seen[id] {
+ seen[id] = true
+ listed = append(listed, id)
+ }
+ }
+ return listed
+}
+
+func codebuddyDrop(listed []any, ids []string, keep []string) []any {
+ dropping := map[string]bool{}
+ for _, id := range ids {
+ dropping[id] = true
+ }
+ for _, id := range keep {
+ dropping[id] = false
+ }
+
+ kept := []any{}
+ for _, value := range listed {
+ if text, ok := value.(string); ok && dropping[text] {
+ continue
+ }
+ kept = append(kept, value)
+ }
+ return kept
+}
diff --git a/agentprovider/kimi.go b/agentprovider/kimi.go
new file mode 100644
index 0000000..580f4ad
--- /dev/null
+++ b/agentprovider/kimi.go
@@ -0,0 +1,263 @@
+// 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"
+ "fmt"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+ "github.com/apache/casbin-gateway/agenthome"
+)
+
+const (
+ // kimiProvider is the provider table Gateway owns in config.toml.
+ // Everything else in the file, other providers included, is left alone.
+ kimiProvider = "casbin-gateway"
+ // kimiType is the provider type that reads the base_url beside it,
which
+ // is what an endpoint Kimi Code does not ship needs.
+ kimiType = "openai"
+ // kimiAliasPrefix opens the model aliases Gateway writes. Kimi Code
keys
+ // models by alias rather than by provider, so this is what tells its
own
+ // tables apart from the ones someone added by hand.
+ kimiAliasPrefix = kimiProvider + "/"
+ // kimiModelKey is the root key naming the alias Kimi Code starts on,
and
+ // how its previous value is remembered.
+ kimiModelKey = "default_model"
+ // kimiLocalKey stands in for a key the endpoint does not have. Kimi
Code
+ // refuses to start a provider without one, while the gateway takes any
+ // credential from this host.
+ kimiLocalKey = "casbin-gateway"
+ // kimiContextSize is the context every alias declares. Kimi Code
requires
+ // one and the endpoint carries none, so it is the smaller of the sizes
in
+ // use: a model with room to spare compacts early, while one told it has
+ // room it does not have fails the request instead.
+ kimiContextSize = 128000
+ // kimiBuiltin is what Kimi Code talks to with no provider of its own:
the
+ // Kimi account, whose model the CLI picks by itself.
+ kimiBuiltin = "Kimi"
+)
+
+var kimiProviderPath = []string{"providers", kimiProvider}
+
+// errKimiNoModel rejects a config Kimi Code could not start on: an alias is
+// keyed by the model it names, so there has to be one.
+var errKimiNoModel = errors.New("Kimi Code needs a model name, so bind a
provider that lists at least one model")
+
+type kimiWriter struct{}
+
+func init() {
+ register(kimiWriter{})
+}
+
+func (kimiWriter) AgentId() string { return "kimi-code" }
+
+func (kimiWriter) Protocol() string { return "openai" }
+
+func (w kimiWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
+ if endpoint.Model == "" {
+ return nil, errKimiNoModel
+ }
+ path, err := w.configPath(target)
+ if err != nil {
+ return nil, err
+ }
+
+ preview := endpoint
+ preview.ApiKey = maskSecret(emptyAs(endpoint.ApiKey, kimiLocalKey))
+ config := tomlSetRootKey("", kimiModelKey, kimiAlias(endpoint.Model))
+ config = tomlTidy(tomlAppend(config, w.tables(preview)))
+ return []File{{Path: path, Format: "toml", Preview: config}}, nil
+}
+
+func (w kimiWriter) Apply(target Target, endpoint Endpoint)
(map[string]string, error) {
+ if endpoint.Model == "" {
+ return nil, errKimiNoModel
+ }
+ path, err := w.configPath(target)
+ if err != nil {
+ return nil, err
+ }
+ data, _, _, err := readFile(path)
+ if err != nil {
+ return nil, err
+ }
+ document, err := w.decode(path, data)
+ if err != nil {
+ return nil, err
+ }
+
+ previous := map[string]string{}
+ // An alias Gateway already wrote is not what a restore should put back.
+ if selected := stringOf(document[kimiModelKey]);
!strings.HasPrefix(selected, kimiAliasPrefix) && selected != "" {
+ previous[kimiModelKey] = selected
+ }
+
+ text := w.cut(string(data))
+ text = tomlSetRootKey(text, kimiModelKey, kimiAlias(endpoint.Model))
+ text = tomlAppend(text, w.tables(endpoint))
+
+ return previous, w.save(path, text)
+}
+
+func (w kimiWriter) Restore(target Target, previous map[string]string) error {
+ path, err := w.configPath(target)
+ if err != nil {
+ return err
+ }
+ data, _, _, err := readFile(path)
+ if err != nil {
+ return err
+ }
+
+ text := w.cut(string(data))
+ if value, ok := previous[kimiModelKey]; ok {
+ text = tomlSetRootKey(text, kimiModelKey, value)
+ } else {
+ text = tomlDeleteRootKey(text, kimiModelKey)
+ }
+ return w.save(path, text)
+}
+
+func (w kimiWriter) Current(target Target) (string, error) {
+ document, err := w.document(target)
+ if err != nil {
+ return "", err
+ }
+
+ selected := stringOf(document[kimiModelKey])
+ if selected == "" {
+ return "", nil
+ }
+ alias := tableOf(tableOf(document, "models"), selected)
+ provider := tableOf(tableOf(document, "providers"),
stringOf(alias["provider"]))
+ if baseUrl := stringOf(provider["base_url"]); baseUrl != "" {
+ return baseUrl, nil
+ }
+ // An alias whose provider names no URL is one Kimi Code reaches itself,
+ // which is still worth naming: it is not the endpoint Gateway wrote.
+ return selected, nil
+}
+
+// Builtin is the model Kimi Code starts on without Gateway: the alias its own
+// config names, or the account it signs in to when it names none.
+func (w kimiWriter) Builtin(target Target, previous map[string]string) string {
+ if previous != nil {
+ return emptyAs(previous[kimiModelKey], kimiBuiltin)
+ }
+
+ document, err := w.document(target)
+ if err != nil {
+ return kimiBuiltin
+ }
+ selected := stringOf(document[kimiModelKey])
+ if strings.HasPrefix(selected, kimiAliasPrefix) {
+ return kimiBuiltin
+ }
+ return emptyAs(selected, kimiBuiltin)
+}
+
+// tables are the blocks a switch appends: the provider, then one alias per
+// model the endpoint serves, so Kimi Code's own picker can switch between them
+// without Gateway writing the file again.
+func (kimiWriter) tables(endpoint Endpoint) string {
+ blocks := []string{tomlTable(kimiProviderPath,
+ []string{"type", "base_url", "api_key"},
+ map[string]string{
+ "type": kimiType,
+ "base_url": endpoint.BaseUrl,
+ "api_key": emptyAs(endpoint.ApiKey, kimiLocalKey),
+ })}
+
+ for _, model := range endpoint.catalog() {
+ blocks = append(blocks, tomlRawTable([]string{"models",
kimiAlias(model)},
+ []string{"provider", "model", "max_context_size"},
+ map[string]string{
+ "provider": strconv.Quote(kimiProvider),
+ "model": strconv.Quote(model),
+ "max_context_size":
strconv.Itoa(kimiContextSize),
+ }))
+ }
+ return strings.Join(blocks, "\n")
+}
+
+// cut takes out everything Gateway owns: its provider table and every alias
+// written under its own name.
+func (kimiWriter) cut(text string) string {
+ return tomlCutTablesUnder(tomlCutTable(text, kimiProviderPath...),
"models", kimiAliasPrefix)
+}
+
+// kimiAlias is the name one model is listed under, kept apart from the aliases
+// Kimi Code ships so a switch never overwrites one.
+func kimiAlias(model string) string {
+ return kimiAliasPrefix + model
+}
+
+func (kimiWriter) save(path string, text string) error {
+ changes := &txn{}
+ if err := changes.write(path, []byte(tomlTidy(text))); err != nil {
+ changes.abort()
+ return err
+ }
+ return changes.commit()
+}
+
+// document is config.toml parsed, empty when the file is not there yet.
+func (w kimiWriter) document(target Target) (map[string]any, error) {
+ path, err := w.configPath(target)
+ if err != nil {
+ return nil, err
+ }
+ data, _, exists, err := readFile(path)
+ if err != nil || !exists {
+ return map[string]any{}, err
+ }
+ return w.decode(path, data)
+}
+
+func (kimiWriter) decode(path string, data []byte) (map[string]any, error) {
+ document := map[string]any{}
+ if len(strings.TrimSpace(string(data))) == 0 {
+ return document, nil
+ }
+ if err := toml.Unmarshal(data, &document); err != nil {
+ return nil, fmt.Errorf("parse %s: %w", path, err)
+ }
+ return document, nil
+}
+
+func (kimiWriter) configPath(target Target) (string, error) {
+ home, err := agenthome.Resolve(target.Owner)
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".kimi-code", "config.toml"), nil
+}
+
+func stringOf(value any) string {
+ text, _ := value.(string)
+ return text
+}
+
+func tableOf(document map[string]any, key string) map[string]any {
+ if document == nil {
+ return nil
+ }
+ value, _ := document[key].(map[string]any)
+ return value
+}
diff --git a/agentprovider/pi.go b/agentprovider/pi.go
new file mode 100644
index 0000000..55ce107
--- /dev/null
+++ b/agentprovider/pi.go
@@ -0,0 +1,284 @@
+// 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"
+)
+
+const (
+ // piProvider is the provider entry Gateway owns in models.json, and the
+ // name settings.json starts pi on. Every other provider is left alone.
+ piProvider = "casbin-gateway"
+ // piApi is the wire format pi talks to the entry with, which is the
one an
+ // endpoint pi does not ship needs.
+ piApi = "openai-completions"
+ // piLocalKey stands in for a key the endpoint does not have. pi hides a
+ // model whose provider has no credential, while the gateway takes any
+ // credential from this host.
+ piLocalKey = "casbin-gateway"
+)
+
+// The settings keys Gateway owns. pi starts on the pair, and falls back to its
+// own picker when either is missing.
+const (
+ piProviderKey = "defaultProvider"
+ piModelKey = "defaultModel"
+)
+
+var piKeys = []string{piProviderKey, piModelKey}
+
+// errPiNoModel rejects a provider entry pi could not start on: an endpoint it
+// does not know has no catalog of its own, and settings.json needs a model id
+// beside the provider name.
+var errPiNoModel = errors.New("pi needs a model name, so bind a provider that
lists at least one model")
+
+type piWriter struct{}
+
+func init() {
+ register(piWriter{})
+}
+
+func (piWriter) AgentId() string { return "pi" }
+
+func (piWriter) Protocol() string { return "openai" }
+
+func (w piWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
+ if endpoint.Model == "" {
+ return nil, errPiNoModel
+ }
+ modelsPath, settingsPath, err := w.paths(target)
+ if err != nil {
+ return nil, err
+ }
+
+ preview := endpoint
+ preview.ApiKey = maskSecret(emptyAs(endpoint.ApiKey, piLocalKey))
+ models, err := encodeJSON(map[string]any{
+ "providers": map[string]any{piProvider: w.entry(preview)},
+ })
+ if err != nil {
+ return nil, err
+ }
+ settings, err := encodeJSON(w.settings(endpoint))
+ if err != nil {
+ return nil, err
+ }
+ return []File{
+ {Path: modelsPath, Format: "json", Preview: string(models)},
+ {Path: settingsPath, Format: "json", Preview: string(settings)},
+ }, nil
+}
+
+func (w piWriter) Apply(target Target, endpoint Endpoint) (map[string]string,
error) {
+ if endpoint.Model == "" {
+ return nil, errPiNoModel
+ }
+ modelsPath, settingsPath, err := w.paths(target)
+ if err != nil {
+ return nil, err
+ }
+ models, _, err := readJSON(modelsPath)
+ if err != nil {
+ return nil, err
+ }
+ settings, _, err := readJSON(settingsPath)
+ if err != nil {
+ return nil, err
+ }
+
+ previous := map[string]string{}
+ // A selection Gateway already made is not what a restore should put
back.
+ if stringAt(settings, piProviderKey) != piProvider {
+ for _, key := range piKeys {
+ if value := stringAt(settings, key); value != "" {
+ previous[key] = value
+ }
+ }
+ }
+
+ ensureNested(models, "providers")[piProvider] = w.entry(endpoint)
+ for key, value := range w.settings(endpoint) {
+ settings[key] = value
+ }
+
+ changes := &txn{}
+ if err := w.stageModels(changes, modelsPath, models); err != nil {
+ changes.abort()
+ return nil, err
+ }
+ if err := w.stage(changes, settingsPath, settings); err != nil {
+ changes.abort()
+ return nil, err
+ }
+ if err := changes.commit(); err != nil {
+ return nil, err
+ }
+ return previous, nil
+}
+
+func (w piWriter) Restore(target Target, previous map[string]string) error {
+ modelsPath, settingsPath, err := w.paths(target)
+ if err != nil {
+ return err
+ }
+ models, _, err := readJSON(modelsPath)
+ if err != nil {
+ return err
+ }
+ settings, _, err := readJSON(settingsPath)
+ if err != nil {
+ return err
+ }
+
+ if providers := objectAt(models, "providers"); providers != nil {
+ delete(providers, piProvider)
+ }
+ for _, key := range piKeys {
+ if value, ok := previous[key]; ok {
+ settings[key] = value
+ } else {
+ delete(settings, key)
+ }
+ }
+
+ changes := &txn{}
+ if err := w.stageModels(changes, modelsPath, models); err != nil {
+ changes.abort()
+ return err
+ }
+ if err := w.stage(changes, settingsPath, settings); err != nil {
+ changes.abort()
+ return err
+ }
+ return changes.commit()
+}
+
+func (w piWriter) Current(target Target) (string, error) {
+ models, settings, err := w.read(target)
+ if err != nil {
+ return "", err
+ }
+
+ selected := stringAt(settings, piProviderKey)
+ if selected == "" {
+ return "", nil
+ }
+ if baseUrl := stringAt(nestedObject(models, "providers", selected),
"baseUrl"); baseUrl != "" {
+ return baseUrl, nil
+ }
+ // A provider without a base URL is one pi ships itself, which is still
+ // worth naming: it is not the endpoint Gateway wrote.
+ return selected, nil
+}
+
+// Builtin is the model pi starts on without Gateway. pi signs in to a provider
+// rather than shipping one, so settings that name none have no name to show.
+func (w piWriter) Builtin(target Target, previous map[string]string) string {
+ if previous != nil {
+ return previous[piModelKey]
+ }
+
+ _, settings, err := w.read(target)
+ if err != nil || stringAt(settings, piProviderKey) == piProvider {
+ return ""
+ }
+ return stringAt(settings, piModelKey)
+}
+
+// entry is the provider as models.json stores it, with the whole catalog under
+// it so pi's own picker can switch models without Gateway writing the file
+// again.
+func (piWriter) entry(endpoint Endpoint) map[string]any {
+ models := []any{}
+ for _, model := range endpoint.catalog() {
+ models = append(models, map[string]any{"id": model, "name":
model})
+ }
+ return map[string]any{
+ "baseUrl": endpoint.BaseUrl,
+ "api": piApi,
+ "apiKey": piValue(emptyAs(endpoint.ApiKey, piLocalKey)),
+ // The gateway fronts providers that reject the developer role,
and
+ // every one of them takes a system message instead.
+ "compat": map[string]any{"supportsDeveloperRole": false},
+ "models": models,
+ }
+}
+
+func (piWriter) settings(endpoint Endpoint) map[string]any {
+ return map[string]any{
+ piProviderKey: piProvider,
+ piModelKey: endpoint.Model,
+ }
+}
+
+// piValue escapes a literal for the fields pi resolves before use: a "$" would
+// name an environment variable, and a leading "!" would run the whole value as
+// a command.
+func piValue(value string) string {
+ value = strings.ReplaceAll(value, "$", "$$")
+ if strings.HasPrefix(value, "!") {
+ return "$!" + value[1:]
+ }
+ return value
+}
+
+// stageModels queues models.json, removing it once Gateway's provider was the
+// only thing in it: an empty file is not what pi had before.
+func (w piWriter) stageModels(changes *txn, path string, models
map[string]any) error {
+ pruneEmpty(models, "providers")
+ return w.stage(changes, path, models)
+}
+
+func (piWriter) stage(changes *txn, path string, config map[string]any) error {
+ if len(config) == 0 {
+ return removeFile(path)
+ }
+ data, err := encodeJSON(config)
+ if err != nil {
+ return err
+ }
+ return changes.write(path, data)
+}
+
+func (w piWriter) read(target Target) (map[string]any, map[string]any, error) {
+ modelsPath, settingsPath, err := w.paths(target)
+ if err != nil {
+ return nil, nil, err
+ }
+ models, _, err := readJSON(modelsPath)
+ if err != nil {
+ return nil, nil, err
+ }
+ settings, _, err := readJSON(settingsPath)
+ if err != nil {
+ return nil, nil, err
+ }
+ return models, settings, nil
+}
+
+// paths are the two files a switch writes, both in pi's own agent directory.
+func (piWriter) paths(target Target) (string, string, error) {
+ home, err := agenthome.Resolve(target.Owner)
+ if err != nil {
+ return "", "", err
+ }
+ dir := filepath.Join(home, ".pi", "agent")
+ return filepath.Join(dir, "models.json"), filepath.Join(dir,
"settings.json"), nil
+}
diff --git a/agentprovider/toml.go b/agentprovider/toml.go
index 757481f..3f0c470 100644
--- a/agentprovider/toml.go
+++ b/agentprovider/toml.go
@@ -139,19 +139,68 @@ func tomlAssigns(line string, key string) bool {
return strings.HasPrefix(strings.TrimSpace(text[len(key):]), "=")
}
+// tomlCutTablesUnder removes every table under parent whose own key starts
with
+// prefix, and their sub-tables. It is how a set of entries written under one
+// name is taken back out without knowing what was in it.
+func tomlCutTablesUnder(text string, parent string, prefix string) string {
+ lines := strings.Split(text, "\n")
+ kept := make([]string, 0, len(lines))
+ dropping := false
+
+ for _, line := range lines {
+ if header, ok := tomlHeader(line); ok {
+ dropping = len(header) > 1 && header[0] == parent &&
strings.HasPrefix(header[1], prefix)
+ }
+ if !dropping {
+ kept = append(kept, line)
+ }
+ }
+ return strings.Join(kept, "\n")
+}
+
// tomlTable renders one table with string values, sorted by the given order so
// the file reads the same after every switch.
func tomlTable(path []string, keys []string, values map[string]string) string {
+ quoted := map[string]string{}
+ for key, value := range values {
+ if value != "" {
+ quoted[key] = strconv.Quote(value)
+ }
+ }
+ return tomlRawTable(path, keys, quoted)
+}
+
+// tomlRawTable is tomlTable for values that are already TOML literals, which
is
+// what a table mixing strings with numbers needs.
+func tomlRawTable(path []string, keys []string, values map[string]string)
string {
+ names := make([]string, 0, len(path))
+ for _, part := range path {
+ names = append(names, tomlKey(part))
+ }
+
builder := &strings.Builder{}
- fmt.Fprintf(builder, "[%s]\n", strings.Join(path, "."))
+ fmt.Fprintf(builder, "[%s]\n", strings.Join(names, "."))
for _, key := range keys {
if values[key] != "" {
- fmt.Fprintf(builder, "%s = %s\n", key,
strconv.Quote(values[key]))
+ fmt.Fprintf(builder, "%s = %s\n", key, values[key])
}
}
return builder.String()
}
+// tomlKey is one part of a table path, quoted when it holds anything a bare
key
+// may not: a "." would otherwise nest the table a level deeper.
+func tomlKey(part string) string {
+ for _, letter := range part {
+ if letter >= 'a' && letter <= 'z' || letter >= 'A' && letter <=
'Z' ||
+ letter >= '0' && letter <= '9' || letter == '-' ||
letter == '_' {
+ continue
+ }
+ return strconv.Quote(part)
+ }
+ return part
+}
+
// tomlAppend puts a rendered block at the end of a document, separated from
// whatever precedes it by one blank line.
func tomlAppend(text string, block string) string {
diff --git a/web/src/components/AgentIcon.tsx b/web/src/components/AgentIcon.tsx
index f9fb73d..5134a9b 100644
--- a/web/src/components/AgentIcon.tsx
+++ b/web/src/components/AgentIcon.tsx
@@ -42,6 +42,9 @@ const agentSites: Record<string, string> = {
Crush: "charm.sh",
Droid: "factory.ai",
Trae: "trae.ai",
+ Pi: "pi.dev",
+ "Kimi Code CLI": "kimi.com",
+ "CodeBuddy Code": "codebuddy.ai",
};
const agentKey = (agent: unknown) =>
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 446379a..3f0d1d0 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -115,6 +115,12 @@ export function agentSetupNoteKey(agentId: string) {
return "agent:Droid config hint";
case "zed":
return "agent:Zed config hint";
+ case "pi":
+ return "agent:Pi config hint";
+ case "kimi-code":
+ return "agent:Kimi Code config hint";
+ case "codebuddy":
+ return "agent:CodeBuddy config hint";
default:
return "";
}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 848a40f..af4c0e0 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -70,6 +70,7 @@
"Clear it with": "Clear it with",
"Client auth token hint": "This provider forwards the agent's own
credentials, so no token is set: the agent keeps the sign-in it already has.",
"Cline config hint": "Cline reads no base URL from the environment: the
endpoint is a provider entry in ~/.cline/data/settings/providers.json, started
on through lastUsedProvider, with its models listed in models.json beside it.
The IDE extension, the CLI and the SDK all read that one directory.",
+ "CodeBuddy config hint": "CodeBuddy keeps the endpoint in
~/.codebuddy/models.json: a models entry whose url is the whole path, ending in
/chat/completions, with apiKey beside it, and settings.json names the id it
starts on. CODEBUDDY_MODEL is read before that name, and CODEBUDDY_CONFIG_DIR
moves both files.",
"Codex CLI config hint": "The base URL can also be a model provider in
~/.codex/config.toml, selected with model_provider. Its wire_api has to be
\"responses\", the only wire format Codex still speaks.",
"Configuration preview": "Configuration preview",
"Configuration restored": "Configuration restored",
@@ -140,6 +141,7 @@
"Instances": "Instances",
"Instances hint": "Extra copies of this agent, each with a state directory
of its own, so each is signed in to its own account and they run at the same
time.",
"Keep a task list": "Keep a task list",
+ "Kimi Code config hint": "Kimi Code keeps the endpoint in
~/.kimi-code/config.toml: a [providers.casbin-gateway] table with type
\"openai\", base_url and api_key, and one [models.*] alias per model, picked by
default_model at the root of the file. It reads no credential from the
environment, and KIMI_CODE_HOME moves the whole directory.",
"Last activity": "Last activity",
"Last {count} records": "Last {count} records",
"Leave plan mode": "Leave plan mode",
@@ -189,6 +191,7 @@
"Permissions empty hint": "No agent was found on this machine, so there is
nothing to hold to a rule yet.",
"Permissions page hint": "What every agent on this machine may ask Gateway
for. You set the switches; Casbin decides every request they relay.",
"Permissions saved": "Permissions saved",
+ "Pi config hint": "pi keeps the endpoint in ~/.pi/agent/models.json: a
providers entry with baseUrl, api set to openai-completions and its own model
list, started on through defaultProvider and defaultModel in settings.json
beside it. PI_CODING_AGENT_DIR moves both files elsewhere.",
"Planning and delegation": "Planning and delegation",
"Preview": "Preview",
"Preview hint": "What Gateway would write, with the keys masked.
Everything else in these files is left as it is.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 3267a42..50271cc 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -70,6 +70,7 @@
"Clear it with": "清除命令",
"Client auth token hint": "该供应商转发 Agent 自己的凭据,所以不设置 token,Agent
继续用它已有的登录。",
"Cline config hint": "Cline 不从环境变量读取 base URL:端点是
~/.cline/data/settings/providers.json 中的一个 provider 条目,由 lastUsedProvider
选中,模型列在旁边的 models.json 里。IDE 插件、CLI 和 SDK 读的都是这一个目录。",
+ "CodeBuddy config hint": "CodeBuddy 的端点写在 ~/.codebuddy/models.json
里:models 数组中的一个条目,url 是完整路径,以 /chat/completions 结尾,apiKey 在它旁边;启动时用哪个 id 由
settings.json 的 model 决定。CODEBUDDY_MODEL 会在这个名字之前被读取,CODEBUDDY_CONFIG_DIR
则会把这两个文件挪到别处。",
"Codex CLI config hint": "base URL 也可以写成 ~/.codex/config.toml 里的一个 model
provider,再用 model_provider 选中它。它的 wire_api 必须是 \"responses\",这是 Codex
现在唯一还支持的线格式。",
"Configuration preview": "配置预览",
"Configuration restored": "配置已还原",
@@ -140,6 +141,7 @@
"Instances": "实例",
"Instances hint": "该 Agent 的多个副本,每个副本拥有独立的状态目录,因此各自登录各自的账号,并可同时运行。",
"Keep a task list": "维护任务清单",
+ "Kimi Code config hint": "Kimi Code 的端点写在 ~/.kimi-code/config.toml 里:一个
[providers.casbin-gateway] 表,含 type \"openai\"、base_url 和 api_key,每个模型再写一个
[models.*] 别名,由文件根部的 default_model 选中。它不从环境变量读取任何凭据,KIMI_CODE_HOME 会把整个目录挪到别处。",
"Last activity": "最后活动",
"Last {count} records": "最近 {count} 条记录",
"Leave plan mode": "退出计划模式",
@@ -189,6 +191,7 @@
"Permissions empty hint": "这台机器上没有发现智能体,暂时还没有可以设规则的对象。",
"Permissions page hint": "这台机器上每个智能体能向网关要什么。开关由你来设,每个转发的请求都由 Casbin 依此判定。",
"Permissions saved": "权限已保存",
+ "Pi config hint": "pi 的端点写在 ~/.pi/agent/models.json 里:providers 下的一个条目,含
baseUrl、api(openai-completions)和它自己的模型列表;启动时用哪个则由旁边 settings.json 中的
defaultProvider 和 defaultModel 决定。PI_CODING_AGENT_DIR 会把这两个文件挪到别处。",
"Planning and delegation": "规划与委派",
"Preview": "预览",
"Preview hint": "Gateway 将要写入的内容,密钥已打码。文件中的其他配置保持不变。",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]