From fdb6a3ce16d97ff044a77c3e11ba102e8bc91d19 Mon Sep 17 00:00:00 2001 From: nekohy Date: Sun, 10 Aug 2025 11:36:26 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20feat:=20Enhance=20model=20listi?= =?UTF-8?q?ng=20and=20retrieval=20with=20support=20for=20Anthropic=20and?= =?UTF-8?q?=20Gemini=20models;=20refactor=20routes=20for=20better=20API=20?= =?UTF-8?q?key=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controller/model.go | 56 ++++++++++++++++++++++++++++++++++++------ dto/pricing.go | 24 ++++++++++++++++++ middleware/auth.go | 16 ++++++------ router/relay-router.go | 42 ++++++++++++++++++++++++++++--- 4 files changed, 120 insertions(+), 18 deletions(-) diff --git a/controller/model.go b/controller/model.go index 31a66b29..d03fdeb2 100644 --- a/controller/model.go +++ b/controller/model.go @@ -16,6 +16,7 @@ import ( "one-api/relay/channel/moonshot" relaycommon "one-api/relay/common" "one-api/setting" + "time" ) // https://platform.openai.com/docs/api-reference/models/list @@ -102,7 +103,7 @@ func init() { }) } -func ListModels(c *gin.Context) { +func ListModels(c *gin.Context, modelType int) { userOpenAiModels := make([]dto.OpenAIModels, 0) modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) @@ -171,10 +172,41 @@ func ListModels(c *gin.Context) { } } } - c.JSON(200, gin.H{ - "success": true, - "data": userOpenAiModels, - }) + switch modelType { + case constant.ChannelTypeAnthropic: + useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels)) + for i, model := range userOpenAiModels { + useranthropicModels[i] = dto.AnthropicModel{ + ID: model.Id, + CreatedAt: time.Unix(int64(model.Created), 0).UTC().Format(time.RFC3339), + DisplayName: model.Id, + Type: "model", + } + } + c.JSON(200, gin.H{ + "data": useranthropicModels, + "first_id": useranthropicModels[0].ID, + "has_more": false, + "last_id": useranthropicModels[len(useranthropicModels)-1].ID, + }) + case constant.ChannelTypeGemini: + userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels)) + for i, model := range userOpenAiModels { + userGeminiModels[i] = dto.GeminiModel{ + Name: model.Id, + DisplayName: model.Id, + } + } + c.JSON(200, gin.H{ + "models": userGeminiModels, + "nextPageToken": nil, + }) + default: + c.JSON(200, gin.H{ + "success": true, + "data": userOpenAiModels, + }) + } } func ChannelListModels(c *gin.Context) { @@ -198,10 +230,20 @@ func EnabledListModels(c *gin.Context) { }) } -func RetrieveModel(c *gin.Context) { +func RetrieveModel(c *gin.Context, modelType int) { modelId := c.Param("model") if aiModel, ok := openAIModelsMap[modelId]; ok { - c.JSON(200, aiModel) + switch modelType { + case constant.ChannelTypeAnthropic: + c.JSON(200, dto.AnthropicModel{ + ID: aiModel.Id, + CreatedAt: time.Unix(int64(aiModel.Created), 0).UTC().Format(time.RFC3339), + DisplayName: aiModel.Id, + Type: "model", + }) + default: + c.JSON(200, aiModel) + } } else { openAIError := dto.OpenAIError{ Message: fmt.Sprintf("The model '%s' does not exist", modelId), diff --git a/dto/pricing.go b/dto/pricing.go index 0f317d9d..bc024de3 100644 --- a/dto/pricing.go +++ b/dto/pricing.go @@ -2,6 +2,7 @@ package dto import "one-api/constant" +// 这里不好动就不动了,本来想独立出来的( type OpenAIModels struct { Id string `json:"id"` Object string `json:"object"` @@ -9,3 +10,26 @@ type OpenAIModels struct { OwnedBy string `json:"owned_by"` SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` } + +type AnthropicModel struct { + ID string `json:"id"` + CreatedAt string `json:"created_at"` + DisplayName string `json:"display_name"` + Type string `json:"type"` +} + +type GeminiModel struct { + Name interface{} `json:"name"` + BaseModelId interface{} `json:"baseModelId"` + Version interface{} `json:"version"` + DisplayName interface{} `json:"displayName"` + Description interface{} `json:"description"` + InputTokenLimit interface{} `json:"inputTokenLimit"` + OutputTokenLimit interface{} `json:"outputTokenLimit"` + SupportedGenerationMethods []interface{} `json:"supportedGenerationMethods"` + Thinking interface{} `json:"thinking"` + Temperature interface{} `json:"temperature"` + MaxTemperature interface{} `json:"maxTemperature"` + TopP interface{} `json:"topP"` + TopK interface{} `json:"topK"` +} diff --git a/middleware/auth.go b/middleware/auth.go index 5f6e5d43..ee8d9241 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -192,16 +192,18 @@ func TokenAuth() func(c *gin.Context) { } c.Request.Header.Set("Authorization", "Bearer "+key) } + anthropicKey := c.Request.Header.Get("x-api-key") // 检查path包含/v1/messages - if strings.Contains(c.Request.URL.Path, "/v1/messages") { - // 从x-api-key中获取key - key := c.Request.Header.Get("x-api-key") - if key != "" { - c.Request.Header.Set("Authorization", "Bearer "+key) - } + // 或者是否 x-api-key 不为空且存在anthropic-version + // 谁知道有多少不符合规范没写anthropic-version的 + // 所以就这样随它去吧( + if strings.Contains(c.Request.URL.Path, "/v1/messages") || (anthropicKey != "" && c.Request.Header.Get("anthropic-version") != "") { + c.Request.Header.Set("Authorization", "Bearer "+anthropicKey) } // gemini api 从query中获取key - if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") { + if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models") || + strings.HasPrefix(c.Request.URL.Path, "/v1beta/openai/models") || + strings.HasPrefix(c.Request.URL.Path, "/v1/models/") { skKey := c.Query("key") if skKey != "" { c.Request.Header.Set("Authorization", "Bearer "+skKey) diff --git a/router/relay-router.go b/router/relay-router.go index 5b293dbd..cd656580 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -1,11 +1,11 @@ package router import ( + "github.com/gin-gonic/gin" + "one-api/constant" "one-api/controller" "one-api/middleware" "one-api/relay" - - "github.com/gin-gonic/gin" ) func SetRelayRouter(router *gin.Engine) { @@ -16,9 +16,43 @@ func SetRelayRouter(router *gin.Engine) { modelsRouter := router.Group("/v1/models") modelsRouter.Use(middleware.TokenAuth()) { - modelsRouter.GET("", controller.ListModels) - modelsRouter.GET("/:model", controller.RetrieveModel) + modelsRouter.GET("", func(c *gin.Context) { + switch { + case c.GetHeader("x-api-key") != "" && c.GetHeader("anthropic-version") != "": + controller.ListModels(c, constant.ChannelTypeAnthropic) + case c.GetHeader("x-goog-api-key") != "" || c.Query("key") != "": // 单独的适配 + controller.RetrieveModel(c, constant.ChannelTypeGemini) + default: + controller.ListModels(c, constant.ChannelTypeOpenAI) + } + }) + + modelsRouter.GET("/:model", func(c *gin.Context) { + switch { + case c.GetHeader("x-api-key") != "" && c.GetHeader("anthropic-version") != "": + controller.RetrieveModel(c, constant.ChannelTypeAnthropic) + default: + controller.RetrieveModel(c, constant.ChannelTypeOpenAI) + } + }) } + + geminiRouter := router.Group("/v1beta/models") + geminiRouter.Use(middleware.TokenAuth()) + { + geminiRouter.GET("", func(c *gin.Context) { + controller.ListModels(c, constant.ChannelTypeGemini) + }) + } + + geminiCompatibleRouter := router.Group("/v1beta/openai/models") + geminiCompatibleRouter.Use(middleware.TokenAuth()) + { + geminiCompatibleRouter.GET("", func(c *gin.Context) { + controller.ListModels(c, constant.ChannelTypeOpenAI) + }) + } + playgroundRouter := router.Group("/pg") playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute()) { From 92022360dec397618860a6933c058374c3c53c2a Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 10 Aug 2025 21:09:16 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E2=9C=A8=20feat:=20Update=20request=20URL?= =?UTF-8?q?=20handling=20for=20Azure=20responses=20based=20on=20base=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- relay/channel/openai/adaptor.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index e6b551b6..561b7d3e 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -132,7 +132,13 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { if info.ChannelOtherSettings.AzureResponsesVersion != "" { responsesApiVersion = info.ChannelOtherSettings.AzureResponsesVersion } - requestURL = fmt.Sprintf("/openai/v1/responses?api-version=%s", responsesApiVersion) + + subUrl := "/openai/v1/responses" + if strings.Contains(info.BaseUrl, "cognitiveservices.azure.com") { + subUrl = "/openai/responses" + } + + requestURL = fmt.Sprintf("%s?api-version=%s", subUrl, responsesApiVersion) return relaycommon.GetFullRequestURL(info.BaseUrl, requestURL, info.ChannelType), nil } From 94bd44d0f2b85e01cb38eb1186cb5dec7ea643eb Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 10 Aug 2025 21:09:49 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20feat:=20enhance=20model=20billi?= =?UTF-8?q?ng=20aggregation=20&=20UI=20display=20for=20unknown=20quota=20t?= =?UTF-8?q?ype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary ------- 1. **Backend** • `controller/model_meta.go` – For prefix/suffix/contains rules, aggregate endpoints, bound channels, enable groups, and quota types across all matched models. – When mixed billing types are detected, return `quota_type = -1` (unknown) instead of defaulting to volume-based. 2. **Frontend** • `web/src/helpers/utils.js` – `calculateModelPrice` now handles `quota_type = -1`, returning placeholder `'-'`. • `web/src/components/table/model-pricing/view/card/PricingCardView.jsx` – Billing tag logic updated: displays “按次计费” (times), “按量计费” (volume), or `'-'` for unknown. • `web/src/components/table/model-pricing/view/table/PricingTableColumns.js` – `renderQuotaType` shows “未知” for unknown billing type. • `web/src/components/table/models/ModelsColumnDefs.js` – Unified `renderQuotaType` to return `'-'` when type is unknown. • `web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx` – Group price table honors unknown billing type; pricing columns show `'-'` and neutral tag color. 3. **Utilities** • Added safe fallback colours/tags for unknown billing type across affected components. Impact ------ • Ensures correct data aggregation for non-exact model matches. • Prevents UI from implying volume billing when actual type is ambiguous. • Provides consistent placeholder display (`'-'` or “未知”) across cards, tables and modals. No breaking API changes; frontend gracefully handles legacy values. --- controller/model_meta.go | 104 ++++++++++++++++-- .../modal/components/ModelPricingTable.jsx | 17 ++- .../view/card/PricingCardView.jsx | 21 +++- .../table/models/ModelsColumnDefs.js | 3 +- web/src/helpers/utils.js | 18 ++- 5 files changed, 140 insertions(+), 23 deletions(-) diff --git a/controller/model_meta.go b/controller/model_meta.go index b097c80a..72ba4a41 100644 --- a/controller/model_meta.go +++ b/controller/model_meta.go @@ -3,8 +3,10 @@ package controller import ( "encoding/json" "strconv" + "strings" "one-api/common" + "one-api/constant" "one-api/model" "github.com/gin-gonic/gin" @@ -162,17 +164,105 @@ func DeleteModelMeta(c *gin.Context) { // 辅助函数:填充 Endpoints 和 BoundChannels 和 EnableGroups func fillModelExtra(m *model.Model) { - if m.Endpoints == "" { - eps := model.GetModelSupportEndpointTypes(m.ModelName) + // 若为精确匹配,保持原有逻辑 + if m.NameRule == model.NameRuleExact { + if m.Endpoints == "" { + eps := model.GetModelSupportEndpointTypes(m.ModelName) + if b, err := json.Marshal(eps); err == nil { + m.Endpoints = string(b) + } + } + if channels, err := model.GetBoundChannels(m.ModelName); err == nil { + m.BoundChannels = channels + } + m.EnableGroups = model.GetModelEnableGroups(m.ModelName) + m.QuotaType = model.GetModelQuotaType(m.ModelName) + return + } + + // 非精确匹配:计算并集 + pricings := model.GetPricing() + + // 端点去重集合 + endpointSet := make(map[constant.EndpointType]struct{}) + // 已绑定渠道去重集合 + channelSet := make(map[string]model.BoundChannel) + // 分组去重集合 + groupSet := make(map[string]struct{}) + // 计费类型(若有任意模型为 1,则返回 1) + quotaTypeSet := make(map[int]struct{}) + + for _, p := range pricings { + var matched bool + switch m.NameRule { + case model.NameRulePrefix: + matched = strings.HasPrefix(p.ModelName, m.ModelName) + case model.NameRuleSuffix: + matched = strings.HasSuffix(p.ModelName, m.ModelName) + case model.NameRuleContains: + matched = strings.Contains(p.ModelName, m.ModelName) + } + if !matched { + continue + } + + // 收集端点 + for _, et := range p.SupportedEndpointTypes { + endpointSet[et] = struct{}{} + } + + // 收集分组 + for _, g := range p.EnableGroup { + groupSet[g] = struct{}{} + } + + // 收集计费类型 + quotaTypeSet[p.QuotaType] = struct{}{} + + // 收集渠道 + if channels, err := model.GetBoundChannels(p.ModelName); err == nil { + for _, ch := range channels { + key := ch.Name + "_" + strconv.Itoa(ch.Type) + channelSet[key] = ch + } + } + } + + // 序列化端点 + if len(endpointSet) > 0 && m.Endpoints == "" { + eps := make([]constant.EndpointType, 0, len(endpointSet)) + for et := range endpointSet { + eps = append(eps, et) + } if b, err := json.Marshal(eps); err == nil { m.Endpoints = string(b) } } - if channels, err := model.GetBoundChannels(m.ModelName); err == nil { + + // 序列化渠道 + if len(channelSet) > 0 { + channels := make([]model.BoundChannel, 0, len(channelSet)) + for _, ch := range channelSet { + channels = append(channels, ch) + } m.BoundChannels = channels } - // 填充启用分组 - m.EnableGroups = model.GetModelEnableGroups(m.ModelName) - // 填充计费类型 - m.QuotaType = model.GetModelQuotaType(m.ModelName) + + // 序列化分组 + if len(groupSet) > 0 { + groups := make([]string, 0, len(groupSet)) + for g := range groupSet { + groups = append(groups, g) + } + m.EnableGroups = groups + } + + // 确定计费类型:仅当所有匹配模型计费类型一致时才返回该类型,否则返回 -1 表示未知/不确定 + if len(quotaTypeSet) == 1 { + for k := range quotaTypeSet { + m.QuotaType = k + } + } else { + m.QuotaType = -1 + } } diff --git a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx index f856eecb..89c952ea 100644 --- a/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx +++ b/web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx @@ -63,7 +63,7 @@ const ModelPricingTable = ({ key: group, group: group, ratio: groupRatioValue, - billingType: modelData?.quota_type === 0 ? t('按量计费') : t('按次计费'), + billingType: modelData?.quota_type === 0 ? t('按量计费') : (modelData?.quota_type === 1 ? t('按次计费') : '-'), inputPrice: modelData?.quota_type === 0 ? priceData.inputPrice : '-', outputPrice: modelData?.quota_type === 0 ? (priceData.completionPrice || priceData.outputPrice) : '-', fixedPrice: modelData?.quota_type === 1 ? priceData.price : '-', @@ -100,11 +100,16 @@ const ModelPricingTable = ({ columns.push({ title: t('计费类型'), dataIndex: 'billingType', - render: (text) => ( - - {text} - - ), + render: (text) => { + let color = 'white'; + if (text === t('按量计费')) color = 'violet'; + else if (text === t('按次计费')) color = 'teal'; + return ( + + {text || '-'} + + ); + }, }); // 根据计费类型添加价格列 diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx index 2a3e4109..9eab7d08 100644 --- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx +++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx @@ -144,13 +144,24 @@ const PricingCardView = ({ // 渲染标签 const renderTags = (record) => { // 计费类型标签(左边) - const billingType = record.quota_type === 1 ? 'teal' : 'violet'; - const billingText = record.quota_type === 1 ? t('按次计费') : t('按量计费'); - const billingTag = ( - - {billingText} + let billingTag = ( + + - ); + if (record.quota_type === 1) { + billingTag = ( + + {t('按次计费')} + + ); + } else if (record.quota_type === 0) { + billingTag = ( + + {t('按量计费')} + + ); + } // 自定义标签(右边) const customTags = []; diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js index 6514e752..4306730e 100644 --- a/web/src/components/table/models/ModelsColumnDefs.js +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -137,7 +137,8 @@ const renderQuotaType = (qt, t) => { ); } - return qt ?? '-'; + // 未知 + return '-'; }; // Render bound channels diff --git a/web/src/helpers/utils.js b/web/src/helpers/utils.js index 71903ab8..d41d426e 100644 --- a/web/src/helpers/utils.js +++ b/web/src/helpers/utils.js @@ -632,12 +632,22 @@ export const calculateModelPrice = ({ }; } - // 按次计费 - const priceUSD = parseFloat(record.model_price) * usedGroupRatio; - const displayVal = displayPrice(priceUSD); + if (record.quota_type === 1) { + // 按次计费 + const priceUSD = parseFloat(record.model_price) * usedGroupRatio; + const displayVal = displayPrice(priceUSD); + return { + price: displayVal, + isPerToken: false, + usedGroup, + usedGroupRatio, + }; + } + + // 未知计费类型,返回占位信息 return { - price: displayVal, + price: '-', isPerToken: false, usedGroup, usedGroupRatio, From c8f7aa76e7840a91de3e067512181512f1593a85 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 10 Aug 2025 21:32:18 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=94=8D=20feat:=20Show=20matched=20mod?= =?UTF-8?q?el=20names=20&=20counts=20for=20non-exact=20model=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary ------- 1. **Backend** • `model/model_meta.go` – Add `MatchedModels []string` and `MatchedCount int` (ignored by GORM) to expose matching details in API responses. • `controller/model_meta.go` – When processing prefix/suffix/contains rules in `fillModelExtra`, collect every matched model name, fill `MatchedModels`, and calculate `MatchedCount`. 2. **Frontend** • `web/src/components/table/models/ModelsColumnDefs.js` – Import `Tooltip`. – Enhance `renderNameRule` to: – Display tag text like “前缀 5个模型” for non-exact rules. – Show a tooltip listing all matched model names on hover. Impact ------ Users now see the total number of concrete models aggregated under each prefix/suffix/contains rule and can inspect the exact list via tooltip, improving transparency in model management. --- controller/model_meta.go | 10 +++++++ model/model_meta.go | 3 ++ .../table/models/ModelsColumnDefs.js | 28 +++++++++++++++---- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/controller/model_meta.go b/controller/model_meta.go index 72ba4a41..e7d29fcf 100644 --- a/controller/model_meta.go +++ b/controller/model_meta.go @@ -183,6 +183,9 @@ func fillModelExtra(m *model.Model) { // 非精确匹配:计算并集 pricings := model.GetPricing() + // 匹配到的模型名称集合 + matchedNames := make([]string, 0) + // 端点去重集合 endpointSet := make(map[constant.EndpointType]struct{}) // 已绑定渠道去重集合 @@ -206,6 +209,9 @@ func fillModelExtra(m *model.Model) { continue } + // 记录匹配到的模型名称 + matchedNames = append(matchedNames, p.ModelName) + // 收集端点 for _, et := range p.SupportedEndpointTypes { endpointSet[et] = struct{}{} @@ -265,4 +271,8 @@ func fillModelExtra(m *model.Model) { } else { m.QuotaType = -1 } + + // 设置匹配信息 + m.MatchedModels = matchedNames + m.MatchedCount = len(matchedNames) } diff --git a/model/model_meta.go b/model/model_meta.go index b69608f1..d7bc78c6 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -51,6 +51,9 @@ type Model struct { EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` QuotaType int `json:"quota_type" gorm:"-"` NameRule int `json:"name_rule" gorm:"default:0"` + + MatchedModels []string `json:"matched_models,omitempty" gorm:"-"` + MatchedCount int `json:"matched_count,omitempty" gorm:"-"` } // Insert 创建新的模型元数据记录 diff --git a/web/src/components/table/models/ModelsColumnDefs.js b/web/src/components/table/models/ModelsColumnDefs.js index 4306730e..e1fc257e 100644 --- a/web/src/components/table/models/ModelsColumnDefs.js +++ b/web/src/components/table/models/ModelsColumnDefs.js @@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import React from 'react'; -import { Button, Space, Tag, Typography, Modal } from '@douyinfe/semi-ui'; +import { Button, Space, Tag, Typography, Modal, Tooltip } from '@douyinfe/semi-ui'; import { timestamp2string, getLobeHubIcon, @@ -208,8 +208,8 @@ const renderOperations = (text, record, setEditingModel, setShowEdit, manageMode ); }; -// 名称匹配类型渲染 -const renderNameRule = (rule, t) => { +// 名称匹配类型渲染(带匹配数量 Tooltip) +const renderNameRule = (rule, record, t) => { const map = { 0: { color: 'green', label: t('精确') }, 1: { color: 'blue', label: t('前缀') }, @@ -218,11 +218,27 @@ const renderNameRule = (rule, t) => { }; const cfg = map[rule]; if (!cfg) return '-'; - return ( + + let label = cfg.label; + if (rule !== 0 && record.matched_count) { + label = `${cfg.label} ${record.matched_count}${t('个模型')}`; + } + + const tagElement = ( - {cfg.label} + {label} ); + + if (rule === 0 || !record.matched_models || record.matched_models.length === 0) { + return tagElement; + } + + return ( + + {tagElement} + + ); }; export const getModelsColumns = ({ @@ -253,7 +269,7 @@ export const getModelsColumns = ({ { title: t('匹配类型'), dataIndex: 'name_rule', - render: (val) => renderNameRule(val, t), + render: (val, record) => renderNameRule(val, record, t), }, { title: t('描述'), From 195be56c46612520fb74988e71684caf05806407 Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 10 Aug 2025 23:11:35 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=8F=8E=EF=B8=8F=20perf:=20optimize=20?= =?UTF-8?q?aggregated=20model=20look-ups=20by=20batching=20bound-channel?= =?UTF-8?q?=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary ------- 1. **Backend** • `model/model_meta.go` – Add `GetBoundChannelsForModels([]string)` to retrieve channels for multiple models in a single SQL (`IN (?)`) and deduplicate with `GROUP BY`. • `controller/model_meta.go` – In non-exact `fillModelExtra`: – Remove per-model `GetBoundChannels` calls. – Collect matched model names, then call `GetBoundChannelsForModels` once and merge results into `channelSet`. – Minor cleanup on loop logic; channel aggregation now happens after quota/group/endpoint processing. Impact ------ • Eliminates N+1 query pattern for prefix/suffix/contains rules. • Reduces DB round-trips from *N + 1* to **1**, markedly speeding up the model-management list load. • Keeps existing `GetBoundChannels` API intact for single-model scenarios; no breaking changes. --- controller/model_meta.go | 35 ++++++++++++++++++----------------- model/model_meta.go | 15 +++++++++++++++ 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/controller/model_meta.go b/controller/model_meta.go index e7d29fcf..9b9f7849 100644 --- a/controller/model_meta.go +++ b/controller/model_meta.go @@ -188,6 +188,7 @@ func fillModelExtra(m *model.Model) { // 端点去重集合 endpointSet := make(map[constant.EndpointType]struct{}) + // 已绑定渠道去重集合 channelSet := make(map[string]model.BoundChannel) // 分组去重集合 @@ -224,14 +225,6 @@ func fillModelExtra(m *model.Model) { // 收集计费类型 quotaTypeSet[p.QuotaType] = struct{}{} - - // 收集渠道 - if channels, err := model.GetBoundChannels(p.ModelName); err == nil { - for _, ch := range channels { - key := ch.Name + "_" + strconv.Itoa(ch.Type) - channelSet[key] = ch - } - } } // 序列化端点 @@ -245,15 +238,6 @@ func fillModelExtra(m *model.Model) { } } - // 序列化渠道 - if len(channelSet) > 0 { - channels := make([]model.BoundChannel, 0, len(channelSet)) - for _, ch := range channelSet { - channels = append(channels, ch) - } - m.BoundChannels = channels - } - // 序列化分组 if len(groupSet) > 0 { groups := make([]string, 0, len(groupSet)) @@ -272,6 +256,23 @@ func fillModelExtra(m *model.Model) { m.QuotaType = -1 } + // 批量查询并序列化渠道 + if len(matchedNames) > 0 { + if channels, err := model.GetBoundChannelsForModels(matchedNames); err == nil { + for _, ch := range channels { + key := ch.Name + "_" + strconv.Itoa(ch.Type) + channelSet[key] = ch + } + } + if len(channelSet) > 0 { + chs := make([]model.BoundChannel, 0, len(channelSet)) + for _, ch := range channelSet { + chs = append(chs, ch) + } + m.BoundChannels = chs + } + } + // 设置匹配信息 m.MatchedModels = matchedNames m.MatchedCount = len(matchedNames) diff --git a/model/model_meta.go b/model/model_meta.go index d7bc78c6..552c3c27 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -139,6 +139,21 @@ func GetBoundChannels(modelName string) ([]BoundChannel, error) { return channels, err } +// GetBoundChannelsForModels 批量查询多模型的绑定渠道并去重返回 +func GetBoundChannelsForModels(modelNames []string) ([]BoundChannel, error) { + if len(modelNames) == 0 { + return make([]BoundChannel, 0), nil + } + var channels []BoundChannel + err := DB.Table("channels"). + Select("channels.name, channels.type"). + Joins("join abilities on abilities.channel_id = channels.id"). + Where("abilities.model IN ? AND abilities.enabled = ?", modelNames, true). + Group("channels.id"). + Scan(&channels).Error + return channels, err +} + // FindModelByNameWithRule 根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含 func FindModelByNameWithRule(name string) (*Model, error) { // 1. 精确匹配 From dd9d2a150d5b1d149d5d17a8608c5ffefc4ab9cf Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Sun, 10 Aug 2025 23:17:04 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=A4=93=20chore:=20format=20code=20fil?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- model/model_meta.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/model/model_meta.go b/model/model_meta.go index 552c3c27..205c8975 100644 --- a/model/model_meta.go +++ b/model/model_meta.go @@ -141,17 +141,17 @@ func GetBoundChannels(modelName string) ([]BoundChannel, error) { // GetBoundChannelsForModels 批量查询多模型的绑定渠道并去重返回 func GetBoundChannelsForModels(modelNames []string) ([]BoundChannel, error) { - if len(modelNames) == 0 { - return make([]BoundChannel, 0), nil - } - var channels []BoundChannel - err := DB.Table("channels"). - Select("channels.name, channels.type"). - Joins("join abilities on abilities.channel_id = channels.id"). - Where("abilities.model IN ? AND abilities.enabled = ?", modelNames, true). - Group("channels.id"). - Scan(&channels).Error - return channels, err + if len(modelNames) == 0 { + return make([]BoundChannel, 0), nil + } + var channels []BoundChannel + err := DB.Table("channels"). + Select("channels.name, channels.type"). + Joins("join abilities on abilities.channel_id = channels.id"). + Where("abilities.model IN ? AND abilities.enabled = ?", modelNames, true). + Group("channels.id"). + Scan(&channels).Error + return channels, err } // FindModelByNameWithRule 根据模型名称和匹配规则查找模型元数据,优先级:精确 > 前缀 > 后缀 > 包含 From e64b13c925b4a9c05849ab54f4794a39774294eb Mon Sep 17 00:00:00 2001 From: t0ng7u Date: Mon, 11 Aug 2025 01:25:13 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=94=A7=20chore(db):=20drop=20legacy?= =?UTF-8?q?=20single-column=20UNIQUE=20indexes=20to=20prevent=20duplicate-?= =?UTF-8?q?key=20errors=20after=20soft-delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why Previous versions created single-column UNIQUE constraints (`models.model_name`, `vendors.name`). After introducing composite indexes on `(model_name, deleted_at)` and `(name, deleted_at)` for soft-delete support, those legacy constraints could still exist in user databases. When a record was soft-deleted and re-inserted with the same name, MySQL raised `Error 1062 … for key 'models.model_name'`. What • In `migrateDB` and `migrateDBFast` paths of `model/main.go`, proactively drop: – `models.uk_model_name` and fallback `models.model_name` – `vendors.uk_vendor_name` and fallback `vendors.name` • Keeps existing helper `dropIndexIfExists` to ensure the operation is MySQL-only and error-free when indexes are already absent. Result Startup migration now removes every possible legacy UNIQUE index, ensuring composite index strategy works correctly. Users can soft-delete and recreate models/vendors with identical names without hitting duplicate-entry errors. --- model/main.go | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/model/main.go b/model/main.go index 49ccc56f..3c4931e2 100644 --- a/model/main.go +++ b/model/main.go @@ -66,18 +66,18 @@ var LOG_DB *gorm.DB // dropIndexIfExists drops a MySQL index only if it exists to avoid noisy 1091 errors func dropIndexIfExists(tableName string, indexName string) { - if !common.UsingMySQL { - return - } - var count int64 - // Check index existence via information_schema - err := DB.Raw( - "SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?", - tableName, indexName, - ).Scan(&count).Error - if err == nil && count > 0 { - _ = DB.Exec("ALTER TABLE " + tableName + " DROP INDEX " + indexName + ";").Error - } + if !common.UsingMySQL { + return + } + var count int64 + // Check index existence via information_schema + err := DB.Raw( + "SELECT COUNT(1) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?", + tableName, indexName, + ).Scan(&count).Error + if err == nil && count > 0 { + _ = DB.Exec("ALTER TABLE " + tableName + " DROP INDEX " + indexName + ";").Error + } } func createRootAccountIfNeed() error { @@ -252,8 +252,12 @@ func InitLogDB() (err error) { func migrateDB() error { // 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录 - dropIndexIfExists("models", "uk_model_name") - dropIndexIfExists("vendors", "uk_vendor_name") + // 删除单列唯一索引(列级 UNIQUE)及早期命名方式,防止与新复合唯一索引 (model_name, deleted_at) 冲突 + dropIndexIfExists("models", "uk_model_name") // 新版复合索引名称(若已存在) + dropIndexIfExists("models", "model_name") // 旧版列级唯一索引名称 + + dropIndexIfExists("vendors", "uk_vendor_name") // 新版复合索引名称(若已存在) + dropIndexIfExists("vendors", "name") // 旧版列级唯一索引名称 if !common.UsingPostgreSQL { return migrateDBFast() } @@ -284,8 +288,12 @@ func migrateDB() error { func migrateDBFast() error { // 修复旧版本留下的唯一索引,允许软删除后重新插入同名记录 + // 删除单列唯一索引(列级 UNIQUE)及早期命名方式,防止与新复合唯一索引冲突 dropIndexIfExists("models", "uk_model_name") + dropIndexIfExists("models", "model_name") + dropIndexIfExists("vendors", "uk_vendor_name") + dropIndexIfExists("vendors", "name") var wg sync.WaitGroup @@ -305,7 +313,7 @@ func migrateDBFast() error { {&QuotaData{}, "QuotaData"}, {&Task{}, "Task"}, {&Model{}, "Model"}, - {&Vendor{}, "Vendor"}, + {&Vendor{}, "Vendor"}, {&PrefillGroup{}, "PrefillGroup"}, {&Setup{}, "Setup"}, {&TwoFA{}, "TwoFA"},