From 4b0b5356c2118b839a66b69610bd5e0150ebc25a Mon Sep 17 00:00:00 2001 From: renjue Date: Mon, 29 Jun 2026 21:41:06 +0800 Subject: [PATCH] Split ingress keys into text/image protocols with Replicate model listing. Text keys use OpenAI routes; image keys use predictions with ingress Model ID, OpenAPI input schemas, protocol-aware admin whitelist, and in-app API docs aligned to UI conventions. Co-authored-by: Cursor --- README.md | 81 +++++++++-- cmd/luminary/main.go | 9 +- internal/handler/admin/ingress.go | 46 +++++-- internal/handler/proxy/handler.go | 15 -- internal/handler/v1/models.go | 57 ++++++++ internal/middleware/middleware.go | 38 +++++ internal/model/entities.go | 11 +- internal/model/ingress_protocol.go | 21 +++ internal/model/ingress_protocol_test.go | 22 +++ internal/prediction/models.go | 85 ++++++++++++ internal/prediction/models_test.go | 27 ++++ internal/prediction/params.go | 53 +++++++ internal/prediction/params_test.go | 52 +++++++ internal/prediction/service.go | 7 +- internal/store/sqlite/store.go | 10 ++ web/dist/index.html | 4 +- web/src/api/client.ts | 1 + web/src/i18n/locales/en.ts | 68 +++++++++ web/src/i18n/locales/zh-CN.ts | 68 +++++++++ web/src/layouts/MainLayout.vue | 3 + web/src/router/index.ts | 12 ++ web/src/styles/theme.css | 35 ++++- web/src/views/IngressApiDocView.vue | 118 ++++++++++++++++ web/src/views/IngressKeysView.vue | 176 +++++++++++++++++++----- 24 files changed, 937 insertions(+), 82 deletions(-) create mode 100644 internal/handler/v1/models.go create mode 100644 internal/model/ingress_protocol.go create mode 100644 internal/model/ingress_protocol_test.go create mode 100644 internal/prediction/models.go create mode 100644 internal/prediction/models_test.go create mode 100644 internal/prediction/params_test.go create mode 100644 web/src/views/IngressApiDocView.vue diff --git a/README.md b/README.md index 103de80..2d815de 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Luminary -OpenAI 兼容 AI 网关,带 Web 管理台。支持多 Provider 出口、入口密钥治理、看板监控与安全管控。控制面自研(Go + SQLite),数据面提供 OpenAI 兼容 `/v1/*` 推理 API。 +OpenAI 兼容 AI 网关,带 Web 管理台。支持多 Provider 出口、入口密钥治理、看板监控与安全管控。控制面自研(Go + SQLite),数据面统一通过入口密钥对外提供 **OpenAI 兼容推理 API** 与 **Replicate 兼容 Predictions API** 两套协议。 所有运行参数在 Web 管理台完成配置,**无需手写配置文件**。管理台支持**简体中文、英文**两种界面语言,可在顶栏随时切换。 @@ -32,7 +32,7 @@ OpenAI 兼容 AI 网关,带 Web 管理台。支持多 Provider 出口、入口 | 模块 | 说明 | |------|------| -| 出口管理 | OpenAI / Anthropic / Azure OpenAI / OpenRouter / Ollama / Custom,多 Key 加权负载均衡与故障转移 | +| 出口管理 | OpenAI / Anthropic / Azure OpenAI / OpenRouter / Ollama / Custom / ComfyUI,多 Key 加权负载均衡与故障转移 | | 入口管理 | OpenAI 兼容 API,入口密钥(`sk-lum-*`),Virtual Key 路由、预算与 RPM/TPM 限流 | | 看板监控 | Provider 健康检查、24h 请求/Token 统计、入口用量排行 | | 请求日志 | 全量请求记录、按模型定价的成本估算 | @@ -44,14 +44,27 @@ OpenAI 兼容 AI 网关,带 Web 管理台。支持多 Provider 出口、入口 ``` 客户端 SDK / curl → /v1/*(入口密钥 sk-lum-*) - → Luminary Gateway(路由、限流、故障转移) - → Provider 出口(多 Key 加权负载均衡) - ↑ + ├─ OpenAI 兼容推理(chat / embeddings / rerank …) + └─ Replicate 兼容 Predictions(ComfyUI 工作流) + → Luminary Gateway(路由、限流、故障转移) + → Provider 出口(多 Key 加权负载均衡) + ↑ 管理台 REST API + Vue UI (:8293) ↓ SQLite + 内存缓存 ``` +## 入口协议 + +Luminary 的核心是 **AI 网关**:客户端用入口密钥(`sk-lum-*`)调用,网关负责鉴权、路由、限流与上游故障转移。对外提供两套兼容协议,Base URL 均为 `http://:8293/v1`: + +| 协议 | 兼容标准 | 典型场景 | +|------|----------|----------| +| **OpenAI 兼容推理 API** | [OpenAI `/v1/*`](https://platform.openai.com/docs/api-reference) | 语言模型对话、向量嵌入、重排序等同步推理 | +| **Replicate Predictions API** | [Replicate `/v1/predictions`](https://replicate.com/docs/reference/http) | ComfyUI 工作流异步图像生成 | + +两套协议共用同一入口密钥;具体可调用的 endpoint 取决于「出口管理」中为 Provider 启用的能力分类。 + ## 快速开始(开发) ### 依赖 @@ -143,21 +156,38 @@ data/ 首次启动自动创建数据库与默认系统设置(含随机加密密钥)。默认管理账号 `admin` / `admin123`(仅当数据库中尚无管理员时创建)。 -## 入口 API(推理) +## 入口密钥与协议 -客户端使用在管理台「入口管理」创建的**入口密钥**(`sk-lum-*`)调用,与 OpenAI SDK 兼容。 +管理台「入口管理」中的每个密钥属于以下两类之一: -**鉴权** +| 协议 | 用途 | 可调用的 API | +|------|------|----------------| +| **文本(OpenAI)** | LLM / Embedding / Rerank | `/v1/chat/completions`、`/v1/embeddings` 等 | +| **图片(Replicate)** | ComfyUI 文生图 / 图生图 | `/v1/predictions`、`GET /v1/models`(Replicate 格式) | + +已有密钥默认归类为**文本**,可在管理台编辑修改为**图片**。 + +同一密钥不能混用两套协议:用文本密钥访问 `/v1/predictions` 或用图片密钥访问 `/v1/chat/completions` 将返回 `403`。 + +## 入口 API(OpenAI 兼容) + +客户端使用协议为**文本**的入口密钥(`sk-lum-*`)调用,与 OpenAI SDK 兼容。 + +**鉴权**(二选一) ```http Authorization: Bearer sk-lum- ``` +```http +X-Api-Key: sk-lum- +``` + **Base URL**:`http://:8293/v1` | 方法 | 路径 | 说明 | 状态 | |------|------|------|------| -| `GET` | `/v1/models` | 列出当前入口密钥可用的模型 | 已支持 | +| `GET` | `/v1/models` | 列出当前文本密钥可用的模型(OpenAI 格式) | 已支持 | | `POST` | `/v1/chat/completions` | 对话补全;`stream: true` 时返回 SSE 流式 | 已支持 | | `POST` | `/v1/responses` | OpenAI Responses API;`stream: true` 时返回 SSE 流式 | 已支持 | | `POST` | `/v1/completions` | 文本补全(Legacy) | 已支持 | @@ -216,6 +246,39 @@ curl http://localhost:8293/v1/models \ 入口密钥的 Provider / Model 白名单、预算、RPM/TPM 限流与路由规则均在管理台「入口管理」配置;白名单为空表示不限制。 +## Predictions API(Replicate 兼容) + +ComfyUI 出口的异步任务接口,需使用协议为**图片**的入口密钥。 + +| 方法 | 路径 | 说明 | 状态 | +|------|------|------|------| +| `GET` | `/v1/models` | 列出当前图片密钥可用的工作流模型(Replicate 格式) | 已支持 | +| `POST` | `/v1/predictions` | 创建异步任务;请求头 `Prefer: wait` 时同步等待完成 | 已支持 | +| `GET` | `/v1/predictions` | 列出任务(`cursor` 分页) | 已支持 | +| `GET` | `/v1/predictions/:id` | 查询任务状态与输出 | 已支持 | +| `POST` | `/v1/predictions/:id/cancel` | 取消进行中的任务 | 已支持 | +| `GET` | `/v1/predictions/:id/stream` | SSE 流式推送任务进度 | 已支持 | + +请求体中 `version` 为入口 Model ID(与 `GET /v1/models` 返回的 `latest_version.id` 一致,一般为 `owner/name` 或别名);`input` 字段见模型 `openapi_schema.components.schemas.Input`。 + +**示例:列出工作流模型** + +```bash +curl http://localhost:8293/v1/models \ + -H "Authorization: Bearer sk-lum-..." # 图片协议密钥 +``` + +**示例:创建 Predictions 任务** + +```bash +curl -X POST http://localhost:8293/v1/predictions \ + -H "Authorization: Bearer sk-lum-..." \ + -H "Content-Type: application/json" \ + -d '{"version":"luminary/flux-txt2img","input":{"prompt":"a cat"}}' +``` + +任务完成后,输出图像通过网关签名的 URL 返回(`/files/:id?exp=&sig=`)。 + ## 使用攻略(简要) ### 1. 首次登录 diff --git a/cmd/luminary/main.go b/cmd/luminary/main.go index 6c18120..1a16292 100644 --- a/cmd/luminary/main.go +++ b/cmd/luminary/main.go @@ -14,6 +14,7 @@ import ( "github.com/rose_cat707/luminary/internal/gateway" "github.com/rose_cat707/luminary/internal/handler/admin" "github.com/rose_cat707/luminary/internal/handler/files" + v1handler "github.com/rose_cat707/luminary/internal/handler/v1" "github.com/rose_cat707/luminary/internal/handler/proxy" "github.com/rose_cat707/luminary/internal/handler/replicate" "github.com/rose_cat707/luminary/internal/handler/static" @@ -95,10 +96,14 @@ func main() { proxyHandler := proxy.New(gw) v1 := r.Group("/v1", middleware.IPFilter(memCache, model.IPScopeProxy), middleware.IngressAuth(gw)) - proxyHandler.Register(v1) + v1.GET("/models", v1handler.NewModelsHandler(gw, predSvc, publicURL).List) + + textAPI := v1.Group("", middleware.RequireIngressProtocol(model.IngressProtocolText)) + proxyHandler.Register(textAPI) replicateHandler := replicate.New(predSvc, publicURL) - replicateHandler.Register(v1) + imageAPI := v1.Group("", middleware.RequireIngressProtocol(model.IngressProtocolImage)) + replicateHandler.Register(imageAPI) filesHandler := files.New(imgStore) filesHandler.Register(r) diff --git a/internal/handler/admin/ingress.go b/internal/handler/admin/ingress.go index 57b7557..b856044 100644 --- a/internal/handler/admin/ingress.go +++ b/internal/handler/admin/ingress.go @@ -19,18 +19,27 @@ func (h *Handler) ListIngressKeys(c *gin.Context) { func (h *Handler) CreateIngressKey(c *gin.Context) { var req struct { - Name string `json:"name"` - BudgetLimit float64 `json:"budget_limit"` - RateLimitRPM int `json:"rate_limit_rpm"` - RateLimitTPM int `json:"rate_limit_tpm"` - AllowedProviders []string `json:"allowed_providers"` - AllowedModels []string `json:"allowed_models"` - ExpiresAt *time.Time `json:"expires_at"` + Name string `json:"name"` + Protocol model.IngressKeyProtocol `json:"protocol"` + BudgetLimit float64 `json:"budget_limit"` + RateLimitRPM int `json:"rate_limit_rpm"` + RateLimitTPM int `json:"rate_limit_tpm"` + AllowedProviders []string `json:"allowed_providers"` + AllowedModels []string `json:"allowed_models"` + ExpiresAt *time.Time `json:"expires_at"` } if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } + protocol := req.Protocol + if protocol == "" { + protocol = model.IngressProtocolText + } + if !protocol.Valid() { + c.JSON(http.StatusBadRequest, gin.H{"error": "protocol must be text or image"}) + return + } plainKey, err := generateIngressKey() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"}) @@ -57,6 +66,7 @@ func (h *Handler) CreateIngressKey(c *gin.Context) { Name: req.Name, KeyHash: hash, Prefix: plainKey[:12] + "...", + Protocol: protocol, Enabled: true, BudgetLimit: req.BudgetLimit, RateLimitRPM: req.RateLimitRPM, @@ -91,18 +101,26 @@ func (h *Handler) UpdateIngressKey(c *gin.Context) { return } var req struct { - Name string `json:"name"` - Enabled *bool `json:"enabled"` - BudgetLimit *float64 `json:"budget_limit"` - RateLimitRPM *int `json:"rate_limit_rpm"` - RateLimitTPM *int `json:"rate_limit_tpm"` - AllowedProviders []string `json:"allowed_providers"` - AllowedModels []string `json:"allowed_models"` + Name string `json:"name"` + Protocol *model.IngressKeyProtocol `json:"protocol"` + Enabled *bool `json:"enabled"` + BudgetLimit *float64 `json:"budget_limit"` + RateLimitRPM *int `json:"rate_limit_rpm"` + RateLimitTPM *int `json:"rate_limit_tpm"` + AllowedProviders []string `json:"allowed_providers"` + AllowedModels []string `json:"allowed_models"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } + if req.Protocol != nil { + if !req.Protocol.Valid() { + c.JSON(http.StatusBadRequest, gin.H{"error": "protocol must be text or image"}) + return + } + k.Protocol = *req.Protocol + } if req.Name != "" { k.Name = req.Name } diff --git a/internal/handler/proxy/handler.go b/internal/handler/proxy/handler.go index f76f6f0..216e130 100644 --- a/internal/handler/proxy/handler.go +++ b/internal/handler/proxy/handler.go @@ -21,7 +21,6 @@ func New(gw *gateway.Service) *Handler { } func (h *Handler) Register(r *gin.RouterGroup) { - r.GET("/models", h.ListModels) r.POST("/chat/completions", h.forward(provider.EndpointChatCompletion)) r.POST("/completions", h.forward(provider.EndpointTextCompletion)) r.POST("/responses", h.forward(provider.EndpointResponses)) @@ -38,20 +37,6 @@ func (h *Handler) ingressKey(c *gin.Context) (*model.IngressKey, bool) { return k, ok } -func (h *Handler) ListModels(c *gin.Context) { - ingress, ok := h.ingressKey(c) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) - return - } - models, err := h.gateway.ListModels(c.Request.Context(), ingress) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"object": "list", "data": models}) -} - func (h *Handler) forward(endpoint provider.EndpointType) gin.HandlerFunc { return func(c *gin.Context) { ingress, ok := h.ingressKey(c) diff --git a/internal/handler/v1/models.go b/internal/handler/v1/models.go new file mode 100644 index 0000000..470981d --- /dev/null +++ b/internal/handler/v1/models.go @@ -0,0 +1,57 @@ +package v1handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/rose_cat707/luminary/internal/gateway" + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/prediction" +) + +type ModelsHandler struct { + gateway *gateway.Service + predictions *prediction.Service + baseURL func() string +} + +func NewModelsHandler(gw *gateway.Service, pred *prediction.Service, baseURL func() string) *ModelsHandler { + return &ModelsHandler{gateway: gw, predictions: pred, baseURL: baseURL} +} + +func (h *ModelsHandler) List(c *gin.Context) { + ingress, ok := ingressKey(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + switch ingress.EffectiveProtocol() { + case model.IngressProtocolImage: + results, err := h.predictions.ListPublicModels(c.Request.Context(), ingress, h.baseURL()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "previous": nil, + "next": nil, + "results": results, + }) + default: + models, err := h.gateway.ListModels(c.Request.Context(), ingress) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"object": "list", "data": models}) + } +} + +func ingressKey(c *gin.Context) (*model.IngressKey, bool) { + v, ok := c.Get("ingress_key") + if !ok { + return nil, false + } + k, ok := v.(*model.IngressKey) + return k, ok +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go index 22c0bed..d64520a 100644 --- a/internal/middleware/middleware.go +++ b/internal/middleware/middleware.go @@ -159,6 +159,44 @@ type IngressKeyResolver interface { ResolveIngressKey(key string) (*model.IngressKey, error) } +// RequireIngressProtocol restricts routes to ingress keys of the given protocol family. +func RequireIngressProtocol(want model.IngressKeyProtocol) gin.HandlerFunc { + return func(ctx *gin.Context) { + v, ok := ctx.Get("ingress_key") + if !ok { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing api key"}) + return + } + k, ok := v.(*model.IngressKey) + if !ok { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"}) + return + } + if k.EffectiveProtocol() != want { + abortWrongProtocol(ctx, want, protocolMismatchMessage(k.EffectiveProtocol(), want)) + return + } + ctx.Next() + } +} + +func protocolMismatchMessage(got, want model.IngressKeyProtocol) string { + if want == model.IngressProtocolImage { + return "this api key is for text (OpenAI) API only; use an image (Replicate) ingress key" + } + return "this api key is for image (Replicate) API only; use a text (OpenAI) ingress key" +} + +func abortWrongProtocol(ctx *gin.Context, want model.IngressKeyProtocol, msg string) { + if want == model.IngressProtocolImage { + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"detail": msg}) + return + } + ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": gin.H{"message": msg, "type": "forbidden"}, + }) +} + func Logger() gin.HandlerFunc { return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { return param.TimeStamp.Format(time.RFC3339) + " " + param.Method + " " + param.Path + " " + param.ClientIP + "\n" diff --git a/internal/model/entities.go b/internal/model/entities.go index 2078732..9ab56c3 100644 --- a/internal/model/entities.go +++ b/internal/model/entities.go @@ -88,11 +88,12 @@ type ModelEntry struct { } type IngressKey struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"not null" json:"name"` - KeyHash string `gorm:"uniqueIndex;not null" json:"-"` - Prefix string `gorm:"not null" json:"prefix"` - Enabled bool `gorm:"default:true" json:"enabled"` + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + KeyHash string `gorm:"uniqueIndex;not null" json:"-"` + Prefix string `gorm:"not null" json:"prefix"` + Protocol IngressKeyProtocol `gorm:"not null;default:text" json:"protocol"` + Enabled bool `gorm:"default:true" json:"enabled"` BudgetLimit float64 `gorm:"default:0" json:"budget_limit"` BudgetUsed float64 `gorm:"default:0" json:"budget_used"` RateLimitRPM int `gorm:"default:0" json:"rate_limit_rpm"` diff --git a/internal/model/ingress_protocol.go b/internal/model/ingress_protocol.go new file mode 100644 index 0000000..679ced5 --- /dev/null +++ b/internal/model/ingress_protocol.go @@ -0,0 +1,21 @@ +package model + +// IngressKeyProtocol distinguishes OpenAI-compatible text APIs from Replicate-compatible image APIs. +type IngressKeyProtocol string + +const ( + IngressProtocolText IngressKeyProtocol = "text" + IngressProtocolImage IngressKeyProtocol = "image" +) + +func (p IngressKeyProtocol) Valid() bool { + return p == IngressProtocolText || p == IngressProtocolImage +} + +// EffectiveProtocol returns text for legacy rows without an explicit protocol. +func (k IngressKey) EffectiveProtocol() IngressKeyProtocol { + if k.Protocol == "" { + return IngressProtocolText + } + return k.Protocol +} diff --git a/internal/model/ingress_protocol_test.go b/internal/model/ingress_protocol_test.go new file mode 100644 index 0000000..7f1b8ad --- /dev/null +++ b/internal/model/ingress_protocol_test.go @@ -0,0 +1,22 @@ +package model + +import "testing" + +func TestIngressKeyEffectiveProtocol(t *testing.T) { + if (IngressKey{}).EffectiveProtocol() != IngressProtocolText { + t.Fatal("empty protocol should default to text") + } + k := IngressKey{Protocol: IngressProtocolImage} + if k.EffectiveProtocol() != IngressProtocolImage { + t.Fatal("expected image protocol") + } +} + +func TestIngressKeyProtocolValid(t *testing.T) { + if !IngressProtocolText.Valid() || !IngressProtocolImage.Valid() { + t.Fatal("text and image should be valid") + } + if IngressKeyProtocol("other").Valid() { + t.Fatal("unexpected valid protocol") + } +} diff --git a/internal/prediction/models.go b/internal/prediction/models.go new file mode 100644 index 0000000..d752fbc --- /dev/null +++ b/internal/prediction/models.go @@ -0,0 +1,85 @@ +package prediction + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/rose_cat707/luminary/internal/model" + "github.com/rose_cat707/luminary/internal/store/cache" +) + +func splitOwnerName(ingressID, providerName string) (owner, name string) { + if i := strings.Index(ingressID, "/"); i > 0 { + return ingressID[:i], ingressID[i+1:] + } + owner = providerName + if owner == "" { + owner = "luminary" + } + name = ingressID + if name == "" { + name = "model" + } + return owner, name +} + +func modelDescription(m model.ModelEntry) string { + if m.DisplayName != "" { + return m.DisplayName + } + switch m.WorkflowType { + case model.WorkflowImg2Img: + return "Image-to-image workflow" + default: + return "Text-to-image workflow" + } +} + +// ListPublicModels returns Replicate-compatible model objects visible to the ingress key. +func (s *Service) ListPublicModels(ctx context.Context, ingress *model.IngressKey, baseURL string) ([]map[string]any, error) { + _ = ctx + allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON) + allowedModels := cache.ParseJSONList(ingress.AllowedModelsJSON) + baseURL = strings.TrimRight(baseURL, "/") + + var results []map[string]any + for _, p := range s.cache.ListProviders() { + if p.Category != model.CategoryImage || !p.Enabled { + continue + } + if !cache.MatchesList(allowedProviders, p.Name) && !cache.MatchesList(allowedProviders, string(p.Type)) && !cache.MatchesList(allowedProviders, "*") { + continue + } + for _, m := range s.cache.GetModels(p.ID) { + if !m.Enabled || m.VersionHash == "" { + continue + } + ingressID := m.IngressID() + if !cache.MatchesList(allowedModels, ingressID) && !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, m.VersionHash) && !cache.MatchesList(allowedModels, "*") { + continue + } + owner, name := splitOwnerName(ingressID, p.Name) + desc := modelDescription(m) + createdAt := m.CreatedAt + if createdAt.IsZero() { + createdAt = time.Now() + } + results = append(results, map[string]any{ + "url": fmt.Sprintf("%s/v1/models/%s/%s", baseURL, owner, name), + "owner": owner, + "name": name, + "description": desc, + "visibility": "public", + "latest_version": map[string]any{ + "id": ingressID, + "created_at": createdAt.UTC().Format(time.RFC3339), + "cog_version": "", + "openapi_schema": OpenAPISchemaForWorkflow(m.WorkflowType, name, desc), + }, + }) + } + } + return results, nil +} diff --git a/internal/prediction/models_test.go b/internal/prediction/models_test.go new file mode 100644 index 0000000..3db717e --- /dev/null +++ b/internal/prediction/models_test.go @@ -0,0 +1,27 @@ +package prediction + +import ( + "testing" + + "github.com/rose_cat707/luminary/internal/model" +) + +func TestSplitOwnerName(t *testing.T) { + owner, name := splitOwnerName("luminary/flux-sdxl", "comfy-local") + if owner != "luminary" || name != "flux-sdxl" { + t.Fatalf("got %q/%q", owner, name) + } + owner, name = splitOwnerName("plain-model", "") + if owner != "luminary" || name != "plain-model" { + t.Fatalf("got %q/%q", owner, name) + } +} + +func TestModelDescription(t *testing.T) { + if modelDescription(model.ModelEntry{WorkflowType: model.WorkflowImg2Img}) == "" { + t.Fatal("expected default img2img description") + } + if got := modelDescription(model.ModelEntry{DisplayName: "FLUX"}); got != "FLUX" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/prediction/params.go b/internal/prediction/params.go index 0b878b2..af69bb4 100644 --- a/internal/prediction/params.go +++ b/internal/prediction/params.go @@ -51,6 +51,59 @@ func ParamsForWorkflowType(wt model.WorkflowType) []ParamDef { } } +func openAPIType(pt ParamType) string { + switch pt { + case ParamInt: + return "integer" + case ParamFloat: + return "number" + default: + return "string" + } +} + +// OpenAPISchemaForWorkflow builds a Replicate-compatible schema with components.schemas.Input. +func OpenAPISchemaForWorkflow(wt model.WorkflowType, title, description string) map[string]any { + defs := ParamsForWorkflowType(wt) + properties := make(map[string]any, len(defs)) + required := make([]string, 0, len(defs)) + for i, d := range defs { + prop := map[string]any{ + "type": openAPIType(d.Type), + "title": d.Label, + "description": d.Label, + "x-order": i, + } + if d.Default != nil { + prop["default"] = d.Default + } + properties[d.Name] = prop + if d.Required { + required = append(required, d.Name) + } + } + inputSchema := map[string]any{ + "type": "object", + "properties": properties, + } + if len(required) > 0 { + inputSchema["required"] = required + } + return map[string]any{ + "openapi": "3.0.0", + "info": map[string]any{ + "title": title, + "version": "1.0.0", + "description": description, + }, + "components": map[string]any{ + "schemas": map[string]any{ + "Input": inputSchema, + }, + }, + } +} + func RequiredBindings(wt model.WorkflowType) []string { switch wt { case model.WorkflowImg2Img: diff --git a/internal/prediction/params_test.go b/internal/prediction/params_test.go new file mode 100644 index 0000000..afd0c8a --- /dev/null +++ b/internal/prediction/params_test.go @@ -0,0 +1,52 @@ +package prediction + +import ( + "testing" + + "github.com/rose_cat707/luminary/internal/model" +) + +func TestOpenAPISchemaForWorkflowTxt2Img(t *testing.T) { + schema := OpenAPISchemaForWorkflow(model.WorkflowTxt2Img, "flux", "FLUX txt2img") + components, ok := schema["components"].(map[string]any) + if !ok { + t.Fatal("missing components") + } + schemas, ok := components["schemas"].(map[string]any) + if !ok { + t.Fatal("missing schemas") + } + input, ok := schemas["Input"].(map[string]any) + if !ok { + t.Fatal("missing Input schema") + } + props, ok := input["properties"].(map[string]any) + if !ok { + t.Fatal("missing properties") + } + if _, ok := props["prompt"]; !ok { + t.Fatal("missing prompt property") + } + if _, ok := props["image"]; ok { + t.Fatal("txt2img should not expose image input") + } + required, ok := input["required"].([]string) + if !ok || len(required) == 0 || required[0] != "prompt" { + t.Fatalf("unexpected required: %#v", input["required"]) + } +} + +func TestOpenAPISchemaForWorkflowImg2Img(t *testing.T) { + schema := OpenAPISchemaForWorkflow(model.WorkflowImg2Img, "flux-i2i", "FLUX img2img") + components := schema["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) + input := schemas["Input"].(map[string]any) + props := input["properties"].(map[string]any) + if _, ok := props["image"]; !ok { + t.Fatal("missing image property") + } + required := input["required"].([]string) + if len(required) != 2 { + t.Fatalf("expected prompt and image required, got %#v", required) + } +} diff --git a/internal/prediction/service.go b/internal/prediction/service.go index b92e44e..f8dd9e5 100644 --- a/internal/prediction/service.go +++ b/internal/prediction/service.go @@ -85,14 +85,15 @@ func (s *Service) Create(ctx context.Context, ingress *model.IngressKey, req Cre if err != nil { return nil, err } + ingressModelID := entry.IngressID() inputJSON, _ := json.Marshal(input) p := model.Prediction{ ID: id, IngressKeyID: ingress.ID, ProviderID: provider.ID, ModelEntryID: entry.ID, - Version: req.Version, - Model: entry.ModelID, + Version: ingressModelID, + Model: ingressModelID, InputJSON: string(inputJSON), Status: model.PredictionStarting, ClientID: "luminary-" + id, @@ -455,7 +456,7 @@ func (s *Service) recordUsage(ingress *model.IngressKey, provider *model.Provide ProviderID: providerID, ClientIP: clientIP, Endpoint: model.EndpointPrediction, - Model: p.ID, + Model: p.Model, LatencyMs: time.Since(start).Milliseconds(), Status: status, ErrorMsg: errMsg, diff --git a/internal/store/sqlite/store.go b/internal/store/sqlite/store.go index c827959..44f63c8 100644 --- a/internal/store/sqlite/store.go +++ b/internal/store/sqlite/store.go @@ -49,9 +49,19 @@ func New(path string) (*Store, error) { ); err != nil { return nil, fmt.Errorf("migrate: %w", err) } + if err := ensureIngressKeyProtocol(db); err != nil { + return nil, fmt.Errorf("migrate ingress protocol: %w", err) + } return &Store{db: db}, nil } +func ensureIngressKeyProtocol(db *gorm.DB) error { + if !tableExists(db, "ingress_keys") { + return nil + } + return db.Exec(`UPDATE ingress_keys SET protocol = ? WHERE protocol IS NULL OR protocol = ''`, model.IngressProtocolText).Error +} + // ensureIPRulesSchema manages ip_rules without GORM AutoMigrate (avoids bad table rebuilds). func ensureIPRulesSchema(db *gorm.DB) error { if !tableExists(db, "ip_rules") { diff --git a/web/dist/index.html b/web/dist/index.html index 4492bc4..207412b 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -5,8 +5,8 @@ Luminary AI Gateway - - + +
diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 267dd57..116f6cf 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -62,6 +62,7 @@ export interface IngressKey { id: number name: string prefix: string + protocol: 'text' | 'image' enabled: boolean budget_limit: number budget_used: number diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 20139c0..4fbeaf7 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -186,6 +186,13 @@ export default { confirmDeleteKey: 'Delete this key?', copied: 'Copied', ruleAdded: 'Rule added', + protocol: 'Protocol', + protocolText: 'Text (OpenAI)', + protocolImage: 'Image (Replicate)', + protocolHint: 'Text keys use /v1/chat/completions etc.; image keys use /v1/predictions and Replicate model listing', + editKeyTitle: 'Edit Key', + protocolUpdated: 'Key updated', + apiDoc: 'API Docs', }, logs: { title: 'Request Logs', @@ -244,4 +251,65 @@ export default { settingsSaved: 'Settings saved', passwordUpdated: 'Password updated', }, + apiDocs: { + backToKeys: 'Back to Ingress Keys', + authTitle: 'Authentication', + authDesc: 'Send your ingress key (sk-lum-*) using either header:', + baseUrl: 'Base URL', + endpointsTitle: 'Endpoints', + examplesTitle: 'Examples', + method: 'Method', + path: 'Path', + summary: 'Summary', + text: { + title: 'Text API (OpenAI compatible)', + description: 'For ingress keys with the Text protocol. Compatible with OpenAI SDKs.', + note: 'Available endpoints depend on enabled egress endpoints. Empty whitelist means no provider/model restriction.', + endpointRows: [ + { method: 'GET', path: '/v1/models', summary: 'List models (OpenAI format)', status: 'Supported' }, + { method: 'POST', path: '/v1/chat/completions', summary: 'Chat completions; SSE when stream: true', status: 'Supported' }, + { method: 'POST', path: '/v1/responses', summary: 'OpenAI Responses API', status: 'Supported' }, + { method: 'POST', path: '/v1/completions', summary: 'Legacy text completions', status: 'Supported' }, + { method: 'POST', path: '/v1/embeddings', summary: 'Embeddings', status: 'Supported' }, + { method: 'POST', path: '/v1/rerank', summary: 'Cohere-compatible rerank', status: 'Supported' }, + ], + exampleRows: [ + { + title: 'List models', + code: 'curl {baseURL}/models \\\n -H "Authorization: Bearer sk-lum-..."', + }, + { + title: 'Chat completion', + code: 'curl {baseURL}/chat/completions \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}\'', + }, + { + title: 'Embeddings', + code: 'curl {baseURL}/embeddings \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"model":"text-embedding-3-small","input":"hello"}\'', + }, + ], + }, + image: { + title: 'Image API (Replicate compatible)', + description: 'For ingress keys with the Image protocol. Async ComfyUI txt2img / img2img workflows.', + note: 'Request field version is the ingress Model ID (same as latest_version.id from the model list). See openapi_schema.components.schemas.Input for input fields. Outputs are signed URLs (/files/:id?exp=&sig=).', + endpointRows: [ + { method: 'GET', path: '/v1/models', summary: 'List workflow models (Replicate format)', status: 'Supported' }, + { method: 'POST', path: '/v1/predictions', summary: 'Create async job; Prefer: wait for sync', status: 'Supported' }, + { method: 'GET', path: '/v1/predictions', summary: 'List jobs (cursor pagination)', status: 'Supported' }, + { method: 'GET', path: '/v1/predictions/:id', summary: 'Get job status and output', status: 'Supported' }, + { method: 'POST', path: '/v1/predictions/:id/cancel', summary: 'Cancel a running job', status: 'Supported' }, + { method: 'GET', path: '/v1/predictions/:id/stream', summary: 'SSE progress stream', status: 'Supported' }, + ], + exampleRows: [ + { + title: 'List workflow models', + code: 'curl {baseURL}/models \\\n -H "Authorization: Bearer sk-lum-..."', + }, + { + title: 'Create prediction', + code: 'curl -X POST {baseURL}/predictions \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"version":"luminary/flux-txt2img","input":{"prompt":"a cat"}}\'', + }, + ], + }, + }, } diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts index 566ebc5..031cdbe 100644 --- a/web/src/i18n/locales/zh-CN.ts +++ b/web/src/i18n/locales/zh-CN.ts @@ -202,6 +202,13 @@ export default { confirmDeleteKey: '确认删除此密钥?', copied: '已复制', ruleAdded: '规则已添加', + protocol: '协议', + protocolText: '文本(OpenAI)', + protocolImage: '图片(Replicate)', + protocolHint: '文本密钥调用 /v1/chat/completions 等;图片密钥调用 /v1/predictions 与 Replicate 模型列表', + editKeyTitle: '编辑密钥', + protocolUpdated: '密钥已更新', + apiDoc: '接口文档', }, logs: { title: '请求日志', @@ -265,4 +272,65 @@ export default { settingsSaved: '系统设置已保存', passwordUpdated: '密码已更新', }, + apiDocs: { + backToKeys: '返回入口管理', + authTitle: '鉴权', + authDesc: '请求头二选一携带入口密钥(sk-lum-*):', + baseUrl: 'Base URL', + endpointsTitle: '接口列表', + examplesTitle: '请求示例', + method: '方法', + path: '路径', + summary: '说明', + text: { + title: '文本 API 文档(OpenAI 兼容)', + description: '适用于协议为「文本」的入口密钥,与 OpenAI SDK 兼容。', + note: '实际可访问的接口取决于上游 Provider 在「出口管理」中启用的 endpoint。白名单为空表示不限制 Provider / 模型。', + endpointRows: [ + { method: 'GET', path: '/v1/models', summary: '列出可用模型(OpenAI 格式)', status: '已支持' }, + { method: 'POST', path: '/v1/chat/completions', summary: '对话补全;stream: true 时 SSE 流式', status: '已支持' }, + { method: 'POST', path: '/v1/responses', summary: 'OpenAI Responses API', status: '已支持' }, + { method: 'POST', path: '/v1/completions', summary: '文本补全(Legacy)', status: '已支持' }, + { method: 'POST', path: '/v1/embeddings', summary: '向量嵌入', status: '已支持' }, + { method: 'POST', path: '/v1/rerank', summary: 'Cohere 兼容重排序', status: '已支持' }, + ], + exampleRows: [ + { + title: '列出模型', + code: 'curl {baseURL}/models \\\n -H "Authorization: Bearer sk-lum-..."', + }, + { + title: '对话补全', + code: 'curl {baseURL}/chat/completions \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}\'', + }, + { + title: 'Embeddings', + code: 'curl {baseURL}/embeddings \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"model":"text-embedding-3-small","input":"hello"}\'', + }, + ], + }, + image: { + title: '图片 API 文档(Replicate 兼容)', + description: '适用于协议为「图片」的入口密钥,用于 ComfyUI 文生图 / 图生图异步任务。', + note: '请求字段 version 为入口 Model ID(与模型列表 latest_version.id 一致)。input 字段见 openapi_schema.components.schemas.Input。任务完成后输出图像通过签名 URL 返回(/files/:id?exp=&sig=)。', + endpointRows: [ + { method: 'GET', path: '/v1/models', summary: '列出可用工作流模型(Replicate 格式)', status: '已支持' }, + { method: 'POST', path: '/v1/predictions', summary: '创建异步任务;Prefer: wait 可同步等待', status: '已支持' }, + { method: 'GET', path: '/v1/predictions', summary: '列出任务(cursor 分页)', status: '已支持' }, + { method: 'GET', path: '/v1/predictions/:id', summary: '查询任务状态与输出', status: '已支持' }, + { method: 'POST', path: '/v1/predictions/:id/cancel', summary: '取消进行中的任务', status: '已支持' }, + { method: 'GET', path: '/v1/predictions/:id/stream', summary: 'SSE 流式推送任务进度', status: '已支持' }, + ], + exampleRows: [ + { + title: '列出工作流模型', + code: 'curl {baseURL}/models \\\n -H "Authorization: Bearer sk-lum-..."', + }, + { + title: '创建 Predictions 任务', + code: 'curl -X POST {baseURL}/predictions \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"version":"luminary/flux-txt2img","input":{"prompt":"a cat"}}\'', + }, + ], + }, + }, } diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue index 305be22..7744bbf 100644 --- a/web/src/layouts/MainLayout.vue +++ b/web/src/layouts/MainLayout.vue @@ -76,6 +76,8 @@ const titleMap: Record = { '/dashboard': 'nav.dashboard', '/egress/providers': 'nav.providers', '/ingress/keys': 'nav.ingress', + '/ingress/docs/text': 'apiDocs.text.title', + '/ingress/docs/image': 'apiDocs.image.title', '/logs': 'nav.logs', '/security/ip-rules': 'nav.ipRules', '/security/settings': 'nav.settings', @@ -87,6 +89,7 @@ const currentTitle = computed(() => { }) function isActive(path: string) { + if (path === '/ingress/keys' && route.path.startsWith('/ingress/')) return true return route.path === path || route.path.startsWith(path + '/') } diff --git a/web/src/router/index.ts b/web/src/router/index.ts index c5ce8ad..4ed003b 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -17,6 +17,18 @@ const router = createRouter({ { path: 'dashboard', name: 'dashboard', component: () => import('@/views/DashboardView.vue') }, { path: 'egress/providers', name: 'providers', component: () => import('@/views/ProvidersView.vue') }, { path: 'ingress/keys', name: 'ingress-keys', component: () => import('@/views/IngressKeysView.vue') }, + { + path: 'ingress/docs/text', + name: 'ingress-api-doc-text', + component: () => import('@/views/IngressApiDocView.vue'), + props: { protocol: 'text' }, + }, + { + path: 'ingress/docs/image', + name: 'ingress-api-doc-image', + component: () => import('@/views/IngressApiDocView.vue'), + props: { protocol: 'image' }, + }, { path: 'logs', name: 'logs', component: () => import('@/views/RequestLogsView.vue') }, { path: 'security/ip-rules', name: 'ip-rules', component: () => import('@/views/IPRulesView.vue') }, { path: 'security/settings', name: 'settings', component: () => import('@/views/SettingsView.vue') }, diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css index 8040887..9606a1b 100644 --- a/web/src/styles/theme.css +++ b/web/src/styles/theme.css @@ -588,7 +588,40 @@ code.inline-code { margin-left: var(--space-2); } -@media (max-width: 1024px) { +.app-card__body--padded .card-inset-block { + margin: 0 0 var(--space-4); +} + +.app-card__body--padded pre.card-inset-block { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; + line-height: 1.5; + border: 1px solid var(--border); + white-space: pre-wrap; + word-break: break-all; +} + +.app-card__body--padded .card-inset-block:last-child { + margin-bottom: 0; +} + +.page-form--docs { + max-width: 960px; +} + +.method-tag { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.75rem; + font-weight: 600; +} + +.doc-lead { + margin: 0 0 var(--space-4); + font-size: 0.875rem; + color: var(--muted-foreground); + line-height: 1.6; +} + .stat-grid { grid-template-columns: repeat(2, 1fr); } .content-grid--2-1, .content-grid--2, diff --git a/web/src/views/IngressApiDocView.vue b/web/src/views/IngressApiDocView.vue new file mode 100644 index 0000000..4ac51c4 --- /dev/null +++ b/web/src/views/IngressApiDocView.vue @@ -0,0 +1,118 @@ + + + diff --git a/web/src/views/IngressKeysView.vue b/web/src/views/IngressKeysView.vue index bcd36ed..9556003 100644 --- a/web/src/views/IngressKeysView.vue +++ b/web/src/views/IngressKeysView.vue @@ -13,6 +13,13 @@ + + +