Split ingress keys into text/image protocols with Replicate model listing.
CI / docker (push) Successful in 3m49s
CI / docker (push) Successful in 3m49s
Text keys use OpenAI routes; image keys use predictions with ingress Model ID, OpenAPI input schemas, owner/name or short name resolution, protocol-aware admin whitelist, and in-app API docs aligned to UI conventions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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://<host>: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-<your-key>
|
||||
```
|
||||
|
||||
```http
|
||||
X-Api-Key: sk-lum-<your-key>
|
||||
```
|
||||
|
||||
**Base URL**:`http://<host>: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. 首次登录
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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)
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -145,7 +146,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 +158,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 +462,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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Luminary AI Gateway</title>
|
||||
<script type="module" crossorigin src="/assets/index-CF-14R2-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CDyLXzvy.css">
|
||||
<script type="module" crossorigin src="/assets/index-3LZ4f8m4.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-pxQ9-t6p.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface IngressKey {
|
||||
id: number
|
||||
name: string
|
||||
prefix: string
|
||||
protocol: 'text' | 'image'
|
||||
enabled: boolean
|
||||
budget_limit: number
|
||||
budget_used: number
|
||||
|
||||
@@ -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 accepts owner/name or short name (aligned with latest_version.id, owner, and name 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"}}\'',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 支持 owner/name 或短 name(与模型列表 latest_version.id、owner、name 一致)。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"}}\'',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ const titleMap: Record<string, string> = {
|
||||
'/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 + '/')
|
||||
}
|
||||
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader :title="doc.title" :description="doc.description">
|
||||
<template #actions>
|
||||
<el-button @click="router.push('/ingress/keys')">{{ t('apiDocs.backToKeys') }}</el-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="page-form page-form--docs">
|
||||
<AppCard :title="t('apiDocs.authTitle')" padded>
|
||||
<p class="doc-lead">{{ t('apiDocs.authDesc') }}</p>
|
||||
<pre class="card-inset-block">{{ authExample }}</pre>
|
||||
<div class="kv-block u-mt-4">
|
||||
<p>
|
||||
<span class="label">{{ t('apiDocs.baseUrl') }}</span>
|
||||
<code>{{ baseURL }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard :title="t('apiDocs.endpointsTitle')">
|
||||
<el-table :data="doc.endpoints" size="small">
|
||||
<el-table-column :label="t('apiDocs.method')" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="methodTagType(row.method)" size="small" class="method-tag">{{ row.method }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" :label="t('apiDocs.path')" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<code class="inline-code">{{ row.path }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="summary" :label="t('apiDocs.summary')" min-width="240" />
|
||||
<el-table-column :label="t('common.status')" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="success" size="small">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p v-if="doc.note" class="card-footer-hint form-hint">{{ doc.note }}</p>
|
||||
</AppCard>
|
||||
|
||||
<AppCard :title="t('apiDocs.examplesTitle')" padded>
|
||||
<template v-for="(example, idx) in doc.examples" :key="idx">
|
||||
<div class="section-title">{{ example.title }}</div>
|
||||
<pre class="card-inset-block" :class="{ 'u-mb-4': idx < doc.examples.length - 1 }">{{ example.code }}</pre>
|
||||
</template>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
protocol: 'text' | 'image'
|
||||
}>()
|
||||
|
||||
const { t, tm } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
const host = window.location.origin.replace(/\/$/, '')
|
||||
const baseURL = `${host}/v1`
|
||||
const authExample = `Authorization: Bearer sk-lum-<your-key>\n# or\nX-Api-Key: sk-lum-<your-key>`
|
||||
|
||||
interface EndpointRow {
|
||||
method: string
|
||||
path: string
|
||||
summary: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface ExampleRow {
|
||||
title: string
|
||||
code: string
|
||||
}
|
||||
|
||||
const doc = computed(() => {
|
||||
const key = props.protocol
|
||||
const section = tm(`apiDocs.${key}`) as {
|
||||
title: string
|
||||
description: string
|
||||
note: string
|
||||
endpointRows: EndpointRow[]
|
||||
exampleRows: ExampleRow[]
|
||||
}
|
||||
return {
|
||||
title: t(`apiDocs.${key}.title`),
|
||||
description: t(`apiDocs.${key}.description`),
|
||||
note: t(`apiDocs.${key}.note`),
|
||||
endpoints: section.endpointRows ?? [],
|
||||
examples: (section.exampleRows ?? []).map(row => ({
|
||||
title: row.title,
|
||||
code: row.code.replaceAll('{baseURL}', baseURL).replaceAll('{host}', host),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
function methodTagType(method: string) {
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
return 'success'
|
||||
case 'POST':
|
||||
return 'primary'
|
||||
case 'PUT':
|
||||
return 'warning'
|
||||
case 'DELETE':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -13,6 +13,13 @@
|
||||
<el-table :data="keys" v-loading="loading" size="small">
|
||||
<el-table-column prop="name" :label="t('common.name')" min-width="120" />
|
||||
<el-table-column prop="prefix" :label="t('ingress.keyPrefix')" width="140" />
|
||||
<el-table-column :label="t('ingress.protocol')" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.protocol === 'image' ? 'warning' : 'primary'" size="small">
|
||||
{{ row.protocol === 'image' ? t('ingress.protocolImage') : t('ingress.protocolText') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enabled" :label="t('common.status')" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? t('common.enabled') : t('common.disabled') }}</el-tag>
|
||||
@@ -31,10 +38,11 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="request_count" :label="t('dashboard.requestCount')" width="90" />
|
||||
<el-table-column prop="token_count" :label="t('common.tokens')" width="100" />
|
||||
<el-table-column :label="t('common.actions')" width="240" fixed="right">
|
||||
<el-table-column :label="t('common.actions')" width="320" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link @click="openWhitelist(row)">{{ t('ingress.whitelistAction') }}</el-button>
|
||||
<el-button size="small" link @click="openRouting(row)">{{ t('ingress.routingAction') }}</el-button>
|
||||
<el-button size="small" link @click="openApiDoc(row)">{{ t('ingress.apiDoc') }}</el-button>
|
||||
<el-button size="small" link @click="openEdit(row)">{{ t('common.edit') }}</el-button>
|
||||
<el-button v-if="isTextKey(row)" size="small" link @click="openRouting(row)">{{ t('ingress.routingAction') }}</el-button>
|
||||
<el-button size="small" link @click="toggle(row)">{{ row.enabled ? t('common.disabled') : t('common.enabled') }}</el-button>
|
||||
<el-button size="small" type="danger" link @click="remove(row)">{{ t('common.delete') }}</el-button>
|
||||
</template>
|
||||
@@ -45,11 +53,19 @@
|
||||
<el-dialog v-model="showCreate" :title="t('ingress.createDialogTitle')" width="560px" destroy-on-close @open="loadCatalog">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item :label="t('common.name')"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item :label="t('ingress.protocol')">
|
||||
<el-radio-group v-model="form.protocol" @change="onProtocolChange">
|
||||
<el-radio value="text">{{ t('ingress.protocolText') }}</el-radio>
|
||||
<el-radio value="image">{{ t('ingress.protocolImage') }}</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="form-hint">{{ t('ingress.protocolHint') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('ingress.budgetLimit')"><el-input-number v-model="form.budget_limit" :min="0" :precision="4" class="w-full" /></el-form-item>
|
||||
<el-form-item :label="t('ingress.rpmLimit')"><el-input-number v-model="form.rate_limit_rpm" :min="0" class="w-full" /></el-form-item>
|
||||
<el-form-item :label="t('ingress.tpmLimit')"><el-input-number v-model="form.rate_limit_tpm" :min="0" class="w-full" /></el-form-item>
|
||||
<el-form-item :label="t('ingress.providerWhitelist')">
|
||||
<el-select
|
||||
:key="`create-providers-${form.protocol}`"
|
||||
v-model="form.allowed_providers"
|
||||
multiple
|
||||
filterable
|
||||
@@ -58,12 +74,13 @@
|
||||
collapse-tags-tooltip
|
||||
:placeholder="t('ingress.providerWhitelistPlaceholder')"
|
||||
>
|
||||
<el-option v-for="p in catalogProviders" :key="p.name" :label="p.name" :value="p.name" />
|
||||
<el-option v-for="p in createProviderOptions" :key="p.name" :label="p.name" :value="p.name" />
|
||||
</el-select>
|
||||
<div class="form-hint">{{ t('ingress.whitelistHint') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('ingress.modelWhitelist')">
|
||||
<el-select
|
||||
:key="`create-models-${form.protocol}-${form.allowed_providers.join(',')}`"
|
||||
v-model="form.allowed_models"
|
||||
multiple
|
||||
filterable
|
||||
@@ -73,7 +90,7 @@
|
||||
:placeholder="t('ingress.modelWhitelistPlaceholder')"
|
||||
>
|
||||
<el-option
|
||||
v-for="m in filteredModelOptions(form.allowed_providers)"
|
||||
v-for="m in createModelOptions"
|
||||
:key="m.model_id + '@' + m.provider_name"
|
||||
:label="m.label"
|
||||
:value="m.model_id"
|
||||
@@ -88,11 +105,19 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showWhitelist" :title="t('ingress.editWhitelistTitle')" width="560px" destroy-on-close>
|
||||
<el-dialog v-model="showEdit" :title="t('ingress.editKeyTitle')" width="560px" destroy-on-close @open="loadCatalogForEdit">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item :label="t('ingress.protocol')">
|
||||
<el-radio-group v-model="editForm.protocol" @change="onEditProtocolChange">
|
||||
<el-radio value="text">{{ t('ingress.protocolText') }}</el-radio>
|
||||
<el-radio value="image">{{ t('ingress.protocolImage') }}</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="form-hint">{{ t('ingress.protocolHint') }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('ingress.providerWhitelist')">
|
||||
<el-select
|
||||
v-model="whitelistForm.allowed_providers"
|
||||
:key="`edit-providers-${editForm.protocol}`"
|
||||
v-model="editForm.allowed_providers"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
@@ -100,12 +125,13 @@
|
||||
collapse-tags-tooltip
|
||||
:placeholder="t('ingress.providerWhitelistPlaceholder')"
|
||||
>
|
||||
<el-option v-for="p in catalogProviders" :key="p.name" :label="p.name" :value="p.name" />
|
||||
<el-option v-for="p in editProviderOptions" :key="p.name" :label="p.name" :value="p.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('ingress.modelWhitelist')">
|
||||
<el-select
|
||||
v-model="whitelistForm.allowed_models"
|
||||
:key="`edit-models-${editForm.protocol}-${editForm.allowed_providers.join(',')}`"
|
||||
v-model="editForm.allowed_models"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
@@ -114,7 +140,7 @@
|
||||
:placeholder="t('ingress.modelWhitelistPlaceholder')"
|
||||
>
|
||||
<el-option
|
||||
v-for="m in filteredModelOptions(whitelistForm.allowed_providers)"
|
||||
v-for="m in editModelOptions"
|
||||
:key="m.model_id + '@' + m.provider_name"
|
||||
:label="m.label"
|
||||
:value="m.model_id"
|
||||
@@ -123,8 +149,8 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showWhitelist = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="saveWhitelist">{{ t('common.save') }}</el-button>
|
||||
<el-button @click="showEdit = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" @click="saveEdit">{{ t('common.save') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -204,23 +230,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api, { type IngressKey, type Provider, type ProviderKey } from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
interface CatalogProvider { id: number; name: string }
|
||||
interface CatalogProvider { id: number; name: string; category: string }
|
||||
interface RoutingProvider { id: number; name: string; keys: { id: number; name: string }[] }
|
||||
interface ModelOption { model_id: string; display_name: string; provider_name: string; label: string }
|
||||
interface ModelOption { model_id: string; display_name: string; provider_name: string; provider_category: string; label: string }
|
||||
|
||||
const keys = ref<IngressKey[]>([])
|
||||
const loading = ref(false)
|
||||
const showCreate = ref(false)
|
||||
const showWhitelist = ref(false)
|
||||
const showEdit = ref(false)
|
||||
const showPlainKey = ref(false)
|
||||
const plainKey = ref('')
|
||||
const routingVisible = ref(false)
|
||||
@@ -238,17 +266,51 @@ const catalogProviders = ref<CatalogProvider[]>([])
|
||||
const catalogModels = ref<ModelOption[]>([])
|
||||
const form = ref({
|
||||
name: '',
|
||||
protocol: 'text' as 'text' | 'image',
|
||||
budget_limit: 0,
|
||||
rate_limit_rpm: 0,
|
||||
rate_limit_tpm: 0,
|
||||
allowed_providers: [] as string[],
|
||||
allowed_models: [] as string[],
|
||||
})
|
||||
const whitelistForm = ref({
|
||||
const editForm = ref({
|
||||
protocol: 'text' as 'text' | 'image',
|
||||
allowed_providers: [] as string[],
|
||||
allowed_models: [] as string[],
|
||||
})
|
||||
|
||||
const createProviderOptions = computed(() => catalogProvidersFor(form.value.protocol))
|
||||
const editProviderOptions = computed(() => catalogProvidersFor(editForm.value.protocol))
|
||||
|
||||
const createModelOptions = computed(() =>
|
||||
filteredModelOptions(form.value.allowed_providers, form.value.protocol),
|
||||
)
|
||||
const editModelOptions = computed(() =>
|
||||
filteredModelOptions(editForm.value.allowed_providers, editForm.value.protocol),
|
||||
)
|
||||
|
||||
watch(() => form.value.protocol, (protocol) => {
|
||||
const pruned = pruneWhitelistSelections(protocol, form.value.allowed_providers, form.value.allowed_models)
|
||||
form.value.allowed_providers = pruned.allowed_providers
|
||||
form.value.allowed_models = pruned.allowed_models
|
||||
})
|
||||
|
||||
watch(() => editForm.value.protocol, (protocol) => {
|
||||
const pruned = pruneWhitelistSelections(protocol, editForm.value.allowed_providers, editForm.value.allowed_models)
|
||||
editForm.value.allowed_providers = pruned.allowed_providers
|
||||
editForm.value.allowed_models = pruned.allowed_models
|
||||
})
|
||||
|
||||
watch(() => form.value.allowed_providers, (providers) => {
|
||||
const valid = new Set(filteredModelOptions(providers, form.value.protocol).map(m => m.model_id))
|
||||
form.value.allowed_models = form.value.allowed_models.filter(id => valid.has(id))
|
||||
})
|
||||
|
||||
watch(() => editForm.value.allowed_providers, (providers) => {
|
||||
const valid = new Set(filteredModelOptions(providers, editForm.value.protocol).map(m => m.model_id))
|
||||
editForm.value.allowed_models = editForm.value.allowed_models.filter(id => valid.has(id))
|
||||
})
|
||||
|
||||
const keysForRuleProvider = computed(() => {
|
||||
if (!ruleForm.value.provider_id) return []
|
||||
return routingProviders.value.find(p => p.id === ruleForm.value.provider_id)?.keys ?? []
|
||||
@@ -297,15 +359,54 @@ function selectionToPayload(list: string[]) {
|
||||
return list.length === 0 ? [] : list
|
||||
}
|
||||
|
||||
function filteredModelOptions(selectedProviders: string[]) {
|
||||
if (isAllowAll(selectedProviders)) return catalogModels.value
|
||||
return catalogModels.value.filter(m => selectedProviders.includes(m.provider_name))
|
||||
function isTextKey(row: IngressKey) {
|
||||
return (row.protocol || 'text') === 'text'
|
||||
}
|
||||
|
||||
function providerMatchesProtocol(category: string, protocol: 'text' | 'image') {
|
||||
return protocol === 'image' ? category === 'image' : category !== 'image'
|
||||
}
|
||||
|
||||
function catalogProvidersFor(protocol: 'text' | 'image') {
|
||||
return catalogProviders.value.filter(p => providerMatchesProtocol(p.category, protocol))
|
||||
}
|
||||
|
||||
function pruneWhitelistSelections(
|
||||
protocol: 'text' | 'image',
|
||||
providers: string[],
|
||||
models: string[],
|
||||
) {
|
||||
const validProviders = new Set(catalogProvidersFor(protocol).map(p => p.name))
|
||||
const nextProviders = providers.filter(name => validProviders.has(name))
|
||||
const validModels = new Set(filteredModelOptions(nextProviders, protocol).map(m => m.model_id))
|
||||
return {
|
||||
allowed_providers: nextProviders,
|
||||
allowed_models: models.filter(id => validModels.has(id)),
|
||||
}
|
||||
}
|
||||
|
||||
function onProtocolChange() {
|
||||
const pruned = pruneWhitelistSelections(form.value.protocol, form.value.allowed_providers, form.value.allowed_models)
|
||||
form.value.allowed_providers = pruned.allowed_providers
|
||||
form.value.allowed_models = pruned.allowed_models
|
||||
}
|
||||
|
||||
function onEditProtocolChange() {
|
||||
const pruned = pruneWhitelistSelections(editForm.value.protocol, editForm.value.allowed_providers, editForm.value.allowed_models)
|
||||
editForm.value.allowed_providers = pruned.allowed_providers
|
||||
editForm.value.allowed_models = pruned.allowed_models
|
||||
}
|
||||
|
||||
function filteredModelOptions(selectedProviders: string[], protocol: 'text' | 'image' = 'text') {
|
||||
const models = catalogModels.value.filter(m => providerMatchesProtocol(m.provider_category, protocol))
|
||||
if (isAllowAll(selectedProviders)) return models
|
||||
return models.filter(m => selectedProviders.includes(m.provider_name))
|
||||
}
|
||||
|
||||
async function loadCatalog() {
|
||||
const res = await api.get('/providers')
|
||||
const providers: Provider[] = res.data.data
|
||||
catalogProviders.value = providers.map(p => ({ id: p.id, name: p.name }))
|
||||
catalogProviders.value = providers.map(p => ({ id: p.id, name: p.name, category: p.category }))
|
||||
const models: ModelOption[] = []
|
||||
for (const p of providers) {
|
||||
for (const m of p.models || []) {
|
||||
@@ -315,6 +416,7 @@ async function loadCatalog() {
|
||||
model_id: ingressId,
|
||||
display_name: m.display_name || ingressId,
|
||||
provider_name: p.name,
|
||||
provider_category: p.category,
|
||||
label: `${ingressId} (${p.name})`,
|
||||
})
|
||||
}
|
||||
@@ -322,10 +424,13 @@ async function loadCatalog() {
|
||||
catalogModels.value = models
|
||||
}
|
||||
|
||||
async function loadCatalogForEdit() {
|
||||
await loadCatalog()
|
||||
}
|
||||
|
||||
async function loadRoutingCatalog() {
|
||||
const res = await api.get('/providers')
|
||||
const providers: Provider[] = res.data.data ?? []
|
||||
catalogProviders.value = providers.map(p => ({ id: p.id, name: p.name }))
|
||||
const providers: Provider[] = (res.data.data ?? []).filter(p => p.category !== 'image')
|
||||
routingProviders.value = await Promise.all(providers.map(async (p) => {
|
||||
const keysRes = await api.get(`/providers/${p.id}/keys`)
|
||||
const keys = (keysRes.data.data as ProviderKey[] || [])
|
||||
@@ -348,6 +453,7 @@ async function load() {
|
||||
function openCreate() {
|
||||
form.value = {
|
||||
name: '',
|
||||
protocol: 'text',
|
||||
budget_limit: 0,
|
||||
rate_limit_rpm: 0,
|
||||
rate_limit_tpm: 0,
|
||||
@@ -360,6 +466,7 @@ function openCreate() {
|
||||
async function create() {
|
||||
const res = await api.post('/ingress-keys', {
|
||||
name: form.value.name,
|
||||
protocol: form.value.protocol,
|
||||
budget_limit: form.value.budget_limit,
|
||||
rate_limit_rpm: form.value.rate_limit_rpm,
|
||||
rate_limit_tpm: form.value.rate_limit_tpm,
|
||||
@@ -372,23 +479,30 @@ async function create() {
|
||||
load()
|
||||
}
|
||||
|
||||
function openWhitelist(row: IngressKey) {
|
||||
function openApiDoc(row: IngressKey) {
|
||||
const protocol = row.protocol === 'image' ? 'image' : 'text'
|
||||
router.push(protocol === 'image' ? '/ingress/docs/image' : '/ingress/docs/text')
|
||||
}
|
||||
|
||||
function openEdit(row: IngressKey) {
|
||||
current.value = row
|
||||
whitelistForm.value = {
|
||||
editForm.value = {
|
||||
protocol: row.protocol || 'text',
|
||||
allowed_providers: jsonListToSelection(row.allowed_providers_json),
|
||||
allowed_models: jsonListToSelection(row.allowed_models_json),
|
||||
}
|
||||
showWhitelist.value = true
|
||||
showEdit.value = true
|
||||
}
|
||||
|
||||
async function saveWhitelist() {
|
||||
async function saveEdit() {
|
||||
if (!current.value) return
|
||||
await api.put(`/ingress-keys/${current.value.id}`, {
|
||||
allowed_providers: selectionToPayload(whitelistForm.value.allowed_providers),
|
||||
allowed_models: selectionToPayload(whitelistForm.value.allowed_models),
|
||||
protocol: editForm.value.protocol,
|
||||
allowed_providers: selectionToPayload(editForm.value.allowed_providers),
|
||||
allowed_models: selectionToPayload(editForm.value.allowed_models),
|
||||
})
|
||||
showWhitelist.value = false
|
||||
ElMessage.success(t('ingress.whitelistUpdated'))
|
||||
showEdit.value = false
|
||||
ElMessage.success(t('ingress.protocolUpdated'))
|
||||
load()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user