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 712cd5d feat: add unified LLM API proxy endpoint /v1/chat/completions
(#190)
712cd5d is described below
commit 712cd5dd9e4247ea431900cf42ec1102ab825f25
Author: Ziyang Dong <[email protected]>
AuthorDate: Thu Aug 13 13:41:25 2026 +0800
feat: add unified LLM API proxy endpoint /v1/chat/completions (#190)
---
controllers/proxy.go | 192 +++++++++++++++++++++++++++++++++++++++++++++++++++
object/channel.go | 26 +++++++
routers/filter.go | 2 +-
routers/router.go | 3 +
4 files changed, 222 insertions(+), 1 deletion(-)
diff --git a/controllers/proxy.go b/controllers/proxy.go
new file mode 100644
index 0000000..db96979
--- /dev/null
+++ b/controllers/proxy.go
@@ -0,0 +1,192 @@
+// Copyright 2023 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 (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/apache/casbin-gateway/object"
+ "github.com/beego/beego"
+)
+
+// chatCompletionsRequest contains only the fields needed for routing.
+// All other fields (messages, temperature, etc.) are forwarded as-is to the
upstream.
+// (PM section 3.3.1)
+type chatCompletionsRequest struct {
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+}
+
+// openaiErrorResponse follows the OpenAI API error format.
+// (PM section 3.3.7)
+type openaiErrorResponse struct {
+ Error openaiErrorDetail `json:"error"`
+}
+
+type openaiErrorDetail struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+}
+
+// proxyClient is a shared HTTP client for upstream requests.
+// Reusing a single instance allows TCP connection pooling across requests.
+var proxyClient = &http.Client{
+ CheckRedirect: func(*http.Request, []*http.Request) error { return
http.ErrUseLastResponse },
+}
+
+// ChatCompletions is the OpenAI-compatible chat completions proxy endpoint.
+// It matches an upstream channel by model name and forwards the request
+// and response body as-is (pass-through).
+// Supports SSE streaming when stream=true in the request body.
+// This endpoint does NOT require Casdoor authentication (auth deferred to
milestone 1.3).
+// (PM section 3.3)
+func (c *ApiController) ChatCompletions() {
+ rawBody := c.Ctx.Input.RequestBody
+
+ // 3.3.1 — Parse only model and stream; forward everything else as-is.
+ var req chatCompletionsRequest
+ if err := json.Unmarshal(rawBody, &req); err != nil {
+ c.writeOpenAIError(http.StatusBadRequest,
"invalid_request_error", "invalid request body")
+ return
+ }
+ if req.Model == "" {
+ c.writeOpenAIError(http.StatusBadRequest,
"invalid_request_error", "model is required")
+ return
+ }
+
+ // 3.3.2 — Match channel globally (no owner filter; Judge Q1 → Plan A).
+ channel, err := object.GetChannelByModel(req.Model)
+ if err != nil {
+ if errors.Is(err, object.ErrNoChannelAvailable) {
+ c.writeOpenAIError(http.StatusBadRequest,
"invalid_request_error", err.Error())
+ } else {
+ beego.Error("channel lookup failed:", err)
+ c.writeOpenAIError(http.StatusBadGateway,
"server_error", "channel lookup failed")
+ }
+ return
+ }
+
+ // Guard against misconfigured channels with empty base URL.
+ if channel.BaseUrl == "" {
+ beego.Error("channel has empty base URL:", channel.GetId())
+ c.writeOpenAIError(http.StatusBadGateway, "server_error",
"channel base URL is not configured")
+ return
+ }
+
+ // 3.3.4 — Build upstream request. Path is hardcoded to
OpenAI-compatible /v1/chat/completions.
+ upstreamURL := strings.TrimRight(channel.BaseUrl, "/") +
"/v1/chat/completions"
+ upstreamReq, err := http.NewRequestWithContext(c.Ctx.Request.Context(),
"POST", upstreamURL, bytes.NewReader(rawBody))
+ if err != nil {
+ c.writeOpenAIError(http.StatusBadGateway, "server_error",
"upstream connection failed")
+ return
+ }
+ upstreamReq.Header.Set("Content-Type", "application/json")
+ upstreamReq.Header.Set("Authorization", "Bearer "+channel.ApiKey)
+
+ // 3.3.5 — Per-request timeout via context.
+ ctx, cancel := context.WithTimeout(c.Ctx.Request.Context(),
120*time.Second)
+ defer cancel()
+ upstreamReq = upstreamReq.WithContext(ctx)
+
+ // Execute upstream request.
+ upstreamResp, err := proxyClient.Do(upstreamReq)
+ if err != nil {
+ if ctx.Err() == context.DeadlineExceeded {
+ c.writeOpenAIError(http.StatusGatewayTimeout,
"server_error", "upstream timeout")
+ } else {
+ c.writeOpenAIError(http.StatusBadGateway,
"server_error", "upstream connection failed")
+ }
+ return
+ }
+ defer upstreamResp.Body.Close()
+
+ // 3.3.6 / 3.3.7 — Stream SSE or copy non-stream response as
pass-through.
+ if req.Stream {
+ c.proxySSE(upstreamResp)
+ } else {
+ c.proxyNonStream(upstreamResp)
+ }
+}
+
+// proxyNonStream copies the upstream response headers, status code, and body
+// directly to the client without any transformation (pass-through mode).
+// (PM section 3.3.7)
+func (c *ApiController) proxyNonStream(upstreamResp *http.Response) {
+ for k, vs := range upstreamResp.Header {
+ for _, v := range vs {
+ c.Ctx.ResponseWriter.Header().Add(k, v)
+ }
+ }
+ c.Ctx.ResponseWriter.WriteHeader(upstreamResp.StatusCode)
+ if _, err := io.Copy(c.Ctx.ResponseWriter, upstreamResp.Body); err !=
nil {
+ beego.Error("proxy non-stream response copy failed:", err)
+ }
+}
+
+// proxySSE forwards an SSE (Server-Sent Events) stream from the upstream
+// to the client. Each chunk is written and flushed immediately so that
+// the client receives events in real time.
+// Monitors client disconnection via c.Ctx.Request.Context().Done().
+// (PM section 3.3.6)
+func (c *ApiController) proxySSE(upstreamResp *http.Response) {
+ c.Ctx.ResponseWriter.Header().Set("Content-Type", "text/event-stream")
+ c.Ctx.ResponseWriter.Header().Set("Cache-Control", "no-cache")
+ c.Ctx.ResponseWriter.Header().Set("Connection", "keep-alive")
+ c.Ctx.ResponseWriter.WriteHeader(upstreamResp.StatusCode)
+
+ buf := make([]byte, 4096)
+ for {
+ // Respect client disconnection to avoid goroutine leak.
+ select {
+ case <-c.Ctx.Request.Context().Done():
+ return
+ default:
+ }
+
+ n, err := upstreamResp.Body.Read(buf)
+ if n > 0 {
+ c.Ctx.ResponseWriter.Write(buf[:n])
+ c.Ctx.ResponseWriter.Flush()
+ }
+ if err != nil {
+ if err != io.EOF {
+ beego.Error("SSE upstream read error:", err)
+ }
+ return
+ }
+ }
+}
+
+// writeOpenAIError writes an OpenAI-compatible JSON error response
+// with the given HTTP status code, error type, and message.
+// (PM section 3.3.7)
+func (c *ApiController) writeOpenAIError(statusCode int, errType, message
string) {
+ resp := openaiErrorResponse{
+ Error: openaiErrorDetail{
+ Message: message,
+ Type: errType,
+ },
+ }
+ c.Ctx.ResponseWriter.Header().Set("Content-Type", "application/json")
+ c.Ctx.ResponseWriter.WriteHeader(statusCode)
+ json.NewEncoder(c.Ctx.ResponseWriter).Encode(resp)
+}
diff --git a/object/channel.go b/object/channel.go
index 8dffa1f..99bee30 100644
--- a/object/channel.go
+++ b/object/channel.go
@@ -27,6 +27,12 @@ import (
"github.com/xorm-io/core"
)
+// ErrNoChannelAvailable is returned by GetChannelByModel when no enabled
+// channel matches the requested model name. It is a sentinel error so
+// callers can distinguish "no match" (client error, HTTP 400) from
+// database failures (server error, HTTP 502).
+var ErrNoChannelAvailable = errors.New("no available channel")
+
// ApiKeyMask is what the API returns in place of a stored API key. Sending it
// back in an update means "keep the existing key"; sending anything else
// (including an empty string) overwrites the stored key.
@@ -291,3 +297,23 @@ func TestChannelConnectivity(channel *Channel) (bool, int,
string) {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
return resp.StatusCode >= 200 && resp.StatusCode < 300,
resp.StatusCode, resp.Status
}
+
+// GetChannelByModel returns the highest-priority enabled channel that supports
+// the given model name. It queries all channels globally (no owner filter)
+// because /v1/chat/completions is an unauthenticated public endpoint.
+// Corresponds to PM section 3.4.
+func GetChannelByModel(model string) (*Channel, error) {
+ channels := []*Channel{}
+ err := ormer.Engine.Where("status = ?",
"enabled").Asc("priority").Find(&channels)
+ if err != nil {
+ return nil, fmt.Errorf("channel query failed: %w", err)
+ }
+ for _, ch := range channels {
+ for _, m := range ch.Models {
+ if m == model {
+ return ch, nil
+ }
+ }
+ }
+ return nil, fmt.Errorf("%w: %s", ErrNoChannelAvailable, model)
+}
diff --git a/routers/filter.go b/routers/filter.go
index d092a7d..2b9fc91 100644
--- a/routers/filter.go
+++ b/routers/filter.go
@@ -25,7 +25,7 @@ import (
func TransparentStatic(ctx *context.Context) {
urlPath := ctx.Request.URL.Path
- if strings.HasPrefix(urlPath, "/api/") {
+ if strings.HasPrefix(urlPath, "/api/") || strings.HasPrefix(urlPath,
"/v1/") {
return
}
diff --git a/routers/router.go b/routers/router.go
index 9e6e03f..d42c2c3 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -83,4 +83,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")
+
+ // OpenAI-compatible chat completions endpoint for LLM gateway
milestone 1.2.
+ beego.Router("/v1/chat/completions", &controllers.ApiController{},
"POST:ChatCompletions")
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]