* feat(ui): 补齐权重、分组启停与日志路由入口 订阅账号卡片支持调度权重(#535):徽章行只在手动权重时露出 chip, 点击一次完成展开并进入编辑;权重面板落在展开区代理面板之上,两块上下堆叠。 自动权重等同代理的继承态,不在折叠态制造视觉噪音。 代理模式改用分段控件,三个模式直接可见;表单不再折行,地址栏独自吸收 剩余宽度。分段控件新增 xs 尺寸,与同排输入框、按钮统一 26px, 顺带修掉密钥列表权重编辑器原本 30px 与 26px 不齐的问题。 分组列表支持快速启停(#554):开关并入状态列,与徽章相邻以区分 「我关的」和「凭据不可用」。集合项新增 enabled 字段——status 还叠加了 weight_manual 为 0 的历史数据,拿它当开关会出现点击后状态不变的假故障。 追加凭据改为纯图标,省下的宽度抵掉状态列扩宽,总表宽反而更窄。 模型列表展示归属分组(#555):上游行挂分组 chip,最多两枚、其余折叠为 +N, 数据本就随列表返回,此前未被消费。 请求日志路由拆成访问密钥与路由两列(#555):路由列首行渠道图标加分组名、 次行凭据编号,分组名可点击就地筛选。按各列实际内容重算宽度, 压出的空间正好装下新列,总宽与拆列前持平。 * fix(ui): 修正开关反馈延迟、按钮内边距与列举分隔符 分组开关此前绑查询数据,而 AppSwitch 是纯受控组件,点击后要等请求与 refetch 走完才翻转,看起来是个卡住的开关。改为本地乐观置位,请求结束 (成功交还 refetch 结果、失败回滚原值)后再交还查询数据。 代理指示器的按钮形态补上 padding/font 复位:容器是固定 22×22 的 border-box,UA 默认内边距会把 13px 图标挤变形。 模型分组折叠提示原先硬编码中文顿号,会漏进英文与日文界面,改用 Intl.ListFormat 按当前语言格式化。实测须用 conjunction + narrow: narrow 去掉「和 / and」只留分隔符,而 unit 类型在中日文下根本不插 分隔符,会把名字连成一串。 * fix(monitor): 修正日志骨架屏列数 路由列拆成访问密钥与路由两列后,非访问密钥视图的列头与 grid 都是 9 列, 骨架屏仍按 8 列渲染,加载时与表头错位。两处 SkeletonSurface 一并修正。 * refactor(ui): 撤除分组集合 enabled 字段并精简注释 开关改由 status 派生:weight_manual 为 0 虽然也判定 disabled,但该值经 接口限定在 1~100,只有直接改库才能触发,为此新增响应字段与两个测试 超出了本次 UI 补齐的范围。后端与测试恢复原状,本 PR 回到纯前端改动。 同时精简全部新增注释,只保留解释非显然决策的部分,去掉代码本身已经 说清楚的描述。 * feat(monitor): 日志显示凭据掩码并补齐交互细节 日志响应新增 credential_name:按 ID 从凭据注册表取密文解密后算掩码, 订阅账号给邮箱掩码、API 密钥给密钥掩码。密文常驻内存,整个过程不读 数据库,一页内重复出现的凭据只解密一次;注册表里没有的凭据留空, 前端据此显示「已删除 · #id」,与访问密钥、分组共用同一套回退。 分组开关改走设置页同一条缓存失效链路,切换后进入详情页不再需要刷新。 日志中可点击就地筛选的值扩展到访问密钥、分组、凭据、模型四处,样式 提取为全局 filterable-value,此前两个组件各维护一套雷同规则。 订阅卡片的权重徽标与代理徽标统一改用 AppTooltip 并提示可点击编辑; 模型页的分组 chip 直达该分组的模型 tab;日志详情的凭据后增加复制按钮, 与 request_id 的复制控件同尺寸同图标。 * fix(models): 详情抽屉的分组链接直达模型 tab 与模型列表的分组 chip 保持一致,从模型上下文点进分组不再落到凭据 tab。 * fix: 修复样式 * fix: 访问密钥模型页面修复
795 lines
27 KiB
Go
795 lines
27 KiB
Go
package control
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"gpt-load/internal/catalog"
|
|
"gpt-load/internal/channel"
|
|
app_errors "gpt-load/internal/platform/errors"
|
|
"gpt-load/internal/pricing"
|
|
"gpt-load/internal/protocol"
|
|
"gpt-load/internal/state"
|
|
"gpt-load/internal/storage/models"
|
|
)
|
|
|
|
type ProjectModelGroupDTO struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
ChannelID channel.ID `json:"channel_id"`
|
|
Params json.RawMessage `json:"params"`
|
|
Enabled bool `json:"enabled"`
|
|
ClientProtocols []protocol.Protocol `json:"client_protocols"`
|
|
}
|
|
|
|
type ProjectModelCapabilitiesDTO struct {
|
|
Attachment *bool `json:"attachment"`
|
|
Reasoning *bool `json:"reasoning"`
|
|
ToolCall *bool `json:"tool_call"`
|
|
StructuredOutput *bool `json:"structured_output"`
|
|
Temperature *bool `json:"temperature"`
|
|
}
|
|
|
|
type ProjectModelModalitiesDTO struct {
|
|
Input []string `json:"input"`
|
|
Output []string `json:"output"`
|
|
}
|
|
|
|
type ProjectModelLimitsDTO struct {
|
|
Context *int64 `json:"context"`
|
|
Input *int64 `json:"input"`
|
|
Output *int64 `json:"output"`
|
|
}
|
|
|
|
type ProjectModelCatalogDTO struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Family string `json:"family"`
|
|
Capabilities ProjectModelCapabilitiesDTO `json:"capabilities"`
|
|
Modalities ProjectModelModalitiesDTO `json:"modalities"`
|
|
Limits ProjectModelLimitsDTO `json:"limits"`
|
|
Knowledge string `json:"knowledge"`
|
|
ReleaseDate string `json:"release_date"`
|
|
LastUpdated string `json:"last_updated"`
|
|
OpenWeights *bool `json:"open_weights"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type ProjectModelCatalogReferenceDTO struct {
|
|
Source string `json:"source"`
|
|
ProviderID string `json:"provider_id"`
|
|
ProviderName string `json:"provider_name"`
|
|
Model ProjectModelCatalogDTO `json:"model"`
|
|
}
|
|
|
|
type ProjectUpstreamModelDTO struct {
|
|
ModelID string `json:"model_id"`
|
|
AliasApplied bool `json:"alias_applied"`
|
|
Price ModelPriceDTO `json:"price"`
|
|
RouteGroups []ProjectModelGroupDTO `json:"route_groups"`
|
|
AffectedGroups []ProjectModelGroupDTO `json:"affected_groups"`
|
|
CatalogReference *ProjectModelCatalogReferenceDTO `json:"catalog_reference"`
|
|
}
|
|
|
|
type ProjectModelDTO struct {
|
|
ClientModel string `json:"client_model"`
|
|
Protocols []protocol.Protocol `json:"protocols"`
|
|
UpstreamModels []ProjectUpstreamModelDTO `json:"upstream_models"`
|
|
}
|
|
|
|
type ProjectModelSummaryDTO struct {
|
|
ClientModelCount int `json:"client_model_count"`
|
|
UpstreamModelCount int `json:"upstream_model_count"`
|
|
PriceCount int `json:"price_count"`
|
|
PendingPriceCount int `json:"pending_price_count"`
|
|
UnreferencedPriceCount int `json:"unreferenced_price_count"`
|
|
}
|
|
|
|
type ProjectModelCatalogStatusDTO struct {
|
|
Available bool `json:"available"`
|
|
CheckedAtMS int64 `json:"checked_at_ms"`
|
|
SuccessfulFetchAtMS int64 `json:"successful_fetch_at_ms"`
|
|
ErrorCode string `json:"error_code"`
|
|
}
|
|
|
|
type ProjectModelPaginationDTO struct {
|
|
Page int64 `json:"page"`
|
|
PageSize int64 `json:"page_size"`
|
|
TotalItems int64 `json:"total_items"`
|
|
TotalPages int64 `json:"total_pages"`
|
|
}
|
|
|
|
type ProjectModelListResponse struct {
|
|
Summary ProjectModelSummaryDTO `json:"summary"`
|
|
Catalog ProjectModelCatalogStatusDTO `json:"catalog"`
|
|
Items []ProjectModelDTO `json:"items"`
|
|
Pagination ProjectModelPaginationDTO `json:"pagination"`
|
|
}
|
|
|
|
type UpstreamModelAssociationDTO struct {
|
|
ClientModel string `json:"client_model"`
|
|
AliasApplied bool `json:"alias_applied"`
|
|
Group ProjectModelGroupDTO `json:"group"`
|
|
}
|
|
|
|
// UpstreamModelDetailDTO is the complete, unfiltered relationship view for a
|
|
// price editor. It deliberately does not inherit the Models list's page,
|
|
// search, enabled-group, or pricing-status filters.
|
|
type UpstreamModelDetailDTO struct {
|
|
ModelID string `json:"model_id"`
|
|
Price ModelPriceDTO `json:"price"`
|
|
CatalogReference *ProjectModelCatalogReferenceDTO `json:"catalog_reference"`
|
|
Associations []UpstreamModelAssociationDTO `json:"associations"`
|
|
ClientModelCount int `json:"client_model_count"`
|
|
GroupCount int `json:"group_count"`
|
|
}
|
|
|
|
type projectModelGroupRecord struct {
|
|
row models.Group
|
|
dto ProjectModelGroupDTO
|
|
models []GroupModel
|
|
}
|
|
|
|
type projectModelUpstreamRecord struct {
|
|
modelID string
|
|
aliasApplied bool
|
|
price ModelPriceDTO
|
|
routeGroups []ProjectModelGroupDTO
|
|
affectedGroups []ProjectModelGroupDTO
|
|
catalogReference *ProjectModelCatalogReferenceDTO
|
|
groupSeen map[uint]struct{}
|
|
}
|
|
|
|
type projectModelRecord struct {
|
|
clientModel string
|
|
protocols []protocol.Protocol
|
|
upstreams map[pricing.Identity]*projectModelUpstreamRecord
|
|
}
|
|
|
|
func (s *Service) ListProjectModels(ctx context.Context, query ProjectModelListQuery) (ProjectModelListResponse, error) {
|
|
if err := validateProjectModelListQuery(query); err != nil {
|
|
return ProjectModelListResponse{}, err
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return ProjectModelListResponse{}, err
|
|
}
|
|
|
|
s.writeMu.RLock()
|
|
var catalogSnapshot *catalog.Snapshot
|
|
if s.catalogRuntime != nil {
|
|
catalogSnapshot = s.catalogRuntime.Load()
|
|
}
|
|
var groups []models.Group
|
|
var prices []models.ModelPrice
|
|
err := s.withReadSnapshot(ctx, func(tx *gorm.DB) error {
|
|
if err := tx.Order("id ASC").Find(&groups).Error; err != nil {
|
|
return fmt.Errorf("load groups for model collection: %w", app_errors.ParseDBError(err))
|
|
}
|
|
if err := tx.Order("id ASC").Find(&prices).Error; err != nil {
|
|
return fmt.Errorf("load model prices for model collection: %w", app_errors.ParseDBError(err))
|
|
}
|
|
groups = cloneGroupRows(groups)
|
|
return nil
|
|
})
|
|
protocolOverrides := map[uint][]protocol.Protocol(nil)
|
|
if err == nil && query.AccessKeyID != nil {
|
|
if s.manager == nil {
|
|
err = app_errors.ErrInternalServer
|
|
} else {
|
|
groups, protocolOverrides, err = scopeProjectModelGroups(
|
|
groups,
|
|
s.manager.Current(),
|
|
*query.AccessKeyID,
|
|
s.channelRegistry,
|
|
)
|
|
}
|
|
}
|
|
s.writeMu.RUnlock()
|
|
if parentErr := ctx.Err(); parentErr != nil {
|
|
return ProjectModelListResponse{}, parentErr
|
|
}
|
|
if err != nil {
|
|
return ProjectModelListResponse{}, err
|
|
}
|
|
|
|
references, err := buildPriceReferenceSnapshot(groups)
|
|
if err != nil {
|
|
return ProjectModelListResponse{}, err
|
|
}
|
|
groupRecords, groupDTOs, err := projectModelGroups(
|
|
groups,
|
|
s.channelRegistry,
|
|
protocolOverrides,
|
|
query.AccessKeyID == nil,
|
|
)
|
|
if err != nil {
|
|
return ProjectModelListResponse{}, err
|
|
}
|
|
priceRecords := make(map[pricing.Identity]modelPriceListRecord, len(prices))
|
|
for _, row := range prices {
|
|
identity, err := PriceIdentityForChannelModel(row.ChannelID, row.ModelID)
|
|
if err != nil {
|
|
return ProjectModelListResponse{}, fmt.Errorf("validate persisted price identity: %w", app_errors.ErrInternalServer)
|
|
}
|
|
if query.AccessKeyID != nil {
|
|
if _, referenced := references.references[identity]; !referenced {
|
|
continue
|
|
}
|
|
}
|
|
if _, duplicate := priceRecords[identity]; duplicate {
|
|
return ProjectModelListResponse{}, fmt.Errorf("duplicate persisted price identity: %w", app_errors.ErrInternalServer)
|
|
}
|
|
record, err := projectModelPriceRow(row, references, catalogSnapshot)
|
|
if err != nil {
|
|
return ProjectModelListResponse{}, err
|
|
}
|
|
priceRecords[identity] = record
|
|
}
|
|
|
|
records := make(map[string]*projectModelRecord)
|
|
for _, group := range groupRecords {
|
|
if !projectModelGroupVisible(group.row, query.GroupStatus) {
|
|
continue
|
|
}
|
|
for _, model := range group.models {
|
|
if err := ctx.Err(); err != nil {
|
|
return ProjectModelListResponse{}, err
|
|
}
|
|
identity, err := PriceIdentityForChannelModel(group.row.ChannelID, model.ID)
|
|
if err != nil {
|
|
return ProjectModelListResponse{}, fmt.Errorf("validate group %d model %q: %w", group.row.ID, model.ID, app_errors.ErrInternalServer)
|
|
}
|
|
priceRecord, exists := priceRecords[identity]
|
|
if !exists {
|
|
return ProjectModelListResponse{}, fmt.Errorf("missing model price row for %s: %w", identity.ModelID, app_errors.ErrInternalServer)
|
|
}
|
|
clientModel := model.ID
|
|
if strings.TrimSpace(model.Alias) != "" {
|
|
clientModel = model.Alias
|
|
}
|
|
root := records[clientModel]
|
|
if root == nil {
|
|
root = &projectModelRecord{
|
|
clientModel: clientModel,
|
|
protocols: []protocol.Protocol{},
|
|
upstreams: make(map[pricing.Identity]*projectModelUpstreamRecord),
|
|
}
|
|
records[clientModel] = root
|
|
}
|
|
root.protocols = mergeProjectModelProtocols(root.protocols, group.dto.ClientProtocols)
|
|
upstream := root.upstreams[identity]
|
|
if upstream == nil {
|
|
affectedGroups, err := projectModelAffectedGroups(identity, references, groupDTOs)
|
|
if err != nil {
|
|
return ProjectModelListResponse{}, err
|
|
}
|
|
upstream = &projectModelUpstreamRecord{
|
|
modelID: model.ID, aliasApplied: strings.TrimSpace(model.Alias) != "",
|
|
price: priceRecord.dto, affectedGroups: affectedGroups,
|
|
catalogReference: projectModelCatalogReference(priceRecord.dto, identity, catalogSnapshot),
|
|
groupSeen: make(map[uint]struct{}),
|
|
}
|
|
root.upstreams[identity] = upstream
|
|
}
|
|
if _, duplicate := upstream.groupSeen[group.row.ID]; !duplicate {
|
|
upstream.groupSeen[group.row.ID] = struct{}{}
|
|
upstream.routeGroups = append(upstream.routeGroups, group.dto)
|
|
}
|
|
}
|
|
}
|
|
|
|
summary := projectModelSummary(records, priceRecords)
|
|
result := make([]ProjectModelDTO, 0, len(records))
|
|
for _, record := range records {
|
|
dto := ProjectModelDTO{
|
|
ClientModel: record.clientModel,
|
|
Protocols: append([]protocol.Protocol(nil), record.protocols...),
|
|
UpstreamModels: []ProjectUpstreamModelDTO{},
|
|
}
|
|
for _, upstream := range record.upstreams {
|
|
if !projectModelPricingVisible(upstream.price.PricingStatus, query.PricingStatus) {
|
|
continue
|
|
}
|
|
sortProjectModelGroups(upstream.routeGroups)
|
|
// 分组信息只对管理员可见;访问密钥范围整条不发,而不是发一条
|
|
// 身份被抹空的占位记录——id=0/channel_id="" 这类占位值会违反
|
|
// 前端 projectSafeInteger/projectChannelID 的强校验,把整个
|
|
// 模型页判定为无效响应,比不发这条数据本身还糟。
|
|
routeGroups := []ProjectModelGroupDTO{}
|
|
affectedGroups := []ProjectModelGroupDTO{}
|
|
if query.AccessKeyID == nil {
|
|
routeGroups = append(routeGroups, upstream.routeGroups...)
|
|
affectedGroups = append(affectedGroups, upstream.affectedGroups...)
|
|
}
|
|
upstreamDTO := ProjectUpstreamModelDTO{
|
|
ModelID: upstream.modelID, AliasApplied: upstream.aliasApplied,
|
|
Price: upstream.price, RouteGroups: routeGroups,
|
|
AffectedGroups: affectedGroups,
|
|
CatalogReference: upstream.catalogReference,
|
|
}
|
|
dto.UpstreamModels = append(dto.UpstreamModels, upstreamDTO)
|
|
}
|
|
sort.SliceStable(dto.UpstreamModels, func(left, right int) bool {
|
|
if dto.UpstreamModels[left].ModelID != dto.UpstreamModels[right].ModelID {
|
|
return dto.UpstreamModels[left].ModelID < dto.UpstreamModels[right].ModelID
|
|
}
|
|
return dto.UpstreamModels[left].Price.ChannelID < dto.UpstreamModels[right].Price.ChannelID
|
|
})
|
|
if len(dto.UpstreamModels) == 0 || !projectModelSearchMatch(dto, query.Search) {
|
|
continue
|
|
}
|
|
result = append(result, dto)
|
|
}
|
|
sort.SliceStable(result, func(left, right int) bool { return result[left].ClientModel < result[right].ClientModel })
|
|
totalItems := int64(len(result))
|
|
start := (query.Page - 1) * query.PageSize
|
|
end := start + query.PageSize
|
|
if start > totalItems {
|
|
start = totalItems
|
|
}
|
|
if end > totalItems {
|
|
end = totalItems
|
|
}
|
|
items := append([]ProjectModelDTO{}, result[start:end]...)
|
|
catalogStatus := ProjectModelCatalogStatusDTO{Available: catalogSnapshot != nil}
|
|
if s.catalogSync != nil {
|
|
status := s.catalogSync.readStatus()
|
|
catalogStatus.CheckedAtMS = status.CheckedAtMS
|
|
catalogStatus.SuccessfulFetchAtMS = status.SuccessfulFetchAtMS
|
|
catalogStatus.ErrorCode = status.ErrorCode
|
|
}
|
|
return ProjectModelListResponse{
|
|
Summary: summary,
|
|
Catalog: catalogStatus,
|
|
Items: items,
|
|
Pagination: ProjectModelPaginationDTO{
|
|
Page: query.Page, PageSize: query.PageSize, TotalItems: totalItems,
|
|
TotalPages: projectModelTotalPages(totalItems, query.PageSize),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func scopeProjectModelGroups(
|
|
groups []models.Group,
|
|
snapshot *state.ConfigSnapshot,
|
|
accessKeyID uint,
|
|
registry *channel.Registry,
|
|
) ([]models.Group, map[uint][]protocol.Protocol, error) {
|
|
if snapshot == nil || accessKeyID == 0 {
|
|
return nil, nil, app_errors.ErrUnauthorized
|
|
}
|
|
accessKey, exists := snapshot.AccessKeysByID[accessKeyID]
|
|
if !exists || accessKey.Status != state.AccessKeyStatusActive {
|
|
return nil, nil, app_errors.ErrUnauthorized
|
|
}
|
|
result := make([]models.Group, 0, len(groups))
|
|
protocolsByGroup := make(map[uint][]protocol.Protocol, len(groups))
|
|
for _, group := range groups {
|
|
if !group.Enabled {
|
|
continue
|
|
}
|
|
if len(accessKey.Filters.Groups) > 0 {
|
|
if _, allowed := accessKey.Filters.Groups[group.ID]; !allowed {
|
|
continue
|
|
}
|
|
}
|
|
protocols, err := projectModelGroupProtocols(registry, group)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
filteredProtocols := make([]protocol.Protocol, 0, len(protocols))
|
|
for _, value := range protocols {
|
|
if len(accessKey.Filters.Protocols) > 0 {
|
|
if _, allowed := accessKey.Filters.Protocols[value]; !allowed {
|
|
continue
|
|
}
|
|
}
|
|
filteredProtocols = append(filteredProtocols, value)
|
|
}
|
|
if len(filteredProtocols) == 0 {
|
|
continue
|
|
}
|
|
var groupModels []GroupModel
|
|
if err := decodeGroupDiscoveryJSON(group.Models, &groupModels); err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"decode group %d models for access scope: %w",
|
|
group.ID,
|
|
app_errors.ErrInternalServer,
|
|
)
|
|
}
|
|
filteredModels := make([]GroupModel, 0, len(groupModels))
|
|
for _, model := range groupModels {
|
|
clientModel := strings.TrimSpace(model.Alias)
|
|
if clientModel == "" {
|
|
clientModel = strings.TrimSpace(model.ID)
|
|
}
|
|
if len(accessKey.Filters.Models) > 0 {
|
|
if _, allowed := accessKey.Filters.Models[clientModel]; !allowed {
|
|
continue
|
|
}
|
|
}
|
|
filteredModels = append(filteredModels, model)
|
|
}
|
|
if len(filteredModels) == 0 {
|
|
continue
|
|
}
|
|
modelJSON, err := json.Marshal(filteredModels)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("encode scoped group models: %w", err)
|
|
}
|
|
group.Models = models.JSON(modelJSON)
|
|
protocolsByGroup[group.ID] = append([]protocol.Protocol(nil), filteredProtocols...)
|
|
result = append(result, group)
|
|
}
|
|
return result, protocolsByGroup, nil
|
|
}
|
|
|
|
func (s *Service) GetUpstreamModelDetail(ctx context.Context, priceID uint) (UpstreamModelDetailDTO, error) {
|
|
if priceID == 0 || uint64(priceID) > uint64(maxSafeInteger) {
|
|
return UpstreamModelDetailDTO{}, app_errors.ErrBadRequest
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
s.writeMu.RLock()
|
|
defer s.writeMu.RUnlock()
|
|
var catalogSnapshot *catalog.Snapshot
|
|
if s.catalogRuntime != nil {
|
|
catalogSnapshot = s.catalogRuntime.Load()
|
|
}
|
|
var row models.ModelPrice
|
|
var groups []models.Group
|
|
if err := s.withReadSnapshot(ctx, func(tx *gorm.DB) error {
|
|
if err := tx.First(&row, priceID).Error; err != nil {
|
|
return fmt.Errorf("load model price detail: %w", app_errors.ParseDBError(err))
|
|
}
|
|
if err := tx.Order("id ASC").Find(&groups).Error; err != nil {
|
|
return fmt.Errorf("load groups for model detail: %w", app_errors.ParseDBError(err))
|
|
}
|
|
groups = cloneGroupRows(groups)
|
|
return nil
|
|
}); err != nil {
|
|
return UpstreamModelDetailDTO{}, err
|
|
}
|
|
references, err := buildPriceReferenceSnapshot(groups)
|
|
if err != nil {
|
|
return UpstreamModelDetailDTO{}, err
|
|
}
|
|
price, err := projectModelPriceRow(row, references, catalogSnapshot)
|
|
if err != nil {
|
|
return UpstreamModelDetailDTO{}, err
|
|
}
|
|
groupRecords, _, err := projectModelGroups(groups, s.channelRegistry, nil, true)
|
|
if err != nil {
|
|
return UpstreamModelDetailDTO{}, err
|
|
}
|
|
associations := make([]UpstreamModelAssociationDTO, 0)
|
|
clientModels := make(map[string]struct{})
|
|
groupIDs := make(map[uint]struct{})
|
|
identity, err := PriceIdentityForChannelModel(row.ChannelID, row.ModelID)
|
|
if err != nil {
|
|
return UpstreamModelDetailDTO{}, fmt.Errorf("validate model detail price identity: %w", app_errors.ErrInternalServer)
|
|
}
|
|
for _, group := range groupRecords {
|
|
if group.row.ChannelID != row.ChannelID {
|
|
continue
|
|
}
|
|
for _, model := range group.models {
|
|
if model.ID != row.ModelID {
|
|
continue
|
|
}
|
|
clientModel := model.ID
|
|
aliasApplied := strings.TrimSpace(model.Alias) != ""
|
|
if aliasApplied {
|
|
clientModel = model.Alias
|
|
}
|
|
associations = append(associations, UpstreamModelAssociationDTO{
|
|
ClientModel: clientModel, AliasApplied: aliasApplied, Group: group.dto,
|
|
})
|
|
clientModels[clientModel] = struct{}{}
|
|
groupIDs[group.row.ID] = struct{}{}
|
|
}
|
|
}
|
|
sort.SliceStable(associations, func(left, right int) bool {
|
|
if associations[left].ClientModel != associations[right].ClientModel {
|
|
return associations[left].ClientModel < associations[right].ClientModel
|
|
}
|
|
if associations[left].Group.Name != associations[right].Group.Name {
|
|
return associations[left].Group.Name < associations[right].Group.Name
|
|
}
|
|
return associations[left].Group.ID < associations[right].Group.ID
|
|
})
|
|
return UpstreamModelDetailDTO{
|
|
ModelID: row.ModelID, Price: price.dto,
|
|
CatalogReference: projectModelCatalogReference(price.dto, identity, catalogSnapshot),
|
|
Associations: associations, ClientModelCount: len(clientModels), GroupCount: len(groupIDs),
|
|
}, nil
|
|
}
|
|
|
|
func projectModelGroups(
|
|
groups []models.Group,
|
|
registry *channel.Registry,
|
|
protocolOverrides map[uint][]protocol.Protocol,
|
|
includeParams bool,
|
|
) ([]projectModelGroupRecord, map[uint]ProjectModelGroupDTO, error) {
|
|
records := make([]projectModelGroupRecord, 0, len(groups))
|
|
dtos := make(map[uint]ProjectModelGroupDTO, len(groups))
|
|
for _, group := range groups {
|
|
protocols, overridden := protocolOverrides[group.ID]
|
|
var err error
|
|
if !overridden {
|
|
protocols, err = projectModelGroupProtocols(registry, group)
|
|
}
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(protocols) == 0 {
|
|
return nil, nil, fmt.Errorf("group %d has no client protocols: %w", group.ID, app_errors.ErrInternalServer)
|
|
}
|
|
if registry == nil {
|
|
return nil, nil, app_errors.ErrInternalServer
|
|
}
|
|
params, err := registry.ValidateParams(channel.ID(group.ChannelID), json.RawMessage(group.Params))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("validate group %d channel params: %w", group.ID, app_errors.ErrInternalServer)
|
|
}
|
|
var groupModels []GroupModel
|
|
if err := decodeGroupDiscoveryJSON(group.Models, &groupModels); err != nil {
|
|
return nil, nil, fmt.Errorf("decode group %d models: %w", group.ID, app_errors.ErrInternalServer)
|
|
}
|
|
// AccessKey 模型页只需要可见关系;保留 params 对象形状,但不投影运营侧连接信息。
|
|
projectedParams := json.RawMessage(`{}`)
|
|
if includeParams {
|
|
projectedParams = params.CanonicalJSON()
|
|
}
|
|
dto := ProjectModelGroupDTO{
|
|
ID: group.ID, Name: group.Name, ChannelID: channel.ID(group.ChannelID),
|
|
Params: projectedParams, Enabled: group.Enabled,
|
|
ClientProtocols: append([]protocol.Protocol(nil), protocols...),
|
|
}
|
|
dtos[group.ID] = dto
|
|
records = append(records, projectModelGroupRecord{row: group, dto: dto, models: groupModels})
|
|
}
|
|
return records, dtos, nil
|
|
}
|
|
|
|
func projectModelAffectedGroups(
|
|
identity pricing.Identity,
|
|
references priceReferenceSnapshot,
|
|
groups map[uint]ProjectModelGroupDTO,
|
|
) ([]ProjectModelGroupDTO, error) {
|
|
reference, exists := references.references[identity]
|
|
if !exists || len(reference.groupIDs) == 0 {
|
|
return nil, fmt.Errorf(
|
|
"missing model price references for %s/%s: %w",
|
|
identity.ChannelID,
|
|
identity.ModelID,
|
|
app_errors.ErrInternalServer,
|
|
)
|
|
}
|
|
result := make([]ProjectModelGroupDTO, 0, len(reference.groupIDs))
|
|
for groupID := range reference.groupIDs {
|
|
group, exists := groups[groupID]
|
|
if !exists {
|
|
return nil, fmt.Errorf("missing Group %d for model price reference: %w", groupID, app_errors.ErrInternalServer)
|
|
}
|
|
result = append(result, group)
|
|
}
|
|
sortProjectModelGroups(result)
|
|
return result, nil
|
|
}
|
|
|
|
func projectModelSummary(
|
|
records map[string]*projectModelRecord,
|
|
prices map[pricing.Identity]modelPriceListRecord,
|
|
) ProjectModelSummaryDTO {
|
|
summary := ProjectModelSummaryDTO{ClientModelCount: len(records)}
|
|
seenPrices := make(map[uint]ModelPriceDTO)
|
|
for _, record := range records {
|
|
summary.UpstreamModelCount += len(record.upstreams)
|
|
for _, upstream := range record.upstreams {
|
|
seenPrices[upstream.price.ID] = upstream.price
|
|
}
|
|
}
|
|
summary.PriceCount = len(seenPrices)
|
|
for _, price := range seenPrices {
|
|
if price.PricingStatus == PricingStatusPending {
|
|
summary.PendingPriceCount++
|
|
}
|
|
}
|
|
for _, price := range prices {
|
|
if !price.dto.Referenced {
|
|
summary.UnreferencedPriceCount++
|
|
}
|
|
}
|
|
return summary
|
|
}
|
|
|
|
func projectModelGroupVisible(group models.Group, status ProjectModelGroupStatus) bool {
|
|
return status == ProjectModelGroupStatusAll || group.Enabled
|
|
}
|
|
|
|
func projectModelPricingVisible(status PricingStatus, filter ProjectModelPricingStatus) bool {
|
|
return filter == ProjectModelPricingStatusAll || (filter == ProjectModelPricingStatusPending && status == PricingStatusPending) || (filter == ProjectModelPricingStatusConfigured && status == PricingStatusConfigured)
|
|
}
|
|
|
|
func projectModelGroupProtocols(registry *channel.Registry, group models.Group) ([]protocol.Protocol, error) {
|
|
if registry == nil || group.ChannelID == "" {
|
|
return nil, fmt.Errorf("group %d has no channel: %w", group.ID, app_errors.ErrInternalServer)
|
|
}
|
|
descriptor, ok := registry.Get(channel.ID(group.ChannelID))
|
|
if !ok {
|
|
return nil, fmt.Errorf("group %d has unknown channel: %w", group.ID, app_errors.ErrInternalServer)
|
|
}
|
|
if _, err := registry.ValidateParams(channel.ID(group.ChannelID), json.RawMessage(group.Params)); err != nil {
|
|
return nil, fmt.Errorf("group %d has invalid channel params: %w", group.ID, app_errors.ErrInternalServer)
|
|
}
|
|
result := mergeProjectModelProtocols(nil, descriptor.ClientProtocols)
|
|
if len(result) == 0 {
|
|
return nil, fmt.Errorf("group %d has no valid protocols: %w", group.ID, app_errors.ErrInternalServer)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func mergeProjectModelProtocols(left, right []protocol.Protocol) []protocol.Protocol {
|
|
seen := make(map[protocol.Protocol]struct{}, len(left)+len(right))
|
|
for _, value := range left {
|
|
seen[value] = struct{}{}
|
|
}
|
|
for _, value := range right {
|
|
seen[value] = struct{}{}
|
|
}
|
|
result := make([]protocol.Protocol, 0, len(seen))
|
|
for _, value := range protocol.DataPlaneProtocols() {
|
|
if _, ok := seen[value]; ok {
|
|
result = append(result, value)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func projectModelCatalogReference(
|
|
price ModelPriceDTO,
|
|
identity pricing.Identity,
|
|
snapshot *catalog.Snapshot,
|
|
) *ProjectModelCatalogReferenceDTO {
|
|
if snapshot == nil {
|
|
return nil
|
|
}
|
|
var model catalog.Model
|
|
providerID := ""
|
|
matchSource := ModelPriceMatchSourceProviderPriorityFallback
|
|
if price.MatchedProviderID != nil {
|
|
providerID = *price.MatchedProviderID
|
|
provider, exists := snapshot.Providers[providerID]
|
|
if exists {
|
|
model, exists = provider.Models[identity.ModelID]
|
|
if !exists {
|
|
providerID = ""
|
|
} else if price.MatchSource != nil {
|
|
matchSource = *price.MatchSource
|
|
} else if exactProviderID, known := modelPriceChannelRegistry.CatalogProviderID(channel.ID(identity.ChannelID)); known &&
|
|
exactProviderID != "" && exactProviderID == providerID {
|
|
matchSource = ModelPriceMatchSourceChannelCatalogProvider
|
|
}
|
|
} else {
|
|
providerID = ""
|
|
}
|
|
}
|
|
if providerID == "" {
|
|
var ok bool
|
|
model, providerID, matchSource, ok = resolveCatalogModelForIdentity(snapshot, identity, false)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
}
|
|
provider := snapshot.Providers[providerID]
|
|
providerName := strings.TrimSpace(provider.Name)
|
|
if providerName == "" {
|
|
providerName = providerID
|
|
}
|
|
source := "reference_provider"
|
|
if matchSource == ModelPriceMatchSourceChannelCatalogProvider {
|
|
source = "actual_provider"
|
|
}
|
|
return &ProjectModelCatalogReferenceDTO{
|
|
Source: source, ProviderID: providerID, ProviderName: providerName,
|
|
Model: projectModelCatalogModel(model),
|
|
}
|
|
}
|
|
|
|
func projectModelCatalogModel(model catalog.Model) ProjectModelCatalogDTO {
|
|
metadata := model.Metadata
|
|
name := strings.TrimSpace(model.Name)
|
|
if name == "" {
|
|
name = model.ID
|
|
}
|
|
return ProjectModelCatalogDTO{
|
|
ID: model.ID, Name: name, Description: metadata.Description, Family: metadata.Family,
|
|
Capabilities: ProjectModelCapabilitiesDTO{
|
|
Attachment: cloneProjectBool(metadata.Capabilities.Attachment), Reasoning: cloneProjectBool(metadata.Capabilities.Reasoning),
|
|
ToolCall: cloneProjectBool(metadata.Capabilities.ToolCall), StructuredOutput: cloneProjectBool(metadata.Capabilities.StructuredOutput),
|
|
Temperature: cloneProjectBool(metadata.Capabilities.Temperature),
|
|
},
|
|
Modalities: ProjectModelModalitiesDTO{Input: append([]string{}, metadata.Modalities.Input...), Output: append([]string{}, metadata.Modalities.Output...)},
|
|
Limits: ProjectModelLimitsDTO{
|
|
Context: cloneProjectInt64(metadata.Limits.Context), Input: cloneProjectInt64(metadata.Limits.Input), Output: cloneProjectInt64(metadata.Limits.Output),
|
|
},
|
|
Knowledge: metadata.Knowledge, ReleaseDate: metadata.ReleaseDate, LastUpdated: metadata.LastUpdated,
|
|
OpenWeights: cloneProjectBool(metadata.OpenWeights), Status: metadata.Status,
|
|
}
|
|
}
|
|
|
|
func cloneProjectBool(value *bool) *bool {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
cloned := *value
|
|
return &cloned
|
|
}
|
|
|
|
func cloneProjectInt64(value *int64) *int64 {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
cloned := *value
|
|
return &cloned
|
|
}
|
|
|
|
func sortProjectModelGroups(groups []ProjectModelGroupDTO) {
|
|
sort.SliceStable(groups, func(left, right int) bool {
|
|
if groups[left].Name != groups[right].Name {
|
|
return groups[left].Name < groups[right].Name
|
|
}
|
|
return groups[left].ID < groups[right].ID
|
|
})
|
|
}
|
|
|
|
func projectModelSearchMatch(record ProjectModelDTO, search string) bool {
|
|
search = strings.ToLower(strings.TrimSpace(search))
|
|
if search == "" {
|
|
return true
|
|
}
|
|
if strings.Contains(strings.ToLower(record.ClientModel), search) {
|
|
return true
|
|
}
|
|
for _, upstream := range record.UpstreamModels {
|
|
if strings.Contains(strings.ToLower(upstream.ModelID), search) {
|
|
return true
|
|
}
|
|
if strings.Contains(strings.ToLower(upstream.Price.ChannelID), search) {
|
|
return true
|
|
}
|
|
if reference := upstream.CatalogReference; reference != nil {
|
|
if strings.Contains(strings.ToLower(reference.Model.Name), search) ||
|
|
strings.Contains(strings.ToLower(reference.ProviderID), search) ||
|
|
strings.Contains(strings.ToLower(reference.ProviderName), search) {
|
|
return true
|
|
}
|
|
}
|
|
for _, group := range upstream.RouteGroups {
|
|
if strings.Contains(strings.ToLower(group.Name), search) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func projectModelTotalPages(total, pageSize int64) int64 {
|
|
if total == 0 {
|
|
return 0
|
|
}
|
|
return (total + pageSize - 1) / pageSize
|
|
}
|