mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +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
|
allowed_models?: AllowedModels
|
||||||
capabilities?: Record<string, boolean>
|
capabilities?: Record<string, boolean>
|
||||||
note?: string
|
note?: string
|
||||||
|
auto_fetch_models?: boolean // 是否启用自动获取模型
|
||||||
}
|
}
|
||||||
): Promise<EndpointAPIKey> {
|
): Promise<EndpointAPIKey> {
|
||||||
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/keys`, data)
|
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/keys`, data)
|
||||||
@@ -118,9 +119,11 @@ export async function updateProviderKey(
|
|||||||
cache_ttl_minutes: number
|
cache_ttl_minutes: number
|
||||||
max_probe_interval_minutes: number
|
max_probe_interval_minutes: number
|
||||||
allowed_models: AllowedModels
|
allowed_models: AllowedModels
|
||||||
|
locked_models: string[] // 被锁定的模型列表
|
||||||
capabilities: Record<string, boolean> | null
|
capabilities: Record<string, boolean> | null
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
note: string
|
note: string
|
||||||
|
auto_fetch_models: boolean // 是否启用自动获取模型
|
||||||
}>
|
}>
|
||||||
): Promise<EndpointAPIKey> {
|
): Promise<EndpointAPIKey> {
|
||||||
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
|
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
|
||||||
|
|||||||
@@ -160,6 +160,11 @@ export interface EndpointAPIKey {
|
|||||||
half_open_successes?: number
|
half_open_successes?: number
|
||||||
half_open_failures?: number
|
half_open_failures?: number
|
||||||
request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口
|
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
|
max_probe_interval_minutes?: number
|
||||||
note?: string
|
note?: string
|
||||||
is_active?: boolean
|
is_active?: boolean
|
||||||
|
auto_fetch_models?: boolean // 是否启用自动获取模型
|
||||||
|
locked_models?: string[] // 被锁定的模型列表
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EndpointHealthDetail {
|
export interface EndpointHealthDetail {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
:model-value="isOpen"
|
:model-value="isOpen"
|
||||||
:title="props.apiKey?.name ? `模型权限 - ${props.apiKey.name}` : '模型权限'"
|
:title="props.apiKey?.name ? `模型权限 - ${props.apiKey.name}` : '模型权限'"
|
||||||
description="选中的模型将被允许访问,不选择则允许全部"
|
:description="isAutoFetchMode ? '自动获取模式:只允许已选择的模型,锁定的模型刷新时不会被删除' : '选中的模型将被允许访问,不选择则允许全部'"
|
||||||
:icon="Shield"
|
:icon="Shield"
|
||||||
size="2xl"
|
size="2xl"
|
||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
@@ -34,42 +34,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- 已选数量徽章 -->
|
<!-- 已选数量徽章 -->
|
||||||
<span
|
<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"
|
class="h-6 px-2 text-xs rounded flex items-center bg-muted text-muted-foreground shrink-0"
|
||||||
>
|
>
|
||||||
全部模型
|
全部模型
|
||||||
</span>
|
</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
|
<span
|
||||||
v-else
|
v-else
|
||||||
class="h-6 px-2 text-xs rounded flex items-center bg-primary/10 text-primary shrink-0"
|
class="h-6 px-2 text-xs rounded flex items-center bg-primary/10 text-primary shrink-0"
|
||||||
>
|
>
|
||||||
已选 {{ selectedModels.length }} 个
|
已选 {{ selectedModels.length }} 个
|
||||||
</span>
|
</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
|
<Loader2
|
||||||
v-else
|
v-if="fetchingUpstreamModels"
|
||||||
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
|
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,10 +68,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- 添加自定义模型(搜索内容不在列表中时显示,固定在顶部) -->
|
<!-- 添加自定义模型(搜索内容不在列表中时显示) -->
|
||||||
<div
|
<div
|
||||||
v-if="searchQuery && canAddAsCustom"
|
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
|
<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"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- 自定义模型(手动添加的,始终显示全部,搜索命中的排前面) -->
|
<!-- 自定义模型 -->
|
||||||
<div v-if="customModels.length > 0">
|
<div v-if="customModels.length > 0">
|
||||||
<div
|
<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')"
|
@click="toggleGroupCollapse('custom')"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -136,15 +119,26 @@
|
|||||||
class="w-3 h-3 text-primary-foreground"
|
class="w-3 h-3 text-primary-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 全局模型 -->
|
<!-- 提供商模型 -->
|
||||||
<div v-if="filteredGlobalModels.length > 0">
|
<template v-if="filteredGlobalModels.length > 0">
|
||||||
|
<!-- 标题 sticky top -->
|
||||||
<div
|
<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')"
|
@click="toggleGroupCollapse('global')"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -152,7 +146,7 @@
|
|||||||
class="w-4 h-4 transition-transform shrink-0"
|
class="w-4 h-4 transition-transform shrink-0"
|
||||||
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
|
: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>
|
<span class="text-xs text-muted-foreground">({{ filteredGlobalModels.length }})</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -163,6 +157,7 @@
|
|||||||
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
|
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 内容 -->
|
||||||
<div
|
<div
|
||||||
v-show="!collapsedGroups.has('global')"
|
v-show="!collapsedGroups.has('global')"
|
||||||
class="space-y-1 p-2"
|
class="space-y-1 p-2"
|
||||||
@@ -190,76 +185,91 @@
|
|||||||
{{ model.name }}
|
{{ model.name }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- 上游模型组 -->
|
<!-- 上游模型 -->
|
||||||
<div
|
<template v-if="filteredUpstreamModels.length > 0">
|
||||||
v-for="group in filteredUpstreamGroups"
|
<!-- 标题 sticky(双向粘性:top 和 bottom) -->
|
||||||
:key="group.api_format"
|
|
||||||
>
|
|
||||||
<div
|
<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 z-20 cursor-pointer hover:bg-muted/80 transition-colors border-b border-border/30"
|
||||||
@click="toggleGroupCollapse(group.api_format)"
|
:style="{
|
||||||
|
top: filteredGlobalModels.length > 0 ? '36px' : '0px',
|
||||||
|
bottom: '0px'
|
||||||
|
}"
|
||||||
|
@click="toggleGroupCollapse('upstream')"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class="w-4 h-4 transition-transform shrink-0"
|
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">
|
<span class="text-xs font-medium">上游模型</span>
|
||||||
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
|
<span class="text-xs text-muted-foreground">({{ upstreamModelNames.length }})</span>
|
||||||
</span>
|
|
||||||
<span class="text-xs text-muted-foreground">({{ group.models.length }})</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-xs text-primary hover:underline"
|
class="text-xs text-primary hover:underline"
|
||||||
@click.stop="toggleAllUpstreamGroup(group.api_format)"
|
@click.stop="toggleAllUpstreamModels"
|
||||||
>
|
>
|
||||||
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消全选' : '全选' }}
|
{{ isAllUpstreamModelsSelected ? '取消全选' : '全选' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 内容 -->
|
||||||
<div
|
<div
|
||||||
v-show="!collapsedGroups.has(group.api_format)"
|
v-show="!collapsedGroups.has('upstream')"
|
||||||
class="space-y-1 p-2"
|
class="space-y-1 p-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="model in group.models"
|
v-for="model in filteredUpstreamModels"
|
||||||
:key="model.id"
|
:key="model"
|
||||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
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
|
<div
|
||||||
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
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
|
<Check
|
||||||
v-if="selectedModels.includes(model.id)"
|
v-if="selectedModels.includes(model)"
|
||||||
class="w-3 h-3 text-primary-foreground"
|
class="w-3 h-3 text-primary-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<div
|
<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"
|
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Shield class="w-10 h-10 mb-2 opacity-30" />
|
<Shield class="w-10 h-10 mb-2 opacity-30" />
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}
|
{{ searchQuery ? '无匹配结果' : '暂无可选模型' }}
|
||||||
</p>
|
</p>
|
||||||
<p
|
|
||||||
v-if="!upstreamModelsLoaded"
|
|
||||||
class="text-xs mt-1"
|
|
||||||
>
|
|
||||||
点击闪电按钮从上游获取模型
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,25 +306,24 @@ import { ref, computed, watch, onUnmounted } from 'vue'
|
|||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
Search,
|
Search,
|
||||||
RefreshCw,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Zap,
|
|
||||||
Plus,
|
Plus,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown
|
ChevronDown,
|
||||||
|
Lock,
|
||||||
|
LockOpen
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Dialog, Button, Input } from '@/components/ui'
|
import { Dialog, Button, Input } from '@/components/ui'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { parseApiError, parseUpstreamModelError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import {
|
import {
|
||||||
updateProviderKey,
|
updateProviderKey,
|
||||||
API_FORMAT_LABELS,
|
|
||||||
type EndpointAPIKey,
|
type EndpointAPIKey,
|
||||||
type AllowedModels,
|
type AllowedModels,
|
||||||
} from '@/api/endpoints'
|
} from '@/api/endpoints'
|
||||||
import { getGlobalModels, type GlobalModelResponse } from '@/api/global-models'
|
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'
|
import type { UpstreamModel } from '@/api/endpoints/types'
|
||||||
|
|
||||||
interface AvailableModel {
|
interface AvailableModel {
|
||||||
@@ -335,6 +344,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { success, error: showError } = useToast()
|
const { success, error: showError } = useToast()
|
||||||
const { confirmWarning } = useConfirm()
|
const { confirmWarning } = useConfirm()
|
||||||
|
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||||
|
|
||||||
const isOpen = computed(() => props.open)
|
const isOpen = computed(() => props.open)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -350,31 +360,52 @@ const searchQuery = ref('')
|
|||||||
|
|
||||||
// 可用模型列表(全局模型)
|
// 可用模型列表(全局模型)
|
||||||
const allGlobalModels = ref<AvailableModel[]>([])
|
const allGlobalModels = ref<AvailableModel[]>([])
|
||||||
// 上游模型列表
|
// 上游模型列表(从 API 查询获取)
|
||||||
const upstreamModels = ref<UpstreamModel[]>([])
|
const upstreamModels = ref<UpstreamModel[]>([])
|
||||||
|
|
||||||
// 已选中的模型
|
// 已选中的模型
|
||||||
const selectedModels = ref<string[]>([])
|
const selectedModels = ref<string[]>([])
|
||||||
const initialSelectedModels = ref<string[]>([])
|
const initialSelectedModels = ref<string[]>([])
|
||||||
|
|
||||||
|
// 已锁定的模型
|
||||||
|
const lockedModels = ref<string[]>([])
|
||||||
|
const initialLockedModels = ref<string[]>([])
|
||||||
|
|
||||||
// 所有添加过的自定义模型(包括已取消勾选的,保存前不消失)
|
// 所有添加过的自定义模型(包括已取消勾选的,保存前不消失)
|
||||||
const allCustomModels = ref<string[]>([])
|
const allCustomModels = ref<string[]>([])
|
||||||
|
|
||||||
// 是否为字典模式(按 API 格式区分)
|
// 是否为字典模式(按 API 格式区分)
|
||||||
const isDictMode = ref(false)
|
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 collapsedGroups = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
// 是否有更改
|
// 是否有更改
|
||||||
const hasChanges = computed(() => {
|
const hasChanges = computed(() => {
|
||||||
|
// 检查选中模型是否有变化
|
||||||
if (selectedModels.value.length !== initialSelectedModels.value.length) return true
|
if (selectedModels.value.length !== initialSelectedModels.value.length) return true
|
||||||
const sorted1 = [...selectedModels.value].sort()
|
const sorted1 = [...selectedModels.value].sort()
|
||||||
const sorted2 = [...initialSelectedModels.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 allKnownModels = computed(() => {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
allGlobalModels.value.forEach(m => set.add(m.name))
|
allGlobalModels.value.forEach(m => set.add(m.name))
|
||||||
@@ -382,6 +413,58 @@ const allKnownModels = computed(() => {
|
|||||||
return set
|
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(() => {
|
const customModels = computed(() => {
|
||||||
return allCustomModels.value
|
return allCustomModels.value
|
||||||
@@ -415,7 +498,7 @@ const canAddAsCustom = computed(() => {
|
|||||||
// 精确匹配全局模型就不显示
|
// 精确匹配全局模型就不显示
|
||||||
if (allGlobalModels.value.some(m => m.name === search)) return false
|
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
|
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(() => {
|
const isAllGlobalModelsSelected = computed(() => {
|
||||||
if (filteredGlobalModels.value.length === 0) return false
|
if (filteredGlobalModels.value.length === 0) return false
|
||||||
return filteredGlobalModels.value.every(m => selectedModels.value.includes(m.name))
|
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) {
|
function toggleModel(modelId: string) {
|
||||||
const idx = selectedModels.value.indexOf(modelId)
|
const idx = selectedModels.value.indexOf(modelId)
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
selectedModels.value.push(modelId)
|
selectedModels.value.push(modelId)
|
||||||
|
// 自动获取模式下,勾选全局模型时自动锁定
|
||||||
|
// 防止下次刷新时被覆盖(即使全局模型与上游模型同名)
|
||||||
|
if (isAutoFetchMode.value && isGlobalModel(modelId)) {
|
||||||
|
if (!lockedModels.value.includes(modelId)) {
|
||||||
|
lockedModels.value.push(modelId)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
selectedModels.value.splice(idx, 1)
|
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() {
|
function addCustomModel() {
|
||||||
const model = searchQuery.value.trim()
|
const model = searchQuery.value.trim()
|
||||||
@@ -501,30 +578,17 @@ function toggleAllGlobalModels() {
|
|||||||
if (isAllGlobalModelsSelected.value) {
|
if (isAllGlobalModelsSelected.value) {
|
||||||
// 取消全选
|
// 取消全选
|
||||||
selectedModels.value = selectedModels.value.filter(id => !allNames.includes(id))
|
selectedModels.value = selectedModels.value.filter(id => !allNames.includes(id))
|
||||||
|
// 同时取消锁定
|
||||||
|
lockedModels.value = lockedModels.value.filter(id => !allNames.includes(id))
|
||||||
} else {
|
} else {
|
||||||
// 全选
|
// 全选
|
||||||
allNames.forEach(name => {
|
allNames.forEach(name => {
|
||||||
if (!selectedModels.value.includes(name)) {
|
if (!selectedModels.value.includes(name)) {
|
||||||
selectedModels.value.push(name)
|
selectedModels.value.push(name)
|
||||||
}
|
// 自动获取模式下,勾选全局模型时自动锁定
|
||||||
})
|
if (isAutoFetchMode.value && !lockedModels.value.includes(name)) {
|
||||||
}
|
lockedModels.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)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -558,29 +622,22 @@ async function loadGlobalModels() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从提供商获取模型
|
// 从提供商获取模型(使用缓存)
|
||||||
async function fetchUpstreamModels() {
|
async function fetchUpstreamModels() {
|
||||||
if (!props.providerId || !props.apiKey) return
|
if (!props.providerId || !props.apiKey) return
|
||||||
try {
|
try {
|
||||||
fetchingUpstreamModels.value = true
|
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 (loadingCancelled) return
|
||||||
if (response.success && response.data?.models) {
|
if (result.models.length > 0) {
|
||||||
upstreamModels.value = response.data.models
|
upstreamModels.value = result.models
|
||||||
upstreamModelsLoaded.value = true
|
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))
|
allCustomModels.value = allCustomModels.value.filter(m => !upstreamIds.has(m))
|
||||||
} else {
|
} else if (result.error) {
|
||||||
const errorMsg = response.data?.error
|
showError(result.error, '获取上游模型失败')
|
||||||
? parseUpstreamModelError(response.data.error)
|
|
||||||
: '获取上游模型失败'
|
|
||||||
showError(errorMsg, '获取上游模型失败')
|
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
|
||||||
if (loadingCancelled) return
|
|
||||||
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
|
|
||||||
showError(parseUpstreamModelError(rawError), '获取上游模型失败')
|
|
||||||
} finally {
|
} finally {
|
||||||
fetchingUpstreamModels.value = false
|
fetchingUpstreamModels.value = false
|
||||||
}
|
}
|
||||||
@@ -613,16 +670,45 @@ watch(() => props.open, async (open) => {
|
|||||||
const parsed = parseAllowedModels(props.apiKey.allowed_models ?? null)
|
const parsed = parseAllowedModels(props.apiKey.allowed_models ?? null)
|
||||||
selectedModels.value = [...parsed]
|
selectedModels.value = [...parsed]
|
||||||
initialSelectedModels.value = [...parsed]
|
initialSelectedModels.value = [...parsed]
|
||||||
|
|
||||||
|
// 加载锁定的模型
|
||||||
|
const locked = props.apiKey.locked_models ?? []
|
||||||
|
lockedModels.value = [...locked]
|
||||||
|
initialLockedModels.value = [...locked]
|
||||||
|
|
||||||
searchQuery.value = ''
|
searchQuery.value = ''
|
||||||
upstreamModels.value = []
|
upstreamModels.value = []
|
||||||
upstreamModelsLoaded.value = false
|
upstreamModelsLoaded.value = false
|
||||||
allCustomModels.value = []
|
allCustomModels.value = []
|
||||||
|
|
||||||
|
// 默认全部收缩,自动获取模式下展开上游模型
|
||||||
|
if (props.apiKey.auto_fetch_models) {
|
||||||
|
collapsedGroups.value = new Set(['global', 'custom'])
|
||||||
|
} else {
|
||||||
|
collapsedGroups.value = new Set(['global', 'upstream', 'custom'])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载全局模型
|
||||||
await loadGlobalModels()
|
await loadGlobalModels()
|
||||||
|
|
||||||
// 加载全局模型后,从已选中的模型中提取自定义模型(不在全局模型中的)
|
// 自动获取上游模型
|
||||||
const globalModelNames = new Set(allGlobalModels.value.map(m => m.name))
|
await fetchUpstreamModels()
|
||||||
allCustomModels.value = selectedModels.value.filter(m => !globalModelNames.has(m))
|
|
||||||
|
// 自动获取模式下,用最新上游模型刷新(保留锁定的模型)
|
||||||
|
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 {
|
} else {
|
||||||
loadingCancelled = true
|
loadingCancelled = true
|
||||||
}
|
}
|
||||||
@@ -658,7 +744,13 @@ async function handleSave() {
|
|||||||
? [...selectedModels.value]
|
? [...selectedModels.value]
|
||||||
: null
|
: 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('模型权限已更新', '成功')
|
success('模型权限已更新', '成功')
|
||||||
emit('saved')
|
emit('saved')
|
||||||
emit('close')
|
emit('close')
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
>
|
>
|
||||||
<form
|
<form
|
||||||
class="space-y-4"
|
class="space-y-3"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@submit.prevent="handleSave"
|
@submit.prevent="handleSave"
|
||||||
>
|
>
|
||||||
@@ -219,6 +219,23 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -240,7 +257,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
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 { Key, SquarePen } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useFormDialog } from '@/composables/useFormDialog'
|
import { useFormDialog } from '@/composables/useFormDialog'
|
||||||
@@ -277,6 +294,21 @@ const { success, error: showError } = useToast()
|
|||||||
// 排序后的可用 API 格式列表
|
// 排序后的可用 API 格式列表
|
||||||
const sortedApiFormats = computed(() => sortApiFormats(props.availableApiFormats))
|
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 isOpen = computed(() => props.open)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const formNonce = ref(createFieldNonce())
|
const formNonce = ref(createFieldNonce())
|
||||||
@@ -303,7 +335,8 @@ const form = ref({
|
|||||||
max_probe_interval_minutes: 32,
|
max_probe_interval_minutes: 32,
|
||||||
note: '',
|
note: '',
|
||||||
is_active: true,
|
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,
|
max_probe_interval_minutes: 32,
|
||||||
note: '',
|
note: '',
|
||||||
is_active: true,
|
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,
|
max_probe_interval_minutes: props.editingKey.max_probe_interval_minutes ?? 32,
|
||||||
note: props.editingKey.note || '',
|
note: props.editingKey.note || '',
|
||||||
is_active: props.editingKey.is_active,
|
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,
|
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
||||||
note: form.value.note,
|
note: form.value.note,
|
||||||
is_active: form.value.is_active,
|
is_active: form.value.is_active,
|
||||||
capabilities: capabilitiesData
|
capabilities: capabilitiesData,
|
||||||
|
auto_fetch_models: form.value.auto_fetch_models
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.value.api_key.trim()) {
|
if (form.value.api_key.trim()) {
|
||||||
@@ -535,7 +571,8 @@ async function handleSave() {
|
|||||||
cache_ttl_minutes: form.value.cache_ttl_minutes,
|
cache_ttl_minutes: form.value.cache_ttl_minutes,
|
||||||
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
||||||
note: form.value.note,
|
note: form.value.note,
|
||||||
capabilities: capabilitiesData || undefined
|
capabilities: capabilitiesData || undefined,
|
||||||
|
auto_fetch_models: form.value.auto_fetch_models
|
||||||
})
|
})
|
||||||
success('密钥已添加', '成功')
|
success('密钥已添加', '成功')
|
||||||
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加
|
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加
|
||||||
|
|||||||
@@ -309,6 +309,17 @@
|
|||||||
@keydown="(e) => handlePriorityKeydown(e, key)"
|
@keydown="(e) => handlePriorityKeydown(e, key)"
|
||||||
@blur="handlePriorityBlur(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 限制信息(第二位) -->
|
<!-- RPM 限制信息(第二位) -->
|
||||||
<template v-if="key.rpm_limit || key.is_adaptive">
|
<template v-if="key.rpm_limit || key.is_adaptive">
|
||||||
<span class="text-muted-foreground/40">|</span>
|
<span class="text-muted-foreground/40">|</span>
|
||||||
@@ -1596,6 +1607,22 @@ function getHealthScoreBarColor(score: number): string {
|
|||||||
return 'bg-red-500 dark:bg-red-400'
|
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 {
|
function isFormatCircuitOpen(key: EndpointAPIKey, format: string): boolean {
|
||||||
if (!key.circuit_breaker_by_format) return false
|
if (!key.circuit_breaker_by_format) return false
|
||||||
|
|||||||
@@ -61,9 +61,11 @@
|
|||||||
/>
|
/>
|
||||||
<!-- 模型信息 -->
|
<!-- 模型信息 -->
|
||||||
<div class="text-left flex-1 min-w-0">
|
<div class="text-left flex-1 min-w-0">
|
||||||
<span class="font-semibold text-sm">
|
<div class="flex items-center gap-1.5">
|
||||||
{{ model.global_model_display_name || model.provider_model_name }}
|
<span class="font-semibold text-sm">
|
||||||
</span>
|
{{ 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">
|
<div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">
|
||||||
<span class="font-mono truncate">{{ model.provider_model_name }}</span>
|
<span class="font-mono truncate">{{ model.provider_model_name }}</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface CacheEntry {
|
|||||||
type FetchResult = { models: UpstreamModel[]; error?: string }
|
type FetchResult = { models: UpstreamModel[]; error?: string }
|
||||||
|
|
||||||
// 全局缓存(模块级别,所有组件共享)
|
// 全局缓存(模块级别,所有组件共享)
|
||||||
|
// 支持两种 key: providerId 或 providerId:apiKeyId
|
||||||
const cache = new Map<string, CacheEntry>()
|
const cache = new Map<string, CacheEntry>()
|
||||||
const CACHE_TTL = 5 * 60 * 1000 // 5分钟
|
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())
|
const loadingMap = ref<Map<string, boolean>>(new Map())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成缓存 key
|
||||||
|
*/
|
||||||
|
function getCacheKey(providerId: string, apiKeyId?: string): string {
|
||||||
|
return apiKeyId ? `${providerId}:${apiKeyId}` : providerId
|
||||||
|
}
|
||||||
|
|
||||||
export function useUpstreamModelsCache() {
|
export function useUpstreamModelsCache() {
|
||||||
/**
|
/**
|
||||||
* 获取上游模型列表
|
* 获取上游模型列表
|
||||||
* @param providerId 提供商ID
|
* @param providerId 提供商ID
|
||||||
|
* @param apiKeyId 可选的 API Key ID(用于获取特定 Key 支持的模型)
|
||||||
* @param forceRefresh 是否强制刷新
|
* @param forceRefresh 是否强制刷新
|
||||||
* @returns 模型列表或 null(如果请求失败)
|
* @returns 模型列表或 null(如果请求失败)
|
||||||
*/
|
*/
|
||||||
async function fetchModels(
|
async function fetchModels(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
|
apiKeyId?: string,
|
||||||
forceRefresh = false
|
forceRefresh = false
|
||||||
): Promise<FetchResult> {
|
): Promise<FetchResult> {
|
||||||
|
const cacheKey = getCacheKey(providerId, apiKeyId)
|
||||||
|
|
||||||
// 检查缓存
|
// 检查缓存
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
const cached = cache.get(providerId)
|
const cached = cache.get(cacheKey)
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
return { models: cached.models }
|
return { models: cached.models }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有进行中的请求(非强制刷新时复用)
|
// 检查是否有进行中的请求(非强制刷新时复用)
|
||||||
if (!forceRefresh && pendingRequests.has(providerId)) {
|
if (!forceRefresh && pendingRequests.has(cacheKey)) {
|
||||||
return pendingRequests.get(providerId)!
|
return pendingRequests.get(cacheKey)!
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建新请求
|
// 创建新请求
|
||||||
const requestPromise = (async (): Promise<FetchResult> => {
|
const requestPromise = (async (): Promise<FetchResult> => {
|
||||||
try {
|
try {
|
||||||
loadingMap.value.set(providerId, true)
|
loadingMap.value.set(cacheKey, true)
|
||||||
const response = await adminApi.queryProviderModels(providerId)
|
const response = await adminApi.queryProviderModels(providerId, apiKeyId)
|
||||||
|
|
||||||
if (response.success && response.data?.models) {
|
if (response.success && response.data?.models) {
|
||||||
// 存入缓存
|
// 存入缓存
|
||||||
cache.set(providerId, {
|
cache.set(cacheKey, {
|
||||||
models: response.data.models,
|
models: response.data.models,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
@@ -73,20 +85,21 @@ export function useUpstreamModelsCache() {
|
|||||||
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
|
const rawError = err.response?.data?.detail || err.message || '获取上游模型失败'
|
||||||
return { models: [], error: parseUpstreamModelError(rawError) }
|
return { models: [], error: parseUpstreamModelError(rawError) }
|
||||||
} finally {
|
} finally {
|
||||||
loadingMap.value.set(providerId, false)
|
loadingMap.value.set(cacheKey, false)
|
||||||
pendingRequests.delete(providerId)
|
pendingRequests.delete(cacheKey)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
pendingRequests.set(providerId, requestPromise)
|
pendingRequests.set(cacheKey, requestPromise)
|
||||||
return requestPromise
|
return requestPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取缓存的模型(不发起请求)
|
* 获取缓存的模型(不发起请求)
|
||||||
*/
|
*/
|
||||||
function getCachedModels(providerId: string): UpstreamModel[] | null {
|
function getCachedModels(providerId: string, apiKeyId?: string): UpstreamModel[] | null {
|
||||||
const cached = cache.get(providerId)
|
const cacheKey = getCacheKey(providerId, apiKeyId)
|
||||||
|
const cached = cache.get(cacheKey)
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
return cached.models
|
return cached.models
|
||||||
}
|
}
|
||||||
@@ -94,17 +107,19 @@ export function useUpstreamModelsCache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除指定提供商的缓存
|
* 清除指定提供商/Key的缓存
|
||||||
*/
|
*/
|
||||||
function clearCache(providerId: string) {
|
function clearCache(providerId: string, apiKeyId?: string) {
|
||||||
cache.delete(providerId)
|
const cacheKey = getCacheKey(providerId, apiKeyId)
|
||||||
|
cache.delete(cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否正在加载
|
* 检查是否正在加载
|
||||||
*/
|
*/
|
||||||
function isLoading(providerId: string): boolean {
|
function isLoading(providerId: string, apiKeyId?: string): boolean {
|
||||||
return loadingMap.value.get(providerId) || false
|
const cacheKey = getCacheKey(providerId, apiKeyId)
|
||||||
|
return loadingMap.value.get(cacheKey) || false
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from src.models.endpoint_models import (
|
|||||||
router = APIRouter(tags=["Provider Keys"])
|
router = APIRouter(tags=["Provider Keys"])
|
||||||
pipeline = ApiRequestPipeline()
|
pipeline = ApiRequestPipeline()
|
||||||
|
|
||||||
|
|
||||||
@router.put("/keys/{key_id}", response_model=EndpointAPIKeyResponse)
|
@router.put("/keys/{key_id}", response_model=EndpointAPIKeyResponse)
|
||||||
async def update_endpoint_key(
|
async def update_endpoint_key(
|
||||||
key_id: str,
|
key_id: str,
|
||||||
@@ -226,11 +227,16 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
|||||||
if "allowed_models" in update_data:
|
if "allowed_models" in update_data:
|
||||||
am = update_data["allowed_models"]
|
am = update_data["allowed_models"]
|
||||||
if am is not None and (
|
if am is not None and (
|
||||||
(isinstance(am, list) and len(am) == 0)
|
(isinstance(am, list) and len(am) == 0) or (isinstance(am, dict) and len(am) == 0)
|
||||||
or (isinstance(am, dict) and len(am) == 0)
|
|
||||||
):
|
):
|
||||||
update_data["allowed_models"] = None
|
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():
|
for field, value in update_data.items():
|
||||||
setattr(key, field, value)
|
setattr(key, field, value)
|
||||||
key.updated_at = datetime.now(timezone.utc)
|
key.updated_at = datetime.now(timezone.utc)
|
||||||
@@ -430,9 +436,7 @@ def _build_key_response(
|
|||||||
|
|
||||||
# 计算整体健康度(取所有格式中的最低值)
|
# 计算整体健康度(取所有格式中的最低值)
|
||||||
if health_by_format:
|
if health_by_format:
|
||||||
health_scores = [
|
health_scores = [float(h.get("health_score") or 1.0) for h in health_by_format.values()]
|
||||||
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
|
min_health_score = min(health_scores) if health_scores else 1.0
|
||||||
# 取最大的连续失败次数
|
# 取最大的连续失败次数
|
||||||
max_consecutive = max(
|
max_consecutive = max(
|
||||||
@@ -441,9 +445,7 @@ def _build_key_response(
|
|||||||
)
|
)
|
||||||
# 取最近的失败时间
|
# 取最近的失败时间
|
||||||
failure_times = [
|
failure_times = [
|
||||||
h.get("last_failure_at")
|
h.get("last_failure_at") for h in health_by_format.values() if 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
|
last_failure = max(failure_times) if failure_times else None
|
||||||
else:
|
else:
|
||||||
@@ -462,7 +464,11 @@ def _build_key_response(
|
|||||||
"avg_response_time_ms": round(avg_response_time_ms, 2),
|
"avg_response_time_ms": round(avg_response_time_ms, 2),
|
||||||
"is_adaptive": is_adaptive,
|
"is_adaptive": is_adaptive,
|
||||||
"effective_limit": (
|
"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
|
if is_adaptive
|
||||||
else key.rpm_limit
|
else key.rpm_limit
|
||||||
),
|
),
|
||||||
@@ -545,6 +551,8 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
|
|||||||
capabilities=self.key_data.capabilities if self.key_data.capabilities else None,
|
capabilities=self.key_data.capabilities if self.key_data.capabilities else None,
|
||||||
cache_ttl_minutes=self.key_data.cache_ttl_minutes,
|
cache_ttl_minutes=self.key_data.cache_ttl_minutes,
|
||||||
max_probe_interval_minutes=self.key_data.max_probe_interval_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,
|
request_count=0,
|
||||||
success_count=0,
|
success_count=0,
|
||||||
error_count=0,
|
error_count=0,
|
||||||
|
|||||||
17
src/main.py
17
src/main.py
@@ -166,10 +166,12 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("启动月卡额度重置调度器...")
|
logger.info("启动月卡额度重置调度器...")
|
||||||
from src.services.system.cleanup_scheduler import get_cleanup_scheduler
|
from src.services.system.cleanup_scheduler import get_cleanup_scheduler
|
||||||
from src.services.usage.quota_scheduler import get_quota_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
|
from src.utils.task_coordinator import StartupTaskCoordinator
|
||||||
|
|
||||||
quota_scheduler = get_quota_scheduler()
|
quota_scheduler = get_quota_scheduler()
|
||||||
cleanup_scheduler = get_cleanup_scheduler()
|
cleanup_scheduler = get_cleanup_scheduler()
|
||||||
|
model_fetch_scheduler = get_model_fetch_scheduler()
|
||||||
task_coordinator = StartupTaskCoordinator(redis_client)
|
task_coordinator = StartupTaskCoordinator(redis_client)
|
||||||
|
|
||||||
# 启动额度调度器
|
# 启动额度调度器
|
||||||
@@ -189,6 +191,15 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("检测到其他 worker 已运行清理调度器,本实例跳过")
|
logger.info("检测到其他 worker 已运行清理调度器,本实例跳过")
|
||||||
cleanup_scheduler = None
|
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
|
from src.services.system.scheduler import get_scheduler
|
||||||
|
|
||||||
@@ -220,6 +231,12 @@ async def lifespan(app: FastAPI):
|
|||||||
if task_coordinator:
|
if task_coordinator:
|
||||||
await task_coordinator.release("quota_scheduler")
|
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("停止定时任务调度器...")
|
logger.info("停止定时任务调度器...")
|
||||||
task_scheduler.stop()
|
task_scheduler.stop()
|
||||||
|
|||||||
@@ -1079,6 +1079,12 @@ class ProviderAPIKey(Base):
|
|||||||
is_active = Column(Boolean, default=True, nullable=False)
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
expires_at = Column(DateTime(timezone=True), nullable=True) # 过期时间
|
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(
|
created_at = Column(
|
||||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
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="备注说明(可选)")
|
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")
|
@field_validator("api_formats")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_api_formats(cls, v: Optional[List[str]]) -> Optional[List[str]]:
|
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="是否启用")
|
is_active: Optional[bool] = Field(default=None, description="是否启用")
|
||||||
note: Optional[str] = Field(default=None, max_length=500, 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")
|
@field_validator("api_formats")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -488,6 +504,12 @@ class EndpointAPIKeyResponse(BaseModel):
|
|||||||
# 备注
|
# 备注
|
||||||
note: Optional[str] = None
|
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
|
last_used_at: Optional[datetime] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from src.services.model.cost import ModelCostService
|
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.global_model import GlobalModelService
|
||||||
from src.services.model.service import ModelService
|
from src.services.model.service import ModelService
|
||||||
|
|
||||||
@@ -12,4 +13,6 @@ __all__ = [
|
|||||||
"ModelService",
|
"ModelService",
|
||||||
"GlobalModelService",
|
"GlobalModelService",
|
||||||
"ModelCostService",
|
"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