From aca63d75264c0399672c62fbf6b652f665004876 Mon Sep 17 00:00:00 2001 From: renjue Date: Sun, 21 Jun 2026 02:49:49 +0800 Subject: [PATCH] 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 --- internal/gateway/failover.go | 7 +- internal/gateway/health_check.go | 190 +++++++++++++++++++++ internal/gateway/health_check_test.go | 51 ++++++ internal/gateway/model_resolve.go | 53 ++++++ internal/gateway/model_resolve_test.go | 32 ++++ internal/gateway/routing.go | 8 +- internal/gateway/service.go | 40 ++--- internal/handler/admin/providers.go | 14 +- internal/handler/admin/providers_keys.go | 15 ++ internal/handler/admin/providers_util.go | 51 ++++++ internal/model/entities.go | 1 + internal/model/model_entry.go | 20 +++ internal/prediction/service.go | 5 +- web/dist/index.html | 2 +- web/src/api/client.ts | 1 + web/src/components/WorkflowModelDialog.vue | 7 +- web/src/i18n/locales/en.ts | 15 +- web/src/i18n/locales/zh-CN.ts | 15 +- web/src/main.ts | 12 +- web/src/views/IngressKeysView.vue | 128 ++++++++++++-- web/src/views/ProvidersView.vue | 86 +++++++++- 21 files changed, 688 insertions(+), 65 deletions(-) create mode 100644 internal/gateway/health_check.go create mode 100644 internal/gateway/health_check_test.go create mode 100644 internal/gateway/model_resolve.go create mode 100644 internal/gateway/model_resolve_test.go create mode 100644 internal/handler/admin/providers_util.go create mode 100644 internal/model/model_entry.go diff --git a/internal/gateway/failover.go b/internal/gateway/failover.go index 86ae8d6..be4ca14 100644 --- a/internal/gateway/failover.go +++ b/internal/gateway/failover.go @@ -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}) } diff --git a/internal/gateway/health_check.go b/internal/gateway/health_check.go new file mode 100644 index 0000000..00b9c7e --- /dev/null +++ b/internal/gateway/health_check.go @@ -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 +} diff --git a/internal/gateway/health_check_test.go b/internal/gateway/health_check_test.go new file mode 100644 index 0000000..84173dc --- /dev/null +++ b/internal/gateway/health_check_test.go @@ -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) + } +} diff --git a/internal/gateway/model_resolve.go b/internal/gateway/model_resolve.go new file mode 100644 index 0000000..810b313 --- /dev/null +++ b/internal/gateway/model_resolve.go @@ -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) +} diff --git a/internal/gateway/model_resolve_test.go b/internal/gateway/model_resolve_test.go new file mode 100644 index 0000000..7ee3676 --- /dev/null +++ b/internal/gateway/model_resolve_test.go @@ -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"]) + } +} diff --git a/internal/gateway/routing.go b/internal/gateway/routing.go index 2c8b816..dc2b0ef 100644 --- a/internal/gateway/routing.go +++ b/internal/gateway/routing.go @@ -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 } } diff --git a/internal/gateway/service.go b/internal/gateway/service.go index 3c02e8d..5bef403 100644 --- a/internal/gateway/service.go +++ b/internal/gateway/service.go @@ -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) } diff --git a/internal/handler/admin/providers.go b/internal/handler/admin/providers.go index 756f38f..fe0e806 100644 --- a/internal/handler/admin/providers.go +++ b/internal/handler/admin/providers.go @@ -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 != "" { diff --git a/internal/handler/admin/providers_keys.go b/internal/handler/admin/providers_keys.go index d90a18e..52007c7 100644 --- a/internal/handler/admin/providers_keys.go +++ b/internal/handler/admin/providers_keys.go @@ -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 { diff --git a/internal/handler/admin/providers_util.go b/internal/handler/admin/providers_util.go new file mode 100644 index 0000000..298230f --- /dev/null +++ b/internal/handler/admin/providers_util.go @@ -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 "" +} diff --git a/internal/model/entities.go b/internal/model/entities.go index 293a2bc..2078732 100644 --- a/internal/model/entities.go +++ b/internal/model/entities.go @@ -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"` diff --git a/internal/model/model_entry.go b/internal/model/model_entry.go new file mode 100644 index 0000000..fab3782 --- /dev/null +++ b/internal/model/model_entry.go @@ -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 +} diff --git a/internal/prediction/service.go b/internal/prediction/service.go index 0d41836..becb807 100644 --- a/internal/prediction/service.go +++ b/internal/prediction/service.go @@ -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) diff --git a/web/dist/index.html b/web/dist/index.html index f18c3b9..11e8810 100644 --- a/web/dist/index.html +++ b/web/dist/index.html @@ -4,7 +4,7 @@ Luminary AI Gateway - + diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 218603d..267dd57 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 diff --git a/web/src/components/WorkflowModelDialog.vue b/web/src/components/WorkflowModelDialog.vue index 7f61023..f59eac3 100644 --- a/web/src/components/WorkflowModelDialog.vue +++ b/web/src/components/WorkflowModelDialog.vue @@ -4,6 +4,9 @@ + + + @@ -89,6 +92,7 @@ const bindings = ref([]) 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, diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index a935d6a..9039eba 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -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?', diff --git a/web/src/i18n/locales/zh-CN.ts b/web/src/i18n/locales/zh-CN.ts index d37b7e8..90aaadc 100644 --- a/web/src/i18n/locales/zh-CN.ts +++ b/web/src/i18n/locales/zh-CN.ts @@ -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: '确认删除此密钥?', diff --git a/web/src/main.ts b/web/src/main.ts index 1a25eb4..4540a91 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -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 + 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) diff --git a/web/src/views/IngressKeysView.vue b/web/src/views/IngressKeysView.vue index dab3c61..ff19563 100644 --- a/web/src/views/IngressKeysView.vue +++ b/web/src/views/IngressKeysView.vue @@ -137,13 +137,15 @@
- {{ t('ingress.addRule') }} + {{ t('ingress.addRule') }}
- - - - + + + + + + @@ -157,11 +159,40 @@
- + - - - + + + + + + + + + + + + + +