Split ingress keys into text and image protocols with dedicated API docs.
CI / docker (push) Successful in 4m19s

Text keys use OpenAI routes; image keys use Replicate predictions and model listing, with admin UI whitelist filtering and per-key documentation pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-29 21:41:06 +08:00
parent 2665ac1dee
commit 9988ac9b5f
20 changed files with 798 additions and 77 deletions
+72 -9
View File
@@ -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 兼容 PredictionsComfyUI 工作流
→ 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`
## 入口 APIOpenAI 兼容)
客户端使用协议为**文本**的入口密钥(`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 APIReplicate 兼容)
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` 为出口模型的工作流版本哈希(管理台「出口管理」中 ComfyUI 模型配置后自动生成);`input` 为工作流参数(如 `prompt``width``height` 等,取决于工作流类型与参数绑定)。
**示例:列出工作流模型**
```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":"<workflow-version-hash>","input":{"prompt":"a cat"}}'
```
任务完成后,输出图像通过网关签名的 URL 返回(`/files/:id?exp=&sig=`)。
## 使用攻略(简要)
### 1. 首次登录
+7 -2
View File
@@ -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)
+32 -14
View File
@@ -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
}
-15
View File
@@ -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)
+57
View File
@@ -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
}
+38
View File
@@ -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"
+6 -5
View File
@@ -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"`
+21
View File
@@ -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
}
+22
View File
@@ -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")
}
}
+90
View File
@@ -0,0 +1,90 @@
package prediction
import (
"context"
"fmt"
"strings"
"time"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/store/cache"
)
func splitOwnerName(ingressID, providerName string) (owner, name string) {
if i := strings.Index(ingressID, "/"); i > 0 {
return ingressID[:i], ingressID[i+1:]
}
owner = providerName
if owner == "" {
owner = "luminary"
}
name = ingressID
if name == "" {
name = "model"
}
return owner, name
}
func modelDescription(m model.ModelEntry) string {
if m.DisplayName != "" {
return m.DisplayName
}
switch m.WorkflowType {
case model.WorkflowImg2Img:
return "Image-to-image workflow"
default:
return "Text-to-image workflow"
}
}
// ListPublicModels returns Replicate-compatible model objects visible to the ingress key.
func (s *Service) ListPublicModels(ctx context.Context, ingress *model.IngressKey, baseURL string) ([]map[string]any, error) {
_ = ctx
allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON)
allowedModels := cache.ParseJSONList(ingress.AllowedModelsJSON)
baseURL = strings.TrimRight(baseURL, "/")
var results []map[string]any
for _, p := range s.cache.ListProviders() {
if p.Category != model.CategoryImage || !p.Enabled {
continue
}
if !cache.MatchesList(allowedProviders, p.Name) && !cache.MatchesList(allowedProviders, string(p.Type)) && !cache.MatchesList(allowedProviders, "*") {
continue
}
for _, m := range s.cache.GetModels(p.ID) {
if !m.Enabled || m.VersionHash == "" {
continue
}
ingressID := m.IngressID()
if !cache.MatchesList(allowedModels, ingressID) && !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, m.VersionHash) && !cache.MatchesList(allowedModels, "*") {
continue
}
owner, name := splitOwnerName(ingressID, p.Name)
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": modelDescription(m),
"visibility": "public",
"latest_version": map[string]any{
"id": m.VersionHash,
"created_at": createdAt.UTC().Format(time.RFC3339),
"cog_version": "",
"openapi_schema": map[string]any{
"info": map[string]any{
"title": name,
"version": "1.0.0",
"description": modelDescription(m),
},
},
},
})
}
}
return results, nil
}
+27
View File
@@ -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)
}
}
+10
View File
@@ -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") {
+1 -1
View File
@@ -5,7 +5,7 @@
<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>
<script type="module" crossorigin src="/assets/index-Cx4jti5N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CDyLXzvy.css">
</head>
<body>
+1
View File
@@ -62,6 +62,7 @@ export interface IngressKey {
id: number
name: string
prefix: string
protocol: 'text' | 'image'
enabled: boolean
budget_limit: number
budget_used: number
+66
View File
@@ -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,63 @@ 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:',
endpointsTitle: 'Endpoints',
method: 'Method',
path: 'Path',
summary: 'Summary',
text: {
title: 'Text API (OpenAI compatible)',
description: 'For ingress keys with the Text protocol. Compatible with OpenAI SDKs.',
note: 'Available endpoints depend on enabled egress endpoints. Empty whitelist means no provider/model restriction.',
endpointRows: [
{ method: 'GET', path: '/v1/models', summary: 'List models (OpenAI format)', status: 'Supported' },
{ method: 'POST', path: '/v1/chat/completions', summary: 'Chat completions; SSE when stream: true', status: 'Supported' },
{ method: 'POST', path: '/v1/responses', summary: 'OpenAI Responses API', status: 'Supported' },
{ method: 'POST', path: '/v1/completions', summary: 'Legacy text completions', status: 'Supported' },
{ method: 'POST', path: '/v1/embeddings', summary: 'Embeddings', status: 'Supported' },
{ method: 'POST', path: '/v1/rerank', summary: 'Cohere-compatible rerank', status: 'Supported' },
],
exampleRows: [
{
title: 'List models',
code: 'curl {baseURL}/models \\\n -H "Authorization: Bearer sk-lum-..."',
},
{
title: 'Chat completion',
code: 'curl {baseURL}/chat/completions \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}\'',
},
{
title: 'Embeddings',
code: 'curl {baseURL}/embeddings \\\n -H "Authorization: Bearer sk-lum-..." \\\n -H "Content-Type: application/json" \\\n -d \'{"model":"text-embedding-3-small","input":"hello"}\'',
},
],
},
image: {
title: 'Image API (Replicate compatible)',
description: 'For ingress keys with the Image protocol. Async ComfyUI txt2img / img2img workflows.',
note: 'Request field version is the workflow hash from egress model config. 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":"<workflow-version-hash>","input":{"prompt":"a cat"}}\'',
},
],
},
},
}
+66
View File
@@ -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,63 @@ export default {
settingsSaved: '系统设置已保存',
passwordUpdated: '密码已更新',
},
apiDocs: {
backToKeys: '返回入口管理',
authTitle: '鉴权',
authDesc: '请求头二选一携带入口密钥(sk-lum-*):',
endpointsTitle: '接口列表',
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 为工作流版本哈希(出口管理中 ComfyUI 模型配置后自动生成)。任务完成后输出图像通过签名 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":"<workflow-version-hash>","input":{"prompt":"a cat"}}\'',
},
],
},
},
}
+3
View File
@@ -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 + '/')
}
+12
View File
@@ -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') },
+122
View File
@@ -0,0 +1,122 @@
<template>
<div class="page api-doc-page">
<PageHeader :title="doc.title" :description="doc.description">
<template #actions>
<el-button @click="router.push('/ingress/keys')">{{ t('apiDocs.backToKeys') }}</el-button>
</template>
</PageHeader>
<AppCard class="u-mb-3">
<h3 class="doc-section-title">{{ t('apiDocs.authTitle') }}</h3>
<p class="doc-text">{{ t('apiDocs.authDesc') }}</p>
<pre class="doc-code">{{ authExample }}</pre>
<p class="doc-text">
<strong>Base URL</strong>: <code>{{ baseURL }}</code>
</p>
</AppCard>
<AppCard class="u-mb-3">
<h3 class="doc-section-title">{{ t('apiDocs.endpointsTitle') }}</h3>
<el-table :data="doc.endpoints" size="small" class="u-mt-2">
<el-table-column prop="method" :label="t('apiDocs.method')" width="90" />
<el-table-column prop="path" :label="t('apiDocs.path')" min-width="200" />
<el-table-column prop="summary" :label="t('apiDocs.summary')" min-width="240" />
<el-table-column prop="status" :label="t('common.status')" width="100" />
</el-table>
<p v-if="doc.note" class="doc-note u-mt-2">{{ doc.note }}</p>
</AppCard>
<AppCard v-for="(example, idx) in doc.examples" :key="idx" class="u-mb-3">
<h3 class="doc-section-title">{{ example.title }}</h3>
<pre class="doc-code">{{ example.code }}</pre>
</AppCard>
</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),
})),
}
})
</script>
<style scoped>
.doc-section-title {
margin: 0 0 8px;
font-size: 15px;
font-weight: 600;
}
.doc-text {
margin: 0 0 8px;
color: var(--text-secondary, #606266);
line-height: 1.6;
}
.doc-note {
margin: 0;
color: var(--text-secondary, #909399);
font-size: 13px;
line-height: 1.6;
}
.doc-code {
margin: 0;
padding: 12px 14px;
border-radius: 8px;
background: var(--code-bg, #f5f7fa);
font-size: 13px;
line-height: 1.55;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.u-mb-3 {
margin-bottom: 16px;
}
.u-mt-2 {
margin-top: 8px;
}
</style>
+145 -31
View File
@@ -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()
}