模型定价: 一致性检查与提示(渠道可用模型应全部纳入定价)
- /admin/models 返回每模型 used/needs_pricing/denied + summary(total/unpriced/missing) - missing 检测孤儿绑定(渠道提供但目录缺失) - 模型定价页: 缺失警告、在用未定价提示、已禁止/未定价徽章 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -11,13 +12,17 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminModels GET /api/v1/admin/models — 模型列表(含价格与渠道绑定)。
|
// AdminModels GET /api/v1/admin/models — 模型列表(含价格、渠道绑定、定价/禁止状态)。
|
||||||
func (h *Handler) AdminModels(c *gin.Context) {
|
func (h *Handler) AdminModels(c *gin.Context) {
|
||||||
var ms []store.Model
|
var ms []store.Model
|
||||||
if err := h.a.DB.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
if err := h.a.DB.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
||||||
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
|
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全局模型限制策略
|
||||||
|
allow, deny := h.modelPolicyConfig()
|
||||||
|
|
||||||
out := make([]gin.H, 0, len(ms))
|
out := make([]gin.H, 0, len(ms))
|
||||||
for _, m := range ms {
|
for _, m := range ms {
|
||||||
var bindings []store.ChannelModelBinding
|
var bindings []store.ChannelModelBinding
|
||||||
@@ -29,13 +34,79 @@ func (h *Handler) AdminModels(c *gin.Context) {
|
|||||||
"upstream_model": b.UpstreamModel, "weight": b.Weight,
|
"upstream_model": b.UpstreamModel, "weight": b.Weight,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
used := len(bindings) > 0
|
||||||
|
needsPricing := used && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0
|
||||||
|
denied := containsStr(deny, m.Name) || (len(allow) > 0 && !containsStr(allow, m.Name))
|
||||||
out = append(out, gin.H{
|
out = append(out, gin.H{
|
||||||
"id": m.ID, "name": m.Name, "display_name": m.DisplayName,
|
"id": m.ID, "name": m.Name, "display_name": m.DisplayName,
|
||||||
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
|
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
|
||||||
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
|
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
|
||||||
|
"used": used, "needs_pricing": needsPricing, "denied": denied,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
resp.OK(c, gin.H{"items": out})
|
|
||||||
|
// 渠道提供了但目录中缺失的模型(孤儿绑定,数据不一致时出现)
|
||||||
|
var orphans []struct {
|
||||||
|
ChannelName string
|
||||||
|
ModelID uint64
|
||||||
|
}
|
||||||
|
h.a.DB.Raw(`SELECT c.name as channel_name, b.model_id
|
||||||
|
FROM channel_model_bindings b
|
||||||
|
LEFT JOIN models m ON m.id = b.model_id
|
||||||
|
LEFT JOIN channels c ON c.id = b.channel_id
|
||||||
|
WHERE m.id IS NULL`).Scan(&orphans)
|
||||||
|
missing := make([]gin.H, 0, len(orphans))
|
||||||
|
for _, o := range orphans {
|
||||||
|
missing = append(missing, gin.H{"channel": o.ChannelName, "model_id": o.ModelID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在用但未定价的模型数(渠道已提供、需定价)
|
||||||
|
unpriced := 0
|
||||||
|
{
|
||||||
|
var usedBindings []struct {
|
||||||
|
ModelID uint64
|
||||||
|
}
|
||||||
|
h.a.DB.Model(&store.ChannelModelBinding{}).Distinct("model_id").Scan(&usedBindings)
|
||||||
|
usedIDs := map[uint64]bool{}
|
||||||
|
for _, u := range usedBindings {
|
||||||
|
usedIDs[u.ModelID] = true
|
||||||
|
}
|
||||||
|
for _, m := range ms {
|
||||||
|
if usedIDs[m.ID] && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0 {
|
||||||
|
unpriced++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.OK(c, gin.H{
|
||||||
|
"items": out,
|
||||||
|
"summary": gin.H{
|
||||||
|
"total": len(ms),
|
||||||
|
"unpriced": unpriced,
|
||||||
|
"missing": missing,
|
||||||
|
"denied_count": len(deny),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelPolicyConfig 读取全局模型允许/禁止列表。
|
||||||
|
func (h *Handler) modelPolicyConfig() (allow, deny []string) {
|
||||||
|
var raw string
|
||||||
|
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
|
||||||
|
_ = json.Unmarshal([]byte(raw), &allow)
|
||||||
|
raw = ""
|
||||||
|
h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "model_denylist").Pluck("value", &raw)
|
||||||
|
_ = json.Unmarshal([]byte(raw), &deny)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStr(list []string, s string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminCreateModel POST /api/v1/admin/models
|
// AdminCreateModel POST /api/v1/admin/models
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ export interface Model {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
sort: number
|
sort: number
|
||||||
channels: ModelBinding[]
|
channels: ModelBinding[]
|
||||||
|
used?: boolean
|
||||||
|
needs_pricing?: boolean
|
||||||
|
denied?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelSummary {
|
||||||
|
total: number
|
||||||
|
unpriced: number
|
||||||
|
missing: { channel: string; model_id: number }[]
|
||||||
|
denied_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageLog {
|
export interface UsageLog {
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import Button from '@/components/ui/Button.vue'
|
|||||||
import Input from '@/components/ui/Input.vue'
|
import Input from '@/components/ui/Input.vue'
|
||||||
import Modal from '@/components/ui/Modal.vue'
|
import Modal from '@/components/ui/Modal.vue'
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
import type { Channel, Model } from '@/types'
|
import type { Channel, Model, ModelSummary } from '@/types'
|
||||||
|
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
const models = ref<Model[]>([])
|
const models = ref<Model[]>([])
|
||||||
const channels = ref<Channel[]>([])
|
const channels = ref<Channel[]>([])
|
||||||
|
const summary = ref<ModelSummary>({ total: 0, unpriced: 0, missing: [], denied_count: 0 })
|
||||||
const editOpen = ref(false)
|
const editOpen = ref(false)
|
||||||
const editing = ref<Model | null>(null)
|
const editing = ref<Model | null>(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -33,6 +34,7 @@ async function load() {
|
|||||||
const [m, c] = await Promise.all([http.get('/admin/models'), http.get('/admin/channels')])
|
const [m, c] = await Promise.all([http.get('/admin/models'), http.get('/admin/channels')])
|
||||||
models.value = m.data.data.items
|
models.value = m.data.data.items
|
||||||
channels.value = c.data.data.items
|
channels.value = c.data.data.items
|
||||||
|
summary.value = m.data.data.summary
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.err(errMsg(e))
|
toast.err(errMsg(e))
|
||||||
}
|
}
|
||||||
@@ -135,12 +137,26 @@ onMounted(load)
|
|||||||
<Button @click="openCreate">添加模型</Button>
|
<Button @click="openCreate">添加模型</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 一致性提示:渠道提供的未禁止模型应全部纳入定价 -->
|
||||||
|
<div v-if="summary.missing.length" class="card border-err/50 p-4">
|
||||||
|
<p class="text-sm font-medium text-err">以下渠道提供了未纳入定价的模型</p>
|
||||||
|
<p v-for="(x, i) in summary.missing" :key="i" class="mt-1 font-mono text-xs text-muted">
|
||||||
|
{{ x.channel }} → 模型 id#{{ x.model_id }}(请在下方添加并定价)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="summary.unpriced > 0" class="text-xs text-muted">
|
||||||
|
有 <span class="mono-num text-warn">{{ summary.unpriced }}</span> 个在用模型未定价,网关将按示例价计费
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs text-muted">已启用渠道提供的全部模型均已纳入定价</p>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div v-for="m in models" :key="m.id" class="card">
|
<div v-for="m in models" :key="m.id" class="card">
|
||||||
<div class="flex items-center justify-between px-4 py-3">
|
<div class="flex items-center justify-between px-4 py-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="font-mono text-sm text-ink">{{ m.name }}</span>
|
<span class="font-mono text-sm text-ink">{{ m.name }}</span>
|
||||||
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
|
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
|
||||||
|
<Badge v-if="m.denied" variant="err">已禁止</Badge>
|
||||||
|
<Badge v-if="m.needs_pricing" variant="warn">未定价</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="mono-num text-xs text-muted">入 {{ m.input_price }}</span>
|
<span class="mono-num text-xs text-muted">入 {{ m.input_price }}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user