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 0f316be  feat(provider): switch Hermes Agent to a bound provider
0f316be is described below

commit 0f316be9ef59b07d77ea3333fb5d55dee82ab18b
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 09:32:18 2026 +0800

    feat(provider): switch Hermes Agent to a bound provider
---
 agentprovider/hermes.go | 291 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 291 insertions(+)

diff --git a/agentprovider/hermes.go b/agentprovider/hermes.go
new file mode 100644
index 0000000..e33783b
--- /dev/null
+++ b/agentprovider/hermes.go
@@ -0,0 +1,291 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package agentprovider
+
+import (
+       "errors"
+       "fmt"
+       "os"
+       "path/filepath"
+
+       "github.com/apache/casbin-gateway/agenthome"
+       "github.com/apache/casbin-gateway/internal/hermes"
+       "github.com/apache/casbin-gateway/internal/yamledit"
+       "gopkg.in/yaml.v3"
+)
+
+const (
+       // hermesProvider is the provider entry Gateway owns in config.yaml. 
Every
+       // other provider in the file is left alone.
+       hermesProvider = "casbin-gateway"
+       // hermesApiMode is the wire format the endpoint is called with. The 
gateway
+       // answers OpenAI chat completions under the base URL written below.
+       hermesApiMode = "chat_completions"
+)
+
+// The settings Gateway overwrites, remembered under these names.
+const (
+       hermesProviderKey = "model.provider"
+       hermesModelKey    = "model.default"
+       hermesBaseUrlKey  = "model.base_url"
+       hermesApiModeKey  = "model.api_mode"
+)
+
+// hermesModelKeys are the fields of the model section Gateway owns, by the 
name
+// each is remembered under.
+var hermesModelKeys = map[string]string{
+       hermesProviderKey: "provider",
+       hermesModelKey:    "default",
+       hermesBaseUrlKey:  "base_url",
+       hermesApiModeKey:  "api_mode",
+}
+
+var hermesProvidersPath = []string{"providers", hermesProvider}
+
+// errHermesNoKey rejects a provider that forwards the caller's own 
credentials:
+// a provider entry Hermes does not ship holds no sign-in of its own, so the 
key
+// written here is the only one it ever sends.
+var errHermesNoKey = errors.New("Hermes Agent needs an API key, so it cannot 
use a provider that forwards the credentials of the caller")
+
+// errHermesNoModel rejects an entry Hermes could not use: the default model is
+// what it starts a conversation on.
+var errHermesNoModel = errors.New("Hermes Agent needs a model name, so bind a 
provider that lists at least one model")
+
+// hermesProviderConfig is the provider entry as config.yaml spells it.
+type hermesProviderConfig struct {
+       Api    string `yaml:"api"`
+       ApiKey string `yaml:"api_key"`
+}
+
+type hermesWriter struct{}
+
+func init() {
+       register(hermesWriter{})
+}
+
+func (hermesWriter) AgentId() string { return hermes.AgentID }
+
+func (hermesWriter) Protocol() string { return "openai" }
+
+func (w hermesWriter) 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)
+       config, err := yamledit.Render(map[string]any{
+               "model":     w.model(endpoint),
+               "providers": map[string]any{hermesProvider: 
w.provider(preview)},
+       })
+       if err != nil {
+               return nil, err
+       }
+       return []File{{Path: path, Format: "yaml", Preview: config}}, nil
+}
+
+func (w hermesWriter) 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
+       }
+       document, root, err := w.load(path)
+       if err != nil {
+               return nil, err
+       }
+
+       previous := map[string]string{}
+       for key, field := range hermesModelKeys {
+               if value := yamledit.String(root, "model", field); value != "" {
+                       previous[key] = value
+               }
+       }
+
+       if err := yamledit.Set(root, w.provider(endpoint), 
hermesProvidersPath...); err != nil {
+               return nil, err
+       }
+       if err := yamledit.Set(root, hermesProvider, "model", "provider"); err 
!= nil {
+               return nil, err
+       }
+       if err := yamledit.Set(root, endpoint.Model, "model", "default"); err 
!= nil {
+               return nil, err
+       }
+       if err := yamledit.Set(root, hermesApiMode, "model", "api_mode"); err 
!= nil {
+               return nil, err
+       }
+       // A base URL on the model itself bypasses provider resolution, so one 
left
+       // from another tool would send the requests somewhere else entirely.
+       yamledit.Delete(root, "model", "base_url")
+
+       return previous, w.save(path, document)
+}
+
+func (w hermesWriter) Restore(target Target, previous map[string]string) error 
{
+       path, err := w.configPath(target)
+       if err != nil {
+               return err
+       }
+       document, root, err := w.load(path)
+       if err != nil {
+               return err
+       }
+
+       yamledit.Delete(root, hermesProvidersPath...)
+       if yamledit.IsEmpty(yamledit.Get(root, "providers")) {
+               yamledit.Delete(root, "providers")
+       }
+       for key, field := range hermesModelKeys {
+               if value, ok := previous[key]; ok {
+                       if err := yamledit.Set(root, value, "model", field); 
err != nil {
+                               return err
+                       }
+                       continue
+               }
+               yamledit.Delete(root, "model", field)
+       }
+       if yamledit.IsEmpty(yamledit.Get(root, "model")) {
+               yamledit.Delete(root, "model")
+       }
+       // A config.yaml that never held anything but Gateway's own settings is
+       // removed rather than left behind as an empty mapping, which is what 
the
+       // monitoring patch does with the same file.
+       if yamledit.IsEmpty(root) {
+               if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+                       return err
+               }
+               return nil
+       }
+       return w.save(path, document)
+}
+
+func (w hermesWriter) Current(target Target) (string, error) {
+       path, err := w.configPath(target)
+       if err != nil {
+               return "", err
+       }
+       _, root, err := w.load(path)
+       if err != nil {
+               return "", err
+       }
+
+       // A base URL on the model wins over the provider it names, so it is the
+       // endpoint Hermes actually calls.
+       if baseUrl := yamledit.String(root, "model", "base_url"); baseUrl != "" 
{
+               return baseUrl, nil
+       }
+       selected := yamledit.String(root, "model", "provider")
+       if selected == "" {
+               return "", nil
+       }
+       if api := yamledit.String(root, "providers", selected, "api"); api != 
"" {
+               return api, nil
+       }
+       // A provider without an endpoint is one Hermes resolves itself, which 
is
+       // still worth naming: it is not the endpoint Gateway wrote.
+       return selected, nil
+}
+
+// Builtin is the model Hermes runs on its own. It resolves a provider from its
+// own catalog, so a file naming no model has no name to show.
+func (w hermesWriter) Builtin(target Target, previous map[string]string) 
string {
+       if previous != nil {
+               return previous[hermesModelKey]
+       }
+
+       path, err := w.configPath(target)
+       if err != nil {
+               return ""
+       }
+       _, root, err := w.load(path)
+       if err != nil {
+               return ""
+       }
+       if yamledit.String(root, "model", "provider") == hermesProvider {
+               return ""
+       }
+       return yamledit.String(root, "model", "default")
+}
+
+// check reports why Hermes cannot be pointed at endpoint.
+func (hermesWriter) check(endpoint Endpoint) error {
+       if endpoint.ApiKey == "" {
+               return errHermesNoKey
+       }
+       if endpoint.Model == "" {
+               return errHermesNoModel
+       }
+       return nil
+}
+
+func (hermesWriter) provider(endpoint Endpoint) hermesProviderConfig {
+       return hermesProviderConfig{Api: endpoint.BaseUrl, ApiKey: 
endpoint.ApiKey}
+}
+
+// model is the section that says what Hermes starts on: the provider written
+// above, and its first model.
+func (hermesWriter) model(endpoint Endpoint) map[string]any {
+       return map[string]any{
+               "provider": hermesProvider,
+               "default":  endpoint.Model,
+               "api_mode": hermesApiMode,
+       }
+}
+
+func (hermesWriter) load(path string) (*yamledit.Document, *yaml.Node, error) {
+       data, _, _, err := readFile(path)
+       if err != nil {
+               return nil, nil, err
+       }
+       document, err := yamledit.Parse(data)
+       if err != nil {
+               return nil, nil, fmt.Errorf("parse %s: %w", path, err)
+       }
+       root, err := document.Mapping()
+       if err != nil {
+               return nil, nil, fmt.Errorf("parse %s: %w", path, err)
+       }
+       return document, root, nil
+}
+
+func (hermesWriter) save(path string, document *yamledit.Document) error {
+       data, err := document.Bytes()
+       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 Hermes reads its settings from, in the home directory
+// its own launcher keeps them in.
+func (hermesWriter) configPath(target Target) (string, error) {
+       home, err := agenthome.Resolve(target.Owner)
+       if err != nil {
+               return "", err
+       }
+       return filepath.Join(home, ".hermes", "config.yaml"), nil
+}


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

Reply via email to