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]