mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(keys): 新增密钥自动获取模型功能
- 添加 auto_fetch_models 字段支持定时从上游 API 获取可用模型 - 添加 locked_models 字段支持锁定模型,刷新时不会被删除 - 新增 ModelFetchScheduler 调度器定期执行模型获取任务 - 前端 KeyFormDialog 添加自动获取模型开关 - 前端 KeyAllowedModelsEditDialog 支持模型锁定操作 - ProviderDetailDrawer 显示密钥同步状态 - 数据库迁移添加新字段
This commit is contained in:
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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('密钥已添加', '成功')
|
||||
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
17
src/main.py
17
src/main.py
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
405
src/services/model/fetch_scheduler.py
Normal file
405
src/services/model/fetch_scheduler.py
Normal 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
|
||||
Reference in New Issue
Block a user