mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat: 缓存计费细分、能力匹配优化、用户模型调用计数
1. 缓存创建 tokens 区分 5min/1h TTL,支持按缓存时长差异化计费 - Usage 表新增 cache_creation_input_tokens_5m/1h 字段 - Claude handler 解析新格式 (ephemeral_5m/1h, claude_cache_creation_5/1h) - 计费规则支持 cache_ttl_pricing 覆盖 cache_creation 价格 2. 能力匹配机制优化 - COMPATIBLE 能力不再硬过滤,改为排序阶段通过 capability_miss_count 优先级处理 - cache_1h 改为 COMPATIBLE + REQUEST_PARAM(自动检测请求体中的 ttl=1h) - gemini_files 改为 EXCLUSIVE + REQUEST_PARAM(自动检测 fileData.fileUri) - 移除前端模型偏好/能力配置 UI(不再需要用户手动配置) 3. 新增用户-模型维度调用次数计数器 (UserModelUsageCount) - 原子递增,避免从 Usage 表聚合查询 - 前端模型目录和用户可用模型列表展示调用次数 4. 其他改进 - global_model_id 改为必填(NOT NULL),清理孤立模型 - 模型映射对话框支持从上游获取模型列表并分组折叠 - 端点测试不再依赖端点启用状态 - 异步任务页面对普通用户隐藏用户信息列 - Dashboard 响应式布局断点调整 (sm -> lg) - 号池管理仅展示已启用号池的提供商
This commit is contained in:
@@ -179,7 +179,6 @@ const searchQuery = ref('')
|
||||
const existingGlobalModelIds = computed(() => {
|
||||
return new Set(
|
||||
existingModels.value
|
||||
.filter(m => m.global_model_id)
|
||||
.map(m => m.global_model_id)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -63,6 +63,33 @@
|
||||
>
|
||||
已选 {{ selectedNames.length }} 个
|
||||
</span>
|
||||
<!-- 刷新上游模型按钮 -->
|
||||
<button
|
||||
v-if="upstreamModelsLoaded"
|
||||
type="button"
|
||||
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
:disabled="fetchingUpstreamModels"
|
||||
title="刷新上游模型"
|
||||
@click="fetchUpstreamModels()"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!fetchingUpstreamModels"
|
||||
type="button"
|
||||
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="从提供商获取模型"
|
||||
@click="fetchUpstreamModels()"
|
||||
>
|
||||
<Zap class="w-4 h-4" />
|
||||
</button>
|
||||
<Loader2
|
||||
v-else
|
||||
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
@@ -97,14 +124,22 @@
|
||||
<!-- 自定义映射名称 -->
|
||||
<div v-if="customNames.length > 0">
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20"
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
@click="toggleGroupCollapse('custom')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has('custom') ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">自定义模型</span>
|
||||
<span class="text-xs text-muted-foreground">({{ customNames.length }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1 p-2">
|
||||
<div
|
||||
v-show="!collapsedGroups.has('custom')"
|
||||
class="space-y-1 p-2"
|
||||
>
|
||||
<div
|
||||
v-for="name in sortedCustomNames"
|
||||
:key="name"
|
||||
@@ -125,6 +160,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上游模型 -->
|
||||
<template v-if="filteredUpstreamModels.length > 0">
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
@click="toggleGroupCollapse('upstream')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has('upstream') ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">上游模型</span>
|
||||
<span class="text-xs text-muted-foreground">({{ upstreamModelNames.length }})</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-primary hover:underline"
|
||||
@click.stop="toggleAllUpstreamModels"
|
||||
>
|
||||
{{ isAllUpstreamModelsSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="!collapsedGroups.has('upstream')"
|
||||
class="space-y-1 p-2"
|
||||
>
|
||||
<div
|
||||
v-for="name in filteredUpstreamModels"
|
||||
:key="name"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
||||
@click="toggleName(name)"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
||||
:class="selectedNames.includes(name) ? 'bg-primary border-primary' : ''"
|
||||
>
|
||||
<Check
|
||||
v-if="selectedNames.includes(name)"
|
||||
class="w-3 h-3 text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-mono truncate flex-1">{{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="showEmptyState"
|
||||
@@ -167,7 +248,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Tag, Loader2, Plus, Search, Check } from 'lucide-vue-next'
|
||||
import { Tag, Loader2, Plus, Search, Check, ChevronDown, RefreshCw, Zap } from 'lucide-vue-next'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -184,8 +265,10 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
type Model,
|
||||
type ProviderModelAlias,
|
||||
type UpstreamModel,
|
||||
} from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
|
||||
|
||||
export interface AliasGroup {
|
||||
model: Model
|
||||
@@ -204,6 +287,7 @@ const props = defineProps<{
|
||||
models: Model[]
|
||||
editingGroup?: AliasGroup | null
|
||||
preselectedModelId?: string | null
|
||||
hasAutoFetchKey?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -212,14 +296,23 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
// 状态
|
||||
const submitting = ref(false)
|
||||
const loadingModels = ref(false)
|
||||
const fetchingUpstreamModels = ref(false)
|
||||
const upstreamModelsLoaded = ref(false)
|
||||
|
||||
// 搜索
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 折叠状态
|
||||
const collapsedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 上游模型
|
||||
const upstreamModels = ref<UpstreamModel[]>([])
|
||||
|
||||
// 表单数据
|
||||
const formData = ref<{
|
||||
modelId: string
|
||||
@@ -233,9 +326,26 @@ const selectedNames = ref<string[]>([])
|
||||
// 自定义名称列表(手动添加的)
|
||||
const allCustomNames = ref<string[]>([])
|
||||
|
||||
// 自定义名称列表
|
||||
// 所有已知名称集合
|
||||
const allKnownNames = computed(() => {
|
||||
const set = new Set<string>()
|
||||
upstreamModels.value.forEach(m => set.add(m.id))
|
||||
return set
|
||||
})
|
||||
|
||||
// 上游模型名称列表(去重后)
|
||||
const upstreamModelNames = computed(() => {
|
||||
const names = new Set<string>()
|
||||
upstreamModels.value.forEach(m => {
|
||||
names.add(m.id)
|
||||
})
|
||||
return Array.from(names).sort()
|
||||
})
|
||||
|
||||
// 自定义名称列表(排除上游模型中已有的)
|
||||
const customNames = computed(() => {
|
||||
return allCustomNames.value
|
||||
const upstreamSet = new Set(upstreamModelNames.value)
|
||||
return allCustomNames.value.filter(name => !upstreamSet.has(name))
|
||||
})
|
||||
|
||||
// 排序后的自定义名称
|
||||
@@ -261,12 +371,26 @@ const canAddAsCustom = computed(() => {
|
||||
if (!search) return false
|
||||
if (selectedNames.value.includes(search)) return false
|
||||
if (allCustomNames.value.includes(search)) return false
|
||||
if (allKnownNames.value.has(search)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// 过滤后的上游模型
|
||||
const filteredUpstreamModels = computed(() => {
|
||||
if (!searchQuery.value.trim()) return upstreamModelNames.value
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return upstreamModelNames.value.filter(name => name.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
// 空状态判断
|
||||
const showEmptyState = computed(() => {
|
||||
return customNames.value.length === 0
|
||||
return filteredUpstreamModels.value.length === 0 && customNames.value.length === 0
|
||||
})
|
||||
|
||||
// 上游模型是否全选
|
||||
const isAllUpstreamModelsSelected = computed(() => {
|
||||
if (filteredUpstreamModels.value.length === 0) return false
|
||||
return filteredUpstreamModels.value.every(name => selectedNames.value.includes(name))
|
||||
})
|
||||
|
||||
// 切换名称选中状态
|
||||
@@ -284,17 +408,71 @@ function addCustomName() {
|
||||
const name = searchQuery.value.trim()
|
||||
if (name && !selectedNames.value.includes(name)) {
|
||||
selectedNames.value.push(name)
|
||||
if (!allCustomNames.value.includes(name)) {
|
||||
if (!allKnownNames.value.has(name) && !allCustomNames.value.includes(name)) {
|
||||
allCustomNames.value.push(name)
|
||||
}
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选上游模型
|
||||
function toggleAllUpstreamModels() {
|
||||
const allNames = filteredUpstreamModels.value
|
||||
if (isAllUpstreamModelsSelected.value) {
|
||||
selectedNames.value = selectedNames.value.filter(name => !allNames.includes(name))
|
||||
} else {
|
||||
allNames.forEach(name => {
|
||||
if (!selectedNames.value.includes(name)) {
|
||||
selectedNames.value.push(name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 切换折叠状态
|
||||
function toggleGroupCollapse(group: string) {
|
||||
if (collapsedGroups.value.has(group)) {
|
||||
collapsedGroups.value.delete(group)
|
||||
} else {
|
||||
collapsedGroups.value.add(group)
|
||||
}
|
||||
collapsedGroups.value = new Set(collapsedGroups.value)
|
||||
}
|
||||
|
||||
// 从提供商获取模型(使用缓存)
|
||||
async function fetchUpstreamModels() {
|
||||
if (!props.providerId) return
|
||||
try {
|
||||
loadingModels.value = true
|
||||
fetchingUpstreamModels.value = true
|
||||
const result = await fetchCachedModels(props.providerId)
|
||||
if (result.models.length > 0) {
|
||||
upstreamModels.value = result.models
|
||||
upstreamModelsLoaded.value = true
|
||||
// 获取上游模型后,将不在上游列表中的已选名称添加到自定义列表
|
||||
const upstreamIds = new Set(result.models.map(m => m.id))
|
||||
const customFromSelected = selectedNames.value.filter(name => !upstreamIds.has(name))
|
||||
const mergedCustom = new Set([...allCustomNames.value, ...customFromSelected])
|
||||
allCustomNames.value = Array.from(mergedCustom).filter(name => !upstreamIds.has(name))
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error, '获取上游模型失败')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '获取上游模型列表失败'), '错误')
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
fetchingUpstreamModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听打开状态
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
initForm()
|
||||
if (props.hasAutoFetchKey) {
|
||||
await fetchUpstreamModels()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -315,6 +493,9 @@ function initForm() {
|
||||
allCustomNames.value = []
|
||||
}
|
||||
searchQuery.value = ''
|
||||
upstreamModels.value = []
|
||||
upstreamModelsLoaded.value = false
|
||||
collapsedGroups.value = new Set()
|
||||
}
|
||||
|
||||
// 处理模型选择变更
|
||||
|
||||
@@ -541,7 +541,7 @@ async function loadAvailableGlobalModels() {
|
||||
|
||||
// 获取当前 provider 已添加的模型的 global_model_id 列表
|
||||
const existingGlobalModelIds = new Set(
|
||||
existingModels.map((m: Model) => m.global_model_id).filter(Boolean)
|
||||
existingModels.map((m: Model) => m.global_model_id)
|
||||
)
|
||||
|
||||
// 过滤掉已添加的模型
|
||||
|
||||
@@ -325,6 +325,7 @@
|
||||
:models="models"
|
||||
:editing-group="editingGroup"
|
||||
:preselected-model-id="preselectedModelId"
|
||||
:has-auto-fetch-key="hasAutoFetchKey"
|
||||
@saved="onDialogSaved"
|
||||
/>
|
||||
|
||||
@@ -420,6 +421,11 @@ const aliasMappingPreview = computed(() => props.mappingPreview ?? null)
|
||||
const providerEndpoints = computed(() => props.endpoints ?? [])
|
||||
const providerKeysState = computed(() => props.providerKeys ?? [])
|
||||
|
||||
// 是否有 key 配置了自动获取上游模型
|
||||
const hasAutoFetchKey = computed(() => {
|
||||
return providerKeysState.value.some(k => k.auto_fetch_models)
|
||||
})
|
||||
|
||||
// 展开状态
|
||||
const expandedItems = ref<Set<string>>(new Set())
|
||||
|
||||
@@ -641,18 +647,17 @@ async function onDialogSaved() {
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 获取可用的 API 格式(有活跃端点,去重)
|
||||
// 获取可用的 API 格式(所有端点,去重;测试只关注 Key 是否支持,不依赖端点启用状态)
|
||||
const availableApiFormats = computed(() => {
|
||||
const formats = new Set(
|
||||
providerEndpoints.value
|
||||
.filter(ep => ep.is_active)
|
||||
.map(ep => ep.api_format)
|
||||
)
|
||||
return [...formats]
|
||||
})
|
||||
|
||||
// 获取映射项支持的 API 格式
|
||||
// 逻辑:找到支持该映射格式的所有活跃 Key,获取这些 Key 支持的所有格式,与活跃端点格式取交集
|
||||
// 逻辑:找到支持该映射格式的所有活跃 Key,获取这些 Key 支持的所有格式,与端点格式取交集
|
||||
function getItemAvailableFormats(item: CombinedMapping): string[] {
|
||||
// 精确映射:基于 group.apiFormats 筛选
|
||||
if (item.type === 'exact' && item.group?.apiFormats && item.group.apiFormats.length > 0) {
|
||||
@@ -677,7 +682,7 @@ function getItemAvailableFormats(item: CombinedMapping): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
// 与活跃端点格式取交集
|
||||
// 与端点格式取交集
|
||||
return availableApiFormats.value.filter(fmt => keyFormats.has(fmt))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user