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:
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user