Files
gpt-load/internal/control/project_model_collection.go
tbphp 2b23d8a62a feat(ui): 补齐权重、分组启停与日志路由入口 (#572)
* 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: 访问密钥模型页面修复
2026-09-05 21:41:41 +08:00

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
}