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 334307a  fix(proxy): let a client ask for a model no provider names
334307a is described below

commit 334307ad97b1c8ef8f732d63b0ea067d8cc37899
Author: Yang Luo <[email protected]>
AuthorDate: Mon Aug 31 00:05:08 2026 +0800

    fix(proxy): let a client ask for a model no provider names
---
 controllers/models.go | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++
 object/provider.go    |  56 ++++++++++++++++---
 routers/router.go     |   6 ++
 3 files changed, 205 insertions(+), 8 deletions(-)

diff --git a/controllers/models.go b/controllers/models.go
new file mode 100644
index 0000000..4b9ee59
--- /dev/null
+++ b/controllers/models.go
@@ -0,0 +1,151 @@
+// 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 (
+       "encoding/json"
+       "errors"
+       "net/http"
+       "time"
+
+       "github.com/apache/casbin-gateway/object"
+       "github.com/apache/casbin-gateway/protocol"
+       "github.com/beego/beego"
+)
+
+// Models answers the model list every client asks for to fill its model 
picker.
+// OpenAI and Anthropic both call GET /v1/models and read a different shape out
+// of the answer, so one entry carries the fields of both.
+func (c *ApiController) Models() {
+       if !c.allowModelListing() {
+               return
+       }
+
+       models, err := object.ListEnabledModels()
+       if err != nil {
+               beego.Error("model listing failed:", err)
+               c.writeModelsError(http.StatusBadGateway, "server_error", 
"provider lookup failed")
+               return
+       }
+       c.writeModelList(models)
+}
+
+// Model answers the retrieve-a-model endpoint an Anthropic client checks the
+// configured model with. A name reaches an upstream as long as one provider is
+// enabled, see GetProvidersByModel(), so that is what "available" means here:
+// answering 404 for a name the gateway would happily route would tell the
+// client its model is missing when it is not.
+func (c *ApiController) Model() {
+       if !c.allowModelListing() {
+               return
+       }
+
+       model := c.Ctx.Input.Param(":modelId")
+       if _, err := object.GetProvidersByModel(model); err != nil {
+               if errors.Is(err, object.ErrNoProviderAvailable) {
+                       c.writeModelsError(http.StatusNotFound, 
"not_found_error", err.Error())
+               } else {
+                       beego.Error("model lookup failed:", err)
+                       c.writeModelsError(http.StatusBadGateway, 
"server_error", "provider lookup failed")
+               }
+               return
+       }
+       c.writeProxyBody(http.StatusOK, mustEncode(modelEntry(model)))
+}
+
+// AgentModel is the same check against the agent's own provider chain.
+func (c *ApiController) AgentModel() {
+       if !c.allowModelListing() {
+               return
+       }
+
+       agentId := c.Ctx.Input.Param(":agentId")
+       if _, err := object.GetProvidersByAgent(agentId); err != nil {
+               c.writeModelsError(http.StatusBadRequest, 
"invalid_request_error", err.Error())
+               return
+       }
+       c.writeProxyBody(http.StatusOK, 
mustEncode(modelEntry(c.Ctx.Input.Param(":modelId"))))
+}
+
+// AgentModels is the same list narrowed to what the agent's own provider chain
+// serves, for a client pointed at ".../v1/agents/<agentId>".
+func (c *ApiController) AgentModels() {
+       if !c.allowModelListing() {
+               return
+       }
+
+       agentId := c.Ctx.Input.Param(":agentId")
+       providers, err := object.GetProvidersByAgent(agentId)
+       if err != nil {
+               c.writeModelsError(http.StatusBadRequest, 
"invalid_request_error", err.Error())
+               return
+       }
+       c.writeModelList(object.ModelsOfProviders(providers))
+}
+
+// allowModelListing gates the listing on the same token the proxy endpoints 
ask
+// an off-box caller for.
+func (c *ApiController) allowModelListing() bool {
+       if c.allowRelay() {
+               return true
+       }
+       c.writeModelsError(http.StatusUnauthorized, "authentication_error",
+               "this relay is reachable from the network, so it needs the 
token shown next to the provider in Casbin Gateway")
+       return false
+}
+
+func (c *ApiController) writeModelList(models []string) {
+       entries := []map[string]any{}
+       for _, model := range models {
+               entries = append(entries, modelEntry(model))
+       }
+
+       payload := map[string]any{"object": "list", "data": entries, 
"has_more": false}
+       if len(models) > 0 {
+               payload["first_id"] = models[0]
+               payload["last_id"] = models[len(models)-1]
+       }
+       c.writeProxyBody(http.StatusOK, mustEncode(payload))
+}
+
+// modelEntry carries the fields of both APIs, since one endpoint answers the
+// clients of both.
+func modelEntry(model string) map[string]any {
+       created := time.Now().Unix()
+       return map[string]any{
+               "id":           model,
+               "object":       "model",
+               "type":         "model",
+               "display_name": model,
+               "created":      created,
+               "created_at":   time.Unix(created, 
0).UTC().Format(time.RFC3339),
+               "owned_by":     "casbin-gateway",
+       }
+}
+
+func mustEncode(payload map[string]any) []byte {
+       body, err := json.Marshal(payload)
+       if err != nil {
+               beego.Error("model encoding failed:", err)
+               return []byte(`{"object":"list","data":[],"has_more":false}`)
+       }
+       return body
+}
+
+// writeModelsError uses the Anthropic error shape, whose "error" object is the
+// one an OpenAI client reads too.
+func (c *ApiController) writeModelsError(statusCode int, kind string, message 
string) {
+       c.writeProxyError(protocol.Of(protocol.Anthropic), statusCode, kind, 
message)
+}
diff --git a/object/provider.go b/object/provider.go
index 9b1d4fb..cf39084 100644
--- a/object/provider.go
+++ b/object/provider.go
@@ -779,11 +779,18 @@ func probeDetail(body []byte) string {
        return ": " + text
 }
 
-// GetProvidersByModel returns every enabled provider that supports the given
-// model name, ordered by priority (ascending, so the lowest value comes first)
-// so that the caller can fail over from one provider to the next. It queries 
all
-// providers globally (no owner filter) because /v1/chat/completions is an
-// unauthenticated public endpoint.
+// GetProvidersByModel returns the enabled providers a request naming this 
model
+// is forwarded to, ordered by priority (ascending, so the lowest value comes
+// first) so that the caller can fail over from one provider to the next. It
+// queries all providers globally (no owner filter) because 
/v1/chat/completions
+// is an unauthenticated public endpoint.
+//
+// A model no provider names falls back to every enabled provider, which is 
then
+// sent a model it does serve, see ProviderModel(). An Anthropic client asks 
for
+// model names of its own whatever it is configured with - claude-haiku-4-5 for
+// its background work, and whichever built-in it probes availability with - so
+// rejecting them leaves the client unable to talk to any third-party provider
+// at all.
 func GetProvidersByModel(model string) ([]*Provider, error) {
        providers := []*Provider{}
        err := ormer.Engine.Where("status = ?", 
"enabled").Asc("priority").Find(&providers)
@@ -815,14 +822,47 @@ func GetProvidersByModel(model string) ([]*Provider, 
error) {
        }
        matchedProviders = append(matchedProviders, wildcardProviders...)
 
-       decryptProviders(matchedProviders)
-
        if len(matchedProviders) == 0 {
-               return nil, fmt.Errorf("%w: %s", ErrNoProviderAvailable, model)
+               if len(providers) == 0 {
+                       return nil, fmt.Errorf("%w: %s", 
ErrNoProviderAvailable, model)
+               }
+               matchedProviders = providers
        }
+
+       decryptProviders(matchedProviders)
        return matchedProviders, nil
 }
 
+// ListEnabledModels is every model the enabled providers serve, in priority
+// order and without duplicates. It is what the models endpoint answers with, 
so
+// that a client fills its model picker with what this gateway can actually
+// reach.
+func ListEnabledModels() ([]string, error) {
+       providers := []*Provider{}
+       err := ormer.Engine.Where("status = ?", 
"enabled").Asc("priority").Find(&providers)
+       if err != nil {
+               return nil, fmt.Errorf("provider query failed: %w", err)
+       }
+       return ModelsOfProviders(providers), nil
+}
+
+// ModelsOfProviders flattens the model lists of providers into one, keeping 
the
+// order they are tried in.
+func ModelsOfProviders(providers []*Provider) []string {
+       models := []string{}
+       seen := map[string]bool{}
+       for _, provider := range providers {
+               for _, model := range provider.Models {
+                       if model == "" || seen[model] {
+                               continue
+                       }
+                       seen[model] = true
+                       models = append(models, model)
+               }
+       }
+       return models
+}
+
 // ProviderModel is the model name to send a provider for the model the client
 // asked for. An agent picks its model on its own - Codex Desktop remembers the
 // one chosen in its own state, whatever the config file says - so a provider
diff --git a/routers/router.go b/routers/router.go
index 042d1b7..4fea082 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -138,8 +138,14 @@ func initAPI() {
        beego.Router("/v1/responses", &controllers.ApiController{}, 
"POST:Responses")
        beego.Router("/v1/messages", &controllers.ApiController{}, 
"POST:Messages")
        beego.Router("/v1/messages/count_tokens", &controllers.ApiController{}, 
"POST:CountTokens")
+       beego.Router("/v1/models", &controllers.ApiController{}, "GET:Models")
+       beego.Router("/v1/models/:modelId", &controllers.ApiController{}, 
"GET:Model")
        beego.Router("/v1/agents/:agentId/chat/completions", 
&controllers.ApiController{}, "POST:AgentChatCompletions")
        beego.Router("/v1/agents/:agentId/responses", 
&controllers.ApiController{}, "POST:AgentResponses")
        beego.Router("/v1/agents/:agentId/v1/messages", 
&controllers.ApiController{}, "POST:AgentMessages")
        beego.Router("/v1/agents/:agentId/v1/messages/count_tokens", 
&controllers.ApiController{}, "POST:AgentCountTokens")
+       beego.Router("/v1/agents/:agentId/models", 
&controllers.ApiController{}, "GET:AgentModels")
+       beego.Router("/v1/agents/:agentId/v1/models", 
&controllers.ApiController{}, "GET:AgentModels")
+       beego.Router("/v1/agents/:agentId/models/:modelId", 
&controllers.ApiController{}, "GET:AgentModel")
+       beego.Router("/v1/agents/:agentId/v1/models/:modelId", 
&controllers.ApiController{}, "GET:AgentModel")
 }


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

Reply via email to