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 661759a  feat(provider): switch OpenClaw to a bound provider
661759a is described below

commit 661759af04e0ab8b756d49ff47f38b5c8e5bd36b
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 01:15:44 2026 +0800

    feat(provider): switch OpenClaw to a bound provider
---
 agentprovider/file.go     |  43 +++++++
 agentprovider/gemini.go   |  17 +--
 agentprovider/openclaw.go | 309 ++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 353 insertions(+), 16 deletions(-)

diff --git a/agentprovider/file.go b/agentprovider/file.go
index bf3340f..94d5f31 100644
--- a/agentprovider/file.go
+++ b/agentprovider/file.go
@@ -156,3 +156,46 @@ func (t *txn) undo(done []*staged) {
                _ = os.WriteFile(item.path, item.previous, item.mode)
        }
 }
+
+// nestedObject is the object at path, nil when a step is missing or holds
+// something else.
+func nestedObject(config map[string]any, path ...string) map[string]any {
+       for _, key := range path {
+               config = objectAt(config, key)
+               if config == nil {
+                       return nil
+               }
+       }
+       return config
+}
+
+// ensureNested is nestedObject with the missing steps created. A step holding
+// something other than an object is replaced: the caller is about to own it,
+// and has saved what was there.
+func ensureNested(config map[string]any, path ...string) map[string]any {
+       for _, key := range path {
+               next := objectAt(config, key)
+               if next == nil {
+                       next = map[string]any{}
+                       config[key] = next
+               }
+               config = next
+       }
+       return config
+}
+
+// pruneEmpty drops the object at path once it is empty, and every parent that
+// empties with it, so removing an entry leaves the file as it was found.
+func pruneEmpty(config map[string]any, path ...string) {
+       for i := len(path); i > 0; i-- {
+               parent := nestedObject(config, path[:i-1]...)
+               if parent == nil {
+                       return
+               }
+               value := objectAt(parent, path[i-1])
+               if value == nil || len(value) > 0 {
+                       return
+               }
+               delete(parent, path[i-1])
+       }
+}
diff --git a/agentprovider/gemini.go b/agentprovider/gemini.go
index ced253a..d095e08 100644
--- a/agentprovider/gemini.go
+++ b/agentprovider/gemini.go
@@ -104,7 +104,7 @@ func (w geminiWriter) Apply(target Target, endpoint 
Endpoint) (map[string]string
                env.set(key, value)
        }
 
-       auth := nestedObject(settings, "security", "auth")
+       auth := ensureNested(settings, "security", "auth")
        if value, ok := auth["selectedType"].(string); ok {
                previous[geminiAuthKey] = value
        }
@@ -250,18 +250,3 @@ func (geminiWriter) paths(target Target) (string, string, 
error) {
        }
        return filepath.Join(home, ".gemini", ".env"), filepath.Join(home, 
".gemini", "settings.json"), nil
 }
-
-// nestedObject is the object at a path of keys, adding the ones the file does
-// not have yet.
-func nestedObject(config map[string]any, keys ...string) map[string]any {
-       current := config
-       for _, key := range keys {
-               next := objectAt(current, key)
-               if next == nil {
-                       next = map[string]any{}
-                       current[key] = next
-               }
-               current = next
-       }
-       return current
-}
diff --git a/agentprovider/openclaw.go b/agentprovider/openclaw.go
new file mode 100644
index 0000000..45f36b3
--- /dev/null
+++ b/agentprovider/openclaw.go
@@ -0,0 +1,309 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentprovider
+
+import (
+       "encoding/json"
+       "errors"
+       "fmt"
+       "strings"
+
+       "github.com/apache/casbin-gateway/agentconfig"
+       "github.com/apache/casbin-gateway/agenthome"
+       "github.com/apache/casbin-gateway/internal/jsonc"
+)
+
+const (
+       // openclawProvider is the provider entry Gateway owns in openclaw.json.
+       // Every other provider in the file is left alone.
+       openclawProvider = "casbin-gateway"
+       // openclawApi is the request adapter OpenClaw drives the endpoint 
with. The
+       // gateway answers OpenAI chat completions under the base URL written 
below.
+       openclawApi = "openai-completions"
+)
+
+// openclawModelKey is the setting Gateway overwrites, remembered under this
+// name. OpenClaw spells it either as a model reference or as an object with a
+// primary and its fallbacks, so whatever was there is kept as JSON.
+const openclawModelKey = "agents.defaults.model"
+
+// errOpenclawNoKey rejects a provider that forwards the caller's own
+// credentials: a provider OpenClaw does not ship holds no sign-in of its own,
+// so the key written here is the only one it ever sends.
+var errOpenclawNoKey = errors.New("OpenClaw needs an API key, so it cannot use 
a provider that forwards the credentials of the caller")
+
+// errOpenclawNoModel rejects a provider entry OpenClaw could not use: an
+// endpoint it does not ship has no model catalog of its own.
+var errOpenclawNoModel = errors.New("OpenClaw needs a model name, so bind a 
provider that lists at least one model")
+
+type openclawWriter struct{}
+
+func init() {
+       register(openclawWriter{})
+}
+
+func (openclawWriter) AgentId() string { return "openclaw" }
+
+func (openclawWriter) Protocol() string { return "openai" }
+
+func (w openclawWriter) Plan(target Target, endpoint Endpoint) ([]File, error) 
{
+       if err := w.check(endpoint); err != nil {
+               return nil, err
+       }
+       path, err := w.configPath(target)
+       if err != nil {
+               return nil, err
+       }
+
+       preview := endpoint
+       preview.ApiKey = maskSecret(endpoint.ApiKey)
+       data, err := encodeJSON(map[string]any{
+               "models": map[string]any{"providers": 
map[string]any{openclawProvider: w.provider(preview)}},
+               "agents": map[string]any{"defaults": map[string]any{"model": 
map[string]any{"primary": w.ref(endpoint.Model)}}},
+       })
+       if err != nil {
+               return nil, err
+       }
+       return []File{{Path: path, Format: "json", Preview: string(data)}}, nil
+}
+
+func (w openclawWriter) Apply(target Target, endpoint Endpoint) 
(map[string]string, error) {
+       if err := w.check(endpoint); err != nil {
+               return nil, err
+       }
+       path, err := w.configPath(target)
+       if err != nil {
+               return nil, err
+       }
+       config, err := w.load(path)
+       if err != nil {
+               return nil, err
+       }
+
+       previous := map[string]string{}
+       if defaults := nestedObject(config, "agents", "defaults"); defaults != 
nil {
+               if value, ok := defaults["model"]; ok {
+                       text, err := json.Marshal(value)
+                       if err != nil {
+                               return nil, err
+                       }
+                       previous[openclawModelKey] = string(text)
+               }
+       }
+
+       ensureNested(config, "models", "providers")[openclawProvider] = 
w.provider(endpoint)
+       // Writing the primary alone leaves the fallbacks the file names, which 
are
+       // the models OpenClaw drops to when this endpoint fails.
+       ensureNested(config, "agents", "defaults", "model")["primary"] = 
w.ref(endpoint.Model)
+       // An allowlist that is set rejects every model outside it, this one
+       // included. A file with none allows them all and gets none written.
+       if allowed := nestedObject(config, "agents", "defaults", "models"); 
allowed != nil {
+               for _, model := range endpoint.catalog() {
+                       allowed[w.ref(model)] = map[string]any{}
+               }
+       }
+
+       return previous, w.save(path, config)
+}
+
+func (w openclawWriter) Restore(target Target, previous map[string]string) 
error {
+       path, err := w.configPath(target)
+       if err != nil {
+               return err
+       }
+       config, err := w.load(path)
+       if err != nil {
+               return err
+       }
+
+       if providers := nestedObject(config, "models", "providers"); providers 
!= nil {
+               delete(providers, openclawProvider)
+       }
+       pruneEmpty(config, "models", "providers")
+
+       // The allowlist keeps what it held: only the references Gateway added 
are
+       // taken back out, and one it never had is not created here.
+       if allowed := nestedObject(config, "agents", "defaults", "models"); 
allowed != nil {
+               for ref := range allowed {
+                       if strings.HasPrefix(ref, openclawProvider+"/") {
+                               delete(allowed, ref)
+                       }
+               }
+       }
+
+       if text, ok := previous[openclawModelKey]; ok {
+               var value any
+               if err := json.Unmarshal([]byte(text), &value); err != nil {
+                       return fmt.Errorf("restore %s: %w", openclawModelKey, 
err)
+               }
+               ensureNested(config, "agents", "defaults")["model"] = value
+       } else if defaults := nestedObject(config, "agents", "defaults"); 
defaults != nil {
+               delete(defaults, "model")
+       }
+       pruneEmpty(config, "agents", "defaults")
+
+       return w.save(path, config)
+}
+
+func (w openclawWriter) Current(target Target) (string, error) {
+       path, err := w.configPath(target)
+       if err != nil {
+               return "", err
+       }
+       config, err := w.load(path)
+       if err != nil {
+               return "", err
+       }
+
+       selected, _, _ := strings.Cut(w.selected(config), "/")
+       if selected == "" {
+               return "", nil
+       }
+       if baseUrl := stringAt(nestedObject(config, "models", "providers", 
selected), "baseUrl"); baseUrl != "" {
+               return baseUrl, nil
+       }
+       // A provider without a base URL is one OpenClaw ships itself, which is
+       // still worth naming: it is not the endpoint Gateway wrote.
+       return selected, nil
+}
+
+// Builtin is the model OpenClaw runs on its own. It ships a catalog but no
+// account, so a file that selects nothing has no name to show.
+func (w openclawWriter) Builtin(target Target, previous map[string]string) 
string {
+       if previous != nil {
+               var value any
+               if err := json.Unmarshal([]byte(previous[openclawModelKey]), 
&value); err != nil {
+                       return ""
+               }
+               return openclawRef(value)
+       }
+
+       path, err := w.configPath(target)
+       if err != nil {
+               return ""
+       }
+       config, err := w.load(path)
+       if err != nil {
+               return ""
+       }
+       model := w.selected(config)
+       if strings.HasPrefix(model, openclawProvider+"/") {
+               return ""
+       }
+       return model
+}
+
+// check reports why OpenClaw cannot be pointed at endpoint.
+func (openclawWriter) check(endpoint Endpoint) error {
+       if endpoint.ApiKey == "" {
+               return errOpenclawNoKey
+       }
+       if endpoint.Model == "" {
+               return errOpenclawNoModel
+       }
+       return nil
+}
+
+// provider is the entry OpenClaw reaches the endpoint through. It ships no
+// catalog for a provider it does not know, so the entry carries the whole of
+// it: the adapter, the endpoint, the key and the models.
+func (openclawWriter) provider(endpoint Endpoint) map[string]any {
+       models := []any{}
+       for _, model := range endpoint.catalog() {
+               models = append(models, map[string]any{"id": model, "name": 
model})
+       }
+       return map[string]any{
+               "baseUrl": endpoint.BaseUrl,
+               "apiKey":  endpoint.ApiKey,
+               "api":     openclawApi,
+               "models":  models,
+       }
+}
+
+// ref is how OpenClaw names one model: by the provider serving it.
+func (openclawWriter) ref(model string) string {
+       return openclawProvider + "/" + model
+}
+
+// selected is the model reference the file names right now.
+func (openclawWriter) selected(config map[string]any) string {
+       defaults := nestedObject(config, "agents", "defaults")
+       if defaults == nil {
+               return ""
+       }
+       return openclawRef(defaults["model"])
+}
+
+// openclawRef reads a model reference out of either spelling of the setting:
+// the reference on its own, or the object naming it as the primary.
+func openclawRef(value any) string {
+       switch typed := value.(type) {
+       case string:
+               return typed
+       case map[string]any:
+               return stringAt(typed, "primary")
+       }
+       return ""
+}
+
+// load reads the config file. OpenClaw accepts comments and trailing commas in
+// it, so a file that has them is read rather than refused; saving writes plain
+// JSON, which is what editing the file this way costs.
+func (openclawWriter) load(path string) (map[string]any, error) {
+       data, _, _, err := readFile(path)
+       if err != nil {
+               return nil, err
+       }
+
+       config := map[string]any{}
+       if strings.TrimSpace(string(data)) == "" {
+               return config, nil
+       }
+       if err := json.Unmarshal(jsonc.Strip(data), &config); err != nil {
+               return nil, fmt.Errorf("parse %s: %w", path, err)
+       }
+       if config == nil {
+               return nil, fmt.Errorf("parse %s: the root must be a JSON 
object", path)
+       }
+       return config, nil
+}
+
+func (openclawWriter) save(path string, config map[string]any) error {
+       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()
+}
+
+// configPath is the file OpenClaw reads its settings from, which is the same
+// one its MCP servers are listed in.
+func (w openclawWriter) configPath(target Target) (string, error) {
+       home, err := agenthome.Resolve(target.Owner)
+       if err != nil {
+               return "", err
+       }
+       path, ok := agentconfig.McpConfigPath(w.AgentId(), home)
+       if !ok {
+               return "", fmt.Errorf("no configuration layout for %s", 
w.AgentId())
+       }
+       return path, nil
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to