Add model aliases, routing UX, and admin hardening.

Support ingress-facing model aliases with upstream rewrite, category-aware health checks, provider name validation, modal click-outside guard, and dropdown-based routing rule targets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-21 02:49:49 +08:00
parent af1f714fa2
commit aca63d7526
21 changed files with 688 additions and 65 deletions
+5 -2
View File
@@ -63,8 +63,11 @@ func (s *Service) listEligibleKeys(providerID uint, modelName string, route *rou
continue
}
allowed := cache.ParseJSONList(k.ModelsJSON)
if modelName != "" && !cache.MatchesList(allowed, modelName) && !cache.MatchesList(allowed, "*") {
continue
if modelName != "" && !cache.MatchesList(allowed, "*") {
upstream := s.resolveUpstreamModelID(providerID, modelName)
if !cache.MatchesList(allowed, modelName) && !cache.MatchesList(allowed, upstream) {
continue
}
}
candidates = append(candidates, balancer.KeyCandidate{Key: k, Weight: k.Weight})
}
+190
View File
@@ -0,0 +1,190 @@
package gateway
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/provider"
)
func (s *Service) healthCheckEndpoints(p *model.Provider) []provider.EndpointType {
var out []provider.EndpointType
if s.providerAllowsEndpoint(p, provider.EndpointListModels) {
out = append(out, provider.EndpointListModels)
}
switch p.Category {
case model.CategoryEmbedding:
if s.providerAllowsEndpoint(p, provider.EndpointEmbeddings) {
out = append(out, provider.EndpointEmbeddings)
}
case model.CategoryRerank:
if s.providerAllowsEndpoint(p, provider.EndpointRerank) {
out = append(out, provider.EndpointRerank)
}
}
return out
}
func (s *Service) runHealthChecks(
ctx context.Context,
p *model.Provider,
keys []model.ProviderKey,
cfg provider.ProviderConfig,
) (model.ProviderStatus, int64, string) {
endpoints := s.healthCheckEndpoints(p)
if len(endpoints) == 0 {
return model.ProviderUnhealthy, 0, "no health check endpoint enabled"
}
start := time.Now()
var lastErr string
for _, ep := range endpoints {
status, _, msg := s.runHealthCheck(ctx, p, keys, cfg, ep)
if status == model.ProviderHealthy {
return model.ProviderHealthy, time.Since(start).Milliseconds(), ""
}
if msg != "" {
lastErr = msg
}
}
if lastErr == "" {
lastErr = "health check failed"
}
return model.ProviderUnhealthy, time.Since(start).Milliseconds(), lastErr
}
func (s *Service) runHealthCheck(
ctx context.Context,
p *model.Provider,
keys []model.ProviderKey,
cfg provider.ProviderConfig,
endpoint provider.EndpointType,
) (model.ProviderStatus, int64, string) {
start := time.Now()
latency := func() int64 { return time.Since(start).Milliseconds() }
switch endpoint {
case provider.EndpointListModels:
if len(keys) == 0 && p.Type == model.ProviderOllama {
if err := s.openai.HealthCheckWithConfig(ctx, "", cfg); err == nil {
return model.ProviderHealthy, latency(), ""
}
return model.ProviderUnhealthy, latency(), "health check failed"
}
for _, k := range keys {
if !k.Enabled {
continue
}
apiKey, err := s.DecryptAPIKey(k.APIKeyEnc)
if err != nil {
continue
}
var checkErr error
if p.Type == model.ProviderAnthropic {
checkErr = s.anthropic.HealthCheckWithConfig(ctx, apiKey, cfg)
} else {
checkErr = s.openai.HealthCheckWithConfig(ctx, apiKey, cfg)
}
if checkErr == nil {
return model.ProviderHealthy, latency(), ""
}
}
return model.ProviderUnhealthy, latency(), "health check failed"
case provider.EndpointEmbeddings, provider.EndpointRerank:
body, err := healthProbeBody(s, p, endpoint)
if err != nil {
return model.ProviderUnhealthy, latency(), err.Error()
}
if len(keys) == 0 && p.Type == model.ProviderOllama {
ok, msg := s.probeEndpoint(ctx, "", cfg, endpoint, body)
if ok {
return model.ProviderHealthy, latency(), ""
}
return model.ProviderUnhealthy, latency(), msg
}
var lastErr string
for _, k := range keys {
if !k.Enabled {
continue
}
apiKey, err := s.DecryptAPIKey(k.APIKeyEnc)
if err != nil {
continue
}
if ok, msg := s.probeEndpoint(ctx, apiKey, cfg, endpoint, body); ok {
return model.ProviderHealthy, latency(), ""
} else if msg != "" {
lastErr = msg
}
}
if lastErr == "" {
lastErr = "health check failed"
}
return model.ProviderUnhealthy, latency(), lastErr
default:
return model.ProviderUnhealthy, latency(), "unsupported health check endpoint"
}
}
func (s *Service) probeEndpoint(
ctx context.Context,
apiKey string,
cfg provider.ProviderConfig,
endpoint provider.EndpointType,
body []byte,
) (bool, string) {
resp, err := s.openai.ForwardWithConfig(ctx, apiKey, cfg, endpoint, body)
if err != nil {
return false, err.Error()
}
if resp == nil {
return false, "empty response"
}
if isHealthCheckHTTPStatus(resp.StatusCode) {
return true, ""
}
return false, fmt.Sprintf("%s: HTTP %d", endpoint, resp.StatusCode)
}
func healthProbeBody(s *Service, p *model.Provider, endpoint provider.EndpointType) ([]byte, error) {
modelID := probeModelID(s, p.ID, endpoint)
switch endpoint {
case provider.EndpointEmbeddings:
return json.Marshal(map[string]string{"model": modelID, "input": "."})
case provider.EndpointRerank:
return json.Marshal(map[string]any{
"model": modelID,
"query": "ping",
"documents": []string{"ping"},
})
default:
return nil, fmt.Errorf("unsupported probe endpoint %s", endpoint)
}
}
func probeModelID(s *Service, providerID uint, endpoint provider.EndpointType) string {
for _, m := range s.cache.GetModels(providerID) {
if m.Enabled && m.ModelID != "" {
return m.ModelID
}
}
switch endpoint {
case provider.EndpointEmbeddings:
return "text-embedding-3-small"
case provider.EndpointRerank:
return "rerank-multilingual-v3.0"
default:
return "health-check"
}
}
func isHealthCheckHTTPStatus(code int) bool {
if code >= 200 && code < 300 {
return true
}
// Upstream reachable but rejected payload/model — still counts as healthy.
return code == 400 || code == 422
}
+51
View File
@@ -0,0 +1,51 @@
package gateway
import (
"testing"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/provider"
)
func TestIsHealthCheckHTTPStatus(t *testing.T) {
cases := map[int]bool{
200: true,
204: true,
400: true,
422: true,
401: false,
404: false,
500: false,
}
for code, want := range cases {
if got := isHealthCheckHTTPStatus(code); got != want {
t.Fatalf("code %d: got %v want %v", code, got, want)
}
}
}
func TestHealthCheckEndpointsEmbedding(t *testing.T) {
s := &Service{}
p := &model.Provider{
Category: model.CategoryEmbedding,
Type: model.ProviderOpenAI,
AllowedEndpointsJSON: `["embeddings"]`,
}
eps := s.healthCheckEndpoints(p)
if len(eps) != 1 || eps[0] != provider.EndpointEmbeddings {
t.Fatalf("got %v", eps)
}
}
func TestHealthCheckEndpointsEmbeddingWithListModels(t *testing.T) {
s := &Service{}
p := &model.Provider{
Category: model.CategoryEmbedding,
Type: model.ProviderOpenAI,
AllowedEndpointsJSON: `["list_models","embeddings"]`,
}
eps := s.healthCheckEndpoints(p)
if len(eps) != 2 || eps[0] != provider.EndpointListModels || eps[1] != provider.EndpointEmbeddings {
t.Fatalf("got %v", eps)
}
}
+53
View File
@@ -0,0 +1,53 @@
package gateway
import (
"encoding/json"
"github.com/rose_cat707/luminary/internal/model"
)
// IngressModelID returns the model identifier exposed to ingress clients.
func IngressModelID(m model.ModelEntry) string {
return m.IngressID()
}
func modelEntryMatches(m model.ModelEntry, name string) bool {
return m.MatchesIngressName(name)
}
func (s *Service) resolveUpstreamModelID(providerID uint, ingressModel string) string {
if ingressModel == "" {
return ""
}
for _, m := range s.cache.GetModels(providerID) {
if modelEntryMatches(m, ingressModel) {
return m.ModelID
}
}
return ingressModel
}
func rewriteModelInBody(body []byte, upstreamModel string) ([]byte, error) {
if upstreamModel == "" || len(body) == 0 {
return body, nil
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return body, nil
}
current, _ := payload["model"].(string)
if current == "" || current == upstreamModel {
return body, nil
}
payload["model"] = upstreamModel
return json.Marshal(payload)
}
func (s *Service) rewriteBodyForProvider(providerID uint, body []byte) ([]byte, error) {
ingressModel := extractModelName(body)
if ingressModel == "" {
return body, nil
}
upstream := s.resolveUpstreamModelID(providerID, ingressModel)
return rewriteModelInBody(body, upstream)
}
+32
View File
@@ -0,0 +1,32 @@
package gateway
import (
"encoding/json"
"testing"
"github.com/rose_cat707/luminary/internal/model"
)
func TestIngressModelID(t *testing.T) {
if got := IngressModelID(model.ModelEntry{ModelID: "text-embedding-3-small", Alias: "my-embed"}); got != "my-embed" {
t.Fatalf("got %q", got)
}
if got := (model.ModelEntry{ModelID: "gpt-4o"}).IngressID(); got != "gpt-4o" {
t.Fatalf("got %q", got)
}
}
func TestRewriteModelInBody(t *testing.T) {
body := []byte(`{"model":"my-embed","input":"hi"}`)
out, err := rewriteModelInBody(body, "text-embedding-3-small")
if err != nil {
t.Fatal(err)
}
var payload map[string]string
if err := json.Unmarshal(out, &payload); err != nil {
t.Fatal(err)
}
if payload["model"] != "text-embedding-3-small" {
t.Fatalf("got %q", payload["model"])
}
}
+5 -3
View File
@@ -65,8 +65,10 @@ func (s *Service) findProvidersForModel(modelName string, allowed []string, cate
}
if len(matched) == 0 {
if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 {
if p, ok := s.cache.GetProviderByName(parts[0]); ok && p.Category == category && p.Enabled {
return []model.Provider{p}, nil
if p, ok := s.cache.GetProviderByName(parts[0]); ok && p.Category == category && p.Enabled {
if s.providerSupportsModel(&p, parts[1]) {
return []model.Provider{p}, nil
}
}
}
return nil, errNoProvider(modelName)
@@ -86,7 +88,7 @@ func (s *Service) providerSupportsModel(p *model.Provider, modelName string) boo
return false
}
for _, m := range models {
if m.Enabled && m.ModelID == modelName {
if m.Enabled && modelEntryMatches(m, modelName) {
return true
}
}
+13 -27
View File
@@ -79,6 +79,11 @@ func (s *Service) shouldAdaptResponses(p *model.Provider) bool {
}
func (s *Service) forward(ctx context.Context, p *model.Provider, apiKey string, endpoint provider.EndpointType, body []byte) (*provider.ChatResponse, error) {
var err error
body, err = s.rewriteBodyForProvider(p.ID, body)
if err != nil {
return nil, err
}
actualEndpoint := endpoint
actualBody := body
adaptResponses := false
@@ -132,6 +137,11 @@ func (s *Service) forwardRaw(ctx context.Context, p *model.Provider, apiKey stri
}
func (s *Service) forwardStream(ctx context.Context, p *model.Provider, apiKey string, endpoint provider.EndpointType, body []byte, w io.Writer, flusher http.Flusher) (*provider.StreamResult, error) {
var err error
body, err = s.rewriteBodyForProvider(p.ID, body)
if err != nil {
return nil, err
}
actualEndpoint := endpoint
actualBody := body
adaptResponses := false
@@ -401,7 +411,7 @@ func (s *Service) ListModels(ctx context.Context, ingress *model.IngressKey) ([]
for _, m := range s.cache.GetModels(p.ID) {
if m.Enabled {
result = append(result, map[string]any{
"id": m.ModelID, "object": "model", "owned_by": p.Name,
"id": IngressModelID(m), "object": "model", "owned_by": p.Name,
})
}
}
@@ -458,32 +468,8 @@ func (s *Service) HealthCheckProvider(ctx context.Context, providerID uint) (mod
}
keys := s.cache.ListProviderKeys(providerID)
if len(keys) == 0 && p.Type != model.ProviderOllama {
return model.ProviderUnhealthy, 0, "no keys"
return model.ProviderUnhealthy, 0, "no provider keys configured"
}
cfg := s.providerConfig(&p)
start := time.Now()
if len(keys) == 0 && p.Type == model.ProviderOllama {
if err := s.openai.HealthCheckWithConfig(ctx, "", cfg); err == nil {
return model.ProviderHealthy, time.Since(start).Milliseconds(), ""
}
}
for _, k := range keys {
if !k.Enabled {
continue
}
apiKey, err := s.DecryptAPIKey(k.APIKeyEnc)
if err != nil {
continue
}
var checkErr error
if p.Type == model.ProviderAnthropic {
checkErr = s.anthropic.HealthCheckWithConfig(ctx, apiKey, cfg)
} else {
checkErr = s.openai.HealthCheckWithConfig(ctx, apiKey, cfg)
}
if checkErr == nil {
return model.ProviderHealthy, time.Since(start).Milliseconds(), ""
}
}
return model.ProviderUnhealthy, time.Since(start).Milliseconds(), "health check failed"
return s.runHealthChecks(ctx, &p, keys, cfg)
}
+13 -1
View File
@@ -91,6 +91,10 @@ func (h *Handler) CreateProvider(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "protocol type not supported for this category"})
return
}
if h.providerNameTaken(req.Name, 0) {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider name already exists"})
return
}
allowed := req.AllowedEndpoints
if len(allowed) == 0 {
allowed = provider.DefaultEndpointsForCategory(req.Category)
@@ -113,6 +117,10 @@ func (h *Handler) CreateProvider(c *gin.Context) {
Status: model.ProviderUnknown,
}
if err := h.db.DB().Create(&p).Error; err != nil {
if h.providerNameTaken(req.Name, 0) {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider name already exists"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -151,7 +159,11 @@ func (h *Handler) UpdateProvider(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.Name != "" {
if req.Name != "" && req.Name != p.Name {
if h.providerNameTaken(req.Name, p.ID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider name already exists"})
return
}
p.Name = req.Name
}
if req.BaseURL != "" {
+15
View File
@@ -223,6 +223,7 @@ func (h *Handler) CreateModel(c *gin.Context) {
}
var req struct {
ModelID string `json:"model_id"`
Alias string `json:"alias"`
DisplayName string `json:"display_name"`
WorkflowType model.WorkflowType `json:"workflow_type"`
WorkflowJSON string `json:"workflow_json"`
@@ -239,6 +240,10 @@ func (h *Handler) CreateModel(c *gin.Context) {
return
}
}
if msg := validateModelAlias(h, req.Alias, 0); msg != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
if prov.Category == model.CategoryImage {
if err := validateImageModelCreate(struct {
ModelID, DisplayName string
@@ -262,6 +267,7 @@ func (h *Handler) CreateModel(c *gin.Context) {
m := model.ModelEntry{
ProviderID: providerID,
ModelID: req.ModelID,
Alias: normalizeAlias(req.Alias),
DisplayName: req.DisplayName,
WorkflowType: wt,
WorkflowJSON: req.WorkflowJSON,
@@ -288,6 +294,7 @@ func (h *Handler) UpdateModel(c *gin.Context) {
var req struct {
Enabled *bool `json:"enabled"`
DisplayName string `json:"display_name"`
Alias *string `json:"alias"`
ModelID string `json:"model_id"`
WorkflowType model.WorkflowType `json:"workflow_type"`
WorkflowJSON string `json:"workflow_json"`
@@ -309,6 +316,14 @@ func (h *Handler) UpdateModel(c *gin.Context) {
if req.DisplayName != "" {
m.DisplayName = req.DisplayName
}
if req.Alias != nil {
normalized := normalizeAlias(*req.Alias)
if msg := validateModelAlias(h, normalized, m.ID); msg != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
m.Alias = normalized
}
if req.ModelID != "" && req.ModelID != m.ModelID {
for _, existing := range h.cache.GetModels(m.ProviderID) {
if existing.ID != m.ID && existing.ModelID == req.ModelID {
+51
View File
@@ -0,0 +1,51 @@
package admin
import (
"strings"
)
func (h *Handler) providerNameTaken(name string, excludeID uint) bool {
name = strings.TrimSpace(name)
if name == "" {
return false
}
for _, p := range h.cache.ListProviders() {
if p.ID != excludeID && p.Name == name {
return true
}
}
return false
}
func (h *Handler) modelAliasTaken(alias string, excludeModelID uint) bool {
alias = strings.TrimSpace(alias)
if alias == "" {
return false
}
for _, p := range h.cache.ListProviders() {
for _, m := range h.cache.GetModels(p.ID) {
if m.ID == excludeModelID {
continue
}
if m.Alias == alias {
return true
}
}
}
return false
}
func normalizeAlias(alias string) string {
return strings.TrimSpace(alias)
}
func validateModelAlias(h *Handler, alias string, excludeModelID uint) string {
alias = normalizeAlias(alias)
if alias == "" {
return ""
}
if h.modelAliasTaken(alias, excludeModelID) {
return "model alias already exists"
}
return ""
}
+1
View File
@@ -77,6 +77,7 @@ type ModelEntry struct {
ID uint `gorm:"primaryKey" json:"id"`
ProviderID uint `gorm:"index;not null" json:"provider_id"`
ModelID string `gorm:"not null" json:"model_id"`
Alias string `gorm:"index" json:"alias"`
DisplayName string `json:"display_name"`
WorkflowType WorkflowType `gorm:"default:txt2img" json:"workflow_type"`
WorkflowJSON string `gorm:"type:text" json:"workflow_json"`
+20
View File
@@ -0,0 +1,20 @@
package model
// IngressID returns the model name exposed to ingress API clients.
func (m ModelEntry) IngressID() string {
if m.Alias != "" {
return m.Alias
}
return m.ModelID
}
// MatchesIngressName reports whether the entry matches an ingress model identifier.
func (m ModelEntry) MatchesIngressName(name string) bool {
if name == "" || !m.Enabled {
return false
}
if m.ModelID == name || m.VersionHash == name {
return true
}
return m.Alias != "" && m.Alias == name
}
+3 -2
View File
@@ -145,10 +145,11 @@ func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model
if !m.Enabled {
continue
}
if m.ModelID != version && m.VersionHash != version {
ingressID := m.IngressID()
if !m.MatchesIngressName(version) {
continue
}
if !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, "*") {
if !cache.MatchesList(allowedModels, ingressID) && !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, "*") {
continue
}
candidates = append(candidates, m)
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Luminary AI Gateway</title>
<script type="module" crossorigin src="/assets/index-ZUfE9zuc.js"></script>
<script type="module" crossorigin src="/assets/index-DZZ57wgd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CDyLXzvy.css">
</head>
<body>
+1
View File
@@ -48,6 +48,7 @@ export interface ModelEntry {
id: number
provider_id: number
model_id: string
alias?: string
display_name: string
workflow_type?: string
workflow_json?: string
+6 -1
View File
@@ -4,6 +4,9 @@
<el-form-item :label="t('providers.modelId')">
<el-input v-model="form.model_id" placeholder="luminary/flux-sdxl" />
</el-form-item>
<el-form-item :label="t('providers.modelAlias')">
<el-input v-model="form.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
</el-form-item>
<el-form-item :label="t('providers.displayName')">
<el-input v-model="form.display_name" />
</el-form-item>
@@ -89,6 +92,7 @@ const bindings = ref<BindingRow[]>([])
const form = ref({
model_id: '',
alias: '',
display_name: '',
workflow_type: 'txt2img',
workflow_json: '',
@@ -99,7 +103,7 @@ const requiredParams = computed(() =>
)
function reset() {
form.value = { model_id: '', display_name: '', workflow_type: 'txt2img', workflow_json: '' }
form.value = { model_id: '', alias: '', display_name: '', workflow_type: 'txt2img', workflow_json: '' }
nodes.value = []
bindings.value = []
}
@@ -161,6 +165,7 @@ async function save() {
try {
await api.post(`/providers/${props.providerId}/models`, {
model_id: form.value.model_id.trim(),
alias: form.value.alias.trim(),
display_name: form.value.display_name || form.value.model_id,
workflow_type: form.value.workflow_type,
workflow_json: form.value.workflow_json,
+13 -2
View File
@@ -4,6 +4,7 @@ export default {
cancel: 'Cancel',
confirm: 'Confirm',
save: 'Save',
edit: 'Edit',
create: 'Create',
add: 'Add',
delete: 'Delete',
@@ -121,6 +122,13 @@ export default {
addModel: 'Add Model',
addModelDialogTitle: 'Add Model',
modelId: 'Model ID',
modelAlias: 'Ingress alias',
modelAliasPlaceholder: 'Optional; ingress API uses this name',
modelAliasHint: 'Leave empty to use upstream Model ID at ingress',
upstreamModelId: 'Upstream Model ID',
editModelDialogTitle: 'Edit model',
providerNameExists: 'Provider name already exists',
modelAliasExists: 'Model alias already exists',
modelIdRequired: 'Model ID is required',
modelAdded: 'Model added',
modelDeleted: 'Model deleted',
@@ -165,8 +173,11 @@ export default {
addRule: 'Add Rule',
modelPattern: 'Model Pattern',
modelPatternPlaceholder: 'gpt-4o* or claude-3-5-sonnet',
providerId: 'Provider ID',
keyIdOptional: 'Key ID (optional)',
providerId: 'Provider',
providerIdPlaceholder: 'Select provider',
keyIdOptional: 'Provider key',
keyIdPlaceholder: 'Leave empty for auto load balancing',
providerRequired: 'Please select a provider',
addRuleTitle: 'Add Routing Rule',
whitelistUpdated: 'Whitelist updated',
confirmDeleteKey: 'Delete this key?',
+13 -2
View File
@@ -4,6 +4,7 @@ export default {
cancel: '取消',
confirm: '确认',
save: '保存',
edit: '编辑',
create: '创建',
add: '添加',
delete: '删除',
@@ -143,6 +144,13 @@ export default {
confirmDeleteModel: '确认删除此模型?',
workflowDialogTitle: '注册 Workflow 模型',
modelId: 'Model ID',
modelAlias: '入口别名',
modelAliasPlaceholder: '可选,入口 API 使用此名称',
modelAliasHint: '留空则入口使用上游 Model ID',
upstreamModelId: '上游 Model ID',
editModelDialogTitle: '编辑模型',
providerNameExists: '出口名称已存在',
modelAliasExists: '模型别名已存在',
workflowType: '类型',
txt2img: '文生图',
img2img: '图生图',
@@ -181,8 +189,11 @@ export default {
addRule: '添加规则',
modelPattern: '模型匹配',
modelPatternPlaceholder: 'gpt-4o* 或 claude-3-5-sonnet',
providerId: 'Provider ID',
keyIdOptional: 'Key ID (可选)',
providerId: '出口',
providerIdPlaceholder: '选择出口',
keyIdOptional: '出口密钥',
keyIdPlaceholder: '留空则自动负载均衡',
providerRequired: '请选择出口',
addRuleTitle: '添加路由规则',
whitelistUpdated: '白名单已更新',
confirmDeleteKey: '确认删除此密钥?',
+11 -1
View File
@@ -1,6 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import ElementPlus, { ElDialog, ElDrawer } from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/theme.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
@@ -8,6 +8,16 @@ import App from './App.vue'
import router from './router'
import { i18n } from './i18n'
function disableOverlayClose(component: typeof ElDialog) {
const props = component.props as Record<string, { default?: unknown }>
if (props.closeOnClickModal) {
props.closeOnClickModal.default = false
}
}
disableOverlayClose(ElDialog)
disableOverlayClose(ElDrawer)
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
+112 -16
View File
@@ -137,13 +137,15 @@
<el-drawer v-model="routingVisible" :title="t('ingress.routingTitle', { name: current?.name || '' })" size="50%">
<div class="drawer-toolbar">
<el-button type="primary" size="small" @click="showRuleForm = true">{{ t('ingress.addRule') }}</el-button>
<el-button type="primary" size="small" @click="openRuleForm">{{ t('ingress.addRule') }}</el-button>
</div>
<el-table :data="routingRules" size="small">
<el-table-column prop="model_pattern" :label="t('ingress.modelPattern')" />
<el-table-column prop="provider_id" :label="t('ingress.providerId')" width="110" />
<el-table-column prop="provider_key_id" :label="t('ingress.keyIdOptional')" width="90">
<template #default="{ row }">{{ row.provider_key_id || t('common.auto') }}</template>
<el-table-column prop="model_pattern" :label="t('ingress.modelPattern')" min-width="140" />
<el-table-column :label="t('ingress.providerId')" min-width="120">
<template #default="{ row }">{{ providerName(row.provider_id) }}</template>
</el-table-column>
<el-table-column :label="t('ingress.keyIdOptional')" min-width="120">
<template #default="{ row }">{{ keyName(row.provider_id, row.provider_key_id) }}</template>
</el-table-column>
<el-table-column prop="priority" :label="t('common.priority')" width="80" />
<el-table-column prop="enabled" :label="t('common.enabled')" width="70">
@@ -157,11 +159,40 @@
</el-table>
</el-drawer>
<el-dialog v-model="showRuleForm" :title="t('ingress.addRuleTitle')" width="480px" destroy-on-close>
<el-dialog v-model="showRuleForm" :title="t('ingress.addRuleTitle')" width="480px" destroy-on-close @open="loadRoutingCatalog">
<el-form label-width="120px">
<el-form-item :label="t('ingress.modelPattern')"><el-input v-model="ruleForm.model_pattern" :placeholder="t('ingress.modelPatternPlaceholder')" /></el-form-item>
<el-form-item :label="t('ingress.providerId')"><el-input-number v-model="ruleForm.provider_id" :min="1" class="w-full" /></el-form-item>
<el-form-item :label="t('ingress.keyIdOptional')"><el-input-number v-model="ruleForm.provider_key_id" :min="0" class="w-full" /></el-form-item>
<el-form-item :label="t('ingress.modelPattern')">
<el-input v-model="ruleForm.model_pattern" :placeholder="t('ingress.modelPatternPlaceholder')" />
</el-form-item>
<el-form-item :label="t('ingress.providerId')" required>
<el-select
v-model="ruleForm.provider_id"
filterable
class="w-full"
:placeholder="t('ingress.providerIdPlaceholder')"
@change="onRuleProviderChange"
>
<el-option v-for="p in routingProviders" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item :label="t('ingress.keyIdOptional')">
<el-select
v-model="ruleForm.provider_key_id"
filterable
clearable
class="w-full"
:placeholder="t('ingress.keyIdPlaceholder')"
:disabled="!ruleForm.provider_id"
>
<el-option :label="t('common.auto')" :value="0" />
<el-option
v-for="k in keysForRuleProvider"
:key="k.id"
:label="k.name"
:value="k.id"
/>
</el-select>
</el-form-item>
<el-form-item :label="t('common.priority')"><el-input-number v-model="ruleForm.priority" class="w-full" /></el-form-item>
</el-form>
<template #footer>
@@ -173,16 +204,17 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import api, { type IngressKey, type Provider } from '@/api/client'
import api, { type IngressKey, type Provider, type ProviderKey } from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
const { t } = useI18n()
interface CatalogProvider { 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 }
const keys = ref<IngressKey[]>([])
@@ -195,7 +227,13 @@ const routingVisible = ref(false)
const showRuleForm = ref(false)
const current = ref<IngressKey | null>(null)
const routingRules = ref<any[]>([])
const ruleForm = ref({ model_pattern: '', provider_id: 1, provider_key_id: 0, priority: 10 })
const ruleForm = ref({
model_pattern: '',
provider_id: undefined as number | undefined,
provider_key_id: 0,
priority: 10,
})
const routingProviders = ref<RoutingProvider[]>([])
const catalogProviders = ref<CatalogProvider[]>([])
const catalogModels = ref<ModelOption[]>([])
const form = ref({
@@ -211,6 +249,25 @@ const whitelistForm = ref({
allowed_models: [] as string[],
})
const keysForRuleProvider = computed(() => {
if (!ruleForm.value.provider_id) return []
return routingProviders.value.find(p => p.id === ruleForm.value.provider_id)?.keys ?? []
})
function providerName(id: number) {
return routingProviders.value.find(p => p.id === id)?.name ?? catalogProviders.value.find(p => p.id === id)?.name ?? `#${id}`
}
function keyName(providerId: number, keyId: number) {
if (!keyId) return t('common.auto')
const provider = routingProviders.value.find(p => p.id === providerId)
return provider?.keys.find(k => k.id === keyId)?.name ?? `#${keyId}`
}
function onRuleProviderChange() {
ruleForm.value.provider_key_id = 0
}
function parseJsonList(json: string): string[] {
try {
const v = JSON.parse(json || '[]')
@@ -253,17 +310,33 @@ async function loadCatalog() {
for (const p of providers) {
for (const m of p.models || []) {
if (!m.enabled) continue
const ingressId = m.alias || m.model_id
models.push({
model_id: m.model_id,
model_id: ingressId,
display_name: m.display_name || m.model_id,
provider_name: p.name,
label: `${m.model_id} (${p.name})`,
label: m.alias
? `${m.alias}${m.model_id} (${p.name})`
: `${m.model_id} (${p.name})`,
})
}
}
catalogModels.value = models
}
async function loadRoutingCatalog() {
const res = await api.get('/providers')
const providers: Provider[] = res.data.data ?? []
catalogProviders.value = providers.map(p => ({ id: p.id, name: p.name }))
routingProviders.value = await Promise.all(providers.map(async (p) => {
const keysRes = await api.get(`/providers/${p.id}/keys`)
const keys = (keysRes.data.data as ProviderKey[] || [])
.filter(k => k.enabled)
.map(k => ({ id: k.id, name: k.name }))
return { id: p.id, name: p.name, keys }
}))
}
async function load() {
loading.value = true
try {
@@ -340,16 +413,39 @@ function copyKey() {
async function openRouting(row: IngressKey) {
current.value = row
await loadRoutingCatalog()
const res = await api.get(`/ingress-keys/${row.id}/routing-rules`)
routingRules.value = res.data.data
routingVisible.value = true
}
function openRuleForm() {
ruleForm.value = {
model_pattern: '',
provider_id: undefined,
provider_key_id: 0,
priority: 10,
}
showRuleForm.value = true
}
async function createRule() {
if (!current.value) return
await api.post(`/ingress-keys/${current.value.id}/routing-rules`, ruleForm.value)
if (!ruleForm.value.model_pattern.trim()) {
ElMessage.warning(t('ingress.modelPatternPlaceholder'))
return
}
if (!ruleForm.value.provider_id) {
ElMessage.warning(t('ingress.providerRequired'))
return
}
await api.post(`/ingress-keys/${current.value.id}/routing-rules`, {
model_pattern: ruleForm.value.model_pattern.trim(),
provider_id: ruleForm.value.provider_id,
provider_key_id: ruleForm.value.provider_key_id || 0,
priority: ruleForm.value.priority,
})
showRuleForm.value = false
ruleForm.value = { model_pattern: '', provider_id: 1, provider_key_id: 0, priority: 10 }
openRouting(current.value)
ElMessage.success(t('ingress.ruleAdded'))
}
+79 -7
View File
@@ -29,7 +29,10 @@
<div class="models-expand">
<div v-if="!row.models?.length" class="models-empty">{{ t('providers.modelsEmpty') }}</div>
<el-table v-else :data="row.models" size="small" class="models-table">
<el-table-column prop="model_id" label="Model ID" min-width="200" show-overflow-tooltip />
<el-table-column prop="model_id" :label="t('providers.upstreamModelId')" min-width="180" show-overflow-tooltip />
<el-table-column :label="t('providers.modelAlias')" min-width="140" show-overflow-tooltip>
<template #default="{ row: m }">{{ m.alias || '—' }}</template>
</el-table-column>
<el-table-column v-if="row.category === 'image'" prop="workflow_type" :label="t('providers.workflowType')" width="100" />
<el-table-column prop="display_name" :label="t('providers.displayName')" min-width="160" show-overflow-tooltip />
<el-table-column :label="t('common.enabled')" width="80">
@@ -190,7 +193,10 @@
</span>
</div>
<el-table :data="providerModels" size="small" v-loading="modelsLoading">
<el-table-column prop="model_id" label="Model ID" min-width="200" show-overflow-tooltip />
<el-table-column prop="model_id" :label="t('providers.upstreamModelId')" min-width="180" show-overflow-tooltip />
<el-table-column :label="t('providers.modelAlias')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.alias || '—' }}</template>
</el-table-column>
<el-table-column v-if="current?.category === 'image'" prop="workflow_type" :label="t('providers.workflowType')" width="100" />
<el-table-column prop="display_name" :label="t('providers.displayName')" min-width="160" show-overflow-tooltip />
<el-table-column :label="t('common.enabled')" width="80">
@@ -205,8 +211,9 @@
<el-table-column prop="created_at" :label="t('providers.syncedAt')" width="180">
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="80">
<el-table-column :label="t('common.actions')" width="120">
<template #default="{ row }">
<el-button size="small" link @click="openEditModel(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" type="danger" link @click="deleteModel(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
@@ -218,6 +225,10 @@
<el-form-item :label="t('providers.modelId')" required>
<el-input v-model="addModelForm.model_id" placeholder="text-embedding-3-small" />
</el-form-item>
<el-form-item :label="t('providers.modelAlias')">
<el-input v-model="addModelForm.alias" :placeholder="t('providers.modelAliasPlaceholder')" />
<p class="form-hint">{{ t('providers.modelAliasHint') }}</p>
</el-form-item>
<el-form-item :label="t('providers.displayName')">
<el-input v-model="addModelForm.display_name" :placeholder="t('providers.modelId')" />
</el-form-item>
@@ -228,6 +239,25 @@
</template>
</el-dialog>
<el-dialog v-model="showEditModel" :title="t('providers.editModelDialogTitle')" width="480px" destroy-on-close>
<el-form label-width="110px">
<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>
<el-form-item :label="t('providers.displayName')">
<el-input v-model="editModelForm.display_name" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditModel = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="saveEditModel">{{ t('common.save') }}</el-button>
</template>
</el-dialog>
<WorkflowModelDialog
v-if="current"
v-model="showWorkflowDialog"
@@ -286,7 +316,9 @@ const enabledProviderModelCount = computed(() =>
)
const showWorkflowDialog = ref(false)
const showAddModel = ref(false)
const addModelForm = ref({ model_id: '', display_name: '' })
const showEditModel = ref(false)
const addModelForm = ref({ model_id: '', alias: '', display_name: '' })
const editModelForm = ref({ id: 0, model_id: '', alias: '', display_name: '' })
const keyForm = ref({ name: '', api_key: '', weight: 1, models: '["*"]', max_requests_per_day: 0, max_tokens_per_day: 0 })
let createMetaSeq = 0
@@ -309,6 +341,12 @@ function defaultProviderType(category: string, types: ProviderTypeMeta[]) {
return category === 'image' ? 'comfyui' : 'openai'
}
function apiErrorMessage(error: string | undefined, fallback: string) {
if (error === 'provider name already exists') return t('providers.providerNameExists')
if (error === 'model alias already exists') return t('providers.modelAliasExists')
return error || fallback
}
function providerHasListModels(row: Provider) {
return parseList(row.allowed_endpoints_json).includes('list_models')
}
@@ -493,7 +531,12 @@ async function create() {
category: form.value.category,
...buildPayload(form.value.base_url, form.value.allowedMap, form.value.pathMap),
}
await api.post('/providers', payload)
try {
await api.post('/providers', payload)
} catch (e: any) {
ElMessage.error(apiErrorMessage(e.response?.data?.error, t('providers.updateFailed')))
return
}
showCreate.value = false
ElMessage.success(t('providers.createSuccess'))
load()
@@ -592,10 +635,38 @@ async function deleteKey(row: ProviderKey) {
}
function openAddModel() {
addModelForm.value = { model_id: '', display_name: '' }
addModelForm.value = { model_id: '', alias: '', display_name: '' }
showAddModel.value = true
}
function openEditModel(m: ModelEntry) {
editModelForm.value = {
id: m.id,
model_id: m.model_id,
alias: m.alias || '',
display_name: m.display_name || '',
}
showEditModel.value = true
}
async function saveEditModel() {
if (!current.value || !editModelForm.value.id) return
try {
await api.put(`/providers/${current.value.id}/models/${editModelForm.value.id}`, {
model_id: editModelForm.value.model_id.trim(),
alias: editModelForm.value.alias.trim(),
display_name: editModelForm.value.display_name.trim(),
})
} catch (e: any) {
ElMessage.error(apiErrorMessage(e.response?.data?.error, t('providers.updateFailed')))
return
}
showEditModel.value = false
ElMessage.success(t('providers.saved'))
await openModels(current.value)
await load()
}
async function createModel() {
if (!current.value) return
const modelId = addModelForm.value.model_id.trim()
@@ -607,10 +678,11 @@ async function createModel() {
try {
await api.post(`/providers/${current.value.id}/models`, {
model_id: modelId,
alias: addModelForm.value.alias.trim(),
display_name: displayName,
})
} catch (e: any) {
ElMessage.error(e.response?.data?.error || t('providers.updateFailed'))
ElMessage.error(apiErrorMessage(e.response?.data?.error, t('providers.updateFailed')))
return
}
showAddModel.value = false