feat(keys): 新增密钥自动获取模型功能

- 添加 auto_fetch_models 字段支持定时从上游 API 获取可用模型
- 添加 locked_models 字段支持锁定模型,刷新时不会被删除
- 新增 ModelFetchScheduler 调度器定期执行模型获取任务
- 前端 KeyFormDialog 添加自动获取模型开关
- 前端 KeyAllowedModelsEditDialog 支持模型锁定操作
- ProviderDetailDrawer 显示密钥同步状态
- 数据库迁移添加新字段
This commit is contained in:
fawney19
2026-01-14 13:12:19 +08:00
parent 3b194d3c23
commit a8813cfb89
14 changed files with 923 additions and 184 deletions

View File

@@ -0,0 +1,95 @@
"""add auto_fetch_models and locked_models to provider_api_keys
Revision ID: e4ebe3233b40
Revises: m4n5o6p7q8r9
Create Date: 2026-01-13 17:59:53.119479+00:00
为 provider_api_keys 表添加自动获取模型相关字段:
1. auto_fetch_models: 是否启用自动获取模型
2. last_models_fetch_at: 最后获取时间
3. last_models_fetch_error: 最后获取错误信息
4. locked_models: 被锁定的模型列表(刷新时不会被删除)
注意: downgrade 操作会永久删除 auto_fetch_models 配置和 locked_models 数据
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
def _index_exists(index_name: str) -> bool:
"""Check if an index exists"""
bind = op.get_bind()
inspector = inspect(bind)
indexes = inspector.get_indexes("provider_api_keys")
return any(idx["name"] == index_name for idx in indexes)
# revision identifiers, used by Alembic.
revision = 'e4ebe3233b40'
down_revision = 'm4n5o6p7q8r9'
branch_labels = None
depends_on = None
def _column_exists(table_name: str, column_name: str) -> bool:
"""Check if a column exists in the table"""
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
"""添加自动获取模型相关字段"""
if not _column_exists("provider_api_keys", "auto_fetch_models"):
op.add_column(
"provider_api_keys",
sa.Column("auto_fetch_models", sa.Boolean(), nullable=False, server_default="false"),
)
if not _column_exists("provider_api_keys", "last_models_fetch_at"):
op.add_column(
"provider_api_keys",
sa.Column("last_models_fetch_at", sa.DateTime(timezone=True), nullable=True),
)
if not _column_exists("provider_api_keys", "last_models_fetch_error"):
op.add_column(
"provider_api_keys",
sa.Column("last_models_fetch_error", sa.Text(), nullable=True),
)
if not _column_exists("provider_api_keys", "locked_models"):
op.add_column(
"provider_api_keys",
sa.Column("locked_models", sa.JSON(), nullable=True),
)
# 添加复合索引以优化调度器查询
if not _index_exists("ix_provider_api_keys_auto_fetch_active"):
op.create_index(
"ix_provider_api_keys_auto_fetch_active",
"provider_api_keys",
["auto_fetch_models", "is_active"],
postgresql_where=sa.text("auto_fetch_models = true AND is_active = true"),
)
def downgrade() -> None:
"""移除自动获取模型相关字段"""
# 先删除索引
if _index_exists("ix_provider_api_keys_auto_fetch_active"):
op.drop_index("ix_provider_api_keys_auto_fetch_active", table_name="provider_api_keys")
if _column_exists("provider_api_keys", "locked_models"):
op.drop_column("provider_api_keys", "locked_models")
if _column_exists("provider_api_keys", "last_models_fetch_error"):
op.drop_column("provider_api_keys", "last_models_fetch_error")
if _column_exists("provider_api_keys", "last_models_fetch_at"):
op.drop_column("provider_api_keys", "last_models_fetch_at")
if _column_exists("provider_api_keys", "auto_fetch_models"):
op.drop_column("provider_api_keys", "auto_fetch_models")

View File

@@ -95,6 +95,7 @@ export async function addProviderKey(
allowed_models?: AllowedModels
capabilities?: Record<string, boolean>
note?: string
auto_fetch_models?: boolean // 是否启用自动获取模型
}
): Promise<EndpointAPIKey> {
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/keys`, data)
@@ -118,9 +119,11 @@ export async function updateProviderKey(
cache_ttl_minutes: number
max_probe_interval_minutes: number
allowed_models: AllowedModels
locked_models: string[] // 被锁定的模型列表
capabilities: Record<string, boolean> | null
is_active: boolean
note: string
auto_fetch_models: boolean // 是否启用自动获取模型
}>
): Promise<EndpointAPIKey> {
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)

View File

@@ -160,6 +160,11 @@ export interface EndpointAPIKey {
half_open_successes?: number
half_open_failures?: number
request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口
// 自动获取模型
auto_fetch_models?: boolean // 是否启用自动获取模型
last_models_fetch_at?: string // 最后获取模型时间
last_models_fetch_error?: string // 最后获取模型错误信息
locked_models?: string[] // 被锁定的模型列表
}
// 按格式的健康度数据
@@ -197,6 +202,8 @@ export interface EndpointAPIKeyUpdate {
max_probe_interval_minutes?: number
note?: string
is_active?: boolean
auto_fetch_models?: boolean // 是否启用自动获取模型
locked_models?: string[] // 被锁定的模型列表
}
export interface EndpointHealthDetail {

View File

@@ -2,7 +2,7 @@
<Dialog
:model-value="isOpen"
:title="props.apiKey?.name ? `模型权限 - ${props.apiKey.name}` : '模型权限'"
description="选中的模型将被允许访问,不选择则允许全部"
:description="isAutoFetchMode ? '自动获取模式:只允许已选择的模型,锁定的模型刷新时不会被删除' : '选中的模型将被允许访问,不选择则允许全部'"
:icon="Shield"
size="2xl"
@update:model-value="handleDialogUpdate"
@@ -34,42 +34,25 @@
</div>
<!-- 已选数量徽章 -->
<span
v-if="selectedModels.length === 0"
v-if="selectedModels.length === 0 && !isAutoFetchMode"
class="h-6 px-2 text-xs rounded flex items-center bg-muted text-muted-foreground shrink-0"
>
全部模型
</span>
<span
v-else-if="selectedModels.length === 0 && isAutoFetchMode"
class="h-6 px-2 text-xs rounded flex items-center bg-amber-500/10 text-amber-600 dark:text-amber-400 shrink-0"
>
未选择模型
</span>
<span
v-else
class="h-6 px-2 text-xs rounded flex items-center bg-primary/10 text-primary shrink-0"
>
已选 {{ selectedModels.length }}
</span>
<button
v-if="upstreamModelsLoaded"
type="button"
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
title="刷新上游模型"
:disabled="fetchingUpstreamModels"
@click="fetchUpstreamModels()"
>
<RefreshCw
class="w-4 h-4"
:class="{ 'animate-spin': fetchingUpstreamModels }"
/>
</button>
<Button
v-else-if="!fetchingUpstreamModels"
variant="outline"
size="sm"
class="h-8"
title="从提供<E68F90><E4BE9B><EFBFBD>获取模型"
@click="fetchUpstreamModels()"
>
<Zap class="w-4 h-4" />
</Button>
<Loader2
v-else
v-if="fetchingUpstreamModels"
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
/>
</div>
@@ -85,10 +68,10 @@
</div>
<template v-else>
<!-- 添加自定义模型搜索内容不在列表中时显示固定在顶部 -->
<!-- 添加自定义模型搜索内容不在列表中时显示 -->
<div
v-if="searchQuery && canAddAsCustom"
class="px-3 py-2 border-b bg-background sticky top-0 z-10"
class="px-3 py-2 border-b bg-background sticky top-0 z-30"
>
<div
class="flex items-center justify-between px-3 py-2 rounded-lg border border-dashed hover:border-primary hover:bg-primary/5 cursor-pointer transition-colors"
@@ -102,10 +85,10 @@
</div>
</div>
<!-- 自定义模型手动添加的始终显示全部搜索命中的排前面 -->
<!-- 自定义模型 -->
<div v-if="customModels.length > 0">
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
class="flex items-center justify-between px-3 h-9 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors border-b border-border/30"
@click="toggleGroupCollapse('custom')"
>
<div class="flex items-center gap-2">
@@ -136,15 +119,26 @@
class="w-3 h-3 text-primary-foreground"
/>
</div>
<span class="text-sm font-mono truncate">{{ model }}</span>
<span class="text-sm font-mono truncate flex-1">{{ model }}</span>
<button
v-if="selectedModels.includes(model)"
type="button"
class="p-1 rounded hover:bg-muted-foreground/10 transition-colors shrink-0 text-muted-foreground"
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
@click="toggleLock(model, $event)"
>
<Lock v-if="isLocked(model)" class="w-3.5 h-3.5" />
<LockOpen v-else class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
<!-- 全局模型 -->
<div v-if="filteredGlobalModels.length > 0">
<!-- 提供商模型 -->
<template v-if="filteredGlobalModels.length > 0">
<!-- 标题 sticky top -->
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
class="flex items-center justify-between px-3 h-9 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors border-b border-border/30"
@click="toggleGroupCollapse('global')"
>
<div class="flex items-center gap-2">
@@ -152,7 +146,7 @@
class="w-4 h-4 transition-transform shrink-0"
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
/>
<span class="text-xs font-medium">全局模型</span>
<span class="text-xs font-medium">提供商模型</span>
<span class="text-xs text-muted-foreground">({{ filteredGlobalModels.length }})</span>
</div>
<button
@@ -163,6 +157,7 @@
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
</button>
</div>
<!-- 内容 -->
<div
v-show="!collapsedGroups.has('global')"
class="space-y-1 p-2"
@@ -190,76 +185,91 @@
{{ model.name }}
</p>
</div>
<button
v-if="selectedModels.includes(model.name)"
type="button"
class="p-1 rounded hover:bg-muted-foreground/10 transition-colors shrink-0 text-muted-foreground"
:title="isLocked(model.name) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
@click="toggleLock(model.name, $event)"
>
<Lock v-if="isLocked(model.name)" class="w-3.5 h-3.5" />
<LockOpen v-else class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</template>
<!-- 上游模型 -->
<div
v-for="group in filteredUpstreamGroups"
:key="group.api_format"
>
<!-- 上游模型 -->
<template v-if="filteredUpstreamModels.length > 0">
<!-- 标题 sticky双向粘性top bottom -->
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
@click="toggleGroupCollapse(group.api_format)"
class="flex items-center justify-between px-3 h-9 bg-muted sticky z-20 cursor-pointer hover:bg-muted/80 transition-colors border-b border-border/30"
:style="{
top: filteredGlobalModels.length > 0 ? '36px' : '0px',
bottom: '0px'
}"
@click="toggleGroupCollapse('upstream')"
>
<div class="flex items-center gap-2">
<ChevronDown
class="w-4 h-4 transition-transform shrink-0"
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
:class="collapsedGroups.has('upstream') ? '-rotate-90' : ''"
/>
<span class="text-xs font-medium">
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
</span>
<span class="text-xs text-muted-foreground">({{ group.models.length }})</span>
<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="toggleAllUpstreamGroup(group.api_format)"
@click.stop="toggleAllUpstreamModels"
>
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消全选' : '全选' }}
{{ isAllUpstreamModelsSelected ? '取消全选' : '全选' }}
</button>
</div>
<!-- 内容 -->
<div
v-show="!collapsedGroups.has(group.api_format)"
v-show="!collapsedGroups.has('upstream')"
class="space-y-1 p-2"
>
<div
v-for="model in group.models"
:key="model.id"
v-for="model in filteredUpstreamModels"
:key="model"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
@click="toggleModel(model.id)"
@click="toggleModel(model)"
>
<div
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="selectedModels.includes(model.id) ? 'bg-primary border-primary' : ''"
:class="selectedModels.includes(model) ? 'bg-primary border-primary' : ''"
>
<Check
v-if="selectedModels.includes(model.id)"
v-if="selectedModels.includes(model)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<span class="text-sm font-mono truncate">{{ model.id }}</span>
<span class="text-sm font-mono truncate flex-1">{{ model }}</span>
<button
v-if="selectedModels.includes(model)"
type="button"
class="p-1 rounded hover:bg-muted-foreground/10 transition-colors shrink-0 text-muted-foreground"
:title="isLocked(model) ? '已锁定 - 点击解锁' : '点击锁定(刷新时不会被删除)'"
@click="toggleLock(model, $event)"
>
<Lock v-if="isLocked(model)" class="w-3.5 h-3.5" />
<LockOpen v-else class="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</template>
<!-- 空状态 -->
<div
v-if="filteredGlobalModels.length === 0 && filteredUpstreamGroups.length === 0 && customModels.length === 0"
v-if="showEmptyState"
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Shield class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}
</p>
<p
v-if="!upstreamModelsLoaded"
class="text-xs mt-1"
>
点击闪电按钮从上游获取模型
</p>
</div>
</template>
</div>
@@ -296,25 +306,24 @@ import { ref, computed, watch, onUnmounted } from 'vue'
import {
Shield,
Search,
RefreshCw,
Loader2,
Zap,
Plus,
Check,
ChevronDown
ChevronDown,
Lock,
LockOpen
} from 'lucide-vue-next'
import { Dialog, Button, Input } from '@/components/ui'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { parseApiError, parseUpstreamModelError } from '@/utils/errorParser'
import { parseApiError } from '@/utils/errorParser'
import {
updateProviderKey,
API_FORMAT_LABELS,
type EndpointAPIKey,
type AllowedModels,
} from '@/api/endpoints'
import { getGlobalModels, type GlobalModelResponse } from '@/api/global-models'
import { adminApi } from '@/api/admin'
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
import type { UpstreamModel } from '@/api/endpoints/types'
interface AvailableModel {
@@ -335,6 +344,7 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
const { confirmWarning } = useConfirm()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
const isOpen = computed(() => props.open)
const saving = ref(false)
@@ -350,31 +360,52 @@ const searchQuery = ref('')
// 可用模型列表(全局模型)
const allGlobalModels = ref<AvailableModel[]>([])
// 上游模型列表
// 上游模型列表(从 API 查询获取)
const upstreamModels = ref<UpstreamModel[]>([])
// 已选中的模型
const selectedModels = ref<string[]>([])
const initialSelectedModels = ref<string[]>([])
// 已锁定的模型
const lockedModels = ref<string[]>([])
const initialLockedModels = ref<string[]>([])
// 所有添加过的自定义模型(包括已取消勾选的,保存前不消失)
const allCustomModels = ref<string[]>([])
// 是否为字典模式(按 API 格式区分)
const isDictMode = ref(false)
// 是否为自动获取模式
const isAutoFetchMode = computed(() => props.apiKey?.auto_fetch_models ?? false)
// 空状态判断
const showEmptyState = computed(() => {
return filteredGlobalModels.value.length === 0 &&
filteredUpstreamModels.value.length === 0 &&
customModels.value.length === 0
})
// 折叠状态
const collapsedGroups = ref<Set<string>>(new Set())
// 是否有更改
const hasChanges = computed(() => {
// 检查选中模型是否有变化
if (selectedModels.value.length !== initialSelectedModels.value.length) return true
const sorted1 = [...selectedModels.value].sort()
const sorted2 = [...initialSelectedModels.value].sort()
return sorted1.some((v, i) => v !== sorted2[i])
if (sorted1.some((v, i) => v !== sorted2[i])) return true
// 检查锁定模型是否有变化
if (lockedModels.value.length !== initialLockedModels.value.length) return true
const sortedLocked1 = [...lockedModels.value].sort()
const sortedLocked2 = [...initialLockedModels.value].sort()
return sortedLocked1.some((v, i) => v !== sortedLocked2[i])
})
// 所有已知模型的集合(全局 + 上游)
// 所有已知模型的集合(全局 + 上游模型
const allKnownModels = computed(() => {
const set = new Set<string>()
allGlobalModels.value.forEach(m => set.add(m.name))
@@ -382,6 +413,58 @@ const allKnownModels = computed(() => {
return set
})
// 全局模型名称集合(用于判断模型是否为全局模型)
const globalModelNamesSet = computed(() => {
return new Set(allGlobalModels.value.map(m => m.name))
})
// 判断模型是否为全局模型(提供商模型)
function isGlobalModel(modelId: string): boolean {
return globalModelNamesSet.value.has(modelId)
}
// Key 支持的 API 格式
const keyApiFormats = computed(() => props.apiKey?.api_formats ?? [])
// 上游模型名称列表(去重后)
const upstreamModelNames = computed(() => {
const names = new Set<string>()
upstreamModels.value.forEach(m => {
// 只包含 Key 支持的 API 格式的模型
if (!m.api_format || keyApiFormats.value.includes(m.api_format)) {
names.add(m.id)
}
})
return Array.from(names).sort()
})
// 过滤后的上游模型
const filteredUpstreamModels = computed(() => {
if (!searchQuery.value.trim()) return upstreamModelNames.value
const query = searchQuery.value.toLowerCase()
return upstreamModelNames.value.filter(m => m.toLowerCase().includes(query))
})
// 上游模型是否全选
const isAllUpstreamModelsSelected = computed(() => {
if (filteredUpstreamModels.value.length === 0) return false
return filteredUpstreamModels.value.every(m => selectedModels.value.includes(m))
})
// 全选/取消全选上游模型
function toggleAllUpstreamModels() {
const allIds = filteredUpstreamModels.value
if (isAllUpstreamModelsSelected.value) {
selectedModels.value = selectedModels.value.filter(id => !allIds.includes(id))
} else {
allIds.forEach(id => {
if (!selectedModels.value.includes(id)) {
selectedModels.value.push(id)
}
})
}
}
// 自定义模型列表(显示所有添加过的,不因取消勾选而消失)
const customModels = computed(() => {
return allCustomModels.value
@@ -415,7 +498,7 @@ const canAddAsCustom = computed(() => {
// 精确匹配全局模型就不显示
if (allGlobalModels.value.some(m => m.name === search)) return false
// 精确匹配上游模型就不显示
if (upstreamModels.value.some(m => m.id === search)) return false
if (upstreamModelNames.value.includes(search)) return false
return true
})
@@ -429,59 +512,53 @@ const filteredGlobalModels = computed(() => {
)
})
// 按 API 格式分组的上游模型(过滤后)
const filteredUpstreamGroups = computed(() => {
if (!upstreamModelsLoaded.value) return []
const query = searchQuery.value.toLowerCase().trim()
const groups: Record<string, UpstreamModel[]> = {}
for (const model of upstreamModels.value) {
// 搜索过滤
if (query && !model.id.toLowerCase().includes(query)) continue
const format = model.api_format || 'unknown'
if (!groups[format]) groups[format] = []
groups[format].push(model)
}
const order = Object.keys(API_FORMAT_LABELS)
return Object.entries(groups)
.map(([api_format, models]) => ({ api_format, models }))
.filter(g => g.models.length > 0)
.sort((a, b) => {
const aIndex = order.indexOf(a.api_format)
const bIndex = order.indexOf(b.api_format)
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
if (aIndex === -1) return 1
if (bIndex === -1) return -1
return aIndex - bIndex
})
})
// 全局模型是否全选
const isAllGlobalModelsSelected = computed(() => {
if (filteredGlobalModels.value.length === 0) return false
return filteredGlobalModels.value.every(m => selectedModels.value.includes(m.name))
})
// 检查某个上游组是否全选
function isUpstreamGroupAllSelected(apiFormat: string): boolean {
const group = filteredUpstreamGroups.value.find(g => g.api_format === apiFormat)
if (!group || group.models.length === 0) return false
return group.models.every(m => selectedModels.value.includes(m.id))
}
// 切换模型选中状态
function toggleModel(modelId: string) {
const idx = selectedModels.value.indexOf(modelId)
if (idx === -1) {
selectedModels.value.push(modelId)
// 自动获取模式下,勾选全局模型时自动锁定
// 防止下次刷新时被覆盖(即使全局模型与上游模型同名)
if (isAutoFetchMode.value && isGlobalModel(modelId)) {
if (!lockedModels.value.includes(modelId)) {
lockedModels.value.push(modelId)
}
}
} else {
selectedModels.value.splice(idx, 1)
// 取消选中时也取消锁定
const lockIdx = lockedModels.value.indexOf(modelId)
if (lockIdx !== -1) {
lockedModels.value.splice(lockIdx, 1)
}
}
}
// 切换模型锁定状态
function toggleLock(modelId: string, event: Event) {
event.stopPropagation()
// 只有已选中的模型才能锁定
if (!selectedModels.value.includes(modelId)) return
const idx = lockedModels.value.indexOf(modelId)
if (idx === -1) {
lockedModels.value.push(modelId)
} else {
lockedModels.value.splice(idx, 1)
}
}
// 检查模型是否被锁定
function isLocked(modelId: string): boolean {
return lockedModels.value.includes(modelId)
}
// 添加自定义模型
function addCustomModel() {
const model = searchQuery.value.trim()
@@ -501,30 +578,17 @@ function toggleAllGlobalModels() {
if (isAllGlobalModelsSelected.value) {
// 取消全选
selectedModels.value = selectedModels.value.filter(id => !allNames.includes(id))
// 同时取消锁定
lockedModels.value = lockedModels.value.filter(id => !allNames.includes(id))
} else {
// 全选
allNames.forEach(name => {
if (!selectedModels.value.includes(name)) {
selectedModels.value.push(name)
}
})
}
}
// 全选/取消全选某个上游组
function toggleAllUpstreamGroup(apiFormat: string) {
const group = filteredUpstreamGroups.value.find(g => g.api_format === apiFormat)
if (!group) return
const allIds = group.models.map(m => m.id)
if (isUpstreamGroupAllSelected(apiFormat)) {
// 取消全选
selectedModels.value = selectedModels.value.filter(id => !allIds.includes(id))
} else {
// 全选
allIds.forEach(id => {
if (!selectedModels.value.includes(id)) {
selectedModels.value.push(id)
// 自动获取模式下,勾选全局模型时自动锁定
if (isAutoFetchMode.value && !lockedModels.value.includes(name)) {
lockedModels.value.push(name)
}
}
})
}
@@ -558,29 +622,22 @@ async function loadGlobalModels() {
}
}
// 从提供商获取模型
// 从提供商获取模型(使用缓存)
async function fetchUpstreamModels() {
if (!props.providerId || !props.apiKey) return
try {
fetchingUpstreamModels.value = true
const response = await adminApi.queryProviderModels(props.providerId, props.apiKey.id)
const result = await fetchCachedModels(props.providerId, props.apiKey.id)
if (loadingCancelled) return
if (response.success && response.data?.models) {
upstreamModels.value = response.data.models
if (result.models.length > 0) {
upstreamModels.value = result.models
upstreamModelsLoaded.value = true
// 获取上游模型后,从自定义模型列表中移除已变成已知的模型
const upstreamIds = new Set(response.data.models.map((m: UpstreamModel) => m.id))
const upstreamIds = new Set(result.models.map((m: UpstreamModel) => m.id))
allCustomModels.value = allCustomModels.value.filter(m => !upstreamIds.has(m))
} else {
const errorMsg = response.data?.error
? parseUpstreamModelError(response.data.error)
: '获取上游模型失败'
showError(errorMsg, '获取上游模型失败')
} else if (result.error) {
showError(result.error, '获取上游模型失败')
}
} catch (err: any) {
if (loadingCancelled) return
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
showError(parseUpstreamModelError(rawError), '获取上游模型失败')
} finally {
fetchingUpstreamModels.value = false
}
@@ -613,16 +670,45 @@ watch(() => props.open, async (open) => {
const parsed = parseAllowedModels(props.apiKey.allowed_models ?? null)
selectedModels.value = [...parsed]
initialSelectedModels.value = [...parsed]
// 加载锁定的模型
const locked = props.apiKey.locked_models ?? []
lockedModels.value = [...locked]
initialLockedModels.value = [...locked]
searchQuery.value = ''
upstreamModels.value = []
upstreamModelsLoaded.value = false
allCustomModels.value = []
// 默认全部收缩,自动获取模式下展开上游模型
if (props.apiKey.auto_fetch_models) {
collapsedGroups.value = new Set(['global', 'custom'])
} else {
collapsedGroups.value = new Set(['global', 'upstream', 'custom'])
}
// 加载全局模型
await loadGlobalModels()
// 加载全局模型后,从已选中的模型中提取自定义模型(不在全局模型中的)
const globalModelNames = new Set(allGlobalModels.value.map(m => m.name))
allCustomModels.value = selectedModels.value.filter(m => !globalModelNames.has(m))
// 自动获取上游模型
await fetchUpstreamModels()
// 自动获取模式下,用最新上游模型刷新(保留锁定的模型)
if (props.apiKey.auto_fetch_models) {
// 锁定的模型 + 最新上游模型(去重)
const newSelected = new Set(lockedModels.value)
upstreamModelNames.value.forEach(m => newSelected.add(m))
selectedModels.value = Array.from(newSelected)
initialSelectedModels.value = [...selectedModels.value]
}
// 提取自定义模型(不在全局模型和上游模型中的)
const upstreamModelIdsSet = new Set(upstreamModels.value.map(m => m.id))
// 自定义模型是用户手动添加的、不在已知模型列表中的
allCustomModels.value = selectedModels.value.filter(m =>
!globalModelNamesSet.value.has(m) && !upstreamModelIdsSet.has(m)
)
} else {
loadingCancelled = true
}
@@ -658,7 +744,13 @@ async function handleSave() {
? [...selectedModels.value]
: null
await updateProviderKey(props.apiKey.id, { allowed_models: newAllowed })
// 只保存已选中且被锁定的模型
const newLocked = lockedModels.value.filter(m => selectedModels.value.includes(m))
await updateProviderKey(props.apiKey.id, {
allowed_models: newAllowed,
locked_models: newLocked
})
success('模型权限已更新', '成功')
emit('saved')
emit('close')

View File

@@ -8,7 +8,7 @@
@update:model-value="handleDialogUpdate"
>
<form
class="space-y-4"
class="space-y-3"
autocomplete="off"
@submit.prevent="handleSave"
>
@@ -219,6 +219,23 @@
</button>
</div>
</div>
<!-- 自动获取模型 -->
<div class="flex items-center justify-between py-2 px-3 rounded-md border border-border/60 bg-muted/30">
<div class="space-y-0.5">
<Label class="text-sm font-medium">自动获取模型</Label>
<p class="text-xs text-muted-foreground">
系统将定时从上游获取可用模型, 但无法默认接受提供商模型
</p>
<p
v-if="showAutoFetchWarning"
class="text-xs text-amber-600 dark:text-amber-400"
>
已配置的模型权限将在下次获取时被覆盖
</p>
</div>
<Switch v-model="form.auto_fetch_models" />
</div>
</form>
<template #footer>
@@ -240,7 +257,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { Dialog, Button, Input, Label } from '@/components/ui'
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
import { Key, SquarePen } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useFormDialog } from '@/composables/useFormDialog'
@@ -277,6 +294,21 @@ const { success, error: showError } = useToast()
// 排序后的可用 API 格式列表
const sortedApiFormats = computed(() => sortApiFormats(props.availableApiFormats))
// 显示自动获取模型警告:编辑模式下,原本未启用但现在启用,且已有 allowed_models
const showAutoFetchWarning = computed(() => {
if (!props.editingKey) return false
// 原本已启用,不需要警告
if (props.editingKey.auto_fetch_models) return false
// 现在未启用,不需要警告
if (!form.value.auto_fetch_models) return false
// 检查是否有已配置的模型权限
const allowedModels = props.editingKey.allowed_models
if (!allowedModels) return false
if (Array.isArray(allowedModels) && allowedModels.length === 0) return false
if (typeof allowedModels === 'object' && Object.keys(allowedModels).length === 0) return false
return true
})
const isOpen = computed(() => props.open)
const saving = ref(false)
const formNonce = ref(createFieldNonce())
@@ -303,7 +335,8 @@ const form = ref({
max_probe_interval_minutes: 32,
note: '',
is_active: true,
capabilities: {} as Record<string, boolean>
capabilities: {} as Record<string, boolean>,
auto_fetch_models: false
})
// 加载能力列表
@@ -404,7 +437,8 @@ function resetForm() {
max_probe_interval_minutes: 32,
note: '',
is_active: true,
capabilities: {}
capabilities: {},
auto_fetch_models: false
}
}
@@ -435,7 +469,8 @@ function loadKeyData() {
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
note: props.editingKey.note || '',
is_active: props.editingKey.is_active,
capabilities: { ...(props.editingKey.capabilities || {}) }
capabilities: { ...(props.editingKey.capabilities || {}) },
auto_fetch_models: props.editingKey.auto_fetch_models ?? false
}
}
@@ -514,7 +549,8 @@ async function handleSave() {
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
is_active: form.value.is_active,
capabilities: capabilitiesData
capabilities: capabilitiesData,
auto_fetch_models: form.value.auto_fetch_models
}
if (form.value.api_key.trim()) {
@@ -535,7 +571,8 @@ async function handleSave() {
cache_ttl_minutes: form.value.cache_ttl_minutes,
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note,
capabilities: capabilitiesData || undefined
capabilities: capabilitiesData || undefined,
auto_fetch_models: form.value.auto_fetch_models
})
success('密钥已添加', '成功')
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加

View File

@@ -309,6 +309,17 @@
@keydown="(e) => handlePriorityKeydown(e, key)"
@blur="handlePriorityBlur(key)"
>
<!-- 自动获取模型状态 -->
<template v-if="key.auto_fetch_models">
<span class="text-muted-foreground/40">|</span>
<span
class="cursor-help"
:class="key.last_models_fetch_error ? 'text-amber-600 dark:text-amber-400' : ''"
:title="getAutoFetchStatusTitle(key)"
>
{{ key.last_models_fetch_error ? '同步失败' : '自动同步' }}
</span>
</template>
<!-- RPM 限制信息第二位 -->
<template v-if="key.rpm_limit || key.is_adaptive">
<span class="text-muted-foreground/40">|</span>
@@ -1596,6 +1607,22 @@ function getHealthScoreBarColor(score: number): string {
return 'bg-red-500 dark:bg-red-400'
}
// 获取自动获取模型状态的 title 提示
function getAutoFetchStatusTitle(key: EndpointAPIKey): string {
const parts: string[] = ['自动获取模型已启用']
if (key.last_models_fetch_at) {
const date = new Date(key.last_models_fetch_at)
parts.push(`上次同步: ${date.toLocaleString()}`)
}
if (key.last_models_fetch_error) {
parts.push(`错误: ${key.last_models_fetch_error}`)
}
return parts.join('\n')
}
// 检查指定格式是否熔断
function isFormatCircuitOpen(key: EndpointAPIKey, format: string): boolean {
if (!key.circuit_breaker_by_format) return false

View File

@@ -61,9 +61,11 @@
/>
<!-- 模型信息 -->
<div class="text-left flex-1 min-w-0">
<span class="font-semibold text-sm">
{{ model.global_model_display_name || model.provider_model_name }}
</span>
<div class="flex items-center gap-1.5">
<span class="font-semibold text-sm">
{{ model.global_model_display_name || model.provider_model_name }}
</span>
</div>
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
<span class="font-mono truncate">{{ model.provider_model_name }}</span>
<button

View File

@@ -17,6 +17,7 @@ interface CacheEntry {
type FetchResult = { models: UpstreamModel[]; error?: string }
// 全局缓存(模块级别,所有组件共享)
// 支持两种 key: providerId 或 providerId:apiKeyId
const cache = new Map<string, CacheEntry>()
const CACHE_TTL = 5 * 60 * 1000 // 5分钟
@@ -26,39 +27,50 @@ const pendingRequests = new Map<string, Promise<FetchResult>>()
// 请求状态
const loadingMap = ref<Map<string, boolean>>(new Map())
/**
* 生成缓存 key
*/
function getCacheKey(providerId: string, apiKeyId?: string): string {
return apiKeyId ? `${providerId}:${apiKeyId}` : providerId
}
export function useUpstreamModelsCache() {
/**
* 获取上游模型列表
* @param providerId 提供商ID
* @param apiKeyId 可选的 API Key ID用于获取特定 Key 支持的模型)
* @param forceRefresh 是否强制刷新
* @returns 模型列表或 null如果请求失败
*/
async function fetchModels(
providerId: string,
apiKeyId?: string,
forceRefresh = false
): Promise<FetchResult> {
const cacheKey = getCacheKey(providerId, apiKeyId)
// 检查缓存
if (!forceRefresh) {
const cached = cache.get(providerId)
const cached = cache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return { models: cached.models }
}
}
// 检查是否有进行中的请求(非强制刷新时复用)
if (!forceRefresh && pendingRequests.has(providerId)) {
return pendingRequests.get(providerId)!
if (!forceRefresh && pendingRequests.has(cacheKey)) {
return pendingRequests.get(cacheKey)!
}
// 创建新请求
const requestPromise = (async (): Promise<FetchResult> => {
try {
loadingMap.value.set(providerId, true)
const response = await adminApi.queryProviderModels(providerId)
loadingMap.value.set(cacheKey, true)
const response = await adminApi.queryProviderModels(providerId, apiKeyId)
if (response.success && response.data?.models) {
// 存入缓存
cache.set(providerId, {
cache.set(cacheKey, {
models: response.data.models,
timestamp: Date.now()
})
@@ -73,20 +85,21 @@ export function useUpstreamModelsCache() {
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
return { models: [], error: parseUpstreamModelError(rawError) }
} finally {
loadingMap.value.set(providerId, false)
pendingRequests.delete(providerId)
loadingMap.value.set(cacheKey, false)
pendingRequests.delete(cacheKey)
}
})()
pendingRequests.set(providerId, requestPromise)
pendingRequests.set(cacheKey, requestPromise)
return requestPromise
}
/**
* 获取缓存的模型(不发起请求)
*/
function getCachedModels(providerId: string): UpstreamModel[] | null {
const cached = cache.get(providerId)
function getCachedModels(providerId: string, apiKeyId?: string): UpstreamModel[] | null {
const cacheKey = getCacheKey(providerId, apiKeyId)
const cached = cache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return cached.models
}
@@ -94,17 +107,19 @@ export function useUpstreamModelsCache() {
}
/**
* 清除指定提供商的缓存
* 清除指定提供商/Key的缓存
*/
function clearCache(providerId: string) {
cache.delete(providerId)
function clearCache(providerId: string, apiKeyId?: string) {
const cacheKey = getCacheKey(providerId, apiKeyId)
cache.delete(cacheKey)
}
/**
* 检查是否正在加载
*/
function isLoading(providerId: string): boolean {
return loadingMap.value.get(providerId) || false
function isLoading(providerId: string, apiKeyId?: string): boolean {
const cacheKey = getCacheKey(providerId, apiKeyId)
return loadingMap.value.get(cacheKey) || false
}
return {

View File

@@ -29,6 +29,7 @@ from src.models.endpoint_models import (
router = APIRouter(tags=["Provider Keys"])
pipeline = ApiRequestPipeline()
@router.put("/keys/{key_id}", response_model=EndpointAPIKeyResponse)
async def update_endpoint_key(
key_id: str,
@@ -226,11 +227,16 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
if "allowed_models" in update_data:
am = update_data["allowed_models"]
if am is not None and (
(isinstance(am, list) and len(am) == 0)
or (isinstance(am, dict) and len(am) == 0)
(isinstance(am, list) and len(am) == 0) or (isinstance(am, dict) and len(am) == 0)
):
update_data["allowed_models"] = None
# 统一处理 locked_models空列表 -> None
if "locked_models" in update_data:
lm = update_data["locked_models"]
if isinstance(lm, list) and len(lm) == 0:
update_data["locked_models"] = None
for field, value in update_data.items():
setattr(key, field, value)
key.updated_at = datetime.now(timezone.utc)
@@ -430,9 +436,7 @@ def _build_key_response(
# 计算整体健康度(取所有格式中的最低值)
if health_by_format:
health_scores = [
float(h.get("health_score") or 1.0) for h in health_by_format.values()
]
health_scores = [float(h.get("health_score") or 1.0) for h in health_by_format.values()]
min_health_score = min(health_scores) if health_scores else 1.0
# 取最大的连续失败次数
max_consecutive = max(
@@ -441,9 +445,7 @@ def _build_key_response(
)
# 取最近的失败时间
failure_times = [
h.get("last_failure_at")
for h in health_by_format.values()
if h.get("last_failure_at")
h.get("last_failure_at") for h in health_by_format.values() if h.get("last_failure_at")
]
last_failure = max(failure_times) if failure_times else None
else:
@@ -462,7 +464,11 @@ def _build_key_response(
"avg_response_time_ms": round(avg_response_time_ms, 2),
"is_adaptive": is_adaptive,
"effective_limit": (
(key.learned_rpm_limit if key.learned_rpm_limit is not None else RPMDefaults.INITIAL_LIMIT)
(
key.learned_rpm_limit
if key.learned_rpm_limit is not None
else RPMDefaults.INITIAL_LIMIT
)
if is_adaptive
else key.rpm_limit
),
@@ -545,6 +551,8 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
capabilities=self.key_data.capabilities if self.key_data.capabilities else None,
cache_ttl_minutes=self.key_data.cache_ttl_minutes,
max_probe_interval_minutes=self.key_data.max_probe_interval_minutes,
auto_fetch_models=self.key_data.auto_fetch_models,
locked_models=self.key_data.locked_models if self.key_data.locked_models else None,
request_count=0,
success_count=0,
error_count=0,

View File

@@ -166,10 +166,12 @@ async def lifespan(app: FastAPI):
logger.info("启动月卡额度重置调度器...")
from src.services.system.cleanup_scheduler import get_cleanup_scheduler
from src.services.usage.quota_scheduler import get_quota_scheduler
from src.services.model.fetch_scheduler import get_model_fetch_scheduler
from src.utils.task_coordinator import StartupTaskCoordinator
quota_scheduler = get_quota_scheduler()
cleanup_scheduler = get_cleanup_scheduler()
model_fetch_scheduler = get_model_fetch_scheduler()
task_coordinator = StartupTaskCoordinator(redis_client)
# 启动额度调度器
@@ -189,6 +191,15 @@ async def lifespan(app: FastAPI):
logger.info("检测到其他 worker 已运行清理调度器,本实例跳过")
cleanup_scheduler = None
# 启动模型自动获取调度器
model_fetch_scheduler_active = await task_coordinator.acquire("model_fetch_scheduler")
if model_fetch_scheduler_active:
logger.info("启动模型自动获取调度器...")
await model_fetch_scheduler.start()
else:
logger.info("检测到其他 worker 已运行模型获取调度器,本实例跳过")
model_fetch_scheduler = None
# 启动统一的定时任务调度器
from src.services.system.scheduler import get_scheduler
@@ -220,6 +231,12 @@ async def lifespan(app: FastAPI):
if task_coordinator:
await task_coordinator.release("quota_scheduler")
# 停止模型自动获取调度器
if model_fetch_scheduler:
logger.info("停止模型自动获取调度器...")
await model_fetch_scheduler.stop()
await task_coordinator.release("model_fetch_scheduler")
# 停止统一的定时任务调度器
logger.info("停止定时任务调度器...")
task_scheduler.stop()

View File

@@ -1079,6 +1079,12 @@ class ProviderAPIKey(Base):
is_active = Column(Boolean, default=True, nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=True) # 过期时间
# 自动获取模型配置
auto_fetch_models = Column(Boolean, default=False, nullable=False) # 是否启用自动获取模型
last_models_fetch_at = Column(DateTime(timezone=True), nullable=True) # 最后获取时间
last_models_fetch_error = Column(Text, nullable=True) # 最后获取错误信息
locked_models = Column(JSON, nullable=True) # 被锁定的模型列表(刷新时不会被删除)
# 时间戳
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False

View File

@@ -166,6 +166,16 @@ class EndpointAPIKeyCreate(BaseModel):
# 备注
note: Optional[str] = Field(default=None, max_length=500, description="备注说明(可选)")
# 自动获取模型
auto_fetch_models: bool = Field(
default=False, description="是否启用自动获取模型(启用后系统定时从上游 API 获取可用模型)"
)
# 锁定的模型列表
locked_models: Optional[List[str]] = Field(
default=None, description="被锁定的模型列表(刷新时不会被删除)"
)
@field_validator("api_formats")
@classmethod
def validate_api_formats(cls, v: Optional[List[str]]) -> Optional[List[str]]:
@@ -335,6 +345,12 @@ class EndpointAPIKeyUpdate(BaseModel):
)
is_active: Optional[bool] = Field(default=None, description="是否启用")
note: Optional[str] = Field(default=None, max_length=500, description="备注说明")
auto_fetch_models: Optional[bool] = Field(
default=None, description="是否启用自动获取模型"
)
locked_models: Optional[List[str]] = Field(
default=None, description="被锁定的模型列表(刷新时不会被删除)"
)
@field_validator("api_formats")
@classmethod
@@ -488,6 +504,12 @@ class EndpointAPIKeyResponse(BaseModel):
# 备注
note: Optional[str] = None
# 自动获取模型
auto_fetch_models: bool = Field(default=False, description="是否启用自动获取模型")
last_models_fetch_at: Optional[datetime] = Field(None, description="最后获取模型时间")
last_models_fetch_error: Optional[str] = Field(None, description="最后获取模型错误信息")
locked_models: Optional[List[str]] = Field(None, description="被锁定的模型列表")
# 时间戳
last_used_at: Optional[datetime] = None
created_at: datetime

View File

@@ -5,6 +5,7 @@
"""
from src.services.model.cost import ModelCostService
from src.services.model.fetch_scheduler import ModelFetchScheduler, get_model_fetch_scheduler
from src.services.model.global_model import GlobalModelService
from src.services.model.service import ModelService
@@ -12,4 +13,6 @@ __all__ = [
"ModelService",
"GlobalModelService",
"ModelCostService",
"ModelFetchScheduler",
"get_model_fetch_scheduler",
]

View File

@@ -0,0 +1,405 @@
"""
模型自动获取调度器
定时从上游 API 获取可用模型列表,并更新 ProviderAPIKey 的 allowed_models。
功能:
- 扫描所有启用了 auto_fetch_models 的 ProviderAPIKey
- 调用 Adapter.fetch_models() 获取模型列表
- 更新 Key 的 allowed_models保留 locked_models 中的模型)
- 记录获取结果和错误信息
"""
import asyncio
import os
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy.orm import Session, joinedload
from src.core.crypto import crypto_service
from src.core.logger import logger
from src.database import create_session
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.services.system.scheduler import get_scheduler
# 从环境变量读取间隔,默认 1440 分钟1 天),限制在 60-10080 分钟之间
_interval_env = int(os.getenv("MODEL_FETCH_INTERVAL_MINUTES", "1440"))
MODEL_FETCH_INTERVAL_MINUTES = max(60, min(10080, _interval_env))
# 并发请求限制
MAX_CONCURRENT_REQUESTS = 5
# 单个 Key 处理的超时时间(秒)
KEY_FETCH_TIMEOUT_SECONDS = 120
def _get_adapter_for_format(api_format: str) -> Optional[type]:
"""根据 API 格式获取对应的 Adapter 类"""
# 延迟导入避免循环依赖
from src.api.handlers.base.chat_adapter_base import get_adapter_class
from src.api.handlers.base.cli_adapter_base import get_cli_adapter_class
adapter_class = get_adapter_class(api_format)
if adapter_class:
return adapter_class
cli_adapter_class = get_cli_adapter_class(api_format)
if cli_adapter_class:
return cli_adapter_class
return None
class ModelFetchScheduler:
"""模型自动获取调度器"""
def __init__(self) -> None:
self._running = False
self._lock = asyncio.Lock()
self._startup_task: Optional[asyncio.Task] = None
async def start(self) -> None:
"""启动调度器"""
if self._running:
logger.warning("ModelFetchScheduler already running")
return
self._running = True
logger.info(f"模型自动获取调度器已启动,间隔: {MODEL_FETCH_INTERVAL_MINUTES} 分钟")
scheduler = get_scheduler()
scheduler.add_interval_job(
self._scheduled_fetch_models,
minutes=MODEL_FETCH_INTERVAL_MINUTES,
job_id="model_auto_fetch",
name="自动获取模型",
)
# 启动时延迟执行一次,保存任务引用
self._startup_task = asyncio.create_task(self._run_startup_task())
async def stop(self) -> None:
"""停止调度器"""
self._running = False
# 取消并等待启动任务完成
if self._startup_task and not self._startup_task.done():
self._startup_task.cancel()
try:
await self._startup_task
except asyncio.CancelledError:
pass
logger.info("模型自动获取调度器已停止")
async def _run_startup_task(self) -> None:
"""启动时执行的初始化任务"""
try:
await asyncio.sleep(10) # 等待系统完全启动
if not self._running:
return
logger.info("启动时执行首次模型获取...")
await self._perform_fetch_all_keys()
except asyncio.CancelledError:
logger.debug("启动任务被取消")
raise
except Exception:
logger.exception("启动时模型获取出错")
async def _scheduled_fetch_models(self) -> None:
"""定时任务入口"""
async with self._lock:
await self._perform_fetch_all_keys()
async def _perform_fetch_all_keys(self) -> None:
"""获取所有启用自动获取的 Key 并拉取模型"""
logger.info("开始自动获取模型任务...")
# 统计信息
success_count = 0
error_count = 0
skip_count = 0
with create_session() as db:
# 查询所有启用了 auto_fetch_models 的 Key只获取 ID 列表)
key_ids = [
row[0]
for row in db.query(ProviderAPIKey.id)
.filter(
ProviderAPIKey.auto_fetch_models == True, # noqa: E712
ProviderAPIKey.is_active == True, # noqa: E712
)
.all()
]
if not key_ids:
logger.debug("没有启用自动获取模型的 Key")
return
logger.info(f"找到 {len(key_ids)} 个启用自动获取模型的 Key")
# 逐个处理每个 Key每个 Key 使用独立的数据库会话
for key_id in key_ids:
if not self._running:
logger.info("调度器已停止,中断模型获取任务")
break
try:
# 添加超时保护
result = await asyncio.wait_for(
self._fetch_models_for_key_by_id(key_id),
timeout=KEY_FETCH_TIMEOUT_SECONDS,
)
if result == "success":
success_count += 1
elif result == "skip":
skip_count += 1
else:
error_count += 1
except asyncio.TimeoutError:
logger.error(f"处理 Key {key_id} 超时({KEY_FETCH_TIMEOUT_SECONDS}s")
self._update_key_error(key_id, f"Timeout after {KEY_FETCH_TIMEOUT_SECONDS}s")
error_count += 1
except Exception:
logger.exception(f"处理 Key {key_id} 时出错")
error_count += 1
logger.info(
f"自动获取模型任务完成: 成功={success_count}, 失败={error_count}, 跳过={skip_count}"
)
def _update_key_error(self, key_id: str, error_msg: str) -> None:
"""更新 Key 的错误信息(独立事务)"""
try:
with create_session() as db:
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
if key:
key.last_models_fetch_at = datetime.now(timezone.utc)
key.last_models_fetch_error = error_msg
db.commit()
except Exception:
logger.exception(f"更新 Key {key_id} 错误信息失败")
async def _fetch_models_for_key_by_id(self, key_id: str) -> str:
"""根据 Key ID 获取模型并更新,返回结果状态"""
with create_session() as db:
key = (
db.query(ProviderAPIKey)
.options(joinedload(ProviderAPIKey.provider))
.filter(ProviderAPIKey.id == key_id)
.first()
)
if not key:
logger.warning(f"Key {key_id} 不存在,跳过")
return "skip"
if not key.is_active or not key.auto_fetch_models:
logger.debug(f"Key {key_id} 已禁用或关闭自动获取,跳过")
return "skip"
try:
result = await self._fetch_models_for_key(db, key)
db.commit()
return result
except Exception:
db.rollback()
raise
async def _fetch_models_for_key(
self,
db: "Session",
key: ProviderAPIKey,
) -> str:
"""为单个 Key 获取模型并更新 allowed_models返回结果状态"""
now = datetime.now(timezone.utc)
provider_id = key.provider_id
# 获取 Provider 和 Endpoints
provider = (
db.query(Provider)
.options(joinedload(Provider.endpoints))
.filter(Provider.id == provider_id)
.first()
)
if not provider:
logger.warning(f"Provider {provider_id} 不存在,跳过 Key {key.id}")
key.last_models_fetch_error = "Provider not found"
key.last_models_fetch_at = now
return "error"
# 解密 API Key
if not key.api_key:
logger.warning(f"Key {key.id} 没有 API Key跳过")
key.last_models_fetch_error = "No API key configured"
key.last_models_fetch_at = now
return "error"
try:
api_key_value = crypto_service.decrypt(key.api_key)
except Exception:
# 不记录异常详情,避免泄露密钥信息
logger.error(f"解密 Key {key.id} 失败")
key.last_models_fetch_error = "Decrypt error"
key.last_models_fetch_at = now
return "error"
# 构建 api_format -> endpoint 映射
format_to_endpoint: dict[str, ProviderEndpoint] = {}
for endpoint in provider.endpoints: # type: ignore[attr-defined]
if endpoint.is_active:
format_to_endpoint[endpoint.api_format] = endpoint
if not format_to_endpoint:
logger.warning(f"Provider {provider.name} 没有活跃的端点,跳过 Key {key.id}")
key.last_models_fetch_error = "No active endpoints"
key.last_models_fetch_at = now
return "error"
# 收集端点配置
endpoint_configs: list[dict] = []
key_formats = key.api_formats or []
for fmt in key_formats:
endpoint = format_to_endpoint.get(fmt)
if endpoint:
endpoint_configs.append(
{
"api_key": api_key_value,
"base_url": endpoint.base_url,
"api_format": fmt,
"extra_headers": endpoint.headers,
}
)
if not endpoint_configs:
logger.warning(f"Provider {provider.name} 没有匹配 Key {key.id} 格式的端点配置")
key.last_models_fetch_error = "No matching endpoints for key formats"
key.last_models_fetch_at = now
return "error"
# 并发获取模型
all_models, errors, has_success = await self._fetch_models_from_endpoints(endpoint_configs)
# 记录获取结果
error_msg = "; ".join(errors) if errors else None
key.last_models_fetch_at = now
key.last_models_fetch_error = error_msg
# 如果没有任何成功的响应,不更新 allowed_models保留旧数据
if not has_success:
logger.warning(
f"Provider {provider.name} Key {key.id} 所有端点获取失败,保留现有模型列表"
)
if not error_msg:
key.last_models_fetch_error = "All endpoints failed"
return "error"
# 去重获取模型 ID 列表
fetched_model_ids: set[str] = set()
for model in all_models:
model_id = model.get("id")
if model_id:
fetched_model_ids.add(model_id)
logger.info(
f"Provider {provider.name} Key {key.id} 获取到 {len(fetched_model_ids)} 个唯一模型"
)
# 更新 allowed_models保留 locked_models
self._update_key_allowed_models(key, fetched_model_ids)
return "success"
def _update_key_allowed_models(self, key: ProviderAPIKey, fetched_model_ids: set[str]) -> None:
"""更新 Key 的 allowed_models保留 locked_models"""
# 获取当前锁定的模型
locked_models = set(key.locked_models or [])
# 新的 allowed_models = 获取到的模型 + 锁定的模型
# 锁定模型无论上游是否返回都会保留
new_allowed_models = list(fetched_model_ids | locked_models)
new_allowed_models.sort() # 保持顺序稳定
# 检查是否有变化
current_allowed = set(key.allowed_models or [])
new_allowed_set = set(new_allowed_models)
if current_allowed != new_allowed_set:
added = new_allowed_set - current_allowed
removed = current_allowed - new_allowed_set
if added:
logger.info(f"Key {key.id} 新增模型: {sorted(added)}")
if removed:
logger.info(f"Key {key.id} 移除模型: {sorted(removed)}")
key.allowed_models = new_allowed_models
else:
logger.debug(f"Key {key.id} 模型列表无变化")
async def _fetch_models_from_endpoints(
self, endpoint_configs: list[dict]
) -> tuple[list[dict], list[str], bool]:
"""从多个端点并发获取模型,返回 (模型列表, 错误列表, 是否有成功)"""
all_models: list[dict] = []
errors: list[str] = []
has_success = False
semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
async def fetch_one(
client: httpx.AsyncClient, config: dict
) -> tuple[list, Optional[str], bool]:
base_url = config["base_url"]
if not base_url:
return [], None, False
base_url = base_url.rstrip("/")
api_format = config["api_format"]
api_key_value = config["api_key"]
extra_headers = config.get("extra_headers")
try:
adapter_class = _get_adapter_for_format(api_format)
if not adapter_class:
return [], f"Unknown API format: {api_format}", False
async with semaphore:
models, error = await adapter_class.fetch_models( # type: ignore[attr-defined]
client, base_url, api_key_value, extra_headers
)
for m in models:
if "api_format" not in m:
m["api_format"] = api_format
# 即使返回空列表,只要没有错误也算成功
success = error is None
return models, error, success
except httpx.TimeoutException:
logger.warning(f"获取 {api_format} 模型超时")
return [], f"{api_format}: timeout", False
except Exception as e:
# 只记录异常类型,避免泄露敏感信息
logger.exception(f"获取 {api_format} 模型出错")
return [], f"{api_format}: {type(e).__name__}", False
async with httpx.AsyncClient(timeout=30.0) as client:
results = await asyncio.gather(*[fetch_one(client, c) for c in endpoint_configs])
for models, error, success in results:
all_models.extend(models)
if error:
errors.append(error)
if success:
has_success = True
return all_models, errors, has_success
# 单例模式
_model_fetch_scheduler: Optional[ModelFetchScheduler] = None
def get_model_fetch_scheduler() -> ModelFetchScheduler:
"""获取模型获取调度器单例"""
global _model_fetch_scheduler
if _model_fetch_scheduler is None:
_model_fetch_scheduler = ModelFetchScheduler()
return _model_fetch_scheduler