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 3f96ed3 feat(agent): switch providers by writing each CLI's own config
3f96ed3 is described below
commit 3f96ed30fb92f5fad09b889f716f547926d5ba6d
Author: Yang Luo <[email protected]>
AuthorDate: Fri Aug 21 16:17:55 2026 +0800
feat(agent): switch providers by writing each CLI's own config
---
agentprovider/claude_code.go | 178 +++++++++++++++++
agentprovider/codex.go | 245 ++++++++++++++++++++++++
agentprovider/file.go | 158 ++++++++++++++++
agentprovider/provider.go | 267 ++++++++++++++++++++++++++
agentprovider/state.go | 87 +++++++++
agentprovider/toml.go | 171 +++++++++++++++++
controllers/agent.go | 57 ++++--
controllers/agent_provider.go | 183 ++++++++++++++++++
controllers/proxy.go | 33 +++-
object/agent.go | 136 ++++++++++---
object/agent_test.go | 10 +-
object/channel.go | 8 +
object/channel_health.go | 167 ++++++++++++++++
object/llm_record.go | 47 ++++-
routers/router.go | 4 +
web/src/backend/AgentBackend.ts | 25 ++-
web/src/backend/ChannelBackend.ts | 8 +-
web/src/components/ProviderCard.tsx | 367 ++++++++++++++++++++++++++++++++++++
web/src/lib/agents.ts | 64 ++++++-
web/src/lib/usage.ts | 37 ++++
web/src/locales/en/data.json | 33 ++++
web/src/locales/zh/data.json | 33 ++++
web/src/pages/AgentDetailPage.tsx | 150 ++++++++-------
web/src/pages/AgentsPage.tsx | 26 ++-
web/src/pages/ChannelListPage.tsx | 54 +++++-
web/src/pages/LlmRecordsPage.tsx | 24 +--
web/src/types.ts | 46 +++++
27 files changed, 2451 insertions(+), 167 deletions(-)
diff --git a/agentprovider/claude_code.go b/agentprovider/claude_code.go
new file mode 100644
index 0000000..e462531
--- /dev/null
+++ b/agentprovider/claude_code.go
@@ -0,0 +1,178 @@
+// 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 (
+ "path/filepath"
+
+ "github.com/apache/casbin-gateway/agenthome"
+)
+
+// The env keys Gateway owns in settings.json. ANTHROPIC_API_KEY is one of them
+// even though a switch never sets it: a key left over from another provider
+// would otherwise be sent to the new one.
+var claudeCodeKeys = []string{
+ "ANTHROPIC_BASE_URL",
+ "ANTHROPIC_AUTH_TOKEN",
+ "ANTHROPIC_API_KEY",
+ "ANTHROPIC_MODEL",
+}
+
+type claudeCodeWriter struct{}
+
+func init() {
+ register(claudeCodeWriter{})
+}
+
+func (claudeCodeWriter) AgentId() string { return "claude-code" }
+
+func (claudeCodeWriter) Protocol() string { return "anthropic" }
+
+func (w claudeCodeWriter) Plan(target Target, endpoint Endpoint) ([]File,
error) {
+ path, err := w.configPath(target)
+ if err != nil {
+ return nil, err
+ }
+
+ preview, err := encodeJSON(map[string]any{"env": w.env(endpoint, true)})
+ if err != nil {
+ return nil, err
+ }
+ return []File{{Path: path, Format: "json", Preview: string(preview)}},
nil
+}
+
+func (w claudeCodeWriter) Apply(target Target, endpoint Endpoint)
(map[string]string, error) {
+ path, err := w.configPath(target)
+ if err != nil {
+ return nil, err
+ }
+ config, _, err := readJSON(path)
+ if err != nil {
+ return nil, err
+ }
+
+ env := objectAt(config, "env")
+ if env == nil {
+ env = map[string]any{}
+ }
+ previous := map[string]string{}
+ for _, key := range claudeCodeKeys {
+ if value, ok := env[key].(string); ok {
+ previous[key] = value
+ }
+ delete(env, key)
+ }
+ for key, value := range w.env(endpoint, false) {
+ env[key] = value
+ }
+ config["env"] = env
+
+ data, err := encodeJSON(config)
+ if err != nil {
+ return nil, err
+ }
+
+ changes := &txn{}
+ if err := changes.write(path, data); err != nil {
+ changes.abort()
+ return nil, err
+ }
+ if err := changes.commit(); err != nil {
+ return nil, err
+ }
+ return previous, nil
+}
+
+func (w claudeCodeWriter) Restore(target Target, previous map[string]string)
error {
+ path, err := w.configPath(target)
+ if err != nil {
+ return err
+ }
+ config, _, err := readJSON(path)
+ if err != nil {
+ return err
+ }
+
+ env := objectAt(config, "env")
+ if env == nil {
+ return nil
+ }
+ for _, key := range claudeCodeKeys {
+ if value, ok := previous[key]; ok {
+ env[key] = value
+ } else {
+ delete(env, key)
+ }
+ }
+ if len(env) == 0 {
+ delete(config, "env")
+ }
+
+ data, err := encodeJSON(config)
+ if err != nil {
+ return err
+ }
+
+ changes := &txn{}
+ if err := changes.write(path, data); err != nil {
+ changes.abort()
+ return err
+ }
+ return changes.commit()
+}
+
+func (w claudeCodeWriter) Current(target Target) (string, error) {
+ path, err := w.configPath(target)
+ if err != nil {
+ return "", err
+ }
+ config, _, err := readJSON(path)
+ if err != nil {
+ return "", err
+ }
+ env := objectAt(config, "env")
+ if env == nil {
+ return "", nil
+ }
+ baseUrl, _ := env["ANTHROPIC_BASE_URL"].(string)
+ return baseUrl, nil
+}
+
+// env is the block written into settings.json. Claude Code authenticates with
+// ANTHROPIC_AUTH_TOKEN, so the channel key goes there and not into
+// ANTHROPIC_API_KEY, which stays cleared.
+func (claudeCodeWriter) env(endpoint Endpoint, masked bool) map[string]any {
+ token := endpoint.ApiKey
+ if masked {
+ token = maskSecret(token)
+ }
+
+ env := map[string]any{
+ "ANTHROPIC_BASE_URL": endpoint.BaseUrl,
+ "ANTHROPIC_AUTH_TOKEN": token,
+ }
+ if endpoint.Model != "" {
+ env["ANTHROPIC_MODEL"] = endpoint.Model
+ }
+ return env
+}
+
+func (claudeCodeWriter) configPath(target Target) (string, error) {
+ home, err := agenthome.Resolve(target.Owner)
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(home, ".claude", "settings.json"), nil
+}
diff --git a/agentprovider/codex.go b/agentprovider/codex.go
new file mode 100644
index 0000000..66dc5b9
--- /dev/null
+++ b/agentprovider/codex.go
@@ -0,0 +1,245 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentprovider
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+ "github.com/apache/casbin-gateway/agentmonitor"
+)
+
+const (
+ // codexProviderName is the model provider Gateway owns in config.toml.
+ // Everything else in the file, including other providers, is left
alone.
+ codexProviderName = "casbin-gateway"
+ // codexAuthKey is where Codex reads the key of a provider whose
env_key is
+ // not exported in the shell.
+ codexAuthKey = "OPENAI_API_KEY"
+)
+
+// The keys of the root table Gateway owns, remembered under these names.
+const (
+ codexModelProviderKey = "model_provider"
+ codexModelKey = "model"
+ codexAuthStateKey = "auth." + codexAuthKey
+)
+
+var codexProviderPath = []string{"model_providers", codexProviderName}
+
+type codexWriter struct {
+ id string
+}
+
+func init() {
+ register(codexWriter{id: "codex"})
+ register(codexWriter{id: "codex-cli"})
+ // The VS Code integration drives the same CLI, and with it the same
+ // ~/.codex directory.
+ register(codexWriter{id: "codex_vscode"})
+ register(codexWriter{id: "codex-vscode"})
+}
+
+func (w codexWriter) AgentId() string { return w.id }
+
+func (codexWriter) Protocol() string { return "openai" }
+
+func (w codexWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
+ home, err := agentmonitor.ResolveCodexHome(target.Path, target.Owner)
+ if err != nil {
+ return nil, err
+ }
+
+ config := tomlSetRootKey("", codexModelProviderKey, codexProviderName)
+ if endpoint.Model != "" {
+ config = tomlSetRootKey(config, codexModelKey, endpoint.Model)
+ }
+ config = tomlTidy(tomlAppend(config, w.providerTable(endpoint)))
+
+ auth, err := encodeJSON(map[string]any{codexAuthKey:
maskSecret(endpoint.ApiKey)})
+ if err != nil {
+ return nil, err
+ }
+ return []File{
+ {Path: filepath.Join(home, "config.toml"), Format: "toml",
Preview: config},
+ {Path: filepath.Join(home, "auth.json"), Format: "json",
Preview: string(auth)},
+ }, nil
+}
+
+func (w codexWriter) Apply(target Target, endpoint Endpoint)
(map[string]string, error) {
+ configPath, authPath, err := w.paths(target)
+ if err != nil {
+ return nil, err
+ }
+
+ data, _, _, err := readFile(configPath)
+ if err != nil {
+ return nil, err
+ }
+ document, err := w.decode(configPath, data)
+ if err != nil {
+ return nil, err
+ }
+
+ previous := map[string]string{}
+ if value, ok := document[codexModelProviderKey].(string); ok {
+ previous[codexModelProviderKey] = value
+ }
+ if value, ok := document[codexModelKey].(string); ok {
+ previous[codexModelKey] = value
+ }
+
+ text := tomlCutTable(string(data), codexProviderPath...)
+ text = tomlSetRootKey(text, codexModelProviderKey, codexProviderName)
+ if endpoint.Model != "" {
+ text = tomlSetRootKey(text, codexModelKey, endpoint.Model)
+ }
+ text = tomlAppend(text, w.providerTable(endpoint))
+
+ auth, _, err := readJSON(authPath)
+ if err != nil {
+ return nil, err
+ }
+ if value, ok := auth[codexAuthKey].(string); ok {
+ previous[codexAuthStateKey] = value
+ }
+ auth[codexAuthKey] = endpoint.ApiKey
+ authData, err := encodeJSON(auth)
+ if err != nil {
+ return nil, err
+ }
+
+ changes := &txn{}
+ if err := changes.write(configPath, []byte(text)); err != nil {
+ changes.abort()
+ return nil, err
+ }
+ if err := changes.write(authPath, authData); err != nil {
+ changes.abort()
+ return nil, err
+ }
+ if err := changes.commit(); err != nil {
+ return nil, err
+ }
+ return previous, nil
+}
+
+func (w codexWriter) Restore(target Target, previous map[string]string) error {
+ configPath, authPath, err := w.paths(target)
+ if err != nil {
+ return err
+ }
+
+ data, _, _, err := readFile(configPath)
+ if err != nil {
+ return err
+ }
+ text := tomlCutTable(string(data), codexProviderPath...)
+ for _, key := range []string{codexModelProviderKey, codexModelKey} {
+ if value, ok := previous[key]; ok {
+ text = tomlSetRootKey(text, key, value)
+ } else {
+ text = tomlDeleteRootKey(text, key)
+ }
+ }
+ text = strings.TrimLeft(text, "\n")
+
+ auth, _, err := readJSON(authPath)
+ if err != nil {
+ return err
+ }
+ if value, ok := previous[codexAuthStateKey]; ok {
+ auth[codexAuthKey] = value
+ } else {
+ delete(auth, codexAuthKey)
+ }
+ authData, err := encodeJSON(auth)
+ if err != nil {
+ return err
+ }
+
+ changes := &txn{}
+ if err := changes.write(configPath, []byte(text)); err != nil {
+ changes.abort()
+ return err
+ }
+ if err := changes.write(authPath, authData); err != nil {
+ changes.abort()
+ return err
+ }
+ return changes.commit()
+}
+
+func (w codexWriter) Current(target Target) (string, error) {
+ configPath, _, err := w.paths(target)
+ if err != nil {
+ return "", err
+ }
+ data, _, exists, err := readFile(configPath)
+ if err != nil || !exists {
+ return "", err
+ }
+ document, err := w.decode(configPath, data)
+ if err != nil {
+ return "", err
+ }
+
+ selected, _ := document[codexModelProviderKey].(string)
+ if selected == "" {
+ return "", nil
+ }
+ providers, _ := document["model_providers"].(map[string]any)
+ provider, _ := providers[selected].(map[string]any)
+ if baseUrl, ok := provider["base_url"].(string); ok {
+ return baseUrl, nil
+ }
+ // A provider without a base URL is one Codex knows itself, which is
still
+ // worth naming: it is not the endpoint Gateway wrote.
+ return selected, nil
+}
+
+// providerTable is the [model_providers.casbin-gateway] block. The key lives
in
+// auth.json under env_key, so config.toml can be read without exposing it.
+func (codexWriter) providerTable(endpoint Endpoint) string {
+ return tomlTable(codexProviderPath,
+ []string{"name", "base_url", "wire_api", "env_key"},
+ map[string]string{
+ "name": "Casbin Gateway",
+ "base_url": endpoint.BaseUrl,
+ "wire_api": "chat",
+ "env_key": codexAuthKey,
+ })
+}
+
+func (codexWriter) 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 (codexWriter) paths(target Target) (string, string, error) {
+ home, err := agentmonitor.ResolveCodexHome(target.Path, target.Owner)
+ if err != nil {
+ return "", "", err
+ }
+ return filepath.Join(home, "config.toml"), filepath.Join(home,
"auth.json"), nil
+}
diff --git a/agentprovider/file.go b/agentprovider/file.go
new file mode 100644
index 0000000..bf3340f
--- /dev/null
+++ b/agentprovider/file.go
@@ -0,0 +1,158 @@
+// 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"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// defaultMode is what a config file Gateway creates is born with. These files
+// carry API keys, so a new one is not world-readable.
+const defaultMode = os.FileMode(0o600)
+
+func readFile(path string) ([]byte, os.FileMode, bool, error) {
+ data, err := os.ReadFile(path)
+ if os.IsNotExist(err) {
+ return nil, defaultMode, false, nil
+ }
+ if err != nil {
+ return nil, 0, false, err
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ return nil, 0, false, err
+ }
+ return data, info.Mode().Perm(), true, nil
+}
+
+func readJSON(path string) (map[string]any, os.FileMode, error) {
+ data, mode, _, err := readFile(path)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ config := map[string]any{}
+ if strings.TrimSpace(string(data)) == "" {
+ return config, mode, nil
+ }
+ if err := json.Unmarshal(data, &config); err != nil {
+ return nil, 0, fmt.Errorf("parse %s: %w", path, err)
+ }
+ if config == nil {
+ return nil, 0, fmt.Errorf("parse %s: the root must be a JSON
object", path)
+ }
+ return config, mode, nil
+}
+
+func encodeJSON(config map[string]any) ([]byte, error) {
+ data, err := json.MarshalIndent(config, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ return append(data, '\n'), nil
+}
+
+func objectAt(config map[string]any, key string) map[string]any {
+ if value, ok := config[key].(map[string]any); ok {
+ return value
+ }
+ return nil
+}
+
+func stringAt(config map[string]any, key string) string {
+ value, _ := config[key].(string)
+ return value
+}
+
+// staged is one file waiting to be renamed over its target.
+type staged struct {
+ path string
+ temp string
+ mode os.FileMode
+ previous []byte
+ existed bool
+}
+
+// txn replaces a set of configuration files in one step. An agent may read its
+// own config while Gateway rewrites it, so nothing is truncated in place:
every
+// file is staged next to its target and renamed over it, and a rename that
+// fails halfway puts the files already renamed back.
+type txn struct {
+ files []*staged
+}
+
+func (t *txn) write(path string, data []byte) error {
+ previous, mode, existed, err := readFile(path)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+
+ file, err := os.CreateTemp(filepath.Dir(path),
"."+filepath.Base(path)+".gateway-*")
+ if err != nil {
+ return err
+ }
+ item := &staged{path: path, temp: file.Name(), mode: mode, previous:
previous, existed: existed}
+ t.files = append(t.files, item)
+
+ if _, err := file.Write(data); err != nil {
+ file.Close()
+ return err
+ }
+ if err := file.Sync(); err != nil {
+ file.Close()
+ return err
+ }
+ if err := file.Close(); err != nil {
+ return err
+ }
+ return os.Chmod(item.temp, mode)
+}
+
+func (t *txn) commit() error {
+ for index, item := range t.files {
+ if err := os.Rename(item.temp, item.path); err != nil {
+ t.undo(t.files[:index])
+ t.abort()
+ return err
+ }
+ }
+ t.files = nil
+ return nil
+}
+
+func (t *txn) abort() {
+ for _, item := range t.files {
+ _ = os.Remove(item.temp)
+ }
+ t.files = nil
+}
+
+func (t *txn) undo(done []*staged) {
+ for index := len(done) - 1; index >= 0; index-- {
+ item := done[index]
+ if !item.existed {
+ _ = os.Remove(item.path)
+ continue
+ }
+ _ = os.WriteFile(item.path, item.previous, item.mode)
+ }
+}
diff --git a/agentprovider/provider.go b/agentprovider/provider.go
new file mode 100644
index 0000000..e27f568
--- /dev/null
+++ b/agentprovider/provider.go
@@ -0,0 +1,267 @@
+// 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 writes the selected upstream provider into the
+// configuration file each agent CLI reads on startup, in that CLI's own
format.
+package agentprovider
+
+import (
+ "errors"
+ "fmt"
+ "sync"
+)
+
+// ErrNotSupported reports an agent whose configuration format Gateway cannot
+// write yet. Those agents are still usable through the environment variables
+// the UI shows.
+var ErrNotSupported = errors.New("switching the provider of this agent is not
supported yet")
+
+// The two ways an agent reaches its provider.
+const (
+ // ModeGateway points the agent at the local proxy, so changing the
bound
+ // channel afterwards takes effect without touching a file again.
+ ModeGateway = "gateway"
+ // ModeDirect writes the provider's own base URL and key into the
config,
+ // which is what a switcher without a proxy does.
+ ModeDirect = "direct"
+)
+
+// Target identifies one discovered agent installation.
+type Target struct {
+ AgentId string `json:"agentId"`
+ Path string `json:"path"`
+ Owner string `json:"owner"`
+}
+
+// Endpoint is the upstream one agent is switched to, already resolved: in
+// gateway mode BaseUrl is the local proxy, in direct mode the channel's own.
+type Endpoint struct {
+ Channel string `json:"channel"`
+ Protocol string `json:"protocol"`
+ BaseUrl string `json:"baseUrl"`
+ ApiKey string `json:"apiKey"`
+ Model string `json:"model"`
+ Mode string `json:"mode"`
+}
+
+// File is one configuration file a switch writes, with the section it will
+// contain afterwards.
+type File struct {
+ Path string `json:"path"`
+ Format string `json:"format"`
+ Preview string `json:"preview"`
+}
+
+// Status is what the UI shows beside an installation: whether Gateway owns its
+// provider configuration, and which one it points at.
+type Status struct {
+ Supported bool `json:"supported"`
+ Applied bool `json:"applied"`
+ Channel string `json:"channel"`
+ Mode string `json:"mode"`
+ BaseUrl string `json:"baseUrl"`
+ Time string `json:"time"`
+ Files []string `json:"files"`
+ Detail string `json:"detail"`
+}
+
+type writer interface {
+ AgentId() string
+ // Protocol is the wire format this agent's client speaks.
+ Protocol() string
+ // Plan renders what Apply would write, without touching a file.
+ Plan(Target, Endpoint) ([]File, error)
+ // Apply writes the endpoint and returns the previous values of the
keys it
+ // owns, which is what Restore puts back.
+ Apply(Target, Endpoint) (map[string]string, error)
+ Restore(Target, map[string]string) error
+ // Current is the base URL the files point at right now, empty when the
+ // agent has no provider configured.
+ Current(Target) (string, error)
+}
+
+var (
+ writers = map[string]writer{}
+ writerMutex sync.Mutex
+)
+
+func register(value writer) {
+ writers[value.AgentId()] = value
+}
+
+// Plan is the preview shown before a switch is confirmed.
+func Plan(target Target, endpoint Endpoint) ([]File, error) {
+ value, err := writerFor(target, endpoint)
+ if err != nil {
+ return nil, err
+ }
+ return value.Plan(target, endpoint)
+}
+
+// Apply switches one installation to endpoint. The previous values of the keys
+// it overwrites are saved first, so Restore can put the agent back on its own
+// provider.
+func Apply(target Target, endpoint Endpoint) error {
+ value, err := writerFor(target, endpoint)
+ if err != nil {
+ return err
+ }
+
+ writerMutex.Lock()
+ defer writerMutex.Unlock()
+
+ files, err := value.Plan(target, endpoint)
+ if err != nil {
+ return err
+ }
+
+ // Only the first switch records what the agent looked like before
Gateway
+ // touched it; a later one would otherwise save Gateway's own values.
+ saved, err := loadState(target)
+ if err != nil {
+ return err
+ }
+
+ previous, err := value.Apply(target, endpoint)
+ if err != nil {
+ return err
+ }
+ if saved != nil {
+ previous = saved.Previous
+ }
+
+ paths := make([]string, 0, len(files))
+ for _, file := range files {
+ paths = append(paths, file.Path)
+ }
+ return saveState(target, &state{
+ AgentId: target.AgentId,
+ Channel: endpoint.Channel,
+ Mode: endpoint.Mode,
+ BaseUrl: endpoint.BaseUrl,
+ Time: nowString(),
+ Files: paths,
+ Previous: previous,
+ })
+}
+
+// Restore puts back the provider settings the agent had before the first Apply
+// and forgets the installation. It is a no-op when Gateway never switched it.
+func Restore(target Target) error {
+ value, ok := writers[target.AgentId]
+ if !ok {
+ return fmt.Errorf("%s: %w", target.AgentId, ErrNotSupported)
+ }
+
+ writerMutex.Lock()
+ defer writerMutex.Unlock()
+
+ saved, err := loadState(target)
+ if err != nil || saved == nil {
+ return err
+ }
+ if err := value.Restore(target, saved.Previous); err != nil {
+ return err
+ }
+ return clearState(target)
+}
+
+// StatusOf reports the provider state of one installation. A configuration
file
+// that cannot be read is reported as detail rather than as an error, so the
+// agent list stays usable.
+func StatusOf(target Target) Status {
+ value, ok := writers[target.AgentId]
+ if !ok {
+ return Status{
+ Files: []string{},
+ Detail: "Gateway cannot write this agent's provider
configuration yet",
+ }
+ }
+
+ status := Status{Supported: true, Files: []string{}}
+ saved, err := loadState(target)
+ if err != nil {
+ status.Detail = err.Error()
+ return status
+ }
+
+ current, err := value.Current(target)
+ if err != nil {
+ status.Detail = err.Error()
+ return status
+ }
+
+ if saved == nil {
+ if current == "" {
+ status.Detail = "This agent uses its own provider
configuration"
+ } else {
+ status.Detail = "Configured outside Gateway: " + current
+ }
+ return status
+ }
+
+ status.Applied = true
+ status.Channel = saved.Channel
+ status.Mode = saved.Mode
+ status.BaseUrl = saved.BaseUrl
+ status.Time = saved.Time
+ status.Files = saved.Files
+ if current != saved.BaseUrl {
+ status.Applied = false
+ status.Detail = "Changed outside Gateway, the agent now points
at " + emptyAs(current, "no provider")
+ } else if saved.Mode == ModeGateway {
+ status.Detail = "Routed through the local proxy, switching the
channel needs no restart"
+ } else {
+ status.Detail = "Written directly into the agent configuration"
+ }
+ return status
+}
+
+func writerFor(target Target, endpoint Endpoint) (writer, error) {
+ if target.AgentId == "" {
+ return nil, errors.New("agentId is required")
+ }
+ if endpoint.Channel == "" {
+ return nil, errors.New("no channel is bound to this agent")
+ }
+ if endpoint.BaseUrl == "" {
+ return nil, errors.New("the base URL of the bound channel is
empty")
+ }
+
+ value, ok := writers[target.AgentId]
+ if !ok {
+ return nil, fmt.Errorf("%s: %w", target.AgentId,
ErrNotSupported)
+ }
+ if value.Protocol() != endpoint.Protocol {
+ return nil, fmt.Errorf("%s speaks the %s API, but channel %s
speaks %s",
+ target.AgentId, value.Protocol(), endpoint.Channel,
endpoint.Protocol)
+ }
+ return value, nil
+}
+
+func emptyAs(value string, fallback string) string {
+ if value == "" {
+ return fallback
+ }
+ return value
+}
+
+// maskSecret is what a preview shows in place of a key: enough to recognize
it,
+// not enough to use it.
+func maskSecret(value string) string {
+ if len(value) <= 8 {
+ return "***"
+ }
+ return value[:4] + "***" + value[len(value)-4:]
+}
diff --git a/agentprovider/state.go b/agentprovider/state.go
new file mode 100644
index 0000000..6fe8d79
--- /dev/null
+++ b/agentprovider/state.go
@@ -0,0 +1,87 @@
+// 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 (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/apache/casbin-gateway/conf"
+)
+
+// state is what one installation was switched to, and what it looked like
+// before the first switch. Previous holds only the keys Gateway writes: a key
+// missing from it was not set, and is deleted again on restore.
+type state struct {
+ AgentId string `json:"agentId"`
+ Channel string `json:"channel"`
+ Mode string `json:"mode"`
+ BaseUrl string `json:"baseUrl"`
+ Time string `json:"time"`
+ Files []string `json:"files"`
+ Previous map[string]string `json:"previous"`
+}
+
+func stateDir() string {
+ return filepath.Join(conf.GetAgentPatchStateDir(), "providers")
+}
+
+func statePath(target Target) string {
+ sum := sha256.Sum256([]byte(target.AgentId + "|" + target.Owner + "|" +
target.Path))
+ return filepath.Join(stateDir(),
target.AgentId+"-"+hex.EncodeToString(sum[:])[:16]+".json")
+}
+
+func loadState(target Target) (*state, error) {
+ data, err := os.ReadFile(statePath(target))
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ saved := &state{}
+ if err := json.Unmarshal(data, saved); err != nil {
+ return nil, err
+ }
+ return saved, nil
+}
+
+func saveState(target Target, saved *state) error {
+ if err := os.MkdirAll(stateDir(), 0o700); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(saved, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(statePath(target), append(data, '\n'), 0o600)
+}
+
+func clearState(target Target) error {
+ err := os.Remove(statePath(target))
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+}
+
+func nowString() string {
+ return time.Now().Format("2006-01-02T15:04:05-07:00")
+}
diff --git a/agentprovider/toml.go b/agentprovider/toml.go
new file mode 100644
index 0000000..757481f
--- /dev/null
+++ b/agentprovider/toml.go
@@ -0,0 +1,171 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentprovider
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// config.toml is hand-written, with comments and formatting in it, so it is
+// edited as text: decoding and re-encoding it would hand it back stripped of
+// both. Reading, where nothing is written back, decodes instead.
+
+// tomlHeader parses a table header line such as "[model_providers.gateway]".
+func tomlHeader(line string) ([]string, bool) {
+ text := strings.TrimSpace(line)
+ if !strings.HasPrefix(text, "[") || strings.HasPrefix(text, "[[") {
+ return nil, false
+ }
+ end := strings.Index(text, "]")
+ if end < 0 {
+ return nil, false
+ }
+
+ parts := strings.Split(text[1:end], ".")
+ for index, part := range parts {
+ part = strings.TrimSpace(part)
+ if unquoted, err := strconv.Unquote(part); err == nil {
+ part = unquoted
+ }
+ parts[index] = part
+ }
+ return parts, true
+}
+
+// tomlCutTable removes one table and its sub-tables, leaving every other line,
+// comment and blank line exactly as it was.
+func tomlCutTable(text string, path ...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 = tomlHeaderMatches(header, path)
+ }
+ if !dropping {
+ kept = append(kept, line)
+ }
+ }
+ return strings.Join(kept, "\n")
+}
+
+func tomlHeaderMatches(header []string, path []string) bool {
+ if len(header) < len(path) {
+ return false
+ }
+ for index, part := range path {
+ if header[index] != part {
+ return false
+ }
+ }
+ return true
+}
+
+// tomlSetRootKey writes a key of the root table, which is everything before
the
+// first table header. An existing assignment is replaced in place.
+func tomlSetRootKey(text string, key string, value string) string {
+ assignment := fmt.Sprintf("%s = %s", key, strconv.Quote(value))
+ lines := strings.Split(text, "\n")
+ end := len(lines)
+
+ for index, line := range lines {
+ if _, ok := tomlHeader(line); ok {
+ end = index
+ break
+ }
+ if tomlAssigns(line, key) {
+ lines[index] = assignment
+ return strings.Join(lines, "\n")
+ }
+ }
+
+ // Keep the assignment inside the root table: after the first header it
+ // would belong to that table instead.
+ inserted := []string{assignment}
+ if end < len(lines) {
+ inserted = append(inserted, "")
+ }
+ rest := append(inserted, lines[end:]...)
+ return strings.Join(append(lines[:end:end], rest...), "\n")
+}
+
+func tomlDeleteRootKey(text string, key string) string {
+ lines := strings.Split(text, "\n")
+ kept := make([]string, 0, len(lines))
+ dropBlank := false
+
+ for index, line := range lines {
+ if _, ok := tomlHeader(line); ok {
+ kept = append(kept, lines[index:]...)
+ break
+ }
+ if tomlAssigns(line, key) {
+ // The assignment was written with a blank line of its
own, so
+ // leaving both behind would drift the layout of the
rest of the file.
+ dropBlank = len(kept) > 0 &&
strings.TrimSpace(kept[len(kept)-1]) == ""
+ continue
+ }
+ if dropBlank {
+ dropBlank = false
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ }
+ kept = append(kept, line)
+ }
+ return strings.Join(kept, "\n")
+}
+
+func tomlAssigns(line string, key string) bool {
+ text := strings.TrimSpace(line)
+ if !strings.HasPrefix(text, key) {
+ return false
+ }
+ return strings.HasPrefix(strings.TrimSpace(text[len(key):]), "=")
+}
+
+// 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 {
+ builder := &strings.Builder{}
+ fmt.Fprintf(builder, "[%s]\n", strings.Join(path, "."))
+ for _, key := range keys {
+ if values[key] != "" {
+ fmt.Fprintf(builder, "%s = %s\n", key,
strconv.Quote(values[key]))
+ }
+ }
+ return builder.String()
+}
+
+// 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 {
+ text = strings.TrimRight(text, "\n")
+ if text != "" {
+ text += "\n\n"
+ }
+ return text + block
+}
+
+// tomlTidy collapses the blank lines a removed assignment leaves behind.
+func tomlTidy(text string) string {
+ for strings.Contains(text, "\n\n\n") {
+ text = strings.ReplaceAll(text, "\n\n\n", "\n\n")
+ }
+ return strings.TrimLeft(text, "\n")
+}
diff --git a/controllers/agent.go b/controllers/agent.go
index 7954217..3a3eead 100644
--- a/controllers/agent.go
+++ b/controllers/agent.go
@@ -24,6 +24,7 @@ import (
"github.com/apache/casbin-gateway/agent"
"github.com/apache/casbin-gateway/agentmonitor"
"github.com/apache/casbin-gateway/agentpatch"
+ "github.com/apache/casbin-gateway/agentprovider"
"github.com/apache/casbin-gateway/object"
)
@@ -31,9 +32,15 @@ type discoveredAgent struct {
agent.Installation
agentpatch.Status
- // Channel is the "owner/name" id of the bound channel. Installations
are
- // discovered per host and the binding is stored per agent id, so they
merge here.
- Channel string `json:"channel"`
+ // Channel is the "owner/name" id of the bound channel, and Fallbacks
are the
+ // channels tried after it. Installations are discovered per host while
the
+ // routing is stored per agent id, so they merge here.
+ Channel string `json:"channel"`
+ Fallbacks []string `json:"fallbacks"`
+ Mode string `json:"mode"`
+ // Provider is the state of the agent's own configuration file, which is
+ // what the config orchestrator writes.
+ Provider agentprovider.Status `json:"provider"`
}
// GetAgents scans known installation locations and returns the AI agents
@@ -49,7 +56,7 @@ func (c *ApiController) GetAgents() {
return
}
- channels, err := object.GetAgentChannels()
+ agents, err := object.GetAgents()
if err != nil {
c.ResponseError(err.Error())
return
@@ -57,25 +64,44 @@ func (c *ApiController) GetAgents() {
result := make([]*discoveredAgent, 0, len(installations))
for _, installation := range installations {
- result = append(result, &discoveredAgent{
+ target := targetOf(installation)
+ item := &discoveredAgent{
Installation: installation,
- Status:
agentpatch.StatusOf(targetOf(installation)),
- Channel: channels[installation.AgentId],
- })
+ Status: agentpatch.StatusOf(target),
+ Fallbacks: []string{},
+ Mode: object.ModeGateway,
+ Provider:
agentprovider.StatusOf(providerTarget(target)),
+ }
+ if stored, ok := agents[installation.AgentId]; ok {
+ item.Channel = stored.Channel
+ item.Mode = stored.Mode
+ if stored.Fallbacks != nil {
+ item.Fallbacks = stored.Fallbacks
+ }
+ }
+ result = append(result, item)
}
c.ResponseOk(result)
}
// UpdateAgentChannel binds one agent to the channel its requests are forwarded
-// to, or unbinds it when the channel is empty. The binding is per agent id.
+// to, to the channels tried when that one cannot answer, and to the way it
+// reaches them. The binding is per agent id.
+//
+// An installation whose configuration file Gateway already wrote is rewritten
+// here: in gateway mode the file does not change, which is what makes a switch
+// take effect without restarting the agent, but in direct mode the new channel
+// only reaches the agent through its own configuration.
func (c *ApiController) UpdateAgentChannel() {
if c.RequireAdmin() {
return
}
var form struct {
- AgentId string `json:"agentId"`
- Channel string `json:"channel"`
+ AgentId string `json:"agentId"`
+ Channel string `json:"channel"`
+ Fallbacks []string `json:"fallbacks"`
+ Mode string `json:"mode"`
}
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
c.ResponseError(err.Error())
@@ -86,10 +112,17 @@ func (c *ApiController) UpdateAgentChannel() {
return
}
- if err := object.SetAgentChannel(form.AgentId, form.Channel); err !=
nil {
+ if err := object.SetAgentRouting(form.AgentId, form.Channel,
form.Fallbacks, form.Mode); err != nil {
c.ResponseError(err.Error())
return
}
+
+ if form.Channel != "" {
+ if failure := reapplyAgentProvider(form.AgentId); failure != ""
{
+ c.ResponseError("the routing was saved, but the agent
configuration was not rewritten: " + failure)
+ return
+ }
+ }
c.ResponseOk(form.Channel)
}
diff --git a/controllers/agent_provider.go b/controllers/agent_provider.go
new file mode 100644
index 0000000..fac55d4
--- /dev/null
+++ b/controllers/agent_provider.go
@@ -0,0 +1,183 @@
+// 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 controllers
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/apache/casbin-gateway/agent"
+ "github.com/apache/casbin-gateway/agentpatch"
+ "github.com/apache/casbin-gateway/agentprovider"
+ "github.com/apache/casbin-gateway/conf"
+ "github.com/apache/casbin-gateway/object"
+)
+
+// gatewayToken is what an agent sends to the local proxy. The proxy
authenticates
+// upstream with the channel's own key, so this value is never used for
anything.
+const gatewayToken = "casbin-gateway"
+
+// PlanAgentProvider renders what a switch would write, without touching a
file.
+func (c *ApiController) PlanAgentProvider() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ target, ok := c.readAgentPatchTarget()
+ if !ok {
+ return
+ }
+ endpoint, err := agentEndpoint(target.AgentId)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ files, err := agentprovider.Plan(providerTarget(target), endpoint)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(files)
+}
+
+// ApplyAgentProvider writes the bound channel into the agent's own
configuration
+// file, in that agent's format.
+func (c *ApiController) ApplyAgentProvider() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ target, ok := c.readAgentPatchTarget()
+ if !ok {
+ return
+ }
+ endpoint, err := agentEndpoint(target.AgentId)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ if err := agentprovider.Apply(providerTarget(target), endpoint); err !=
nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(agentprovider.StatusOf(providerTarget(target)))
+}
+
+// RestoreAgentProvider puts back the provider settings the agent had before
+// Gateway first switched it.
+func (c *ApiController) RestoreAgentProvider() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ target, ok := c.readAgentPatchTarget()
+ if !ok {
+ return
+ }
+ if err := agentprovider.Restore(providerTarget(target)); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(agentprovider.StatusOf(providerTarget(target)))
+}
+
+// GetChannelHealth reports what the proxy has seen of each channel, which is
+// what says why a request went to a fallback rather than to the bound channel.
+func (c *ApiController) GetChannelHealth() {
+ if c.RequireAdmin() {
+ return
+ }
+ c.ResponseOk(object.GetChannelHealth())
+}
+
+// agentEndpoint resolves where one agent should be pointed. In gateway mode
+// that is the local proxy, which is what makes a later switch take effect
+// without rewriting a file; in direct mode it is the channel's own upstream.
+func agentEndpoint(agentId string) (agentprovider.Endpoint, error) {
+ endpoint := agentprovider.Endpoint{Mode: object.ModeGateway}
+
+ stored, err := object.GetAgent(agentId)
+ if err != nil {
+ return endpoint, err
+ }
+ if stored != nil && stored.Mode != "" {
+ endpoint.Mode = stored.Mode
+ }
+
+ channel, err := object.GetChannelByAgent(agentId)
+ if err != nil {
+ return endpoint, err
+ }
+
+ endpoint.Channel = channel.GetId()
+ endpoint.Protocol = object.ChannelProtocol(channel)
+ if len(channel.Models) > 0 {
+ endpoint.Model = channel.Models[0]
+ }
+
+ if endpoint.Mode == object.ModeDirect {
+ endpoint.BaseUrl = channel.BaseUrl
+ endpoint.ApiKey = channel.ApiKey
+ return endpoint, nil
+ }
+
+ endpoint.BaseUrl = gatewayAgentUrl(agentId)
+ endpoint.ApiKey = gatewayToken
+ return endpoint, nil
+}
+
+// gatewayAgentUrl is the loopback base URL an agent reaches its own channel
at.
+// One URL serves both wire formats: an OpenAI client appends /chat/completions
+// to it, an Anthropic one appends /v1/messages.
+func gatewayAgentUrl(agentId string) string {
+ return fmt.Sprintf("http://127.0.0.1:%d/v1/agents/%s",
conf.GetHttpPort(), agentId)
+}
+
+// reapplyAgentProvider rewrites the configuration of every installation
Gateway
+// already switched, which is what a routing change means for an agent pointed
+// straight at a provider. It reports what it could not rewrite rather than
+// failing the routing change, which is already stored by then.
+func reapplyAgentProvider(agentId string) string {
+ installations, err := agent.Scan(false)
+ if err != nil {
+ return err.Error()
+ }
+
+ endpoint, endpointErr := agentEndpoint(agentId)
+ failures := []string{}
+ for _, installation := range installations {
+ if installation.AgentId != agentId {
+ continue
+ }
+ target := providerTarget(targetOf(installation))
+ if !agentprovider.StatusOf(target).Applied {
+ continue
+ }
+ if endpointErr != nil {
+ failures = append(failures, endpointErr.Error())
+ break
+ }
+ if err := agentprovider.Apply(target, endpoint); err != nil {
+ failures = append(failures, err.Error())
+ }
+ }
+ return strings.Join(failures, "; ")
+}
+
+func providerTarget(target agentpatch.Target) agentprovider.Target {
+ return agentprovider.Target{AgentId: target.AgentId, Path: target.Path,
Owner: target.Owner}
+}
diff --git a/controllers/proxy.go b/controllers/proxy.go
index b377bed..db8adc5 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -199,7 +199,7 @@ func (c *ApiController) proxyByModel(target proxyTarget) {
c.forwardToChannels(channels, route)
}
-// proxyByAgent forwards to the single channel bound to the agent in the path.
+// proxyByAgent forwards to the channel chain bound to the agent in the path.
func (c *ApiController) proxyByAgent(target proxyTarget) {
route, ok := c.readProxyRoute(target)
if !ok {
@@ -212,7 +212,9 @@ func (c *ApiController) proxyByAgent(target proxyTarget) {
}
defer c.finishLlmRecord(route)
- channel, err := object.GetChannelByAgent(agentId)
+ // The whole chain is forwarded to, so a bound channel that is down
fails
+ // over to the agent's fallbacks instead of failing the request.
+ channels, err := object.GetChannelsByAgent(agentId)
if err != nil {
if errors.Is(err, object.ErrAgentNoChannel) {
route.recordOutcome(http.StatusBadRequest, err.Error())
@@ -225,7 +227,7 @@ func (c *ApiController) proxyByAgent(target proxyTarget) {
return
}
- c.forwardToChannels([]*object.Channel{channel}, route)
+ c.forwardToChannels(channels, route)
}
func (c *ApiController) readProxyRoute(target proxyTarget) (*proxyRoute, bool)
{
@@ -278,6 +280,10 @@ func (c *ApiController) forwardToChannels(channels
[]*object.Channel, route *pro
return
}
+ // A channel inside its failure cooldown goes last, so a dead upstream
stops
+ // costing every request the time it takes to time out.
+ usableChannels = object.SortChannelsByHealth(usableChannels)
+
// Fail over to the next channel as long as nothing has been written to
the
// client yet. The last channel is never retried, so the client gets the
// real upstream answer instead of a synthesized error.
@@ -310,6 +316,7 @@ func (c *ApiController) forwardToChannel(channel
*object.Channel, route *proxyRo
upstreamUrl, err := object.BuildChannelUrl(channel.BaseUrl,
route.target.protocol, route.target.endpoint)
if err != nil {
+ object.ReportChannelFailure(channel.GetId(), err.Error())
return http.StatusBadGateway, err.Error(), false
}
@@ -339,12 +346,16 @@ func (c *ApiController) forwardToChannel(channel
*object.Channel, route *proxyRo
beego.Error("upstream request to channel", channel.GetId(),
"failed:", err)
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
+ object.ReportChannelFailure(channel.GetId(), "upstream
timeout")
return http.StatusGatewayTimeout, "upstream timeout",
false
}
+ object.ReportChannelFailure(channel.GetId(), "upstream
connection failed")
return http.StatusBadGateway, "upstream connection failed",
false
}
defer upstreamResp.Body.Close()
+ reportChannelStatus(channel, upstreamResp.StatusCode)
+
if !isLast && isRetryableStatus(upstreamResp.StatusCode) {
beego.Error("channel", channel.GetId(), "returned a retryable
status:", upstreamResp.Status)
// Drain a bounded amount so the connection can be pooled and
reused.
@@ -369,6 +380,22 @@ func (c *ApiController) forwardToChannel(channel
*object.Channel, route *proxyRo
return 0, "", true
}
+// reportChannelStatus feeds the breaker that decides in which order channels
are
+// tried. A status the upstream itself rejected the request with counts as a
+// channel failure: a wrong key or an exhausted quota is not something the next
+// request will do better.
+func reportChannelStatus(channel *object.Channel, statusCode int) {
+ switch {
+ case isRetryableStatus(statusCode):
+ object.ReportChannelFailure(channel.GetId(),
fmt.Sprintf("upstream returned %d", statusCode))
+ case statusCode == http.StatusUnauthorized || statusCode ==
http.StatusForbidden ||
+ statusCode == http.StatusPaymentRequired:
+ object.ReportChannelFailure(channel.GetId(),
fmt.Sprintf("upstream rejected the credentials with %d", statusCode))
+ default:
+ object.ReportChannelSuccess(channel.GetId())
+ }
+}
+
// copyAnthropicHeaders passes the client's API version and beta opt-ins on to
// the upstream: they select response features, so dropping them would answer a
// different request than the one that was made.
diff --git a/object/agent.go b/object/agent.go
index d8d8e6b..72dd883 100644
--- a/object/agent.go
+++ b/object/agent.go
@@ -30,6 +30,13 @@ var ErrAgentNoChannel = errors.New("no channel is bound to
this agent")
// the host, and its proxy endpoint is reached without a session.
const AgentOwner = "admin"
+// How an agent reaches its channel. The values are the ones agentprovider
+// writes into the agent's own configuration file.
+const (
+ ModeGateway = "gateway"
+ ModeDirect = "direct"
+)
+
// Agent is the Gateway-side configuration of one kind of AI agent.
Installations
// are discovered by scanning the host, so this table holds only what is
chosen.
type Agent struct {
@@ -40,6 +47,13 @@ type Agent struct {
// Channel is the "owner/name" id of the bound channel, empty when
unbound.
Channel string `xorm:"varchar(200)" json:"channel"`
+ // Fallbacks are the channel ids tried, in order, when Channel cannot
answer.
+ // They are JSON-serialized by xorm, hence the text column.
+ Fallbacks []string `xorm:"mediumtext" json:"fallbacks"`
+ // Mode is how the agent reaches its channel: ModeGateway routes it
through
+ // the local proxy, ModeDirect writes the channel's own endpoint into
the
+ // agent's configuration file.
+ Mode string `xorm:"varchar(20)" json:"mode"`
}
func (agent *Agent) GetId() string {
@@ -58,36 +72,51 @@ func GetAgent(agentId string) (*Agent, error) {
return agent, nil
}
-// GetAgentChannels maps every configured agent id to its channel id.
-func GetAgentChannels() (map[string]string, error) {
+// GetAgents returns every configured agent, keyed by agent id. Installations
+// are discovered per host while the routing is stored per agent id, so the two
+// are merged by the caller.
+func GetAgents() (map[string]*Agent, error) {
agents := []*Agent{}
if err := ormer.Engine.Where("owner = ?", AgentOwner).Find(&agents);
err != nil {
return nil, err
}
- channels := map[string]string{}
+ result := map[string]*Agent{}
for _, agent := range agents {
- if agent.Channel != "" {
- channels[agent.Name] = agent.Channel
+ if agent.Mode == "" {
+ agent.Mode = ModeGateway
}
+ result[agent.Name] = agent
}
- return channels, nil
+ return result, nil
}
-// SetAgentChannel binds an agent to a channel, or unbinds it when channelId is
-// empty. The channel is resolved here so a typo fails at the form, not at
runtime.
-func SetAgentChannel(agentId string, channelId string) error {
+// SetAgentRouting stores where one agent's requests go: the bound channel, the
+// channels to fall over to when it cannot answer, and how the agent reaches
+// them. Every channel is resolved here so a typo fails at the form rather than
+// on the next relayed request.
+func SetAgentRouting(agentId string, channelId string, fallbacks []string,
mode string) error {
if agentId == "" {
return errors.New("the agent id is empty")
}
+ if mode == "" {
+ mode = ModeGateway
+ }
+ if mode != ModeGateway && mode != ModeDirect {
+ return fmt.Errorf("invalid agent mode: %s", mode)
+ }
- if channelId != "" {
- channel, err := getChannelById(channelId)
+ fallbacks = normalizeFallbacks(channelId, fallbacks)
+ for _, id := range append([]string{channelId}, fallbacks...) {
+ if id == "" {
+ continue
+ }
+ channel, err := getChannelById(id)
if err != nil {
return err
}
if channel == nil {
- return fmt.Errorf("the channel does not exist: %s",
channelId)
+ return fmt.Errorf("the channel does not exist: %s", id)
}
}
@@ -104,21 +133,52 @@ func SetAgentChannel(agentId string, channelId string)
error {
CreatedTime: now,
UpdatedTime: now,
Channel: channelId,
+ Fallbacks: fallbacks,
+ Mode: mode,
})
return err
}
// Cols() is what writes an empty channel: xorm skips zero values
otherwise.
_, err = ormer.Engine.ID(core.PK{AgentOwner, agentId}).
- Cols("channel", "updated_time").
- Update(&Agent{Channel: channelId, UpdatedTime: now})
+ Cols("channel", "fallbacks", "mode", "updated_time").
+ Update(&Agent{Channel: channelId, Fallbacks: fallbacks, Mode:
mode, UpdatedTime: now})
return err
}
-// GetChannelByAgent resolves the channel one agent's requests go to. An
unusable
-// binding fails rather than falling back to model routing, so a request never
-// reaches a different upstream than the one configured.
-func GetChannelByAgent(agentId string) (*Channel, error) {
+// SetAgentChannel binds an agent to a channel, or unbinds it when channelId is
+// empty, leaving its fallbacks and mode as they are.
+func SetAgentChannel(agentId string, channelId string) error {
+ stored, err := GetAgent(agentId)
+ if err != nil {
+ return err
+ }
+ if stored == nil {
+ return SetAgentRouting(agentId, channelId, nil, "")
+ }
+ return SetAgentRouting(agentId, channelId, stored.Fallbacks,
stored.Mode)
+}
+
+// normalizeFallbacks drops empty entries, duplicates and the primary channel,
+// so the chain never tries the same upstream twice.
+func normalizeFallbacks(channelId string, fallbacks []string) []string {
+ result := []string{}
+ seen := map[string]bool{channelId: true, "": true}
+ for _, id := range fallbacks {
+ if seen[id] {
+ continue
+ }
+ seen[id] = true
+ result = append(result, id)
+ }
+ return result
+}
+
+// GetChannelsByAgent is the chain one agent's requests are tried against: the
+// bound channel first, then its fallbacks. A missing or disabled entry is
+// skipped rather than failing the request, which is what makes the fallbacks
+// worth configuring; a chain with nothing left in it reports why.
+func GetChannelsByAgent(agentId string) ([]*Channel, error) {
agent, err := GetAgent(agentId)
if err != nil {
return nil, err
@@ -127,17 +187,41 @@ func GetChannelByAgent(agentId string) (*Channel, error) {
return nil, fmt.Errorf("%w: %s", ErrAgentNoChannel, agentId)
}
- channel, err := getChannelById(agent.Channel)
- if err != nil {
- return nil, err
+ channels := []*Channel{}
+ skipped := ""
+ for _, id := range append([]string{agent.Channel}, agent.Fallbacks...) {
+ channel, err := getChannelById(id)
+ if err != nil {
+ skipped = err.Error()
+ continue
+ }
+ if channel == nil {
+ skipped = fmt.Sprintf("the channel bound to agent %s no
longer exists: %s", agentId, id)
+ continue
+ }
+ if channel.Status != "enabled" {
+ skipped = fmt.Sprintf("the channel bound to agent %s is
disabled: %s", agentId, id)
+ continue
+ }
+ channels = append(channels, channel)
}
- if channel == nil {
- return nil, fmt.Errorf("the channel bound to agent %s no longer
exists: %s", agentId, agent.Channel)
+
+ if len(channels) == 0 {
+ if skipped == "" {
+ return nil, fmt.Errorf("%w: %s", ErrAgentNoChannel,
agentId)
+ }
+ return nil, errors.New(skipped)
}
- if channel.Status != "enabled" {
- return nil, fmt.Errorf("the channel bound to agent %s is
disabled: %s", agentId, agent.Channel)
+ return channels, nil
+}
+
+// GetChannelByAgent resolves the first channel of an agent's chain.
+func GetChannelByAgent(agentId string) (*Channel, error) {
+ channels, err := GetChannelsByAgent(agentId)
+ if err != nil {
+ return nil, err
}
- return channel, nil
+ return channels[0], nil
}
// getChannelById is GetChannel() without its panic on a malformed id.
diff --git a/object/agent_test.go b/object/agent_test.go
index c40d091..cc0949c 100644
--- a/object/agent_test.go
+++ b/object/agent_test.go
@@ -31,22 +31,22 @@ func TestSetAgentChannelBindsAndUnbinds(t *testing.T) {
if err := SetAgentChannel("codex", "admin/codex-upstream"); err != nil {
t.Fatal(err)
}
- channels, err := GetAgentChannels()
+ agents, err := GetAgents()
if err != nil {
t.Fatal(err)
}
- if channels["codex"] != "admin/codex-upstream" {
- t.Fatalf("expected the binding to be stored, got %q",
channels["codex"])
+ if agents["codex"].Channel != "admin/codex-upstream" {
+ t.Fatalf("expected the binding to be stored, got %q",
agents["codex"].Channel)
}
if err := SetAgentChannel("codex", ""); err != nil {
t.Fatal(err)
}
- channels, err = GetAgentChannels()
+ agents, err = GetAgents()
if err != nil {
t.Fatal(err)
}
- if _, found := channels["codex"]; found {
+ if agents["codex"].Channel != "" {
t.Fatal("expected an empty channel to unbind the agent")
}
}
diff --git a/object/channel.go b/object/channel.go
index e678078..6202f75 100644
--- a/object/channel.go
+++ b/object/channel.go
@@ -405,11 +405,19 @@ func UpdateChannel(id string, channel *Channel) (bool,
error) {
}
affected, err := session.AllCols().Update(channel)
+ if err == nil {
+ // The edit may be the fix for whatever the proxy last saw, so
the
+ // channel starts from a clean slate.
+ ClearChannelHealth(channel.GetId())
+ }
return affected != 0, err
}
func DeleteChannel(channel *Channel) (bool, error) {
affected, err := ormer.Engine.ID(core.PK{channel.Owner,
channel.Name}).Delete(&Channel{})
+ if err == nil {
+ ClearChannelHealth(channel.GetId())
+ }
return affected != 0, err
}
diff --git a/object/channel_health.go b/object/channel_health.go
new file mode 100644
index 0000000..ef4317b
--- /dev/null
+++ b/object/channel_health.go
@@ -0,0 +1,167 @@
+// 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 object
+
+import (
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/util"
+)
+
+const (
+ // channelFailureThreshold is how many failures in a row take a channel
out
+ // of rotation. One failure is not enough: an upstream that answers 500
once
+ // is still the channel the operator picked.
+ channelFailureThreshold = 2
+ channelCooldown = 15 * time.Second
+ channelMaxCooldown = 5 * time.Minute
+)
+
+// ChannelHealth is what the proxy has seen of one channel. It lives in memory
+// only: it describes this process's view of an upstream, not a stored setting.
+type ChannelHealth struct {
+ Channel string `json:"channel"`
+ Healthy bool `json:"healthy"`
+ Successes int64 `json:"successes"`
+ Failures int64 `json:"failures"`
+ Consecutive int `json:"consecutive"`
+ LastError string `json:"lastError"`
+ LastFailure string `json:"lastFailure"`
+ RetryTime string `json:"retryTime"`
+}
+
+type channelHealth struct {
+ successes int64
+ failures int64
+ consecutive int
+ lastError string
+ lastFailure time.Time
+ retryTime time.Time
+}
+
+var (
+ channelHealthMutex sync.Mutex
+ channelHealthMap = map[string]*channelHealth{}
+)
+
+// ReportChannelSuccess closes the breaker: an upstream that answered is back
in
+// rotation whatever it did before.
+func ReportChannelSuccess(channelId string) {
+ channelHealthMutex.Lock()
+ defer channelHealthMutex.Unlock()
+
+ health := healthOf(channelId)
+ health.successes++
+ health.consecutive = 0
+ health.retryTime = time.Time{}
+}
+
+// ReportChannelFailure records an attempt that could not be relayed. Once a
+// channel has failed channelFailureThreshold times in a row it is suspended
for
+// a window that doubles with every further failure, so a dead upstream stops
+// costing every request the time it takes to time out.
+func ReportChannelFailure(channelId string, reason string) {
+ channelHealthMutex.Lock()
+ defer channelHealthMutex.Unlock()
+
+ health := healthOf(channelId)
+ health.failures++
+ health.consecutive++
+ health.lastError = reason
+ health.lastFailure = time.Now()
+
+ if health.consecutive < channelFailureThreshold {
+ return
+ }
+ cooldown := channelCooldown << (health.consecutive -
channelFailureThreshold)
+ if cooldown > channelMaxCooldown || cooldown <= 0 {
+ cooldown = channelMaxCooldown
+ }
+ health.retryTime = time.Now().Add(cooldown)
+}
+
+// IsChannelSuspended reports whether a channel is inside its cooldown window.
+// A suspended channel is tried last rather than dropped: it may be the only
one
+// the agent has.
+func IsChannelSuspended(channelId string) bool {
+ channelHealthMutex.Lock()
+ defer channelHealthMutex.Unlock()
+
+ health, ok := channelHealthMap[channelId]
+ return ok && time.Now().Before(health.retryTime)
+}
+
+// ClearChannelHealth forgets what the proxy saw of a channel, which is what an
+// edited channel deserves: its base URL or key may be the thing that was
fixed.
+func ClearChannelHealth(channelId string) {
+ channelHealthMutex.Lock()
+ defer channelHealthMutex.Unlock()
+ delete(channelHealthMap, channelId)
+}
+
+// GetChannelHealth lists what is known about every channel used since startup.
+func GetChannelHealth() []*ChannelHealth {
+ channelHealthMutex.Lock()
+ defer channelHealthMutex.Unlock()
+
+ now := time.Now()
+ result := make([]*ChannelHealth, 0, len(channelHealthMap))
+ for id, health := range channelHealthMap {
+ item := &ChannelHealth{
+ Channel: id,
+ Healthy: !now.Before(health.retryTime),
+ Successes: health.successes,
+ Failures: health.failures,
+ Consecutive: health.consecutive,
+ LastError: health.lastError,
+ }
+ if !health.lastFailure.IsZero() {
+ item.LastFailure = util.FormatTime(health.lastFailure)
+ }
+ if now.Before(health.retryTime) {
+ item.RetryTime = util.FormatTime(health.retryTime)
+ }
+ result = append(result, item)
+ }
+
+ sort.Slice(result, func(i, j int) bool { return result[i].Channel <
result[j].Channel })
+ return result
+}
+
+// SortChannelsByHealth puts the channels that are inside a cooldown window
last,
+// keeping the configured order among the rest.
+func SortChannelsByHealth(channels []*Channel) []*Channel {
+ ready := make([]*Channel, 0, len(channels))
+ suspended := []*Channel{}
+ for _, channel := range channels {
+ if IsChannelSuspended(channel.GetId()) {
+ suspended = append(suspended, channel)
+ continue
+ }
+ ready = append(ready, channel)
+ }
+ return append(ready, suspended...)
+}
+
+func healthOf(channelId string) *channelHealth {
+ health, ok := channelHealthMap[channelId]
+ if !ok {
+ health = &channelHealth{}
+ channelHealthMap[channelId] = health
+ }
+ return health
+}
diff --git a/object/llm_record.go b/object/llm_record.go
index 8381886..1f337f3 100644
--- a/object/llm_record.go
+++ b/object/llm_record.go
@@ -105,18 +105,29 @@ type LlmModelStat struct {
Cost float64 `json:"cost"`
}
+// LlmChannelStat is one channel's share of the window the stats cover, which
is
+// what tells two providers serving the same models apart.
+type LlmChannelStat struct {
+ Channel string `json:"channel"`
+ Requests int64 `json:"requests"`
+ Failed int64 `json:"failed"`
+ Tokens int64 `json:"tokens"`
+ Cost float64 `json:"cost"`
+}
+
// LlmRecordStats totals the records a filter matches.
type LlmRecordStats struct {
- Requests int64 `json:"requests"`
- Failed int64 `json:"failed"`
- PromptTokens int64 `json:"promptTokens"`
- CompletionTokens int64 `json:"completionTokens"`
- CacheReadTokens int64 `json:"cacheReadTokens"`
- CacheWriteTokens int64 `json:"cacheWriteTokens"`
- TotalTokens int64 `json:"totalTokens"`
- Cost float64 `json:"cost"`
- Unpriced int64 `json:"unpriced"`
- Models []LlmModelStat `json:"models"`
+ Requests int64 `json:"requests"`
+ Failed int64 `json:"failed"`
+ PromptTokens int64 `json:"promptTokens"`
+ CompletionTokens int64 `json:"completionTokens"`
+ CacheReadTokens int64 `json:"cacheReadTokens"`
+ CacheWriteTokens int64 `json:"cacheWriteTokens"`
+ TotalTokens int64 `json:"totalTokens"`
+ Cost float64 `json:"cost"`
+ Unpriced int64 `json:"unpriced"`
+ Models []LlmModelStat `json:"models"`
+ Channels []LlmChannelStat `json:"channels"`
}
// LlmRecordStatus is what the management page shows about the recorder itself,
@@ -579,6 +590,7 @@ func GetLlmRecordStats(filter LlmRecordFilter, topModels
int) (*LlmRecordStats,
Cost: cost,
Unpriced: unpriced,
Models: []LlmModelStat{},
+ Channels: []LlmChannelStat{},
}
modelSession := llmRecordSession(filter)
@@ -592,6 +604,21 @@ func GetLlmRecordStats(filter LlmRecordFilter, topModels
int) (*LlmRecordStats,
if err != nil {
return nil, err
}
+
+ channelSession := llmRecordSession(filter)
+ defer channelSession.Close()
+ err = channelSession.Table("llm_record").
+ Select("channel as channel, COUNT(*) as requests, " +
+ "SUM(CASE WHEN status >= 200 AND status < 300 THEN 0
ELSE 1 END) as failed, " +
+ "SUM(total_tokens) as tokens, SUM(cost) as cost").
+ Where("channel <> ''").
+ GroupBy("channel").
+ Desc("requests").
+ Limit(topModels).
+ Find(&stats.Channels)
+ if err != nil {
+ return nil, err
+ }
return stats, nil
}
diff --git a/routers/router.go b/routers/router.go
index 95c036d..31167da 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -74,6 +74,9 @@ func initAPI() {
beego.Router("/api/get-agent-config-item",
&controllers.ApiController{}, "GET:GetAgentConfigItem")
beego.Router("/api/delete-agent-config-item",
&controllers.ApiController{}, "POST:DeleteAgentConfigItem")
beego.Router("/api/plan-agent-config-copy",
&controllers.ApiController{}, "POST:PlanAgentConfigCopy")
+ beego.Router("/api/plan-agent-provider", &controllers.ApiController{},
"POST:PlanAgentProvider")
+ beego.Router("/api/apply-agent-provider", &controllers.ApiController{},
"POST:ApplyAgentProvider")
+ beego.Router("/api/restore-agent-provider",
&controllers.ApiController{}, "POST:RestoreAgentProvider")
beego.Router("/api/copy-agent-config", &controllers.ApiController{},
"POST:CopyAgentConfig")
beego.Router("/api/get-records", &controllers.ApiController{},
"GET:GetRecords")
@@ -98,6 +101,7 @@ func initAPI() {
beego.Router("/api/update-channel", &controllers.ApiController{},
"POST:UpdateChannel")
beego.Router("/api/delete-channel", &controllers.ApiController{},
"POST:DeleteChannel")
beego.Router("/api/test-channel", &controllers.ApiController{},
"POST:TestChannel")
+ beego.Router("/api/get-channel-health", &controllers.ApiController{},
"GET:GetChannelHealth")
beego.Router("/api/get-llm-records", &controllers.ApiController{},
"GET:GetLlmRecords")
beego.Router("/api/get-llm-record", &controllers.ApiController{},
"GET:GetLlmRecord")
diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts
index 868b2c2..23f339e 100644
--- a/web/src/backend/AgentBackend.ts
+++ b/web/src/backend/AgentBackend.ts
@@ -13,7 +13,7 @@
// limitations under the License.
import {query, request} from "@/backend/request";
-import type {Agent, AgentRecord, AgentSession} from "@/types";
+import type {Agent, AgentProvider, AgentProviderFile, AgentRecord,
AgentSession} from "@/types";
export interface PatchTarget {
agentId: string;
@@ -33,8 +33,27 @@ export function unpatchAgent(target: PatchTarget) {
return request<{followup?: string}>("/api/unpatch-agent", "POST", target);
}
-export function updateAgentChannel(agentId: string, channel: string) {
- return request("/api/update-agent-channel", "POST", {agentId: agentId,
channel: channel});
+export interface AgentRouting {
+ channel: string;
+ fallbacks: string[];
+ mode: string;
+}
+
+export function updateAgentChannel(agentId: string, routing: AgentRouting) {
+ return request("/api/update-agent-channel", "POST", {agentId: agentId,
...routing});
+}
+
+/** What a switch would write, rendered without touching a file. */
+export function planAgentProvider(target: PatchTarget) {
+ return request<AgentProviderFile[]>("/api/plan-agent-provider", "POST",
target);
+}
+
+export function applyAgentProvider(target: PatchTarget) {
+ return request<AgentProvider>("/api/apply-agent-provider", "POST", target);
+}
+
+export function restoreAgentProvider(target: PatchTarget) {
+ return request<AgentProvider>("/api/restore-agent-provider", "POST", target);
}
export function getAgentRecords(agent = "", eventType = "", outcome = "",
session = "", limit = 200) {
diff --git a/web/src/backend/ChannelBackend.ts
b/web/src/backend/ChannelBackend.ts
index fea65ac..2a83d3c 100644
--- a/web/src/backend/ChannelBackend.ts
+++ b/web/src/backend/ChannelBackend.ts
@@ -13,7 +13,7 @@
// limitations under the License.
import {itemId, query, request} from "@/backend/request";
-import type {Channel, ChannelTestResult} from "@/types";
+import type {Channel, ChannelHealth, ChannelTestResult} from "@/types";
export function getChannels(
owner: string,
@@ -52,3 +52,9 @@ export function deleteChannel(channel: Channel) {
export function testChannel(owner: string, name: string) {
return request<ChannelTestResult>("/api/test-channel", "POST", {owner:
owner, name: name});
}
+
+/** What the proxy has seen of each channel, which is what says why a request
+ * went to a fallback rather than to the bound channel. */
+export function getChannelHealth() {
+ return request<ChannelHealth[]>("/api/get-channel-health");
+}
diff --git a/web/src/components/ProviderCard.tsx
b/web/src/components/ProviderCard.tsx
new file mode 100644
index 0000000..85da2c6
--- /dev/null
+++ b/web/src/components/ProviderCard.tsx
@@ -0,0 +1,367 @@
+// 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.
+
+import * as React from "react";
+import {FileCog, RotateCcw} from "lucide-react";
+import i18next from "i18next";
+
+import * as AgentBackend from "@/backend/AgentBackend";
+import {EnvSnippet} from "@/components/EnvSnippet";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {Loading} from "@/components/shared/loading";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {Switch} from "@/components/ui/switch";
+import {agentProxyBaseUrl, agentSetupNoteKey, directMode, gatewayMode} from
"@/lib/agents";
+import {channelProtocol, shellForPath} from "@/lib/channels";
+import {cn} from "@/lib/utils";
+import type {Agent, AgentProviderFile, Channel, ChannelHealth} from "@/types";
+
+/** Radix rejects an empty item value, so "unbound" needs a stand-in. */
+const noChannel = "-";
+
+function channelIdOf(channel: Channel) {
+ return `${channel.owner}/${channel.name}`;
+}
+
+/** The file preview, loaded when the dialog opens rather than on every
render. */
+function PreviewDialog({
+ agent,
+ open,
+ onOpenChange,
+}: {
+ agent: Agent;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const [files, setFiles] = React.useState<AgentProviderFile[]>([]);
+ const [error, setError] = React.useState("");
+ const [loading, setLoading] = React.useState(false);
+
+ React.useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ setLoading(true);
+ setError("");
+ AgentBackend.planAgentProvider({agentId: agent.agentId, path: agent.path,
owner: agent.owner})
+ .then(res => {
+ if (res.status === "ok") {
+ setFiles(res.data ?? []);
+ } else {
+ setError(res.msg || i18next.t("agent:Failed to write the agent
configuration"));
+ }
+ })
+ .catch(err => setError(err.message || String(err)))
+ .then(() => setLoading(false));
+ }, [open, agent.agentId, agent.path, agent.owner]);
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-h-[85vh] gap-4 sm:max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>{i18next.t("agent:Configuration preview")}</DialogTitle>
+ <DialogDescription>{i18next.t("agent:Preview
hint")}</DialogDescription>
+ </DialogHeader>
+
+ <div className="flex flex-col gap-4 overflow-y-auto">
+ {loading ? <Loading /> : null}
+ {error ? <MessageAlert description={error} /> : null}
+ {files.map(file => (
+ <div key={file.path} className="space-y-1">
+ <div className="flex items-center justify-between gap-2">
+ <code className="min-w-0 truncate text-xs">{file.path}</code>
+ <Badge variant="muted">{file.format}</Badge>
+ </div>
+ <pre className="overflow-x-auto rounded-md bg-muted p-2
text-xs">{file.preview}</pre>
+ </div>
+ ))}
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ {i18next.t("general:Close")}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+/** One channel of the chain, with what the proxy last saw of it. */
+function ChannelChip({
+ label,
+ active,
+ disabled,
+ health,
+ onClick,
+}: {
+ label: string;
+ active: boolean;
+ disabled?: boolean;
+ health?: ChannelHealth;
+ onClick?: () => void;
+}) {
+ return (
+ <button
+ type="button"
+ disabled={disabled}
+ onClick={onClick}
+ className={cn(
+ "flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm
transition-colors",
+ active ? "border-primary bg-primary/10" : "hover:bg-accent",
+ disabled && "cursor-not-allowed opacity-50",
+ )}
+ >
+ <span>{label}</span>
+ {health && !health.healthy ? (
+ <Badge variant="warning">{i18next.t("agent:Cooling down")}</Badge>
+ ) : null}
+ </button>
+ );
+}
+
+/**
+ * Which upstream an agent's requests go to, which upstreams take over when it
+ * cannot answer, and how that choice reaches the agent: through the local
proxy,
+ * or written into the agent's own configuration file.
+ */
+export function ProviderCard({
+ agent,
+ channels,
+ health,
+ busy,
+ onRouting,
+ onWrite,
+}: {
+ agent: Agent;
+ channels: Channel[];
+ health: ChannelHealth[];
+ busy: boolean;
+ onRouting: (routing: Partial<AgentBackend.AgentRouting>) => void;
+ onWrite: (restore: boolean) => void;
+}) {
+ const [preview, setPreview] = React.useState(false);
+
+ const bound = channels.find(channel => channelIdOf(channel) ===
agent.channel);
+ const mode = agent.mode || gatewayMode;
+ const fallbacks = agent.fallbacks ?? [];
+ // An older backend does not report the provider state at all, and the card
+ // still has to render for the parts that do not depend on it.
+ const provider = agent.provider ?? {
+ supported: false,
+ applied: false,
+ channel: "",
+ mode: "",
+ baseUrl: "",
+ time: "",
+ files: [],
+ detail: "",
+ };
+ const noteKey = agentSetupNoteKey(agent.agentId);
+ const healthOf = (id: string) => health.find(item => item.channel === id);
+ const boundHealth = healthOf(agent.channel);
+
+ // A channel can only take over for one that speaks the same wire format: the
+ // proxy relays the request as it arrived.
+ const candidates = channels.filter(
+ channel =>
+ channelIdOf(channel) !== agent.channel &&
+ (bound === undefined || channelProtocol(channel.type) ===
channelProtocol(bound.type)),
+ );
+
+ const toggleFallback = (id: string) => {
+ onRouting({
+ fallbacks: fallbacks.includes(id)
+ ? fallbacks.filter(item => item !== id)
+ : [...fallbacks, id],
+ });
+ };
+
+ return (
+ <Card>
+ <CardHeader className="p-4 pb-2">
+ <CardTitle
className="text-base">{i18next.t("agent:Channel")}</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3 p-4 pt-0">
+ <Select
+ value={agent.channel === "" ? noChannel : agent.channel}
+ disabled={busy}
+ onValueChange={value => onRouting({channel: value === noChannel ? ""
: value, fallbacks: []})}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value={noChannel}>
+ <span className="text-muted-foreground">{i18next.t("agent:No
channel")}</span>
+ </SelectItem>
+ {channels.map(channel => (
+ <SelectItem key={channelIdOf(channel)}
value={channelIdOf(channel)}>
+ {channel.displayName || channel.name}
+ {/* The type is the wire format, which has to match the one
the agent speaks. */}
+ <span className="ml-2 text-xs
text-muted-foreground">{channel.type}</span>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ {boundHealth && !boundHealth.healthy ? (
+ <p className="text-sm text-warning">
+ {`${i18next.t("agent:Bound channel cooling down")}:
${boundHealth.lastError}`}
+ </p>
+ ) : null}
+
+ {agent.channel === "" ? (
+ <p className="text-sm
text-muted-foreground">{i18next.t("agent:Channel hint")}</p>
+ ) : bound === undefined ? null : (
+ <>
+ <div className="space-y-2">
+ <p className="text-sm
text-muted-foreground">{i18next.t("agent:Fallback hint")}</p>
+ {candidates.length === 0 ? (
+ <p className="text-sm text-muted-foreground">
+ {i18next.t("agent:No other channel speaks this API")}
+ </p>
+ ) : (
+ <div className="flex flex-wrap gap-2">
+ {candidates.map(channel => (
+ <ChannelChip
+ key={channelIdOf(channel)}
+ label={channel.displayName || channel.name}
+ active={fallbacks.includes(channelIdOf(channel))}
+ disabled={busy}
+ health={healthOf(channelIdOf(channel))}
+ onClick={() => toggleFallback(channelIdOf(channel))}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+
+ <label className="flex items-start gap-2 text-sm">
+ <Switch
+ className="mt-0.5"
+ checked={mode === gatewayMode}
+ disabled={busy}
+ onCheckedChange={checked =>
+ onRouting({mode: checked ? gatewayMode : directMode})
+ }
+ />
+ <span>
+ {i18next.t("agent:Route through Gateway")}
+ <span className="block text-muted-foreground">
+ {mode === gatewayMode
+ ? i18next.t("agent:Gateway mode hint")
+ : i18next.t("agent:Direct mode hint")}
+ </span>
+ </span>
+ </label>
+
+ {provider.supported ? (
+ <div className="space-y-2 rounded-md border p-3">
+ <div className="flex flex-wrap items-center gap-2">
+ <FileCog className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm font-medium">
+ {i18next.t("agent:Agent configuration")}
+ </span>
+ {provider.applied ? (
+ <Badge variant="success">{i18next.t("agent:Written by
Gateway")}</Badge>
+ ) : (
+ <Badge variant="secondary">{i18next.t("agent:Not
written")}</Badge>
+ )}
+ </div>
+
+ {provider.detail ? (
+ <p className="text-sm
text-muted-foreground">{provider.detail}</p>
+ ) : null}
+ {(provider.files ?? []).map(path => (
+ <code key={path} className="block truncate text-xs">
+ {path}
+ </code>
+ ))}
+
+ <div className="flex flex-wrap gap-2">
+ <ConfirmDialog
+ title={i18next.t("agent:Write the configuration of
{agent}?").replace("{agent}", agent.name)}
+ description={i18next.t("agent:Write confirm hint")}
+ confirmText={i18next.t("agent:Write configuration")}
+ onConfirm={() => onWrite(false)}
+ >
+ <Button disabled={busy}>
+ {provider.applied
+ ? i18next.t("agent:Rewrite configuration")
+ : i18next.t("agent:Write configuration")}
+ </Button>
+ </ConfirmDialog>
+
+ <Button variant="outline" disabled={busy} onClick={() =>
setPreview(true)}>
+ {i18next.t("agent:Preview")}
+ </Button>
+
+ {provider.applied || provider.channel !== "" ? (
+ <ConfirmDialog
+ title={i18next.t("agent:Restore the configuration of
{agent}?").replace("{agent}", agent.name)}
+ description={i18next.t("agent:Restore confirm hint")}
+ confirmText={i18next.t("agent:Restore configuration")}
+ variant="destructive"
+ onConfirm={() => onWrite(true)}
+ >
+ <Button variant="outline" disabled={busy}>
+ <RotateCcw />
+ {i18next.t("agent:Restore configuration")}
+ </Button>
+ </ConfirmDialog>
+ ) : null}
+ </div>
+ </div>
+ ) : (
+ <>
+ <p className="text-sm
text-muted-foreground">{i18next.t("agent:Base URL hint")}</p>
+ <EnvSnippet
+ protocol={channelProtocol(bound.type)}
+ baseUrl={agentProxyBaseUrl(agent.agentId)}
+ defaultShell={shellForPath(agent.path)}
+ />
+ <p className="text-sm
text-muted-foreground">{i18next.t("agent:Token hint")}</p>
+ {noteKey === "" ? null : (
+ <p className="text-sm
text-muted-foreground">{i18next.t(noteKey)}</p>
+ )}
+ </>
+ )}
+ </>
+ )}
+
+ <PreviewDialog agent={agent} open={preview} onOpenChange={setPreview}
/>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 09468e8..7604f45 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -20,6 +20,10 @@ import * as Setting from "@/Setting";
import type {BadgeVariant} from "@/components/ui/badge";
import type {Agent, AgentSession} from "@/types";
+/** Routing through the local proxy, which is what makes a switch hot. */
+export const gatewayMode = "gateway";
+export const directMode = "direct";
+
/** The monitor keys the two Codex front ends under one agent id. */
export function monitorAgentId(agentId: string) {
return agentId === "codex_vscode" || agentId === "codex-vscode" ?
"codex-cli" : agentId;
@@ -133,17 +137,28 @@ export function useAgents(enabled = true) {
[scan],
);
- const setChannel = React.useCallback(
- (agent: Agent, channel: string) => {
+ /**
+ * setRouting saves where an agent's requests go. Only the changed part is
+ * passed in; the rest of the routing is carried over from the agent so a
+ * fallback list is not dropped by a channel change.
+ */
+ const setRouting = React.useCallback(
+ (agent: Agent, routing: Partial<AgentBackend.AgentRouting>) => {
+ const next: AgentBackend.AgentRouting = {
+ channel: routing.channel ?? agent.channel,
+ fallbacks: routing.fallbacks ?? agent.fallbacks ?? [],
+ mode: routing.mode ?? agent.mode ?? gatewayMode,
+ };
+
setBusyKey(agentKey(agent));
- AgentBackend.updateAgentChannel(agent.agentId, channel)
+ AgentBackend.updateAgentChannel(agent.agentId, next)
.then(res => {
if (res.status === "ok") {
Setting.showMessage(
"success",
- channel === ""
+ next.channel === ""
? i18next.t("agent:Channel cleared")
- : `${i18next.t("agent:Channel saved")}: ${channel}`,
+ : `${i18next.t("agent:Channel saved")}: ${next.channel}`,
);
scan();
} else {
@@ -151,6 +166,43 @@ export function useAgents(enabled = true) {
"error",
res.msg || i18next.t("agent:Failed to update agent channel"),
);
+ // The routing itself may already be stored: only writing the
agent's
+ // own configuration file failed, and the page has to show that.
+ scan();
+ }
+ })
+ .catch(err => Setting.showMessage("error", err.message || String(err)))
+ .then(() => setBusyKey(""));
+ },
+ [scan],
+ );
+
+ /**
+ * writeProvider writes the bound channel into the agent's own config file,
or
+ * puts back what the file held before Gateway first wrote it.
+ */
+ const writeProvider = React.useCallback(
+ (agent: Agent, restore = false) => {
+ const target = {agentId: agent.agentId, path: agent.path, owner:
agent.owner};
+
+ setBusyKey(agentKey(agent));
+ (restore
+ ? AgentBackend.restoreAgentProvider(target)
+ : AgentBackend.applyAgentProvider(target))
+ .then(res => {
+ if (res.status === "ok") {
+ Setting.showMessage(
+ "success",
+ restore
+ ? i18next.t("agent:Configuration restored")
+ : i18next.t("agent:Configuration written"),
+ );
+ scan();
+ } else {
+ Setting.showMessage(
+ "error",
+ res.msg || i18next.t("agent:Failed to write the agent
configuration"),
+ );
}
})
.catch(err => Setting.showMessage("error", err.message || String(err)))
@@ -159,7 +211,7 @@ export function useAgents(enabled = true) {
[scan],
);
- return {agents, loading, error, busyKey, scanned, scan, togglePatch,
setChannel};
+ return {agents, loading, error, busyKey, scanned, scan, togglePatch,
setRouting, writeProvider};
}
/** What one agent has been up to, derived from its monitoring sessions. */
diff --git a/web/src/lib/usage.ts b/web/src/lib/usage.ts
new file mode 100644
index 0000000..cf4b8a6
--- /dev/null
+++ b/web/src/lib/usage.ts
@@ -0,0 +1,37 @@
+// 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.
+
+/** US dollars, with enough digits left for a single cheap request to show up.
*/
+export function formatCost(cost: number) {
+ if (cost <= 0) {
+ return "$0";
+ }
+ if (cost < 0.01) {
+ return `$${cost.toFixed(4)}`;
+ }
+ if (cost < 1) {
+ return `$${cost.toFixed(3)}`;
+ }
+ return `$${cost.toFixed(2)}`;
+}
+
+export function formatTokens(tokens: number) {
+ if (tokens < 1000) {
+ return String(tokens);
+ }
+ if (tokens < 1000000) {
+ return `${(tokens / 1000).toFixed(1)}k`;
+ }
+ return `${(tokens / 1000000).toFixed(2)}M`;
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 5cf7830..6169967 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -21,6 +21,7 @@
"Agent": "Agent",
"Agent Records": "Activity",
"Agent Sessions": "Sessions",
+ "Agent configuration": "Agent configuration",
"Agent installation not found": "Agent installation not found",
"Agent path": "Agent path",
"Agents": "Agents",
@@ -30,15 +31,22 @@
"All outcomes": "All outcomes",
"Auto refresh": "Auto refresh",
"Base URL hint": "Give the agent the settings below. Requests that arrive
at this URL always go to the channel selected above, whatever model they name.",
+ "Bound channel cooling down": "The bound channel is out of rotation,
requests go to the fallbacks",
"Channel": "Channel",
"Channel cleared": "Channel cleared",
"Channel hint": "With no channel selected, this agent still talks to its
own upstream and Gateway only watches it.",
"Channel saved": "Channel saved",
"Claude Code config hint": "The same variables can live in the \"env\"
block of ~/.claude/settings.json, so every session picks them up without
exporting anything.",
"Codex CLI config hint": "The base URL can also be a model provider in
~/.codex/config.toml, selected with model_provider.",
+ "Configuration preview": "Configuration preview",
+ "Configuration restored": "Configuration restored",
+ "Configuration written": "Configuration written",
+ "Cooling down": "Cooling down",
"Copy path": "Copy path",
"Detail": "Detail",
"Details": "Details",
+ "Direct": "Direct",
+ "Direct mode hint": "The channel's own base URL and key are written into
the agent's configuration. Switching to another channel rewrites the file, and
the agent has to be restarted to pick it up.",
"Duration": "Duration",
"Event": "Event",
"Failed to get agent records": "Failed to get agent records",
@@ -46,8 +54,12 @@
"Failed to scan agents": "Failed to scan agents",
"Failed to update agent channel": "Failed to update agent channel",
"Failed to update agent patch": "Failed to update agent patch",
+ "Failed to write the agent configuration": "Failed to write the agent
configuration",
+ "Fallback hint": "Channels tried, in the order picked, when the bound one
cannot answer.",
"Filter": "Filter",
"First activity": "First activity",
+ "Gateway": "Gateway",
+ "Gateway mode hint": "The agent is pointed at the local proxy, so
switching to another channel takes effect on the next request without
restarting the agent.",
"Install Method": "Install Method",
"Install an AI agent on this machine, then scan again": "Install an AI
agent on this machine, then scan again",
"Installation": "Installation",
@@ -63,18 +75,28 @@
"No agent records yet - patch an agent to start collecting them": "No
activity yet - turn on monitoring for an agent to start collecting it",
"No agent sessions yet - patch an agent to start collecting them": "No
sessions yet - turn on monitoring for an agent to start collecting them",
"No channel": "No channel",
+ "No other channel speaks this API": "No other channel speaks this API",
"No supported agents found": "No supported agents found",
+ "No usage yet": "No requests have been relayed for this agent yet",
"Not patched": "Not monitored",
"Not supported": "Not supported yet",
+ "Not written": "Not written",
"Patch": "Turn on monitoring",
"Patch Status": "Monitoring",
"Patched": "Monitoring",
"Path copied to clipboard": "Path copied to clipboard",
"Payload": "Payload",
+ "Preview": "Preview",
+ "Preview hint": "What Gateway would write, with the keys masked.
Everything else in these files is left as it is.",
"Prompt ID": "Prompt ID",
"Records": "Records",
"Records are kept in memory only and are lost when Gateway restarts":
"Records are kept in memory only and are lost when Gateway restarts",
"Reported from": "Reported from",
+ "Restore configuration": "Restore configuration",
+ "Restore confirm hint": "Puts back the provider settings this agent had
before Gateway first wrote them, and leaves every other setting untouched.",
+ "Restore the configuration of {agent}?": "Restore the configuration of
{agent}?",
+ "Rewrite configuration": "Rewrite configuration",
+ "Route through Gateway": "Route through Gateway",
"Scan": "Rescan",
"Session": "Session",
"Session title": "Session title",
@@ -86,10 +108,15 @@
"Turn on monitoring to collect activity": "Turn on monitoring to collect
activity",
"Unknown": "Unknown",
"Unpatch": "Turn off monitoring",
+ "Usage": "Usage",
"User": "User",
"Version": "Version",
"View Records": "View Records",
"View all": "View all",
+ "Write configuration": "Write configuration",
+ "Write confirm hint": "Gateway writes the selected channel into this
agent's own configuration file, replacing the file in one step. Restart any
session that is already running.",
+ "Write the configuration of {agent}?": "Write the configuration of
{agent}?",
+ "Written by Gateway": "Written by Gateway",
"{count} sessions": "{count} sessions"
},
"agentConfig": {
@@ -183,6 +210,7 @@
"Channels": "Channels",
"Connection Failed": "Connection Failed",
"Connection Successful": "Connection Successful",
+ "Cooling down": "Cooling down",
"Disabled": "Disabled",
"Edit Channel": "Edit Channel",
"Enabled": "Enabled",
@@ -192,11 +220,16 @@
"Failed to get channels": "Failed to get channels",
"Failed to save": "Failed to save",
"Failed to test": "Failed to test",
+ "Health": "Health",
+ "Healthy": "Healthy",
"Model routing hint": "Requests that arrive here are routed by the model
name they carry, to the enabled channel with the smallest priority value that
serves it. To send one agent's requests to this channel whatever model they
name, bind the channel on the agent's page instead.",
"Models": "Models",
"New Channel": "New Channel",
+ "Not used yet": "Not used yet",
"Priority": "Priority",
"Priority hint": "Smaller number means higher priority, 0 is the highest;
when multiple channels serve the same model, the one with the smaller value is
used first.",
+ "Recovering": "Recovering",
+ "Retried at": "retried at",
"Status": "Status",
"Test Connectivity": "Test Connectivity",
"Type": "Type",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index b20d684..c36bca0 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -21,6 +21,7 @@
"Agent": "智能体",
"Agent Records": "活动记录",
"Agent Sessions": "会话",
+ "Agent configuration": "Agent 配置文件",
"Agent installation not found": "没有找到这个智能体的安装",
"Agent path": "Agent 路径",
"Agents": "智能体",
@@ -30,15 +31,22 @@
"All outcomes": "全部结果",
"Auto refresh": "自动刷新",
"Base URL hint": "把下面的设置配给该 Agent。到达这个地址的请求一律转发到上面选定的渠道,与请求里的模型名无关。",
+ "Bound channel cooling down": "绑定通道已暂时移出轮转,请求将走备用通道",
"Channel": "渠道",
"Channel cleared": "已清除渠道",
"Channel hint": "未选择渠道时,该 Agent 仍直连自己的上游,网关只做观测。",
"Channel saved": "渠道已保存",
"Claude Code config hint": "这两个变量也可以写进 ~/.claude/settings.json 的 env
段,这样不用每次 export,所有会话都会生效。",
"Codex CLI config hint": "base URL 也可以写成 ~/.codex/config.toml 里的一个 model
provider,再用 model_provider 选中它。",
+ "Configuration preview": "配置预览",
+ "Configuration restored": "配置已还原",
+ "Configuration written": "配置已写入",
+ "Cooling down": "冷却中",
"Copy path": "复制路径",
"Detail": "详情",
"Details": "详情",
+ "Direct": "直连",
+ "Direct mode hint": "直接把该通道的 Base URL 和密钥写入 Agent 配置文件。切换通道会重写文件,且需要重启
Agent 才能生效。",
"Duration": "耗时",
"Event": "事件",
"Failed to get agent records": "获取 Agent 记录失败",
@@ -46,8 +54,12 @@
"Failed to scan agents": "探测 Agent 失败",
"Failed to update agent channel": "更新 Agent 渠道失败",
"Failed to update agent patch": "更新 Agent Patch 失败",
+ "Failed to write the agent configuration": "写入 Agent 配置失败",
+ "Fallback hint": "当绑定通道无法响应时,按所选顺序依次尝试的备用通道。",
"Filter": "筛选",
"First activity": "首次活动",
+ "Gateway": "网关",
+ "Gateway mode hint": "把 Agent 指向本地代理,切换通道在下一次请求即生效,无需重启 Agent。",
"Install Method": "安装方式",
"Install an AI agent on this machine, then scan again": "在本机安装一个 AI
智能体后,再扫描一次",
"Installation": "安装信息",
@@ -63,18 +75,28 @@
"No agent records yet - patch an agent to start collecting them":
"还没有活动记录,开启某个智能体的监控后才会开始收集",
"No agent sessions yet - patch an agent to start collecting them":
"还没有会话,开启某个智能体的监控后才会开始收集",
"No channel": "未设置渠道",
+ "No other channel speaks this API": "没有其他通道使用相同的 API 协议",
"No supported agents found": "没有发现本机安装的智能体",
+ "No usage yet": "该 Agent 还没有经过网关的请求",
"Not patched": "未开启",
"Not supported": "暂不支持",
+ "Not written": "未写入",
"Patch": "开启监控",
"Patch Status": "监控状态",
"Patched": "监控中",
"Path copied to clipboard": "路径已复制到剪贴板",
"Payload": "负载",
+ "Preview": "预览",
+ "Preview hint": "Gateway 将要写入的内容,密钥已打码。文件中的其他配置保持不变。",
"Prompt ID": "Prompt ID",
"Records": "记录数",
"Records are kept in memory only and are lost when Gateway restarts":
"记录仅保存在内存中,Gateway 重启后会丢失",
"Reported from": "上报来源",
+ "Restore configuration": "还原配置",
+ "Restore confirm hint": "把该 Agent 在 Gateway 首次写入之前的供应商配置还原回去,其他配置保持不变。",
+ "Restore the configuration of {agent}?": "还原 {agent} 的配置?",
+ "Rewrite configuration": "重新写入配置",
+ "Route through Gateway": "经由 Gateway 路由",
"Scan": "重新扫描",
"Session": "会话",
"Session title": "会话标题",
@@ -86,10 +108,15 @@
"Turn on monitoring to collect activity": "开启监控后才会收集活动",
"Unknown": "未知",
"Unpatch": "关闭监控",
+ "Usage": "用量",
"User": "用户",
"Version": "版本",
"View Records": "查看活动",
"View all": "查看全部",
+ "Write configuration": "写入配置",
+ "Write confirm hint": "Gateway 会把所选通道以该 Agent
自己的格式原子地写入其配置文件。已在运行的会话需要重启。",
+ "Write the configuration of {agent}?": "写入 {agent} 的配置?",
+ "Written by Gateway": "已由 Gateway 写入",
"{count} sessions": "{count} 个会话"
},
"agentConfig": {
@@ -183,6 +210,7 @@
"Channels": "渠道",
"Connection Failed": "连接失败",
"Connection Successful": "连接成功",
+ "Cooling down": "冷却中",
"Disabled": "已禁用",
"Edit Channel": "编辑渠道",
"Enabled": "已启用",
@@ -192,11 +220,16 @@
"Failed to get channels": "获取渠道列表失败",
"Failed to save": "保存失败",
"Failed to test": "测试失败",
+ "Health": "健康状态",
+ "Healthy": "正常",
"Model routing hint": "到达这里的请求按自己带的模型名路由,转发到支持该模型、且 priority
值最小的已启用渠道。如果想让某个 Agent 的请求不管模型名一律走这个渠道,请到该 Agent 的页面上绑定。",
"Models": "模型",
"New Channel": "新建渠道",
+ "Not used yet": "尚未使用",
"Priority": "优先级",
"Priority hint": "数字越小优先级越高,0 为最高;同模型多渠道时优先使用数值小的渠道。",
+ "Recovering": "恢复中",
+ "Retried at": "将于",
"Status": "状态",
"Test Connectivity": "测试连接",
"Type": "类型",
diff --git a/web/src/pages/AgentDetailPage.tsx
b/web/src/pages/AgentDetailPage.tsx
index 870a424..2a5f233 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -20,40 +20,37 @@ import i18next from "i18next";
import * as AgentBackend from "@/backend/AgentBackend";
import * as ChannelBackend from "@/backend/ChannelBackend";
+import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
import * as Setting from "@/Setting";
import {AgentIcon} from "@/components/AgentIcon";
import {DataTable, type Column} from "@/components/shared/data-table";
-import {EnvSnippet} from "@/components/EnvSnippet";
+import {ProviderCard} from "@/components/ProviderCard";
import {ResultScreen, UnauthorizedResult} from "@/components/shared/misc";
import {Alert, AlertDescription} from "@/components/ui/alert";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {ConfirmDialog} from "@/components/shared/confirm-dialog";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
import {AiDots} from "@/components/shared/loading";
import {SimpleTooltip} from "@/components/ui/tooltip";
import {cn} from "@/lib/utils";
import {
agentKey,
- agentProxyBaseUrl,
- agentSetupNoteKey,
getOutcomeVariant,
monitorAgentId,
useAgents,
useAgentSessions,
} from "@/lib/agents";
-import {channelProtocol, shellForPath} from "@/lib/channels";
-import type {Account, Agent, AgentRecord, AgentSession, Channel} from
"@/types";
-
-/** Radix rejects an empty item value, so "unbound" needs a stand-in. */
-const noChannel = "-";
+import {formatCost, formatTokens} from "@/lib/usage";
+import type {
+ Account,
+ Agent,
+ AgentRecord,
+ AgentSession,
+ Channel,
+ ChannelHealth,
+ LlmRecordStats,
+} from "@/types";
const tabs = ["Agent Sessions", "Agent Records"] as const;
type Tab = (typeof tabs)[number];
@@ -124,66 +121,51 @@ function MonitoringCard({
);
}
-/** Which upstream this agent's requests go to, and how to point it at them. */
-function ChannelCard({
- agent,
- channels,
- busy,
- onChange,
-}: {
- agent: Agent;
- channels: Channel[];
- busy: boolean;
- onChange: (channel: string) => void;
-}) {
- // The bound channel decides the wire format, and with it the variable names
- // the agent has to be given. It is undefined while the channels load.
- const bound = channels.find(channel => `${channel.owner}/${channel.name}`
=== agent.channel);
- const noteKey = agentSetupNoteKey(agent.agentId);
-
+/** What this agent has spent through the proxy, and on which channel. */
+function UsageCard({stats}: {stats: LlmRecordStats | null}) {
return (
<Card>
<CardHeader className="p-4 pb-2">
- <CardTitle
className="text-base">{i18next.t("agent:Channel")}</CardTitle>
+ <CardTitle className="text-base">{i18next.t("agent:Usage")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
- <Select
- value={agent.channel === "" ? noChannel : agent.channel}
- disabled={busy}
- onValueChange={value => onChange(value === noChannel ? "" : value)}
- >
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value={noChannel}>
- <span className="text-muted-foreground">{i18next.t("agent:No
channel")}</span>
- </SelectItem>
- {channels.map(channel => (
- <SelectItem key={`${channel.owner}/${channel.name}`}
value={`${channel.owner}/${channel.name}`}>
- {channel.displayName || channel.name}
- {/* The type is the wire format, which has to match the one
the agent speaks. */}
- <span className="ml-2 text-xs
text-muted-foreground">{channel.type}</span>
- </SelectItem>
+ <div className="grid grid-cols-3 gap-2">
+ <div>
+ <div className="text-xs
text-muted-foreground">{i18next.t("llm:Requests")}</div>
+ <div className="text-lg font-semibold">{(stats?.requests ??
0).toLocaleString()}</div>
+ </div>
+ <div>
+ <div className="text-xs
text-muted-foreground">{i18next.t("llm:Tokens")}</div>
+ <div className="text-lg
font-semibold">{formatTokens(stats?.totalTokens ?? 0)}</div>
+ </div>
+ <div>
+ <div className="text-xs
text-muted-foreground">{i18next.t("llm:Cost")}</div>
+ <div className="text-lg font-semibold">{formatCost(stats?.cost ??
0)}</div>
+ </div>
+ </div>
+
+ {stats && stats.failed > 0 ? (
+ <p className="text-sm text-warning">
+ {`${stats.failed.toLocaleString()} ${i18next.t("llm:failed")}`}
+ </p>
+ ) : null}
+
+ {/* The per-channel split is what says whether the fallbacks are
carrying
+ traffic the bound channel could not. */}
+ {stats && stats.channels.length > 0 ? (
+ <div className="flex flex-wrap gap-2">
+ {stats.channels.map(channel => (
+ <Badge key={channel.channel} variant="muted" className="gap-2">
+ <span className="font-mono">{channel.channel}</span>
+ <span>{channel.requests.toLocaleString()}</span>
+ <span className="text-muted-foreground">
+ {formatTokens(channel.tokens)} · {formatCost(channel.cost)}
+ </span>
+ </Badge>
))}
- </SelectContent>
- </Select>
-
- {agent.channel === "" ? (
- <p className="text-sm
text-muted-foreground">{i18next.t("agent:Channel hint")}</p>
- ) : bound === undefined ? null : (
- <>
- <p className="text-sm
text-muted-foreground">{i18next.t("agent:Base URL hint")}</p>
- <EnvSnippet
- protocol={channelProtocol(bound.type)}
- baseUrl={agentProxyBaseUrl(agent.agentId)}
- defaultShell={shellForPath(agent.path)}
- />
- <p className="text-sm
text-muted-foreground">{i18next.t("agent:Token hint")}</p>
- {noteKey === "" ? null : (
- <p className="text-sm
text-muted-foreground">{i18next.t(noteKey)}</p>
- )}
- </>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">{i18next.t("agent:No
usage yet")}</p>
)}
</CardContent>
</Card>
@@ -194,11 +176,14 @@ export default function AgentDetailPage({account}:
{account: Account}) {
const params = useParams();
const [searchParams] = useSearchParams();
const isAdmin = Setting.isAdminUser(account);
- const {agents, error, busyKey, scanned, scan, togglePatch, setChannel} =
useAgents(isAdmin);
+ const {agents, error, busyKey, scanned, scan, togglePatch, setRouting,
writeProvider} =
+ useAgents(isAdmin);
const [tab, setTab] = React.useState<Tab>("Agent Sessions");
const [records, setRecords] = React.useState<AgentRecord[]>([]);
const [recordError, setRecordError] = React.useState("");
const [channels, setChannels] = React.useState<Channel[]>([]);
+ const [health, setHealth] = React.useState<ChannelHealth[]>([]);
+ const [stats, setStats] = React.useState<LlmRecordStats | null>(null);
const agentId = params.agentId ?? "";
const path = searchParams.get("path") ?? "";
@@ -221,8 +206,25 @@ export default function AgentDetailPage({account}:
{account: Account}) {
}
})
.catch(() => setChannels([]));
+
+ ChannelBackend.getChannelHealth()
+ .then(res => {
+ if (res.status === "ok") {
+ setHealth(res.data ?? []);
+ }
+ })
+ .catch(() => setHealth([]));
}, [isAdmin, account.name]);
+ React.useEffect(() => {
+ if (!isAdmin || agentId === "") {
+ return;
+ }
+ LlmRecordBackend.getLlmRecordStats({agent: agentId})
+ .then(res => setStats(res.status === "ok" ? (res.data ?? null) : null))
+ .catch(() => setStats(null));
+ }, [isAdmin, agentId]);
+
const monitorId = agent ? monitorAgentId(agent.agentId) : "";
const watching = Boolean(agent?.patched);
const {sessions} = useAgentSessions(isAdmin && watching, monitorId, 5000);
@@ -388,13 +390,17 @@ export default function AgentDetailPage({account}:
{account: Account}) {
onToggle={() => togglePatch(agent)}
/>
- <ChannelCard
+ <ProviderCard
agent={agent}
channels={channels}
+ health={health}
busy={busyKey === agentKey(agent)}
- onChange={value => setChannel(agent, value)}
+ onRouting={routing => setRouting(agent, routing)}
+ onWrite={restore => writeProvider(agent, restore)}
/>
+ <UsageCard stats={stats} />
+
<Card>
<CardHeader className="p-4 pb-2">
<CardTitle
className="text-base">{i18next.t("agent:Installation")}</CardTitle>
diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx
index a2e378d..c29506f 100644
--- a/web/src/pages/AgentsPage.tsx
+++ b/web/src/pages/AgentsPage.tsx
@@ -26,7 +26,7 @@ import {MessageAlert} from "@/components/ui/alert";
import {Badge} from "@/components/ui/badge";
import {Button} from "@/components/ui/button";
import {SimpleTooltip} from "@/components/ui/tooltip";
-import {agentDetailPath, agentKey, monitorAgentId, useAgents} from
"@/lib/agents";
+import {agentDetailPath, agentKey, directMode, monitorAgentId, useAgents} from
"@/lib/agents";
import type {Account, Agent} from "@/types";
export default function AgentsPage({account}: {account: Account}) {
@@ -77,11 +77,27 @@ export default function AgentsPage({account}: {account:
Account}) {
title: i18next.t("agent:Channel"),
key: "channel",
dataIndex: "channel",
- render: (value: string) =>
+ render: (value: string, record) =>
value ? (
- <Link to={`/channels/${value}`} className="text-primary
hover:underline">
- {value}
- </Link>
+ <div className="flex flex-col items-start gap-1">
+ <Link to={`/channels/${value}`} className="text-primary
hover:underline">
+ {value}
+ </Link>
+ <span className="flex flex-wrap items-center gap-1">
+ {record.fallbacks?.length ? (
+ <SimpleTooltip title={record.fallbacks.join(", ")}>
+ <Badge
variant="muted">{`+${record.fallbacks.length}`}</Badge>
+ </SimpleTooltip>
+ ) : null}
+ {record.provider?.applied ? (
+ <SimpleTooltip title={record.provider.detail}>
+ <Badge variant="success">
+ {i18next.t(record.mode === directMode ? "agent:Direct" :
"agent:Gateway")}
+ </Badge>
+ </SimpleTooltip>
+ ) : null}
+ </span>
+ </div>
) : (
<span className="text-muted-foreground">{i18next.t("agent:No
channel")}</span>
),
diff --git a/web/src/pages/ChannelListPage.tsx
b/web/src/pages/ChannelListPage.tsx
index c5f929f..80adc98 100644
--- a/web/src/pages/ChannelListPage.tsx
+++ b/web/src/pages/ChannelListPage.tsx
@@ -31,7 +31,7 @@ import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {TagsInput} from "@/components/ui/tags-input";
import {SimpleTooltip} from "@/components/ui/tooltip";
-import type {Account, Channel} from "@/types";
+import type {Account, Channel, ChannelHealth} from "@/types";
function newChannel(owner: string): Channel {
const randomName = Setting.getRandomName();
@@ -63,6 +63,7 @@ export default function ChannelListPage({account}: {account:
Account}) {
const [adding, setAdding] = React.useState(false);
const [form, setForm] = React.useState<Channel>(() =>
newChannel(account.name));
const [nameError, setNameError] = React.useState("");
+ const [health, setHealth] = React.useState<ChannelHealth[]>([]);
const fetchChannels = React.useCallback(
(nextPage = page, nextPageSize = pageSize, nextSort = sort) => {
@@ -99,6 +100,20 @@ export default function ChannelListPage({account}:
{account: Account}) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [account.name]);
+ // What the proxy has seen of each channel lives in memory and changes as
+ // requests are relayed, so it is polled rather than read once.
+ React.useEffect(() => {
+ const load = () => {
+ ChannelBackend.getChannelHealth()
+ .then(res => setHealth(res.status === "ok" ? (res.data ?? []) : []))
+ .catch(() => setHealth([]));
+ };
+
+ load();
+ const interval = setInterval(load, 10000);
+ return () => clearInterval(interval);
+ }, []);
+
const openAddDialog = () => {
setForm(newChannel(account.name));
setNameError("");
@@ -243,6 +258,43 @@ export default function ChannelListPage({account}:
{account: Account}) {
</Badge>
),
},
+ {
+ title: i18next.t("channel:Health"),
+ key: "health",
+ width: "150px",
+ render: (_text, record) => {
+ const item = health.find(entry => entry.channel ===
`${record.owner}/${record.name}`);
+ if (!item) {
+ return <span
className="text-muted-foreground">{i18next.t("channel:Not used yet")}</span>;
+ }
+ // A channel that is out of its cooldown but whose last attempts failed
+ // is back in rotation without having proven anything yet.
+ const badge = !item.healthy ? (
+ <Badge variant="warning">
+ <CircleX />
+ {i18next.t("channel:Cooling down")}
+ </Badge>
+ ) : item.consecutive > 0 ? (
+ <Badge variant="muted">{i18next.t("channel:Recovering")}</Badge>
+ ) : (
+ <Badge variant="success">
+ <CircleCheck />
+ {i18next.t("channel:Healthy")}
+ </Badge>
+ );
+ return (
+ <SimpleTooltip
+ title={
+ item.healthy
+ ? `${item.successes} / ${item.successes + item.failures}`
+ : `${item.lastError} · ${i18next.t("channel:Retried at")}
${item.retryTime}`
+ }
+ >
+ {badge}
+ </SimpleTooltip>
+ );
+ },
+ },
{
title: i18next.t("general:Action"),
key: "action",
diff --git a/web/src/pages/LlmRecordsPage.tsx b/web/src/pages/LlmRecordsPage.tsx
index 5a1a3bb..fd5e7ac 100644
--- a/web/src/pages/LlmRecordsPage.tsx
+++ b/web/src/pages/LlmRecordsPage.tsx
@@ -32,6 +32,7 @@ import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {Label} from "@/components/ui/label";
import {Switch} from "@/components/ui/switch";
+import {formatCost, formatTokens} from "@/lib/usage";
import type {Account, LlmPrice, LlmRecord, LlmRecordStats, LlmRecordStatus}
from "@/types";
/** How often the totals are recomputed while the live feed is open. */
@@ -47,29 +48,6 @@ function statusVariant(status: number): BadgeVariant {
return "danger";
}
-function formatCost(cost: number) {
- if (cost <= 0) {
- return "$0";
- }
- if (cost < 0.01) {
- return `$${cost.toFixed(4)}`;
- }
- if (cost < 1) {
- return `$${cost.toFixed(3)}`;
- }
- return `$${cost.toFixed(2)}`;
-}
-
-function formatTokens(tokens: number) {
- if (tokens < 1000) {
- return String(tokens);
- }
- if (tokens < 1000000) {
- return `${(tokens / 1000).toFixed(1)}k`;
- }
- return `${(tokens / 1000000).toFixed(2)}M`;
-}
-
/** Share of the input tokens served out of the prompt cache. */
function cacheHitRate(stats: LlmRecordStats) {
const input = stats.promptTokens + stats.cacheReadTokens +
stats.cacheWriteTokens;
diff --git a/web/src/types.ts b/web/src/types.ts
index d7edd09..73ed0a9 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -164,6 +164,25 @@ export interface ChannelTestResult {
message: string;
}
+/** One configuration file a provider switch writes, with what it will
contain. */
+export interface AgentProviderFile {
+ path: string;
+ format: string;
+ preview: string;
+}
+
+/** The state of the agent's own configuration file, written by the
orchestrator. */
+export interface AgentProvider {
+ supported: boolean;
+ applied: boolean;
+ channel: string;
+ mode: string;
+ baseUrl: string;
+ time: string;
+ files: string[];
+ detail: string;
+}
+
export interface Agent {
agentId: string;
name: string;
@@ -178,6 +197,24 @@ export interface Agent {
followup?: string;
/** The "owner/name" id of the channel this agent's requests are sent to. */
channel: string;
+ /** The channels tried, in order, when the bound one cannot answer. */
+ fallbacks: string[];
+ /** "gateway" routes through the local proxy, "direct" writes the upstream.
*/
+ mode: string;
+ provider: AgentProvider;
+}
+
+/** What the proxy has seen of one channel since Gateway started. */
+export interface ChannelHealth {
+ channel: string;
+ healthy: boolean;
+ successes: number;
+ failures: number;
+ consecutive: number;
+ lastError: string;
+ lastFailure: string;
+ /** When a suspended channel is tried again, empty while it is healthy. */
+ retryTime: string;
}
export interface AgentRecord {
@@ -270,6 +307,14 @@ export interface LlmModelStat {
cost: number;
}
+export interface LlmChannelStat {
+ channel: string;
+ requests: number;
+ failed: number;
+ tokens: number;
+ cost: number;
+}
+
export interface LlmRecordStats {
requests: number;
failed: number;
@@ -282,6 +327,7 @@ export interface LlmRecordStats {
/** Records whose model has no price entry. */
unpriced: number;
models: LlmModelStat[];
+ channels: LlmChannelStat[];
}
export interface MetricPoint {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]