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]

Reply via email to