Split ingress keys into text/image protocols with Replicate model listing.
CI / docker (push) Successful in 3m38s
CI / docker (push) Successful in 3m38s
Add model input defaults (including prompt), workflow model editing, ingress API docs, and fix img2img image upload filenames for signed URLs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Luminary
|
# Luminary
|
||||||
|
|
||||||
OpenAI 兼容 AI 网关,带 Web 管理台。支持多 Provider 出口、入口密钥治理、看板监控与安全管控。控制面自研(Go + SQLite),数据面提供 OpenAI 兼容 `/v1/*` 推理 API。
|
OpenAI 兼容 AI 网关,带 Web 管理台。支持多 Provider 出口、入口密钥治理、看板监控与安全管控。控制面自研(Go + SQLite),数据面统一通过入口密钥对外提供 **OpenAI 兼容推理 API** 与 **Replicate 兼容 Predictions API** 两套协议。
|
||||||
|
|
||||||
所有运行参数在 Web 管理台完成配置,**无需手写配置文件**。管理台支持**简体中文、英文**两种界面语言,可在顶栏随时切换。
|
所有运行参数在 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 限流 |
|
| 入口管理 | OpenAI 兼容 API,入口密钥(`sk-lum-*`),Virtual Key 路由、预算与 RPM/TPM 限流 |
|
||||||
| 看板监控 | Provider 健康检查、24h 请求/Token 统计、入口用量排行 |
|
| 看板监控 | Provider 健康检查、24h 请求/Token 统计、入口用量排行 |
|
||||||
| 请求日志 | 全量请求记录、按模型定价的成本估算 |
|
| 请求日志 | 全量请求记录、按模型定价的成本估算 |
|
||||||
@@ -44,14 +44,27 @@ OpenAI 兼容 AI 网关,带 Web 管理台。支持多 Provider 出口、入口
|
|||||||
```
|
```
|
||||||
客户端 SDK / curl
|
客户端 SDK / curl
|
||||||
→ /v1/*(入口密钥 sk-lum-*)
|
→ /v1/*(入口密钥 sk-lum-*)
|
||||||
→ Luminary Gateway(路由、限流、故障转移)
|
├─ OpenAI 兼容推理(chat / embeddings / rerank …)
|
||||||
→ Provider 出口(多 Key 加权负载均衡)
|
└─ Replicate 兼容 Predictions(ComfyUI 工作流)
|
||||||
↑
|
→ Luminary Gateway(路由、限流、故障转移)
|
||||||
|
→ Provider 出口(多 Key 加权负载均衡)
|
||||||
|
↑
|
||||||
管理台 REST API + Vue UI (:8293)
|
管理台 REST API + Vue UI (:8293)
|
||||||
↓
|
↓
|
||||||
SQLite + 内存缓存
|
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`(仅当数据库中尚无管理员时创建)。
|
首次启动自动创建数据库与默认系统设置(含随机加密密钥)。默认管理账号 `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
|
```http
|
||||||
Authorization: Bearer sk-lum-<your-key>
|
Authorization: Bearer sk-lum-<your-key>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-Api-Key: sk-lum-<your-key>
|
||||||
|
```
|
||||||
|
|
||||||
**Base URL**:`http://<host>:8293/v1`
|
**Base URL**:`http://<host>:8293/v1`
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 | 状态 |
|
| 方法 | 路径 | 说明 | 状态 |
|
||||||
|------|------|------|------|
|
|------|------|------|------|
|
||||||
| `GET` | `/v1/models` | 列出当前入口密钥可用的模型 | 已支持 |
|
| `GET` | `/v1/models` | 列出当前文本密钥可用的模型(OpenAI 格式) | 已支持 |
|
||||||
| `POST` | `/v1/chat/completions` | 对话补全;`stream: true` 时返回 SSE 流式 | 已支持 |
|
| `POST` | `/v1/chat/completions` | 对话补全;`stream: true` 时返回 SSE 流式 | 已支持 |
|
||||||
| `POST` | `/v1/responses` | OpenAI Responses API;`stream: true` 时返回 SSE 流式 | 已支持 |
|
| `POST` | `/v1/responses` | OpenAI Responses API;`stream: true` 时返回 SSE 流式 | 已支持 |
|
||||||
| `POST` | `/v1/completions` | 文本补全(Legacy) | 已支持 |
|
| `POST` | `/v1/completions` | 文本补全(Legacy) | 已支持 |
|
||||||
@@ -216,6 +246,39 @@ curl http://localhost:8293/v1/models \
|
|||||||
|
|
||||||
入口密钥的 Provider / Model 白名单、预算、RPM/TPM 限流与路由规则均在管理台「入口管理」配置;白名单为空表示不限制。
|
入口密钥的 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. 首次登录
|
### 1. 首次登录
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/rose_cat707/luminary/internal/gateway"
|
"github.com/rose_cat707/luminary/internal/gateway"
|
||||||
"github.com/rose_cat707/luminary/internal/handler/admin"
|
"github.com/rose_cat707/luminary/internal/handler/admin"
|
||||||
"github.com/rose_cat707/luminary/internal/handler/files"
|
"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/proxy"
|
||||||
"github.com/rose_cat707/luminary/internal/handler/replicate"
|
"github.com/rose_cat707/luminary/internal/handler/replicate"
|
||||||
"github.com/rose_cat707/luminary/internal/handler/static"
|
"github.com/rose_cat707/luminary/internal/handler/static"
|
||||||
@@ -95,10 +96,14 @@ func main() {
|
|||||||
|
|
||||||
proxyHandler := proxy.New(gw)
|
proxyHandler := proxy.New(gw)
|
||||||
v1 := r.Group("/v1", middleware.IPFilter(memCache, model.IPScopeProxy), middleware.IngressAuth(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 := replicate.New(predSvc, publicURL)
|
||||||
replicateHandler.Register(v1)
|
imageAPI := v1.Group("", middleware.RequireIngressProtocol(model.IngressProtocolImage))
|
||||||
|
replicateHandler.Register(imageAPI)
|
||||||
|
|
||||||
filesHandler := files.New(imgStore)
|
filesHandler := files.New(imgStore)
|
||||||
filesHandler.Register(r)
|
filesHandler.Register(r)
|
||||||
|
|||||||
@@ -19,18 +19,27 @@ func (h *Handler) ListIngressKeys(c *gin.Context) {
|
|||||||
|
|
||||||
func (h *Handler) CreateIngressKey(c *gin.Context) {
|
func (h *Handler) CreateIngressKey(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
BudgetLimit float64 `json:"budget_limit"`
|
Protocol model.IngressKeyProtocol `json:"protocol"`
|
||||||
RateLimitRPM int `json:"rate_limit_rpm"`
|
BudgetLimit float64 `json:"budget_limit"`
|
||||||
RateLimitTPM int `json:"rate_limit_tpm"`
|
RateLimitRPM int `json:"rate_limit_rpm"`
|
||||||
AllowedProviders []string `json:"allowed_providers"`
|
RateLimitTPM int `json:"rate_limit_tpm"`
|
||||||
AllowedModels []string `json:"allowed_models"`
|
AllowedProviders []string `json:"allowed_providers"`
|
||||||
ExpiresAt *time.Time `json:"expires_at"`
|
AllowedModels []string `json:"allowed_models"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
|
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
return
|
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()
|
plainKey, err := generateIngressKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"})
|
||||||
@@ -57,6 +66,7 @@ func (h *Handler) CreateIngressKey(c *gin.Context) {
|
|||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
KeyHash: hash,
|
KeyHash: hash,
|
||||||
Prefix: plainKey[:12] + "...",
|
Prefix: plainKey[:12] + "...",
|
||||||
|
Protocol: protocol,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
BudgetLimit: req.BudgetLimit,
|
BudgetLimit: req.BudgetLimit,
|
||||||
RateLimitRPM: req.RateLimitRPM,
|
RateLimitRPM: req.RateLimitRPM,
|
||||||
@@ -91,18 +101,26 @@ func (h *Handler) UpdateIngressKey(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Enabled *bool `json:"enabled"`
|
Protocol *model.IngressKeyProtocol `json:"protocol"`
|
||||||
BudgetLimit *float64 `json:"budget_limit"`
|
Enabled *bool `json:"enabled"`
|
||||||
RateLimitRPM *int `json:"rate_limit_rpm"`
|
BudgetLimit *float64 `json:"budget_limit"`
|
||||||
RateLimitTPM *int `json:"rate_limit_tpm"`
|
RateLimitRPM *int `json:"rate_limit_rpm"`
|
||||||
AllowedProviders []string `json:"allowed_providers"`
|
RateLimitTPM *int `json:"rate_limit_tpm"`
|
||||||
AllowedModels []string `json:"allowed_models"`
|
AllowedProviders []string `json:"allowed_providers"`
|
||||||
|
AllowedModels []string `json:"allowed_models"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
return
|
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 != "" {
|
if req.Name != "" {
|
||||||
k.Name = req.Name
|
k.Name = req.Name
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,8 +227,10 @@ func (h *Handler) CreateModel(c *gin.Context) {
|
|||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
WorkflowType model.WorkflowType `json:"workflow_type"`
|
WorkflowType model.WorkflowType `json:"workflow_type"`
|
||||||
WorkflowJSON string `json:"workflow_json"`
|
WorkflowJSON string `json:"workflow_json"`
|
||||||
InputBindingJSON string `json:"input_binding_json"`
|
InputBindingJSON string `json:"input_binding_json"`
|
||||||
InputBinding map[string]*prediction.NodeField `json:"input_binding"`
|
InputBinding map[string]*prediction.NodeField `json:"input_binding"`
|
||||||
|
InputDefaultsJSON string `json:"input_defaults_json"`
|
||||||
|
InputDefaults map[string]any `json:"input_defaults"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil || req.ModelID == "" {
|
if err := c.ShouldBindJSON(&req); err != nil || req.ModelID == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
@@ -260,19 +262,25 @@ func (h *Handler) CreateModel(c *gin.Context) {
|
|||||||
b, _ := json.Marshal(req.InputBinding)
|
b, _ := json.Marshal(req.InputBinding)
|
||||||
bindingJSON = string(b)
|
bindingJSON = string(b)
|
||||||
}
|
}
|
||||||
|
defaultsJSON, err := marshalInputDefaults(req.InputDefaultsJSON, req.InputDefaults)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
wt := req.WorkflowType
|
wt := req.WorkflowType
|
||||||
if wt == "" {
|
if wt == "" {
|
||||||
wt = model.WorkflowTxt2Img
|
wt = model.WorkflowTxt2Img
|
||||||
}
|
}
|
||||||
m := model.ModelEntry{
|
m := model.ModelEntry{
|
||||||
ProviderID: providerID,
|
ProviderID: providerID,
|
||||||
ModelID: req.ModelID,
|
ModelID: req.ModelID,
|
||||||
Alias: normalizeAlias(req.Alias),
|
Alias: normalizeAlias(req.Alias),
|
||||||
DisplayName: req.DisplayName,
|
DisplayName: req.DisplayName,
|
||||||
WorkflowType: wt,
|
WorkflowType: wt,
|
||||||
WorkflowJSON: req.WorkflowJSON,
|
WorkflowJSON: req.WorkflowJSON,
|
||||||
InputBindingJSON: bindingJSON,
|
InputBindingJSON: bindingJSON,
|
||||||
VersionHash: prediction.HashWorkflowJSON(req.WorkflowJSON),
|
InputDefaultsJSON: defaultsJSON,
|
||||||
|
VersionHash: prediction.HashWorkflowJSON(req.WorkflowJSON),
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
}
|
}
|
||||||
if prov.Category == model.CategoryImage {
|
if prov.Category == model.CategoryImage {
|
||||||
@@ -301,8 +309,10 @@ func (h *Handler) UpdateModel(c *gin.Context) {
|
|||||||
ModelID string `json:"model_id"`
|
ModelID string `json:"model_id"`
|
||||||
WorkflowType model.WorkflowType `json:"workflow_type"`
|
WorkflowType model.WorkflowType `json:"workflow_type"`
|
||||||
WorkflowJSON string `json:"workflow_json"`
|
WorkflowJSON string `json:"workflow_json"`
|
||||||
InputBindingJSON string `json:"input_binding_json"`
|
InputBindingJSON string `json:"input_binding_json"`
|
||||||
InputBinding map[string]*prediction.NodeField `json:"input_binding"`
|
InputBinding map[string]*prediction.NodeField `json:"input_binding"`
|
||||||
|
InputDefaultsJSON string `json:"input_defaults_json"`
|
||||||
|
InputDefaults map[string]any `json:"input_defaults"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
@@ -349,6 +359,14 @@ func (h *Handler) UpdateModel(c *gin.Context) {
|
|||||||
b, _ := json.Marshal(req.InputBinding)
|
b, _ := json.Marshal(req.InputBinding)
|
||||||
m.InputBindingJSON = string(b)
|
m.InputBindingJSON = string(b)
|
||||||
}
|
}
|
||||||
|
if req.InputDefaults != nil || req.InputDefaultsJSON != "" {
|
||||||
|
defaultsJSON, err := marshalInputDefaults(req.InputDefaultsJSON, req.InputDefaults)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.InputDefaultsJSON = defaultsJSON
|
||||||
|
}
|
||||||
prov, _ := h.cache.GetProvider(m.ProviderID)
|
prov, _ := h.cache.GetProvider(m.ProviderID)
|
||||||
if prov.Category == model.CategoryImage {
|
if prov.Category == model.CategoryImage {
|
||||||
m.Alias = ""
|
m.Alias = ""
|
||||||
@@ -422,3 +440,20 @@ func (h *Handler) DeleteModel(c *gin.Context) {
|
|||||||
h.cache.SetModels(m.ProviderID, filtered)
|
h.cache.SetModels(m.ProviderID, filtered)
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func marshalInputDefaults(raw string, defaults map[string]any) (string, error) {
|
||||||
|
if defaults != nil {
|
||||||
|
b, err := json.Marshal(defaults)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
return "{}", nil
|
||||||
|
}
|
||||||
|
if _, err := prediction.ParseInputDefaults(raw); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ func New(gw *gateway.Service) *Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Register(r *gin.RouterGroup) {
|
func (h *Handler) Register(r *gin.RouterGroup) {
|
||||||
r.GET("/models", h.ListModels)
|
|
||||||
r.POST("/chat/completions", h.forward(provider.EndpointChatCompletion))
|
r.POST("/chat/completions", h.forward(provider.EndpointChatCompletion))
|
||||||
r.POST("/completions", h.forward(provider.EndpointTextCompletion))
|
r.POST("/completions", h.forward(provider.EndpointTextCompletion))
|
||||||
r.POST("/responses", h.forward(provider.EndpointResponses))
|
r.POST("/responses", h.forward(provider.EndpointResponses))
|
||||||
@@ -38,20 +37,6 @@ func (h *Handler) ingressKey(c *gin.Context) (*model.IngressKey, bool) {
|
|||||||
return k, ok
|
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 {
|
func (h *Handler) forward(endpoint provider.EndpointType) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
ingress, ok := h.ingressKey(c)
|
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)
|
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 {
|
func Logger() gin.HandlerFunc {
|
||||||
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
|
||||||
return param.TimeStamp.Format(time.RFC3339) + " " + param.Method + " " + param.Path + " " + param.ClientIP + "\n"
|
return param.TimeStamp.Format(time.RFC3339) + " " + param.Method + " " + param.Path + " " + param.ClientIP + "\n"
|
||||||
|
|||||||
@@ -82,17 +82,19 @@ type ModelEntry struct {
|
|||||||
WorkflowType WorkflowType `gorm:"default:txt2img" json:"workflow_type"`
|
WorkflowType WorkflowType `gorm:"default:txt2img" json:"workflow_type"`
|
||||||
WorkflowJSON string `gorm:"type:text" json:"workflow_json"`
|
WorkflowJSON string `gorm:"type:text" json:"workflow_json"`
|
||||||
InputBindingJSON string `gorm:"type:text" json:"input_binding_json"`
|
InputBindingJSON string `gorm:"type:text" json:"input_binding_json"`
|
||||||
|
InputDefaultsJSON string `gorm:"type:text;default:'{}'" json:"input_defaults_json"`
|
||||||
VersionHash string `gorm:"index" json:"version_hash"`
|
VersionHash string `gorm:"index" json:"version_hash"`
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type IngressKey struct {
|
type IngressKey struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
|
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
|
||||||
Prefix string `gorm:"not null" json:"prefix"`
|
Prefix string `gorm:"not null" json:"prefix"`
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
Protocol IngressKeyProtocol `gorm:"not null;default:text" json:"protocol"`
|
||||||
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
BudgetLimit float64 `gorm:"default:0" json:"budget_limit"`
|
BudgetLimit float64 `gorm:"default:0" json:"budget_limit"`
|
||||||
BudgetUsed float64 `gorm:"default:0" json:"budget_used"`
|
BudgetUsed float64 `gorm:"default:0" json:"budget_used"`
|
||||||
RateLimitRPM int `gorm:"default:0" json:"rate_limit_rpm"`
|
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
|
package model
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// IngressID returns the model name exposed to ingress API clients.
|
// IngressID returns the model name exposed to ingress API clients.
|
||||||
func (m ModelEntry) IngressID() string {
|
func (m ModelEntry) IngressID() string {
|
||||||
if m.Alias != "" {
|
if m.Alias != "" {
|
||||||
@@ -8,6 +10,27 @@ func (m ModelEntry) IngressID() string {
|
|||||||
return m.ModelID
|
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.
|
// MatchesIngressName reports whether the entry matches an ingress model identifier.
|
||||||
func (m ModelEntry) MatchesIngressName(name string) bool {
|
func (m ModelEntry) MatchesIngressName(name string) bool {
|
||||||
if name == "" || !m.Enabled {
|
if name == "" || !m.Enabled {
|
||||||
@@ -18,3 +41,18 @@ func (m ModelEntry) MatchesIngressName(name string) bool {
|
|||||||
}
|
}
|
||||||
return m.Alias != "" && m.Alias == name
|
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,62 @@
|
|||||||
|
package prediction
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/rose_cat707/luminary/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ParseInputDefaults(raw string) (map[string]any, error) {
|
||||||
|
if raw == "" || raw == "{}" {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveDefault(d ParamDef, overrides map[string]any) (any, bool) {
|
||||||
|
if overrides != nil {
|
||||||
|
if v, ok := overrides[d.Name]; ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Default != nil {
|
||||||
|
return d.Default, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateInputWithDefaults(wt model.WorkflowType, input map[string]any, overrides map[string]any) (map[string]any, error) {
|
||||||
|
defs := ParamsForWorkflowType(wt)
|
||||||
|
out := make(map[string]any, len(defs))
|
||||||
|
for _, d := range defs {
|
||||||
|
v, ok := input[d.Name]
|
||||||
|
if !ok || v == nil {
|
||||||
|
if def, has := effectiveDefault(d, overrides); has {
|
||||||
|
coerced, err := coerceValue(d, def)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", d.Name, err)
|
||||||
|
}
|
||||||
|
out[d.Name] = coerced
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if d.Required {
|
||||||
|
return nil, fmt.Errorf("missing required field: %s", d.Name)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
coerced, err := coerceValue(d, v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", d.Name, err)
|
||||||
|
}
|
||||||
|
out[d.Name] = coerced
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package prediction
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rose_cat707/luminary/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateInputWithDefaultsOverride(t *testing.T) {
|
||||||
|
overrides := map[string]any{"width": float64(768), "steps": float64(30)}
|
||||||
|
out, err := ValidateInputWithDefaults(model.WorkflowTxt2Img, map[string]any{
|
||||||
|
"prompt": "hello",
|
||||||
|
}, overrides)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out["width"] != 768 && out["width"] != float64(768) {
|
||||||
|
t.Fatalf("width got %#v", out["width"])
|
||||||
|
}
|
||||||
|
if out["steps"] != 30 && out["steps"] != float64(30) {
|
||||||
|
t.Fatalf("steps got %#v", out["steps"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAPISchemaUsesModelDefaults(t *testing.T) {
|
||||||
|
schema := OpenAPISchemaForWorkflow(model.WorkflowTxt2Img, "flux", "desc", map[string]any{
|
||||||
|
"width": float64(768),
|
||||||
|
})
|
||||||
|
components := schema["components"].(map[string]any)
|
||||||
|
schemas := components["schemas"].(map[string]any)
|
||||||
|
input := schemas["Input"].(map[string]any)
|
||||||
|
props := input["properties"].(map[string]any)
|
||||||
|
width := props["width"].(map[string]any)
|
||||||
|
if width["default"] != float64(768) {
|
||||||
|
t.Fatalf("unexpected width default %#v", width["default"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateInputPromptDefault(t *testing.T) {
|
||||||
|
overrides := map[string]any{"prompt": "a scenic landscape"}
|
||||||
|
out, err := ValidateInputWithDefaults(model.WorkflowTxt2Img, map[string]any{}, overrides)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if out["prompt"] != "a scenic landscape" {
|
||||||
|
t.Fatalf("prompt got %#v", out["prompt"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateInputRequiredWithoutDefault(t *testing.T) {
|
||||||
|
_, err := ValidateInputWithDefaults(model.WorkflowTxt2Img, map[string]any{}, map[string]any{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected missing prompt error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package prediction
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func imageFilenameFromURL(raw string, data []byte) string {
|
||||||
|
name := ""
|
||||||
|
if u, err := url.Parse(raw); err == nil {
|
||||||
|
if base := path.Base(u.Path); base != "" && base != "." && base != "/" {
|
||||||
|
name = base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "input"
|
||||||
|
}
|
||||||
|
name = sanitizeUploadFilename(name)
|
||||||
|
if path.Ext(name) == "" {
|
||||||
|
name += sniffImageExt(data)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageFilenameFromStored(filename, mime string, data []byte) string {
|
||||||
|
name := filename
|
||||||
|
if name == "" {
|
||||||
|
name = "input"
|
||||||
|
}
|
||||||
|
name = sanitizeUploadFilename(name)
|
||||||
|
if path.Ext(name) == "" {
|
||||||
|
if ext := extFromMime(mime); ext != "" {
|
||||||
|
name += ext
|
||||||
|
} else {
|
||||||
|
name += sniffImageExt(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeUploadFilename(name string) string {
|
||||||
|
name = path.Base(name)
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"?", "_",
|
||||||
|
"&", "_",
|
||||||
|
"=", "_",
|
||||||
|
"#", "_",
|
||||||
|
"%", "_",
|
||||||
|
"\\", "_",
|
||||||
|
":", "_",
|
||||||
|
"*", "_",
|
||||||
|
"\"", "_",
|
||||||
|
"<", "_",
|
||||||
|
">", "_",
|
||||||
|
"|", "_",
|
||||||
|
)
|
||||||
|
name = replacer.Replace(name)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" || name == "." {
|
||||||
|
return "input"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func sniffImageExt(data []byte) string {
|
||||||
|
switch {
|
||||||
|
case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
|
||||||
|
return ".jpg"
|
||||||
|
case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}):
|
||||||
|
return ".png"
|
||||||
|
case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
|
||||||
|
return ".webp"
|
||||||
|
case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))):
|
||||||
|
return ".gif"
|
||||||
|
default:
|
||||||
|
return ".png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extFromMime(mime string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mime)) {
|
||||||
|
case "image/jpeg", "image/jpg":
|
||||||
|
return ".jpg"
|
||||||
|
case "image/png":
|
||||||
|
return ".png"
|
||||||
|
case "image/webp":
|
||||||
|
return ".webp"
|
||||||
|
case "image/gif":
|
||||||
|
return ".gif"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGatewayFileID(raw string) (string, bool) {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
segments := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||||
|
if len(segments) == 2 && segments[0] == "files" && segments[1] != "" {
|
||||||
|
return segments[1], true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateImageBytes(data []byte) error {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return fmt.Errorf("empty image payload")
|
||||||
|
}
|
||||||
|
if !looksLikeImage(data) {
|
||||||
|
return fmt.Errorf("unrecognized image format")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeImage(data []byte) bool {
|
||||||
|
switch {
|
||||||
|
case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
|
||||||
|
return true
|
||||||
|
case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}):
|
||||||
|
return true
|
||||||
|
case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
|
||||||
|
return true
|
||||||
|
case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package prediction
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestImageFilenameFromURLStripsQuery(t *testing.T) {
|
||||||
|
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
||||||
|
got := imageFilenameFromURL("https://gateway.example.com/files/abc-123?exp=1&sig=deadbeef", png)
|
||||||
|
if got != "abc-123.png" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageFilenameFromURLAddsExtension(t *testing.T) {
|
||||||
|
jpg := []byte{0xFF, 0xD8, 0xFF, 0x00}
|
||||||
|
got := imageFilenameFromURL("https://example.com/uploads/photo", jpg)
|
||||||
|
if got != "photo.jpg" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGatewayFileID(t *testing.T) {
|
||||||
|
id, ok := parseGatewayFileID("http://localhost:8293/files/img-uuid?exp=1&sig=x")
|
||||||
|
if !ok || id != "img-uuid" {
|
||||||
|
t.Fatalf("got id=%q ok=%v", id, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateImageBytes(t *testing.T) {
|
||||||
|
if err := validateImageBytes(nil); err == nil {
|
||||||
|
t.Fatal("expected empty payload error")
|
||||||
|
}
|
||||||
|
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
||||||
|
if err := validateImageBytes(png); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package prediction
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rose_cat707/luminary/internal/model"
|
||||||
|
"github.com/rose_cat707/luminary/internal/store/cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
func splitOwnerName(ingressID, providerName string) (owner, name string) {
|
||||||
|
return model.SplitIngressOwnerName(ingressID, providerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelDescription(m model.ModelEntry) string {
|
||||||
|
if m.DisplayName != "" {
|
||||||
|
return m.DisplayName
|
||||||
|
}
|
||||||
|
switch m.WorkflowType {
|
||||||
|
case model.WorkflowImg2Img:
|
||||||
|
return "Image-to-image workflow"
|
||||||
|
default:
|
||||||
|
return "Text-to-image workflow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublicModels returns Replicate-compatible model objects visible to the ingress key.
|
||||||
|
func (s *Service) ListPublicModels(ctx context.Context, ingress *model.IngressKey, baseURL string) ([]map[string]any, error) {
|
||||||
|
_ = ctx
|
||||||
|
allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON)
|
||||||
|
allowedModels := cache.ParseJSONList(ingress.AllowedModelsJSON)
|
||||||
|
baseURL = strings.TrimRight(baseURL, "/")
|
||||||
|
|
||||||
|
var results []map[string]any
|
||||||
|
for _, p := range s.cache.ListProviders() {
|
||||||
|
if p.Category != model.CategoryImage || !p.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !cache.MatchesList(allowedProviders, p.Name) && !cache.MatchesList(allowedProviders, string(p.Type)) && !cache.MatchesList(allowedProviders, "*") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, m := range s.cache.GetModels(p.ID) {
|
||||||
|
if !m.Enabled || m.VersionHash == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ingressID := m.IngressID()
|
||||||
|
if !cache.MatchesList(allowedModels, ingressID) && !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, m.VersionHash) && !cache.MatchesList(allowedModels, "*") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
owner, name := splitOwnerName(ingressID, p.Name)
|
||||||
|
desc := modelDescription(m)
|
||||||
|
defaults, _ := ParseInputDefaults(m.InputDefaultsJSON)
|
||||||
|
createdAt := m.CreatedAt
|
||||||
|
if createdAt.IsZero() {
|
||||||
|
createdAt = time.Now()
|
||||||
|
}
|
||||||
|
results = append(results, map[string]any{
|
||||||
|
"url": fmt.Sprintf("%s/v1/models/%s/%s", baseURL, owner, name),
|
||||||
|
"owner": owner,
|
||||||
|
"name": name,
|
||||||
|
"description": desc,
|
||||||
|
"visibility": "public",
|
||||||
|
"latest_version": map[string]any{
|
||||||
|
"id": ingressID,
|
||||||
|
"created_at": createdAt.UTC().Format(time.RFC3339),
|
||||||
|
"cog_version": "",
|
||||||
|
"openapi_schema": OpenAPISchemaForWorkflow(m.WorkflowType, name, desc, defaults),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
@@ -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, overrides map[string]any) map[string]any {
|
||||||
|
defs := ParamsForWorkflowType(wt)
|
||||||
|
properties := make(map[string]any, len(defs))
|
||||||
|
required := make([]string, 0, len(defs))
|
||||||
|
for i, d := range defs {
|
||||||
|
prop := map[string]any{
|
||||||
|
"type": openAPIType(d.Type),
|
||||||
|
"title": d.Label,
|
||||||
|
"description": d.Label,
|
||||||
|
"x-order": i,
|
||||||
|
}
|
||||||
|
if def, ok := effectiveDefault(d, overrides); ok {
|
||||||
|
prop["default"] = def
|
||||||
|
}
|
||||||
|
properties[d.Name] = prop
|
||||||
|
if d.Required {
|
||||||
|
required = append(required, d.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inputSchema := map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": properties,
|
||||||
|
}
|
||||||
|
if len(required) > 0 {
|
||||||
|
inputSchema["required"] = required
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"info": map[string]any{
|
||||||
|
"title": title,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": description,
|
||||||
|
},
|
||||||
|
"components": map[string]any{
|
||||||
|
"schemas": map[string]any{
|
||||||
|
"Input": inputSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func RequiredBindings(wt model.WorkflowType) []string {
|
func RequiredBindings(wt model.WorkflowType) []string {
|
||||||
switch wt {
|
switch wt {
|
||||||
case model.WorkflowImg2Img:
|
case model.WorkflowImg2Img:
|
||||||
@@ -61,26 +114,7 @@ func RequiredBindings(wt model.WorkflowType) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ValidateInput(wt model.WorkflowType, input map[string]any) (map[string]any, error) {
|
func ValidateInput(wt model.WorkflowType, input map[string]any) (map[string]any, error) {
|
||||||
defs := ParamsForWorkflowType(wt)
|
return ValidateInputWithDefaults(wt, input, nil)
|
||||||
out := make(map[string]any, len(defs))
|
|
||||||
for _, d := range defs {
|
|
||||||
v, ok := input[d.Name]
|
|
||||||
if !ok || v == nil {
|
|
||||||
if d.Required {
|
|
||||||
return nil, fmt.Errorf("missing required field: %s", d.Name)
|
|
||||||
}
|
|
||||||
if d.Default != nil {
|
|
||||||
out[d.Name] = d.Default
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
coerced, err := coerceValue(d, v)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s: %w", d.Name, err)
|
|
||||||
}
|
|
||||||
out[d.Name] = coerced
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func coerceValue(d ParamDef, v any) (any, error) {
|
func coerceValue(d ParamDef, v any) (any, error) {
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package prediction
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rose_cat707/luminary/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenAPISchemaForWorkflowTxt2Img(t *testing.T) {
|
||||||
|
schema := OpenAPISchemaForWorkflow(model.WorkflowTxt2Img, "flux", "FLUX txt2img", nil)
|
||||||
|
components, ok := schema["components"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing components")
|
||||||
|
}
|
||||||
|
schemas, ok := components["schemas"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing schemas")
|
||||||
|
}
|
||||||
|
input, ok := schemas["Input"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing Input schema")
|
||||||
|
}
|
||||||
|
props, ok := input["properties"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("missing properties")
|
||||||
|
}
|
||||||
|
if _, ok := props["prompt"]; !ok {
|
||||||
|
t.Fatal("missing prompt property")
|
||||||
|
}
|
||||||
|
if _, ok := props["image"]; ok {
|
||||||
|
t.Fatal("txt2img should not expose image input")
|
||||||
|
}
|
||||||
|
required, ok := input["required"].([]string)
|
||||||
|
if !ok || len(required) == 0 || required[0] != "prompt" {
|
||||||
|
t.Fatalf("unexpected required: %#v", input["required"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAPISchemaForWorkflowImg2Img(t *testing.T) {
|
||||||
|
schema := OpenAPISchemaForWorkflow(model.WorkflowImg2Img, "flux-i2i", "FLUX img2img", nil)
|
||||||
|
components := schema["components"].(map[string]any)
|
||||||
|
schemas := components["schemas"].(map[string]any)
|
||||||
|
input := schemas["Input"].(map[string]any)
|
||||||
|
props := input["properties"].(map[string]any)
|
||||||
|
if _, ok := props["image"]; !ok {
|
||||||
|
t.Fatal("missing image property")
|
||||||
|
}
|
||||||
|
required := input["required"].([]string)
|
||||||
|
if len(required) != 2 {
|
||||||
|
t.Fatalf("expected prompt and image required, got %#v", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -67,7 +66,7 @@ func (s *Service) Create(ctx context.Context, ingress *model.IngressKey, req Cre
|
|||||||
if !provider.Enabled {
|
if !provider.Enabled {
|
||||||
return nil, fmt.Errorf("provider disabled")
|
return nil, fmt.Errorf("provider disabled")
|
||||||
}
|
}
|
||||||
input, err := ValidateInput(entry.WorkflowType, req.Input)
|
input, err := ValidateInputWithDefaults(entry.WorkflowType, req.Input, inputDefaultsForEntry(entry))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -85,14 +84,15 @@ func (s *Service) Create(ctx context.Context, ingress *model.IngressKey, req Cre
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
ingressModelID := entry.IngressID()
|
||||||
inputJSON, _ := json.Marshal(input)
|
inputJSON, _ := json.Marshal(input)
|
||||||
p := model.Prediction{
|
p := model.Prediction{
|
||||||
ID: id,
|
ID: id,
|
||||||
IngressKeyID: ingress.ID,
|
IngressKeyID: ingress.ID,
|
||||||
ProviderID: provider.ID,
|
ProviderID: provider.ID,
|
||||||
ModelEntryID: entry.ID,
|
ModelEntryID: entry.ID,
|
||||||
Version: req.Version,
|
Version: ingressModelID,
|
||||||
Model: entry.ModelID,
|
Model: ingressModelID,
|
||||||
InputJSON: string(inputJSON),
|
InputJSON: string(inputJSON),
|
||||||
Status: model.PredictionStarting,
|
Status: model.PredictionStarting,
|
||||||
ClientID: "luminary-" + id,
|
ClientID: "luminary-" + id,
|
||||||
@@ -129,6 +129,14 @@ func (s *Service) Create(ctx context.Context, ingress *model.IngressKey, req Cre
|
|||||||
return s.toAPI(&p), nil
|
return s.toAPI(&p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inputDefaultsForEntry(entry model.ModelEntry) map[string]any {
|
||||||
|
defs, err := ParseInputDefaults(entry.InputDefaultsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model.ModelEntry, model.Provider, error) {
|
func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model.ModelEntry, model.Provider, error) {
|
||||||
allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON)
|
allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON)
|
||||||
allowedModels := cache.ParseJSONList(ingress.AllowedModelsJSON)
|
allowedModels := cache.ParseJSONList(ingress.AllowedModelsJSON)
|
||||||
@@ -145,7 +153,7 @@ func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ingressID := m.IngressID()
|
ingressID := m.IngressID()
|
||||||
if !m.MatchesIngressName(version) {
|
if !m.MatchesIngressRef(version, p.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !cache.MatchesList(allowedModels, ingressID) && !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, "*") {
|
if !cache.MatchesList(allowedModels, ingressID) && !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, "*") {
|
||||||
@@ -157,6 +165,12 @@ func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model
|
|||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
return model.ModelEntry{}, model.Provider{}, fmt.Errorf("no model found for version %s", version)
|
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]
|
m := candidates[0]
|
||||||
p, ok := s.cache.GetProvider(m.ProviderID)
|
p, ok := s.cache.GetProvider(m.ProviderID)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -180,15 +194,11 @@ func (s *Service) execute(ctx context.Context, p *model.Prediction, entry model.
|
|||||||
runInput := cloneMap(input)
|
runInput := cloneMap(input)
|
||||||
if entry.WorkflowType == model.WorkflowImg2Img {
|
if entry.WorkflowType == model.WorkflowImg2Img {
|
||||||
imageURL, _ := runInput["image"].(string)
|
imageURL, _ := runInput["image"].(string)
|
||||||
data, err := downloadURL(ctx, imageURL)
|
data, name, err := s.loadInputImage(ctx, imageURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(p, clientIP, start, fmt.Errorf("download image: %w", err))
|
s.fail(p, clientIP, start, fmt.Errorf("download image: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := path.Base(imageURL)
|
|
||||||
if name == "" || name == "." || name == "/" {
|
|
||||||
name = "input.png"
|
|
||||||
}
|
|
||||||
uploaded, err := client.UploadImage(ctx, data, name)
|
uploaded, err := client.UploadImage(ctx, data, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.fail(p, clientIP, start, fmt.Errorf("upload image: %w", err))
|
s.fail(p, clientIP, start, fmt.Errorf("upload image: %w", err))
|
||||||
@@ -409,6 +419,26 @@ func formatTime(t *time.Time) any {
|
|||||||
return t.UTC().Format(time.RFC3339Nano)
|
return t.UTC().Format(time.RFC3339Nano)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadInputImage(ctx context.Context, imageURL string) ([]byte, string, error) {
|
||||||
|
if id, ok := parseGatewayFileID(imageURL); ok && s.images != nil {
|
||||||
|
data, img, err := s.images.ReadContent(id)
|
||||||
|
if err == nil {
|
||||||
|
if err := validateImageBytes(data); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return data, imageFilenameFromStored(img.Filename, img.Mime, data), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := downloadURL(ctx, imageURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := validateImageBytes(data); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return data, imageFilenameFromURL(imageURL, data), nil
|
||||||
|
}
|
||||||
|
|
||||||
func downloadURL(ctx context.Context, raw string) ([]byte, error) {
|
func downloadURL(ctx context.Context, raw string) ([]byte, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -455,7 +485,7 @@ func (s *Service) recordUsage(ingress *model.IngressKey, provider *model.Provide
|
|||||||
ProviderID: providerID,
|
ProviderID: providerID,
|
||||||
ClientIP: clientIP,
|
ClientIP: clientIP,
|
||||||
Endpoint: model.EndpointPrediction,
|
Endpoint: model.EndpointPrediction,
|
||||||
Model: p.ID,
|
Model: p.Model,
|
||||||
LatencyMs: time.Since(start).Milliseconds(),
|
LatencyMs: time.Since(start).Milliseconds(),
|
||||||
Status: status,
|
Status: status,
|
||||||
ErrorMsg: errMsg,
|
ErrorMsg: errMsg,
|
||||||
|
|||||||
@@ -146,8 +146,16 @@ func (c *Client) DownloadView(ctx context.Context, filename, subfolder, imageTyp
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) UploadImage(ctx context.Context, data []byte, filename string) (string, error) {
|
func (c *Client) UploadImage(ctx context.Context, data []byte, filename string) (string, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return "", fmt.Errorf("empty image payload")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(filename) == "" {
|
||||||
|
filename = "input.png"
|
||||||
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
w := multipart.NewWriter(&buf)
|
w := multipart.NewWriter(&buf)
|
||||||
|
_ = w.WriteField("type", "input")
|
||||||
|
_ = w.WriteField("overwrite", "true")
|
||||||
part, err := w.CreateFormFile("image", filename)
|
part, err := w.CreateFormFile("image", filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -155,8 +163,9 @@ func (c *Client) UploadImage(ctx context.Context, data []byte, filename string)
|
|||||||
if _, err := part.Write(data); err != nil {
|
if _, err := part.Write(data); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
_ = w.WriteField("overwrite", "true")
|
if err := w.Close(); err != nil {
|
||||||
_ = w.Close()
|
return "", err
|
||||||
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/upload/image", &buf)
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/upload/image", &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ func (s *Store) Get(id string) (*model.StoredImage, error) {
|
|||||||
return &img, nil
|
return &img, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ReadContent(id string) ([]byte, *model.StoredImage, error) {
|
||||||
|
img, err := s.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(img.LocalPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return data, img, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) ListByPrediction(predictionID string) ([]model.StoredImage, error) {
|
func (s *Store) ListByPrediction(predictionID string) ([]model.StoredImage, error) {
|
||||||
var imgs []model.StoredImage
|
var imgs []model.StoredImage
|
||||||
err := s.db.Where("prediction_id = ?", predictionID).Order("created_at asc").Find(&imgs).Error
|
err := s.db.Where("prediction_id = ?", predictionID).Order("created_at asc").Find(&imgs).Error
|
||||||
|
|||||||
@@ -49,9 +49,19 @@ func New(path string) (*Store, error) {
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("migrate: %w", err)
|
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
|
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).
|
// ensureIPRulesSchema manages ip_rules without GORM AutoMigrate (avoids bad table rebuilds).
|
||||||
func ensureIPRulesSchema(db *gorm.DB) error {
|
func ensureIPRulesSchema(db *gorm.DB) error {
|
||||||
if !tableExists(db, "ip_rules") {
|
if !tableExists(db, "ip_rules") {
|
||||||
|
|||||||
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Luminary AI Gateway</title>
|
<title>Luminary AI Gateway</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CF-14R2-.js"></script>
|
<script type="module" crossorigin src="/assets/index-BmYw8w9L.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CDyLXzvy.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-pxQ9-t6p.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export interface ModelEntry {
|
|||||||
workflow_type?: string
|
workflow_type?: string
|
||||||
workflow_json?: string
|
workflow_json?: string
|
||||||
input_binding_json?: string
|
input_binding_json?: string
|
||||||
|
input_defaults_json?: string
|
||||||
version_hash?: string
|
version_hash?: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
created_at?: string
|
created_at?: string
|
||||||
@@ -62,6 +63,7 @@ export interface IngressKey {
|
|||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
prefix: string
|
prefix: string
|
||||||
|
protocol: 'text' | 'image'
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
budget_limit: number
|
budget_limit: number
|
||||||
budget_used: number
|
budget_used: number
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog v-model="visible" :title="t('providers.workflowDialogTitle')" width="720px" destroy-on-close @closed="reset">
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="isEdit ? t('providers.editWorkflowDialogTitle') : t('providers.workflowDialogTitle')"
|
||||||
|
width="960px"
|
||||||
|
destroy-on-close
|
||||||
|
@closed="onClosed"
|
||||||
|
>
|
||||||
<el-form label-width="100px">
|
<el-form label-width="100px">
|
||||||
<el-form-item :label="t('providers.ingressModelId')" required>
|
<el-form-item :label="t('providers.ingressModelId')" required>
|
||||||
<el-input v-model="form.model_id" placeholder="luminary/flux-sdxl" />
|
<el-input v-model="form.model_id" placeholder="luminary/flux-sdxl" />
|
||||||
@@ -23,13 +29,13 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-table v-if="bindings.length" :data="bindings" size="small" class="u-mt-2">
|
<el-table v-if="bindings.length" :data="bindings" size="small" class="u-mt-2">
|
||||||
<el-table-column prop="param" :label="t('providers.paramName')" width="140" />
|
<el-table-column prop="param" :label="t('providers.paramName')" width="130" />
|
||||||
<el-table-column :label="t('providers.bindEnabled')" width="90">
|
<el-table-column :label="t('providers.bindEnabled')" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-switch v-model="row.enabled" :disabled="row.required" />
|
<el-switch v-model="row.enabled" :disabled="row.required" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('providers.targetNode')">
|
<el-table-column :label="t('providers.targetNode')" min-width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-select v-model="row.node" clearable filterable :disabled="!row.enabled" @change="onNodeChange(row)">
|
<el-select v-model="row.node" clearable filterable :disabled="!row.enabled" @change="onNodeChange(row)">
|
||||||
<el-option
|
<el-option
|
||||||
@@ -41,14 +47,32 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column :label="t('providers.targetField')">
|
<el-table-column :label="t('providers.targetField')" min-width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-select v-model="row.field" clearable :disabled="!row.enabled || !row.node">
|
<el-select v-model="row.field" clearable :disabled="!row.enabled || !row.node">
|
||||||
<el-option v-for="f in fieldsFor(row.node)" :key="f" :label="f" :value="f" />
|
<el-option v-for="f in fieldsFor(row.node)" :key="f" :label="f" :value="f" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column :label="t('providers.paramDefault')" min-width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-input-number
|
||||||
|
v-if="row.paramType !== 'string'"
|
||||||
|
v-model="row.defaultValue"
|
||||||
|
:disabled="!row.enabled"
|
||||||
|
controls-position="right"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-else
|
||||||
|
v-model="row.defaultValue"
|
||||||
|
:disabled="!row.enabled"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<p v-if="bindings.length" class="form-hint u-mt-2">{{ t('providers.paramDefaultHint') }}</p>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
|
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
|
||||||
@@ -61,7 +85,7 @@
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import api from '@/api/client'
|
import api, { type ModelEntry } from '@/api/client'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -71,15 +95,32 @@ interface WorkflowNode {
|
|||||||
injectable_fields: string[]
|
injectable_fields: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParamMeta {
|
||||||
|
name: string
|
||||||
|
type: 'string' | 'int' | 'float'
|
||||||
|
default?: string | number
|
||||||
|
}
|
||||||
|
|
||||||
interface BindingRow {
|
interface BindingRow {
|
||||||
param: string
|
param: string
|
||||||
|
paramType: 'string' | 'int' | 'float'
|
||||||
required: boolean
|
required: boolean
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
node: string
|
node: string
|
||||||
field: string
|
field: string
|
||||||
|
defaultValue: string | number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{ providerId: number }>()
|
interface NodeFieldBinding {
|
||||||
|
node: string
|
||||||
|
field: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
providerId: number
|
||||||
|
model?: ModelEntry | null
|
||||||
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ saved: [] }>()
|
const emit = defineEmits<{ saved: [] }>()
|
||||||
|
|
||||||
const visible = defineModel<boolean>({ required: true })
|
const visible = defineModel<boolean>({ required: true })
|
||||||
@@ -87,14 +128,17 @@ const analyzing = ref(false)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const nodes = ref<WorkflowNode[]>([])
|
const nodes = ref<WorkflowNode[]>([])
|
||||||
const bindings = ref<BindingRow[]>([])
|
const bindings = ref<BindingRow[]>([])
|
||||||
|
const paramMetaByType = ref<Record<string, ParamMeta[]>>({})
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
model_id: '',
|
model_id: '',
|
||||||
display_name: '',
|
display_name: '',
|
||||||
workflow_type: 'txt2img',
|
workflow_type: 'txt2img' as 'txt2img' | 'img2img',
|
||||||
workflow_json: '',
|
workflow_json: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.model?.id)
|
||||||
|
|
||||||
const requiredParams = computed(() =>
|
const requiredParams = computed(() =>
|
||||||
form.value.workflow_type === 'img2img' ? ['prompt', 'image'] : ['prompt'],
|
form.value.workflow_type === 'img2img' ? ['prompt', 'image'] : ['prompt'],
|
||||||
)
|
)
|
||||||
@@ -105,6 +149,75 @@ function reset() {
|
|||||||
bindings.value = []
|
bindings.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onClosed() {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureParamMeta() {
|
||||||
|
if (Object.keys(paramMetaByType.value).length > 0) return
|
||||||
|
const res = await api.get('/workflows/param-meta')
|
||||||
|
paramMetaByType.value = {
|
||||||
|
txt2img: res.data.txt2img || [],
|
||||||
|
img2img: res.data.img2img || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemDefault(param: string): string | number | undefined {
|
||||||
|
const defs = paramMetaByType.value[form.value.workflow_type] || []
|
||||||
|
return defs.find(d => d.name === param)?.default
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBindingJSON(raw?: string): Record<string, NodeFieldBinding> {
|
||||||
|
if (!raw) return {}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDefaultsJSON(raw?: string): Record<string, string | number> {
|
||||||
|
if (!raw) return {}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== 'object') return {}
|
||||||
|
return parsed as Record<string, string | number>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialDefaultValue(
|
||||||
|
param: string,
|
||||||
|
paramType: 'string' | 'int' | 'float',
|
||||||
|
saved: Record<string, string | number>,
|
||||||
|
workflowDefault: unknown,
|
||||||
|
): string | number | null {
|
||||||
|
if (saved[param] !== undefined && saved[param] !== null) {
|
||||||
|
return saved[param]
|
||||||
|
}
|
||||||
|
if (workflowDefault !== undefined && workflowDefault !== null && workflowDefault !== '') {
|
||||||
|
if (paramType === 'string') return String(workflowDefault)
|
||||||
|
if (paramType === 'int') return Number(workflowDefault)
|
||||||
|
return Number(workflowDefault)
|
||||||
|
}
|
||||||
|
const builtin = systemDefault(param)
|
||||||
|
if (builtin !== undefined) return builtin
|
||||||
|
return paramType === 'string' ? '' : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySavedBindings(savedBindings: Record<string, NodeFieldBinding>) {
|
||||||
|
for (const row of bindings.value) {
|
||||||
|
const hit = savedBindings[row.param]
|
||||||
|
if (hit?.node && hit?.field) {
|
||||||
|
row.enabled = true
|
||||||
|
row.node = hit.node
|
||||||
|
row.field = hit.field
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fieldsFor(nodeId: string) {
|
function fieldsFor(nodeId: string) {
|
||||||
return nodes.value.find(n => n.id === nodeId)?.injectable_fields || []
|
return nodes.value.find(n => n.id === nodeId)?.injectable_fields || []
|
||||||
}
|
}
|
||||||
@@ -118,25 +231,45 @@ async function onTypeChange() {
|
|||||||
if (form.value.workflow_json) await analyze()
|
if (form.value.workflow_json) await analyze()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function snapshotState() {
|
||||||
|
return {
|
||||||
|
bindings: buildBindingMap(),
|
||||||
|
defaults: buildDefaultsMap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function analyze() {
|
async function analyze() {
|
||||||
if (!form.value.workflow_json.trim()) {
|
if (!form.value.workflow_json.trim()) {
|
||||||
ElMessage.warning(t('providers.workflowJsonRequired'))
|
ElMessage.warning(t('providers.workflowJsonRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const saved = snapshotState()
|
||||||
analyzing.value = true
|
analyzing.value = true
|
||||||
try {
|
try {
|
||||||
|
await ensureParamMeta()
|
||||||
const res = await api.post('/workflows/analyze', {
|
const res = await api.post('/workflows/analyze', {
|
||||||
workflow_json: form.value.workflow_json,
|
workflow_json: form.value.workflow_json,
|
||||||
workflow_type: form.value.workflow_type,
|
workflow_type: form.value.workflow_type,
|
||||||
})
|
})
|
||||||
nodes.value = res.data.nodes || []
|
nodes.value = res.data.nodes || []
|
||||||
bindings.value = (res.data.suggestions || []).map((s: any) => ({
|
bindings.value = (res.data.suggestions || []).map((s: any) => {
|
||||||
param: s.param,
|
const paramType = (s.type || 'string') as 'string' | 'int' | 'float'
|
||||||
required: requiredParams.value.includes(s.param),
|
return {
|
||||||
enabled: requiredParams.value.includes(s.param) || !!(s.node && s.field),
|
param: s.param,
|
||||||
node: s.node || '',
|
paramType,
|
||||||
field: s.field || '',
|
required: requiredParams.value.includes(s.param),
|
||||||
}))
|
enabled: requiredParams.value.includes(s.param) || !!(s.node && s.field),
|
||||||
|
node: s.node || '',
|
||||||
|
field: s.field || '',
|
||||||
|
defaultValue: initialDefaultValue(s.param, paramType, saved.defaults, s.default_from_workflow),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
applySavedBindings(saved.bindings)
|
||||||
|
for (const row of bindings.value) {
|
||||||
|
if (saved.defaults[row.param] !== undefined) {
|
||||||
|
row.defaultValue = saved.defaults[row.param]
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e.response?.data?.error || t('providers.analyzeFailed'))
|
ElMessage.error(e.response?.data?.error || t('providers.analyzeFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -144,8 +277,8 @@ async function analyze() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBinding() {
|
function buildBindingMap() {
|
||||||
const out: Record<string, { node: string; field: string }> = {}
|
const out: Record<string, NodeFieldBinding> = {}
|
||||||
for (const row of bindings.value) {
|
for (const row of bindings.value) {
|
||||||
if (!row.enabled || !row.node || !row.field) continue
|
if (!row.enabled || !row.node || !row.field) continue
|
||||||
out[row.param] = { node: row.node, field: row.field }
|
out[row.param] = { node: row.node, field: row.field }
|
||||||
@@ -153,20 +286,69 @@ function buildBinding() {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDefaultsMap() {
|
||||||
|
const out: Record<string, string | number> = {}
|
||||||
|
for (const row of bindings.value) {
|
||||||
|
if (!row.enabled) continue
|
||||||
|
if (row.defaultValue === null || row.defaultValue === '') continue
|
||||||
|
if (row.paramType === 'int') {
|
||||||
|
out[row.param] = Number(row.defaultValue)
|
||||||
|
} else if (row.paramType === 'float') {
|
||||||
|
out[row.param] = Number(row.defaultValue)
|
||||||
|
} else {
|
||||||
|
out[row.param] = String(row.defaultValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModel(m: ModelEntry) {
|
||||||
|
await ensureParamMeta()
|
||||||
|
form.value = {
|
||||||
|
model_id: m.model_id,
|
||||||
|
display_name: m.display_name || m.model_id,
|
||||||
|
workflow_type: (m.workflow_type as 'txt2img' | 'img2img') || 'txt2img',
|
||||||
|
workflow_json: m.workflow_json || '',
|
||||||
|
}
|
||||||
|
nodes.value = []
|
||||||
|
bindings.value = []
|
||||||
|
const savedBindings = parseBindingJSON(m.input_binding_json)
|
||||||
|
const savedDefaults = parseDefaultsJSON(m.input_defaults_json)
|
||||||
|
if (form.value.workflow_json.trim()) {
|
||||||
|
await analyze()
|
||||||
|
applySavedBindings(savedBindings)
|
||||||
|
for (const row of bindings.value) {
|
||||||
|
if (savedDefaults[row.param] !== undefined) {
|
||||||
|
row.defaultValue = savedDefaults[row.param]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!form.value.model_id.trim()) {
|
if (!form.value.model_id.trim()) {
|
||||||
ElMessage.warning(t('providers.ingressModelIdRequired'))
|
ElMessage.warning(t('providers.ingressModelIdRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!form.value.workflow_json.trim()) {
|
||||||
|
ElMessage.warning(t('providers.workflowJsonRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await api.post(`/providers/${props.providerId}/models`, {
|
const payload = {
|
||||||
model_id: form.value.model_id.trim(),
|
model_id: form.value.model_id.trim(),
|
||||||
display_name: form.value.display_name || form.value.model_id,
|
display_name: form.value.display_name || form.value.model_id,
|
||||||
workflow_type: form.value.workflow_type,
|
workflow_type: form.value.workflow_type,
|
||||||
workflow_json: form.value.workflow_json,
|
workflow_json: form.value.workflow_json,
|
||||||
input_binding: buildBinding(),
|
input_binding: buildBindingMap(),
|
||||||
})
|
input_defaults: buildDefaultsMap(),
|
||||||
|
}
|
||||||
|
if (isEdit.value && props.model?.id) {
|
||||||
|
await api.put(`/providers/${props.providerId}/models/${props.model.id}`, payload)
|
||||||
|
} else {
|
||||||
|
await api.post(`/providers/${props.providerId}/models`, payload)
|
||||||
|
}
|
||||||
ElMessage.success(t('providers.workflowSaved'))
|
ElMessage.success(t('providers.workflowSaved'))
|
||||||
visible.value = false
|
visible.value = false
|
||||||
emit('saved')
|
emit('saved')
|
||||||
@@ -177,7 +359,13 @@ async function save() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(visible, (v) => {
|
watch(visible, async (open) => {
|
||||||
if (v) reset()
|
if (!open) return
|
||||||
|
if (props.model?.id) {
|
||||||
|
await loadModel(props.model)
|
||||||
|
} else {
|
||||||
|
reset()
|
||||||
|
await ensureParamMeta()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -130,6 +130,23 @@ export default {
|
|||||||
modelAliasHint: 'Leave empty to use upstream Model ID at ingress',
|
modelAliasHint: 'Leave empty to use upstream Model ID at ingress',
|
||||||
upstreamModelId: 'Upstream Model ID',
|
upstreamModelId: 'Upstream Model ID',
|
||||||
editModelDialogTitle: 'Edit model',
|
editModelDialogTitle: 'Edit model',
|
||||||
|
addWorkflowModel: 'Add Workflow Model',
|
||||||
|
workflowDialogTitle: 'Register Workflow Model',
|
||||||
|
editWorkflowDialogTitle: 'Edit Workflow Model',
|
||||||
|
workflowType: 'Type',
|
||||||
|
txt2img: 'Text to Image',
|
||||||
|
img2img: 'Image to Image',
|
||||||
|
workflowJson: 'Workflow JSON',
|
||||||
|
analyzeWorkflow: 'Analyze Workflow',
|
||||||
|
workflowJsonRequired: 'Paste Workflow JSON first',
|
||||||
|
analyzeFailed: 'Analysis failed',
|
||||||
|
workflowSaved: 'Workflow model saved',
|
||||||
|
paramName: 'Parameter',
|
||||||
|
paramDefault: 'Default',
|
||||||
|
paramDefaultHint: 'Fields omitted from requests use configured defaults (including prompt); required fields without a default must still be sent by clients.',
|
||||||
|
bindEnabled: 'Bind',
|
||||||
|
targetNode: 'Target Node',
|
||||||
|
targetField: 'Target Field',
|
||||||
providerNameExists: 'Provider name already exists',
|
providerNameExists: 'Provider name already exists',
|
||||||
modelAliasExists: 'Model alias already exists',
|
modelAliasExists: 'Model alias already exists',
|
||||||
modelIdRequired: 'Model ID is required',
|
modelIdRequired: 'Model ID is required',
|
||||||
@@ -186,6 +203,13 @@ export default {
|
|||||||
confirmDeleteKey: 'Delete this key?',
|
confirmDeleteKey: 'Delete this key?',
|
||||||
copied: 'Copied',
|
copied: 'Copied',
|
||||||
ruleAdded: 'Rule added',
|
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: {
|
logs: {
|
||||||
title: 'Request Logs',
|
title: 'Request Logs',
|
||||||
@@ -244,4 +268,65 @@ export default {
|
|||||||
settingsSaved: 'Settings saved',
|
settingsSaved: 'Settings saved',
|
||||||
passwordUpdated: 'Password updated',
|
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"}}\'',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export default {
|
|||||||
modelDeleted: '模型已删除',
|
modelDeleted: '模型已删除',
|
||||||
confirmDeleteModel: '确认删除此模型?',
|
confirmDeleteModel: '确认删除此模型?',
|
||||||
workflowDialogTitle: '注册 Workflow 模型',
|
workflowDialogTitle: '注册 Workflow 模型',
|
||||||
|
editWorkflowDialogTitle: '编辑 Workflow 模型',
|
||||||
modelId: 'Model ID',
|
modelId: 'Model ID',
|
||||||
ingressModelId: '入口 Model ID',
|
ingressModelId: '入口 Model ID',
|
||||||
ingressModelIdHint: '客户端在 /v1/predictions 的 version 字段使用此名称',
|
ingressModelIdHint: '客户端在 /v1/predictions 的 version 字段使用此名称',
|
||||||
@@ -164,6 +165,8 @@ export default {
|
|||||||
modelIdRequired: '请填写 Model ID',
|
modelIdRequired: '请填写 Model ID',
|
||||||
workflowSaved: 'Workflow 模型已保存',
|
workflowSaved: 'Workflow 模型已保存',
|
||||||
paramName: '参数',
|
paramName: '参数',
|
||||||
|
paramDefault: '默认值',
|
||||||
|
paramDefaultHint: '未在请求中传入的字段将使用此处配置的默认值(含 prompt);未配置默认值的必填字段仍须由客户端提供。',
|
||||||
bindEnabled: '绑定',
|
bindEnabled: '绑定',
|
||||||
targetNode: '目标节点',
|
targetNode: '目标节点',
|
||||||
targetField: '目标字段',
|
targetField: '目标字段',
|
||||||
@@ -202,6 +205,13 @@ export default {
|
|||||||
confirmDeleteKey: '确认删除此密钥?',
|
confirmDeleteKey: '确认删除此密钥?',
|
||||||
copied: '已复制',
|
copied: '已复制',
|
||||||
ruleAdded: '规则已添加',
|
ruleAdded: '规则已添加',
|
||||||
|
protocol: '协议',
|
||||||
|
protocolText: '文本(OpenAI)',
|
||||||
|
protocolImage: '图片(Replicate)',
|
||||||
|
protocolHint: '文本密钥调用 /v1/chat/completions 等;图片密钥调用 /v1/predictions 与 Replicate 模型列表',
|
||||||
|
editKeyTitle: '编辑密钥',
|
||||||
|
protocolUpdated: '密钥已更新',
|
||||||
|
apiDoc: '接口文档',
|
||||||
},
|
},
|
||||||
logs: {
|
logs: {
|
||||||
title: '请求日志',
|
title: '请求日志',
|
||||||
@@ -265,4 +275,65 @@ export default {
|
|||||||
settingsSaved: '系统设置已保存',
|
settingsSaved: '系统设置已保存',
|
||||||
passwordUpdated: '密码已更新',
|
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',
|
'/dashboard': 'nav.dashboard',
|
||||||
'/egress/providers': 'nav.providers',
|
'/egress/providers': 'nav.providers',
|
||||||
'/ingress/keys': 'nav.ingress',
|
'/ingress/keys': 'nav.ingress',
|
||||||
|
'/ingress/docs/text': 'apiDocs.text.title',
|
||||||
|
'/ingress/docs/image': 'apiDocs.image.title',
|
||||||
'/logs': 'nav.logs',
|
'/logs': 'nav.logs',
|
||||||
'/security/ip-rules': 'nav.ipRules',
|
'/security/ip-rules': 'nav.ipRules',
|
||||||
'/security/settings': 'nav.settings',
|
'/security/settings': 'nav.settings',
|
||||||
@@ -87,6 +89,7 @@ const currentTitle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function isActive(path: string) {
|
function isActive(path: string) {
|
||||||
|
if (path === '/ingress/keys' && route.path.startsWith('/ingress/')) return true
|
||||||
return route.path === path || route.path.startsWith(path + '/')
|
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: 'dashboard', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
|
||||||
{ path: 'egress/providers', name: 'providers', component: () => import('@/views/ProvidersView.vue') },
|
{ path: 'egress/providers', name: 'providers', component: () => import('@/views/ProvidersView.vue') },
|
||||||
{ path: 'ingress/keys', name: 'ingress-keys', component: () => import('@/views/IngressKeysView.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: 'logs', name: 'logs', component: () => import('@/views/RequestLogsView.vue') },
|
||||||
{ path: 'security/ip-rules', name: 'ip-rules', component: () => import('@/views/IPRulesView.vue') },
|
{ path: 'security/ip-rules', name: 'ip-rules', component: () => import('@/views/IPRulesView.vue') },
|
||||||
{ path: 'security/settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
|
{ path: 'security/settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
|
||||||
|
|||||||
@@ -588,7 +588,40 @@ code.inline-code {
|
|||||||
margin-left: var(--space-2);
|
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); }
|
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
.content-grid--2-1,
|
.content-grid--2-1,
|
||||||
.content-grid--2,
|
.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 :data="keys" v-loading="loading" size="small">
|
||||||
<el-table-column prop="name" :label="t('common.name')" min-width="120" />
|
<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 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">
|
<el-table-column prop="enabled" :label="t('common.status')" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? t('common.enabled') : t('common.disabled') }}</el-tag>
|
<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>
|
||||||
<el-table-column prop="request_count" :label="t('dashboard.requestCount')" width="90" />
|
<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 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 }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" link @click="openWhitelist(row)">{{ t('ingress.whitelistAction') }}</el-button>
|
<el-button size="small" link @click="openApiDoc(row)">{{ t('ingress.apiDoc') }}</el-button>
|
||||||
<el-button size="small" link @click="openRouting(row)">{{ t('ingress.routingAction') }}</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" 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>
|
<el-button size="small" type="danger" link @click="remove(row)">{{ t('common.delete') }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -45,11 +53,19 @@
|
|||||||
<el-dialog v-model="showCreate" :title="t('ingress.createDialogTitle')" width="560px" destroy-on-close @open="loadCatalog">
|
<el-dialog v-model="showCreate" :title="t('ingress.createDialogTitle')" width="560px" destroy-on-close @open="loadCatalog">
|
||||||
<el-form label-width="120px">
|
<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('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.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.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.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-form-item :label="t('ingress.providerWhitelist')">
|
||||||
<el-select
|
<el-select
|
||||||
|
:key="`create-providers-${form.protocol}`"
|
||||||
v-model="form.allowed_providers"
|
v-model="form.allowed_providers"
|
||||||
multiple
|
multiple
|
||||||
filterable
|
filterable
|
||||||
@@ -58,12 +74,13 @@
|
|||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
:placeholder="t('ingress.providerWhitelistPlaceholder')"
|
: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>
|
</el-select>
|
||||||
<div class="form-hint">{{ t('ingress.whitelistHint') }}</div>
|
<div class="form-hint">{{ t('ingress.whitelistHint') }}</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('ingress.modelWhitelist')">
|
<el-form-item :label="t('ingress.modelWhitelist')">
|
||||||
<el-select
|
<el-select
|
||||||
|
:key="`create-models-${form.protocol}-${form.allowed_providers.join(',')}`"
|
||||||
v-model="form.allowed_models"
|
v-model="form.allowed_models"
|
||||||
multiple
|
multiple
|
||||||
filterable
|
filterable
|
||||||
@@ -73,7 +90,7 @@
|
|||||||
:placeholder="t('ingress.modelWhitelistPlaceholder')"
|
:placeholder="t('ingress.modelWhitelistPlaceholder')"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="m in filteredModelOptions(form.allowed_providers)"
|
v-for="m in createModelOptions"
|
||||||
:key="m.model_id + '@' + m.provider_name"
|
:key="m.model_id + '@' + m.provider_name"
|
||||||
:label="m.label"
|
:label="m.label"
|
||||||
:value="m.model_id"
|
:value="m.model_id"
|
||||||
@@ -88,11 +105,19 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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 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-form-item :label="t('ingress.providerWhitelist')">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="whitelistForm.allowed_providers"
|
:key="`edit-providers-${editForm.protocol}`"
|
||||||
|
v-model="editForm.allowed_providers"
|
||||||
multiple
|
multiple
|
||||||
filterable
|
filterable
|
||||||
clearable
|
clearable
|
||||||
@@ -100,12 +125,13 @@
|
|||||||
collapse-tags-tooltip
|
collapse-tags-tooltip
|
||||||
:placeholder="t('ingress.providerWhitelistPlaceholder')"
|
: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-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('ingress.modelWhitelist')">
|
<el-form-item :label="t('ingress.modelWhitelist')">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="whitelistForm.allowed_models"
|
:key="`edit-models-${editForm.protocol}-${editForm.allowed_providers.join(',')}`"
|
||||||
|
v-model="editForm.allowed_models"
|
||||||
multiple
|
multiple
|
||||||
filterable
|
filterable
|
||||||
clearable
|
clearable
|
||||||
@@ -114,7 +140,7 @@
|
|||||||
:placeholder="t('ingress.modelWhitelistPlaceholder')"
|
:placeholder="t('ingress.modelWhitelistPlaceholder')"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="m in filteredModelOptions(whitelistForm.allowed_providers)"
|
v-for="m in editModelOptions"
|
||||||
:key="m.model_id + '@' + m.provider_name"
|
:key="m.model_id + '@' + m.provider_name"
|
||||||
:label="m.label"
|
:label="m.label"
|
||||||
:value="m.model_id"
|
:value="m.model_id"
|
||||||
@@ -123,8 +149,8 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="showWhitelist = false">{{ t('common.cancel') }}</el-button>
|
<el-button @click="showEdit = false">{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" @click="saveWhitelist">{{ t('common.save') }}</el-button>
|
<el-button type="primary" @click="saveEdit">{{ t('common.save') }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
@@ -204,23 +230,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import api, { type IngressKey, type Provider, type ProviderKey } from '@/api/client'
|
import api, { type IngressKey, type Provider, type ProviderKey } from '@/api/client'
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader.vue'
|
||||||
import AppCard from '@/components/AppCard.vue'
|
import AppCard from '@/components/AppCard.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
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 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 keys = ref<IngressKey[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const showWhitelist = ref(false)
|
const showEdit = ref(false)
|
||||||
const showPlainKey = ref(false)
|
const showPlainKey = ref(false)
|
||||||
const plainKey = ref('')
|
const plainKey = ref('')
|
||||||
const routingVisible = ref(false)
|
const routingVisible = ref(false)
|
||||||
@@ -238,17 +266,51 @@ const catalogProviders = ref<CatalogProvider[]>([])
|
|||||||
const catalogModels = ref<ModelOption[]>([])
|
const catalogModels = ref<ModelOption[]>([])
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: '',
|
name: '',
|
||||||
|
protocol: 'text' as 'text' | 'image',
|
||||||
budget_limit: 0,
|
budget_limit: 0,
|
||||||
rate_limit_rpm: 0,
|
rate_limit_rpm: 0,
|
||||||
rate_limit_tpm: 0,
|
rate_limit_tpm: 0,
|
||||||
allowed_providers: [] as string[],
|
allowed_providers: [] as string[],
|
||||||
allowed_models: [] as string[],
|
allowed_models: [] as string[],
|
||||||
})
|
})
|
||||||
const whitelistForm = ref({
|
const editForm = ref({
|
||||||
|
protocol: 'text' as 'text' | 'image',
|
||||||
allowed_providers: [] as string[],
|
allowed_providers: [] as string[],
|
||||||
allowed_models: [] 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(() => {
|
const keysForRuleProvider = computed(() => {
|
||||||
if (!ruleForm.value.provider_id) return []
|
if (!ruleForm.value.provider_id) return []
|
||||||
return routingProviders.value.find(p => p.id === ruleForm.value.provider_id)?.keys ?? []
|
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
|
return list.length === 0 ? [] : list
|
||||||
}
|
}
|
||||||
|
|
||||||
function filteredModelOptions(selectedProviders: string[]) {
|
function isTextKey(row: IngressKey) {
|
||||||
if (isAllowAll(selectedProviders)) return catalogModels.value
|
return (row.protocol || 'text') === 'text'
|
||||||
return catalogModels.value.filter(m => selectedProviders.includes(m.provider_name))
|
}
|
||||||
|
|
||||||
|
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() {
|
async function loadCatalog() {
|
||||||
const res = await api.get('/providers')
|
const res = await api.get('/providers')
|
||||||
const providers: Provider[] = res.data.data
|
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[] = []
|
const models: ModelOption[] = []
|
||||||
for (const p of providers) {
|
for (const p of providers) {
|
||||||
for (const m of p.models || []) {
|
for (const m of p.models || []) {
|
||||||
@@ -315,6 +416,7 @@ async function loadCatalog() {
|
|||||||
model_id: ingressId,
|
model_id: ingressId,
|
||||||
display_name: m.display_name || ingressId,
|
display_name: m.display_name || ingressId,
|
||||||
provider_name: p.name,
|
provider_name: p.name,
|
||||||
|
provider_category: p.category,
|
||||||
label: `${ingressId} (${p.name})`,
|
label: `${ingressId} (${p.name})`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -322,10 +424,13 @@ async function loadCatalog() {
|
|||||||
catalogModels.value = models
|
catalogModels.value = models
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCatalogForEdit() {
|
||||||
|
await loadCatalog()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRoutingCatalog() {
|
async function loadRoutingCatalog() {
|
||||||
const res = await api.get('/providers')
|
const res = await api.get('/providers')
|
||||||
const providers: Provider[] = res.data.data ?? []
|
const providers: Provider[] = (res.data.data ?? []).filter(p => p.category !== 'image')
|
||||||
catalogProviders.value = providers.map(p => ({ id: p.id, name: p.name }))
|
|
||||||
routingProviders.value = await Promise.all(providers.map(async (p) => {
|
routingProviders.value = await Promise.all(providers.map(async (p) => {
|
||||||
const keysRes = await api.get(`/providers/${p.id}/keys`)
|
const keysRes = await api.get(`/providers/${p.id}/keys`)
|
||||||
const keys = (keysRes.data.data as ProviderKey[] || [])
|
const keys = (keysRes.data.data as ProviderKey[] || [])
|
||||||
@@ -348,6 +453,7 @@ async function load() {
|
|||||||
function openCreate() {
|
function openCreate() {
|
||||||
form.value = {
|
form.value = {
|
||||||
name: '',
|
name: '',
|
||||||
|
protocol: 'text',
|
||||||
budget_limit: 0,
|
budget_limit: 0,
|
||||||
rate_limit_rpm: 0,
|
rate_limit_rpm: 0,
|
||||||
rate_limit_tpm: 0,
|
rate_limit_tpm: 0,
|
||||||
@@ -360,6 +466,7 @@ function openCreate() {
|
|||||||
async function create() {
|
async function create() {
|
||||||
const res = await api.post('/ingress-keys', {
|
const res = await api.post('/ingress-keys', {
|
||||||
name: form.value.name,
|
name: form.value.name,
|
||||||
|
protocol: form.value.protocol,
|
||||||
budget_limit: form.value.budget_limit,
|
budget_limit: form.value.budget_limit,
|
||||||
rate_limit_rpm: form.value.rate_limit_rpm,
|
rate_limit_rpm: form.value.rate_limit_rpm,
|
||||||
rate_limit_tpm: form.value.rate_limit_tpm,
|
rate_limit_tpm: form.value.rate_limit_tpm,
|
||||||
@@ -372,23 +479,30 @@ async function create() {
|
|||||||
load()
|
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
|
current.value = row
|
||||||
whitelistForm.value = {
|
editForm.value = {
|
||||||
|
protocol: row.protocol || 'text',
|
||||||
allowed_providers: jsonListToSelection(row.allowed_providers_json),
|
allowed_providers: jsonListToSelection(row.allowed_providers_json),
|
||||||
allowed_models: jsonListToSelection(row.allowed_models_json),
|
allowed_models: jsonListToSelection(row.allowed_models_json),
|
||||||
}
|
}
|
||||||
showWhitelist.value = true
|
showEdit.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveWhitelist() {
|
async function saveEdit() {
|
||||||
if (!current.value) return
|
if (!current.value) return
|
||||||
await api.put(`/ingress-keys/${current.value.id}`, {
|
await api.put(`/ingress-keys/${current.value.id}`, {
|
||||||
allowed_providers: selectionToPayload(whitelistForm.value.allowed_providers),
|
protocol: editForm.value.protocol,
|
||||||
allowed_models: selectionToPayload(whitelistForm.value.allowed_models),
|
allowed_providers: selectionToPayload(editForm.value.allowed_providers),
|
||||||
|
allowed_models: selectionToPayload(editForm.value.allowed_models),
|
||||||
})
|
})
|
||||||
showWhitelist.value = false
|
showEdit.value = false
|
||||||
ElMessage.success(t('ingress.whitelistUpdated'))
|
ElMessage.success(t('ingress.protocolUpdated'))
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@
|
|||||||
|
|
||||||
<el-drawer v-model="modelsVisible" :title="t('providers.modelsTitle', { name: current?.name || '' })" size="50%">
|
<el-drawer v-model="modelsVisible" :title="t('providers.modelsTitle', { name: current?.name || '' })" size="50%">
|
||||||
<div class="drawer-toolbar">
|
<div class="drawer-toolbar">
|
||||||
<el-button v-if="current?.category === 'image'" type="primary" size="small" @click="showWorkflowDialog = true">
|
<el-button v-if="current?.category === 'image'" type="primary" size="small" @click="openAddWorkflowModel">
|
||||||
{{ t('providers.addWorkflowModel') }}
|
{{ t('providers.addWorkflowModel') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<template v-else-if="current">
|
<template v-else-if="current">
|
||||||
@@ -255,21 +255,13 @@
|
|||||||
|
|
||||||
<el-dialog v-model="showEditModel" :title="t('providers.editModelDialogTitle')" width="480px" destroy-on-close>
|
<el-dialog v-model="showEditModel" :title="t('providers.editModelDialogTitle')" width="480px" destroy-on-close>
|
||||||
<el-form label-width="110px">
|
<el-form label-width="110px">
|
||||||
<template v-if="current?.category === 'image'">
|
<el-form-item :label="t('providers.upstreamModelId')">
|
||||||
<el-form-item :label="t('providers.ingressModelId')" required>
|
<el-input v-model="editModelForm.model_id" />
|
||||||
<el-input v-model="editModelForm.model_id" placeholder="luminary/flux-sdxl" />
|
</el-form-item>
|
||||||
<p class="form-hint">{{ t('providers.ingressModelIdHint') }}</p>
|
<el-form-item :label="t('providers.modelAlias')">
|
||||||
</el-form-item>
|
<el-input v-model="editModelForm.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
|
||||||
</template>
|
<p class="form-hint">{{ t('providers.modelAliasHint') }}</p>
|
||||||
<template v-else>
|
</el-form-item>
|
||||||
<el-form-item :label="t('providers.upstreamModelId')">
|
|
||||||
<el-input v-model="editModelForm.model_id" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item :label="t('providers.modelAlias')">
|
|
||||||
<el-input v-model="editModelForm.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
|
|
||||||
<p class="form-hint">{{ t('providers.modelAliasHint') }}</p>
|
|
||||||
</el-form-item>
|
|
||||||
</template>
|
|
||||||
<el-form-item :label="t('providers.displayName')">
|
<el-form-item :label="t('providers.displayName')">
|
||||||
<el-input v-model="editModelForm.display_name" />
|
<el-input v-model="editModelForm.display_name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -284,6 +276,7 @@
|
|||||||
v-if="current"
|
v-if="current"
|
||||||
v-model="showWorkflowDialog"
|
v-model="showWorkflowDialog"
|
||||||
:provider-id="current.id"
|
:provider-id="current.id"
|
||||||
|
:model="editingWorkflowModel"
|
||||||
@saved="current && openModels(current)"
|
@saved="current && openModels(current)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -337,6 +330,7 @@ const enabledProviderModelCount = computed(() =>
|
|||||||
providerModels.value.filter(m => m.enabled).length,
|
providerModels.value.filter(m => m.enabled).length,
|
||||||
)
|
)
|
||||||
const showWorkflowDialog = ref(false)
|
const showWorkflowDialog = ref(false)
|
||||||
|
const editingWorkflowModel = ref<ModelEntry | null>(null)
|
||||||
const showAddModel = ref(false)
|
const showAddModel = ref(false)
|
||||||
const showEditModel = ref(false)
|
const showEditModel = ref(false)
|
||||||
const addModelForm = ref({ model_id: '', alias: '', display_name: '' })
|
const addModelForm = ref({ model_id: '', alias: '', display_name: '' })
|
||||||
@@ -665,12 +659,21 @@ function modelIngressId(m: ModelEntry) {
|
|||||||
return m.alias || m.model_id
|
return m.alias || m.model_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAddWorkflowModel() {
|
||||||
|
editingWorkflowModel.value = null
|
||||||
|
showWorkflowDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function openEditModel(m: ModelEntry) {
|
function openEditModel(m: ModelEntry) {
|
||||||
const isImage = current.value?.category === 'image'
|
if (current.value?.category === 'image') {
|
||||||
|
editingWorkflowModel.value = m
|
||||||
|
showWorkflowDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
editModelForm.value = {
|
editModelForm.value = {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
model_id: isImage ? modelIngressId(m) : m.model_id,
|
model_id: m.model_id,
|
||||||
alias: isImage ? '' : (m.alias || ''),
|
alias: m.alias || '',
|
||||||
display_name: m.display_name || '',
|
display_name: m.display_name || '',
|
||||||
}
|
}
|
||||||
showEditModel.value = true
|
showEditModel.value = true
|
||||||
@@ -678,11 +681,10 @@ function openEditModel(m: ModelEntry) {
|
|||||||
|
|
||||||
async function saveEditModel() {
|
async function saveEditModel() {
|
||||||
if (!current.value || !editModelForm.value.id) return
|
if (!current.value || !editModelForm.value.id) return
|
||||||
const isImage = current.value.category === 'image'
|
|
||||||
try {
|
try {
|
||||||
await api.put(`/providers/${current.value.id}/models/${editModelForm.value.id}`, {
|
await api.put(`/providers/${current.value.id}/models/${editModelForm.value.id}`, {
|
||||||
model_id: editModelForm.value.model_id.trim(),
|
model_id: editModelForm.value.model_id.trim(),
|
||||||
...(isImage ? { alias: '' } : { alias: editModelForm.value.alias.trim() }),
|
alias: editModelForm.value.alias.trim(),
|
||||||
display_name: editModelForm.value.display_name.trim(),
|
display_name: editModelForm.value.display_name.trim(),
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
Reference in New Issue
Block a user