From 8e82a23555d87d6d2c6fdf5c57659e718e8f0bb6 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. Add model input defaults (including prompt), workflow model editing, and ingress API docs. Co-authored-by: Cursor --- README.md | 81 ++++++- cmd/luminary/main.go | 9 +- internal/handler/admin/ingress.go | 46 ++-- internal/handler/admin/providers_keys.go | 59 ++++-- internal/handler/proxy/handler.go | 15 -- internal/handler/v1/models.go | 57 +++++ internal/middleware/middleware.go | 38 ++++ internal/model/entities.go | 12 +- internal/model/ingress_protocol.go | 21 ++ internal/model/ingress_protocol_test.go | 22 ++ internal/model/model_entry.go | 38 ++++ internal/model/model_entry_test.go | 32 +++ internal/prediction/defaults.go | 62 ++++++ internal/prediction/defaults_test.go | 55 +++++ internal/prediction/models.go | 75 +++++++ internal/prediction/models_test.go | 27 +++ internal/prediction/params.go | 74 +++++-- internal/prediction/params_test.go | 52 +++++ internal/prediction/service.go | 25 ++- internal/store/sqlite/store.go | 10 + web/dist/index.html | 4 +- web/src/api/client.ts | 2 + web/src/components/WorkflowModelDialog.vue | 232 +++++++++++++++++++-- web/src/i18n/locales/en.ts | 85 ++++++++ web/src/i18n/locales/zh-CN.ts | 71 +++++++ 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 +++++++++++++--- web/src/views/ProvidersView.vue | 44 ++-- 31 files changed, 1433 insertions(+), 159 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/model/model_entry_test.go create mode 100644 internal/prediction/defaults.go create mode 100644 internal/prediction/defaults_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..5a446d0 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,支持 `owner/name` 或短 `name`(与 `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/admin/providers_keys.go b/internal/handler/admin/providers_keys.go index b2c107e..b8a77ba 100644 --- a/internal/handler/admin/providers_keys.go +++ b/internal/handler/admin/providers_keys.go @@ -227,8 +227,10 @@ func (h *Handler) CreateModel(c *gin.Context) { DisplayName string `json:"display_name"` WorkflowType model.WorkflowType `json:"workflow_type"` WorkflowJSON string `json:"workflow_json"` - InputBindingJSON string `json:"input_binding_json"` - InputBinding map[string]*prediction.NodeField `json:"input_binding"` + InputBindingJSON string `json:"input_binding_json"` + InputBinding map[string]*prediction.NodeField `json:"input_binding"` + InputDefaultsJSON string `json:"input_defaults_json"` + InputDefaults map[string]any `json:"input_defaults"` } if err := c.ShouldBindJSON(&req); err != nil || req.ModelID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) @@ -260,19 +262,25 @@ func (h *Handler) CreateModel(c *gin.Context) { b, _ := json.Marshal(req.InputBinding) bindingJSON = string(b) } + defaultsJSON, err := marshalInputDefaults(req.InputDefaultsJSON, req.InputDefaults) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } wt := req.WorkflowType if wt == "" { wt = model.WorkflowTxt2Img } m := model.ModelEntry{ - ProviderID: providerID, - ModelID: req.ModelID, - Alias: normalizeAlias(req.Alias), - DisplayName: req.DisplayName, - WorkflowType: wt, - WorkflowJSON: req.WorkflowJSON, - InputBindingJSON: bindingJSON, - VersionHash: prediction.HashWorkflowJSON(req.WorkflowJSON), + ProviderID: providerID, + ModelID: req.ModelID, + Alias: normalizeAlias(req.Alias), + DisplayName: req.DisplayName, + WorkflowType: wt, + WorkflowJSON: req.WorkflowJSON, + InputBindingJSON: bindingJSON, + InputDefaultsJSON: defaultsJSON, + VersionHash: prediction.HashWorkflowJSON(req.WorkflowJSON), Enabled: true, } if prov.Category == model.CategoryImage { @@ -301,8 +309,10 @@ func (h *Handler) UpdateModel(c *gin.Context) { ModelID string `json:"model_id"` WorkflowType model.WorkflowType `json:"workflow_type"` WorkflowJSON string `json:"workflow_json"` - InputBindingJSON string `json:"input_binding_json"` - InputBinding map[string]*prediction.NodeField `json:"input_binding"` + InputBindingJSON string `json:"input_binding_json"` + InputBinding map[string]*prediction.NodeField `json:"input_binding"` + InputDefaultsJSON string `json:"input_defaults_json"` + InputDefaults map[string]any `json:"input_defaults"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) @@ -349,6 +359,14 @@ func (h *Handler) UpdateModel(c *gin.Context) { b, _ := json.Marshal(req.InputBinding) m.InputBindingJSON = string(b) } + if req.InputDefaults != nil || req.InputDefaultsJSON != "" { + defaultsJSON, err := marshalInputDefaults(req.InputDefaultsJSON, req.InputDefaults) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + m.InputDefaultsJSON = defaultsJSON + } prov, _ := h.cache.GetProvider(m.ProviderID) if prov.Category == model.CategoryImage { m.Alias = "" @@ -422,3 +440,20 @@ func (h *Handler) DeleteModel(c *gin.Context) { h.cache.SetModels(m.ProviderID, filtered) c.JSON(http.StatusOK, gin.H{"ok": true}) } + +func marshalInputDefaults(raw string, defaults map[string]any) (string, error) { + if defaults != nil { + b, err := json.Marshal(defaults) + if err != nil { + return "", err + } + return string(b), nil + } + if raw == "" { + return "{}", nil + } + if _, err := prediction.ParseInputDefaults(raw); err != nil { + return "", err + } + return raw, nil +} 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..07a3d42 100644 --- a/internal/model/entities.go +++ b/internal/model/entities.go @@ -82,17 +82,19 @@ type ModelEntry struct { WorkflowType WorkflowType `gorm:"default:txt2img" json:"workflow_type"` WorkflowJSON string `gorm:"type:text" json:"workflow_json"` InputBindingJSON string `gorm:"type:text" json:"input_binding_json"` + InputDefaultsJSON string `gorm:"type:text;default:'{}'" json:"input_defaults_json"` VersionHash string `gorm:"index" json:"version_hash"` Enabled bool `gorm:"default:true" json:"enabled"` CreatedAt time.Time `json:"created_at"` } 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/model/model_entry.go b/internal/model/model_entry.go index fab3782..041a08f 100644 --- a/internal/model/model_entry.go +++ b/internal/model/model_entry.go @@ -1,5 +1,7 @@ package model +import "strings" + // IngressID returns the model name exposed to ingress API clients. func (m ModelEntry) IngressID() string { if m.Alias != "" { @@ -8,6 +10,27 @@ func (m ModelEntry) IngressID() string { return m.ModelID } +// SplitIngressOwnerName splits an ingress model identifier into Replicate owner/name parts. +func SplitIngressOwnerName(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 +} + +// OwnerName returns the Replicate-style owner/name for an ingress model entry. +func (m ModelEntry) OwnerName(providerName string) (string, string) { + return SplitIngressOwnerName(m.IngressID(), providerName) +} + // MatchesIngressName reports whether the entry matches an ingress model identifier. func (m ModelEntry) MatchesIngressName(name string) bool { if name == "" || !m.Enabled { @@ -18,3 +41,18 @@ func (m ModelEntry) MatchesIngressName(name string) bool { } return m.Alias != "" && m.Alias == name } + +// MatchesIngressRef reports whether ref identifies this model (ingress ID, owner/name, or short name). +func (m ModelEntry) MatchesIngressRef(ref, providerName string) bool { + if !m.Enabled || ref == "" { + return false + } + if m.MatchesIngressName(ref) { + return true + } + owner, name := m.OwnerName(providerName) + if ref == name || ref == owner+"/"+name { + return true + } + return false +} diff --git a/internal/model/model_entry_test.go b/internal/model/model_entry_test.go new file mode 100644 index 0000000..f5a454b --- /dev/null +++ b/internal/model/model_entry_test.go @@ -0,0 +1,32 @@ +package model + +import "testing" + +func TestSplitIngressOwnerName(t *testing.T) { + owner, name := SplitIngressOwnerName("luminary/flux-sdxl", "comfy-local") + if owner != "luminary" || name != "flux-sdxl" { + t.Fatalf("got %q/%q", owner, name) + } + owner, name = SplitIngressOwnerName("plain-model", "comfy-local") + if owner != "comfy-local" || name != "plain-model" { + t.Fatalf("got %q/%q", owner, name) + } +} + +func TestMatchesIngressRef(t *testing.T) { + entry := ModelEntry{ + Enabled: true, + Alias: "luminary/flux-txt2img", + ModelID: "internal-flux", + } + provider := "comfy-ui" + if !entry.MatchesIngressRef("luminary/flux-txt2img", provider) { + t.Fatal("expected ingress id / owner/name match") + } + if !entry.MatchesIngressRef("flux-txt2img", provider) { + t.Fatal("expected short name match") + } + if entry.MatchesIngressRef("other/flux-txt2img", provider) { + t.Fatal("unexpected owner mismatch match") + } +} diff --git a/internal/prediction/defaults.go b/internal/prediction/defaults.go new file mode 100644 index 0000000..bc7fb28 --- /dev/null +++ b/internal/prediction/defaults.go @@ -0,0 +1,62 @@ +package prediction + +import ( + "encoding/json" + "fmt" + + "github.com/rose_cat707/luminary/internal/model" +) + +func ParseInputDefaults(raw string) (map[string]any, error) { + if raw == "" || raw == "{}" { + return map[string]any{}, nil + } + var out map[string]any + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil, err + } + if out == nil { + return map[string]any{}, nil + } + return out, nil +} + +func effectiveDefault(d ParamDef, overrides map[string]any) (any, bool) { + if overrides != nil { + if v, ok := overrides[d.Name]; ok { + return v, true + } + } + if d.Default != nil { + return d.Default, true + } + return nil, false +} + +func ValidateInputWithDefaults(wt model.WorkflowType, input map[string]any, overrides map[string]any) (map[string]any, error) { + defs := ParamsForWorkflowType(wt) + out := make(map[string]any, len(defs)) + for _, d := range defs { + v, ok := input[d.Name] + if !ok || v == nil { + if def, has := effectiveDefault(d, overrides); has { + coerced, err := coerceValue(d, def) + if err != nil { + return nil, fmt.Errorf("%s: %w", d.Name, err) + } + out[d.Name] = coerced + continue + } + if d.Required { + return nil, fmt.Errorf("missing required field: %s", d.Name) + } + continue + } + coerced, err := coerceValue(d, v) + if err != nil { + return nil, fmt.Errorf("%s: %w", d.Name, err) + } + out[d.Name] = coerced + } + return out, nil +} diff --git a/internal/prediction/defaults_test.go b/internal/prediction/defaults_test.go new file mode 100644 index 0000000..ee18bae --- /dev/null +++ b/internal/prediction/defaults_test.go @@ -0,0 +1,55 @@ +package prediction + +import ( + "testing" + + "github.com/rose_cat707/luminary/internal/model" +) + +func TestValidateInputWithDefaultsOverride(t *testing.T) { + overrides := map[string]any{"width": float64(768), "steps": float64(30)} + out, err := ValidateInputWithDefaults(model.WorkflowTxt2Img, map[string]any{ + "prompt": "hello", + }, overrides) + if err != nil { + t.Fatal(err) + } + if out["width"] != 768 && out["width"] != float64(768) { + t.Fatalf("width got %#v", out["width"]) + } + if out["steps"] != 30 && out["steps"] != float64(30) { + t.Fatalf("steps got %#v", out["steps"]) + } +} + +func TestOpenAPISchemaUsesModelDefaults(t *testing.T) { + schema := OpenAPISchemaForWorkflow(model.WorkflowTxt2Img, "flux", "desc", map[string]any{ + "width": float64(768), + }) + components := schema["components"].(map[string]any) + schemas := components["schemas"].(map[string]any) + input := schemas["Input"].(map[string]any) + props := input["properties"].(map[string]any) + width := props["width"].(map[string]any) + if width["default"] != float64(768) { + t.Fatalf("unexpected width default %#v", width["default"]) + } +} + +func TestValidateInputPromptDefault(t *testing.T) { + overrides := map[string]any{"prompt": "a scenic landscape"} + out, err := ValidateInputWithDefaults(model.WorkflowTxt2Img, map[string]any{}, overrides) + if err != nil { + t.Fatal(err) + } + if out["prompt"] != "a scenic landscape" { + t.Fatalf("prompt got %#v", out["prompt"]) + } +} + +func TestValidateInputRequiredWithoutDefault(t *testing.T) { + _, err := ValidateInputWithDefaults(model.WorkflowTxt2Img, map[string]any{}, map[string]any{}) + if err == nil { + t.Fatal("expected missing prompt error") + } +} diff --git a/internal/prediction/models.go b/internal/prediction/models.go new file mode 100644 index 0000000..e4d26d7 --- /dev/null +++ b/internal/prediction/models.go @@ -0,0 +1,75 @@ +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) { + return model.SplitIngressOwnerName(ingressID, providerName) +} + +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) + defaults, _ := ParseInputDefaults(m.InputDefaultsJSON) + 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, defaults), + }, + }) + } + } + 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..4c6838e 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, overrides map[string]any) 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 def, ok := effectiveDefault(d, overrides); ok { + prop["default"] = def + } + 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: @@ -61,26 +114,7 @@ func RequiredBindings(wt model.WorkflowType) []string { } func ValidateInput(wt model.WorkflowType, input map[string]any) (map[string]any, error) { - defs := ParamsForWorkflowType(wt) - out := make(map[string]any, len(defs)) - for _, d := range defs { - v, ok := input[d.Name] - if !ok || v == nil { - if d.Required { - return nil, fmt.Errorf("missing required field: %s", d.Name) - } - if d.Default != nil { - out[d.Name] = d.Default - } - continue - } - coerced, err := coerceValue(d, v) - if err != nil { - return nil, fmt.Errorf("%s: %w", d.Name, err) - } - out[d.Name] = coerced - } - return out, nil + return ValidateInputWithDefaults(wt, input, nil) } func coerceValue(d ParamDef, v any) (any, error) { diff --git a/internal/prediction/params_test.go b/internal/prediction/params_test.go new file mode 100644 index 0000000..14b1205 --- /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", nil) + 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", nil) + 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..a3cf14e 100644 --- a/internal/prediction/service.go +++ b/internal/prediction/service.go @@ -67,7 +67,7 @@ func (s *Service) Create(ctx context.Context, ingress *model.IngressKey, req Cre if !provider.Enabled { return nil, fmt.Errorf("provider disabled") } - input, err := ValidateInput(entry.WorkflowType, req.Input) + input, err := ValidateInputWithDefaults(entry.WorkflowType, req.Input, inputDefaultsForEntry(entry)) if err != nil { return nil, err } @@ -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, @@ -129,6 +130,14 @@ func (s *Service) Create(ctx context.Context, ingress *model.IngressKey, req Cre return s.toAPI(&p), nil } +func inputDefaultsForEntry(entry model.ModelEntry) map[string]any { + defs, err := ParseInputDefaults(entry.InputDefaultsJSON) + if err != nil { + return map[string]any{} + } + return defs +} + func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model.ModelEntry, model.Provider, error) { allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON) allowedModels := cache.ParseJSONList(ingress.AllowedModelsJSON) @@ -145,7 +154,7 @@ func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model continue } ingressID := m.IngressID() - if !m.MatchesIngressName(version) { + if !m.MatchesIngressRef(version, p.Name) { continue } if !cache.MatchesList(allowedModels, ingressID) && !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, "*") { @@ -157,6 +166,12 @@ func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model if len(candidates) == 0 { return model.ModelEntry{}, model.Provider{}, fmt.Errorf("no model found for version %s", version) } + if len(candidates) > 1 && !strings.Contains(version, "/") { + return model.ModelEntry{}, model.Provider{}, fmt.Errorf("ambiguous model name %q; use owner/name", version) + } + if len(candidates) > 1 { + return model.ModelEntry{}, model.Provider{}, fmt.Errorf("ambiguous model %q", version) + } m := candidates[0] p, ok := s.cache.GetProvider(m.ProviderID) if !ok { @@ -455,7 +470,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..2a29a9a 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..07437a1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -53,6 +53,7 @@ export interface ModelEntry { workflow_type?: string workflow_json?: string input_binding_json?: string + input_defaults_json?: string version_hash?: string enabled: boolean created_at?: string @@ -62,6 +63,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/components/WorkflowModelDialog.vue b/web/src/components/WorkflowModelDialog.vue index 9a62e63..b8b12c8 100644 --- a/web/src/components/WorkflowModelDialog.vue +++ b/web/src/components/WorkflowModelDialog.vue @@ -1,5 +1,11 @@