mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: 添加格式转换追踪、模型过滤规则和 Provider 超时配置
1. Usage 格式转换追踪 - 新增 endpoint_api_format 和 has_format_conversion 字段 - 在用量记录中显示格式转换信息(请求格式 → 端点格式) - 兼容历史数据的回填逻辑 2. Provider API Key 模型过滤规则 - 新增 model_include_patterns 和 model_exclude_patterns 字段 - 支持 * 和 ? 通配符,不区分大小写 - 自动获取模型时应用过滤规则 3. Provider 超时配置 - 新增 stream_first_byte_timeout 和 request_timeout 字段 - 支持每个 Provider 单独配置超时时间 - 优先使用 Provider 配置,否则回退到全局配置 Close #122, Close #123 Co-Authored-By: AAEE86 <33052466+AAEE86@users.noreply.github.com>
This commit is contained in:
@@ -98,6 +98,8 @@ export async function addProviderKey(
|
||||
capabilities?: Record<string, boolean>
|
||||
note?: string
|
||||
auto_fetch_models?: boolean // 是否启用自动获取模型
|
||||
model_include_patterns?: string[] // 模型包含规则
|
||||
model_exclude_patterns?: string[] // 模型排除规则
|
||||
}
|
||||
): Promise<EndpointAPIKey> {
|
||||
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/keys`, data)
|
||||
@@ -125,6 +127,8 @@ export async function updateProviderKey(
|
||||
is_active: boolean
|
||||
note: string
|
||||
auto_fetch_models: boolean // 是否启用自动获取模型
|
||||
model_include_patterns: string[] // 模型包含规则
|
||||
model_exclude_patterns: string[] // 模型排除规则
|
||||
}>
|
||||
): Promise<EndpointAPIKey> {
|
||||
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
|
||||
|
||||
@@ -189,6 +189,9 @@ export interface EndpointAPIKey {
|
||||
last_models_fetch_at?: string // 最后获取模型时间
|
||||
last_models_fetch_error?: string // 最后获取模型错误信息
|
||||
locked_models?: string[] // 被锁定的模型列表
|
||||
// 模型过滤规则(仅当 auto_fetch_models=true 时生效)
|
||||
model_include_patterns?: string[] // 模型包含规则(支持 * 和 ? 通配符)
|
||||
model_exclude_patterns?: string[] // 模型排除规则(支持 * 和 ? 通配符)
|
||||
}
|
||||
|
||||
// 按格式的健康度数据
|
||||
@@ -227,6 +230,9 @@ export interface EndpointAPIKeyUpdate {
|
||||
is_active?: boolean
|
||||
auto_fetch_models?: boolean // 是否启用自动获取模型
|
||||
locked_models?: string[] // 被锁定的模型列表
|
||||
// 模型过滤规则(仅当 auto_fetch_models=true 时生效)
|
||||
model_include_patterns?: string[] // 模型包含规则(支持 * 和 ? 通配符)
|
||||
model_exclude_patterns?: string[] // 模型排除规则(支持 * 和 ? 通配符)
|
||||
}
|
||||
|
||||
export interface EndpointHealthDetail {
|
||||
@@ -312,6 +318,9 @@ export interface ProviderWithEndpointsSummary {
|
||||
// 请求配置(从 Endpoint 迁移)
|
||||
max_retries?: number // 最大重试次数
|
||||
proxy?: ProxyConfig | null // 代理配置
|
||||
// 超时配置(秒),为空时使用全局配置
|
||||
stream_first_byte_timeout?: number // 流式请求首字节超时
|
||||
request_timeout?: number // 非流式请求整体超时
|
||||
is_active: boolean
|
||||
total_endpoints: number
|
||||
active_endpoints: number
|
||||
|
||||
@@ -754,14 +754,12 @@ watch(() => props.open, async (open) => {
|
||||
// 加载全局模型
|
||||
await loadGlobalModels()
|
||||
|
||||
// 自动获取模式下,获取上游模型并刷新(保留锁定的模型)
|
||||
// 自动获取模式下,获取上游模型用于显示(但选中状态使用已保存的 allowed_models)
|
||||
if (props.apiKey.auto_fetch_models) {
|
||||
await fetchUpstreamModels()
|
||||
// 锁定的模型 + 最新上游模型(去重)
|
||||
const newSelected = new Set(lockedModels.value)
|
||||
upstreamModelNames.value.forEach(m => newSelected.add(m))
|
||||
selectedModels.value = Array.from(newSelected)
|
||||
initialSelectedModels.value = [...selectedModels.value]
|
||||
// 注意:不再将所有上游模型自动标记为选中
|
||||
// 因为后端有过滤规则,实际保存的 allowed_models 是过滤后的结果
|
||||
// selectedModels 已在上面从 props.apiKey.allowed_models 初始化
|
||||
}
|
||||
|
||||
// 提取自定义模型(不在全局模型和上游模型中的)
|
||||
|
||||
@@ -211,20 +211,48 @@
|
||||
</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>
|
||||
<div class="space-y-3 py-2 px-3 rounded-md border border-border/60 bg-muted/30">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
<!-- 模型过滤规则(仅当开启自动获取时显示) -->
|
||||
<div
|
||||
v-if="form.auto_fetch_models"
|
||||
class="space-y-2 pt-2 border-t border-border/40"
|
||||
>
|
||||
<div>
|
||||
<Label class="text-xs">包含规则</Label>
|
||||
<Input
|
||||
v-model="form.model_include_patterns_text"
|
||||
placeholder="gpt-*, claude-*, 留空包含全部"
|
||||
class="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label class="text-xs">排除规则</Label>
|
||||
<Input
|
||||
v-model="form.model_exclude_patterns_text"
|
||||
placeholder="*-preview, *-beta"
|
||||
class="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -335,7 +363,9 @@ const form = ref({
|
||||
note: '',
|
||||
is_active: true,
|
||||
capabilities: {} as Record<string, boolean>,
|
||||
auto_fetch_models: false
|
||||
auto_fetch_models: false,
|
||||
model_include_patterns_text: '', // 包含规则文本(逗号分隔)
|
||||
model_exclude_patterns_text: '' // 排除规则文本(逗号分隔)
|
||||
})
|
||||
|
||||
// 加载能力列表
|
||||
@@ -419,7 +449,9 @@ function resetForm() {
|
||||
note: '',
|
||||
is_active: true,
|
||||
capabilities: {},
|
||||
auto_fetch_models: false
|
||||
auto_fetch_models: false,
|
||||
model_include_patterns_text: '',
|
||||
model_exclude_patterns_text: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +481,9 @@ function loadKeyData() {
|
||||
note: props.editingKey.note || '',
|
||||
is_active: props.editingKey.is_active,
|
||||
capabilities: { ...(props.editingKey.capabilities || {}) },
|
||||
auto_fetch_models: props.editingKey.auto_fetch_models ?? false
|
||||
auto_fetch_models: props.editingKey.auto_fetch_models ?? false,
|
||||
model_include_patterns_text: (props.editingKey.model_include_patterns || []).join(', '),
|
||||
model_exclude_patterns_text: (props.editingKey.model_exclude_patterns || []).join(', ')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +501,17 @@ function createFieldNonce(): string {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
// 将逗号分隔的文本解析为数组(去空、去重)
|
||||
// 返回空数组而非 undefined,以便后端能正确清除已有规则
|
||||
function parsePatternText(text: string): string[] {
|
||||
if (!text.trim()) return []
|
||||
const patterns = text
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0)
|
||||
return [...new Set(patterns)]
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// 必须有 providerId
|
||||
if (!props.providerId) {
|
||||
@@ -529,7 +574,9 @@ async function handleSave() {
|
||||
note: form.value.note,
|
||||
is_active: form.value.is_active,
|
||||
capabilities: capabilitiesData,
|
||||
auto_fetch_models: form.value.auto_fetch_models
|
||||
auto_fetch_models: form.value.auto_fetch_models,
|
||||
model_include_patterns: parsePatternText(form.value.model_include_patterns_text),
|
||||
model_exclude_patterns: parsePatternText(form.value.model_exclude_patterns_text)
|
||||
}
|
||||
|
||||
if (form.value.api_key.trim()) {
|
||||
@@ -551,7 +598,9 @@ async function handleSave() {
|
||||
max_probe_interval_minutes: form.value.max_probe_interval_minutes,
|
||||
note: form.value.note,
|
||||
capabilities: capabilitiesData || undefined,
|
||||
auto_fetch_models: form.value.auto_fetch_models
|
||||
auto_fetch_models: form.value.auto_fetch_models,
|
||||
model_include_patterns: parsePatternText(form.value.model_include_patterns_text),
|
||||
model_exclude_patterns: parsePatternText(form.value.model_exclude_patterns_text)
|
||||
})
|
||||
success('密钥已添加', '成功')
|
||||
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加
|
||||
|
||||
@@ -93,6 +93,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 超时配置 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
流式首字节超时
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.stream_first_byte_timeout ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="300"
|
||||
step="1"
|
||||
placeholder="30"
|
||||
@update:model-value="(v) => form.stream_first_byte_timeout = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
非流式请求超时
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.request_timeout ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="600"
|
||||
step="1"
|
||||
placeholder="300"
|
||||
@update:model-value="(v) => form.request_timeout = parseNumberInput(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 月卡配置 -->
|
||||
<div
|
||||
v-if="form.billing_type === 'monthly_quota'"
|
||||
@@ -267,6 +301,9 @@ const form = ref({
|
||||
concurrent_limit: undefined as number | undefined,
|
||||
// 请求配置
|
||||
max_retries: undefined as number | undefined,
|
||||
// 超时配置(秒)
|
||||
stream_first_byte_timeout: undefined as number | undefined,
|
||||
request_timeout: undefined as number | undefined,
|
||||
// 代理配置(扁平化便于表单绑定)
|
||||
proxy_enabled: false,
|
||||
proxy_url: '',
|
||||
@@ -291,6 +328,9 @@ function resetForm() {
|
||||
concurrent_limit: undefined,
|
||||
// 请求配置
|
||||
max_retries: undefined,
|
||||
// 超时配置
|
||||
stream_first_byte_timeout: undefined,
|
||||
request_timeout: undefined,
|
||||
// 代理配置
|
||||
proxy_enabled: false,
|
||||
proxy_url: '',
|
||||
@@ -321,6 +361,9 @@ function loadProviderData() {
|
||||
concurrent_limit: undefined,
|
||||
// 请求配置
|
||||
max_retries: props.provider.max_retries ?? undefined,
|
||||
// 超时配置
|
||||
stream_first_byte_timeout: props.provider.stream_first_byte_timeout ?? undefined,
|
||||
request_timeout: props.provider.request_timeout ?? undefined,
|
||||
// 代理配置
|
||||
proxy_enabled: proxy?.enabled ?? false,
|
||||
proxy_url: proxy?.url || '',
|
||||
@@ -376,6 +419,9 @@ const handleSubmit = async () => {
|
||||
is_active: form.value.is_active,
|
||||
// 请求配置
|
||||
max_retries: form.value.max_retries ?? undefined,
|
||||
// 超时配置(null 表示清除,使用全局配置)
|
||||
stream_first_byte_timeout: form.value.stream_first_byte_timeout ?? null,
|
||||
request_timeout: form.value.request_timeout ?? null,
|
||||
proxy,
|
||||
}
|
||||
|
||||
|
||||
@@ -299,75 +299,90 @@
|
||||
v-if="isAdmin"
|
||||
class="py-4 w-[60px]"
|
||||
>
|
||||
<div class="flex flex-col text-xs gap-0.5">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex flex-col text-xs gap-0.5">
|
||||
<span>{{ record.provider }}</span>
|
||||
<!-- 故障转移图标(优先显示) -->
|
||||
<span
|
||||
v-if="record.has_fallback"
|
||||
class="inline-flex items-center justify-center w-4 h-4 text-xs text-amber-600 dark:text-amber-400"
|
||||
title="此请求发生了 Provider 故障转移"
|
||||
v-if="record.api_key_name"
|
||||
class="text-muted-foreground truncate"
|
||||
:title="record.api_key_name"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-3.5 h-3.5"
|
||||
>
|
||||
<path d="m16 3 4 4-4 4" />
|
||||
<path d="M20 7H4" />
|
||||
<path d="m8 21-4-4 4-4" />
|
||||
<path d="M4 17h16" />
|
||||
</svg>
|
||||
</span>
|
||||
<!-- 重试图标(仅在无故障转移时显示) -->
|
||||
<span
|
||||
v-else-if="record.has_retry"
|
||||
class="inline-flex items-center justify-center w-4 h-4 text-xs text-blue-600 dark:text-blue-400"
|
||||
title="此请求发生了亲和缓存重试"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-3.5 h-3.5"
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M21 21v-5h-5" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
{{ record.api_key_name }}
|
||||
<span
|
||||
v-if="record.rate_multiplier && record.rate_multiplier !== 1.0"
|
||||
class="text-foreground/60"
|
||||
>({{ record.rate_multiplier }}x)</span>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="record.api_key_name"
|
||||
class="text-muted-foreground truncate"
|
||||
:title="record.api_key_name"
|
||||
<!-- 故障转移图标(优先显示) -->
|
||||
<svg
|
||||
v-if="record.has_fallback"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 flex-shrink-0"
|
||||
title="此请求发生了 Provider 故障转移"
|
||||
>
|
||||
{{ record.api_key_name }}
|
||||
<span
|
||||
v-if="record.rate_multiplier && record.rate_multiplier !== 1.0"
|
||||
class="text-foreground/60"
|
||||
>({{ record.rate_multiplier }}x)</span>
|
||||
</span>
|
||||
<path d="m16 3 4 4-4 4" />
|
||||
<path d="M20 7H4" />
|
||||
<path d="m8 21-4-4 4-4" />
|
||||
<path d="M4 17h16" />
|
||||
</svg>
|
||||
<!-- 重试图标(仅在无故障转移时显示) -->
|
||||
<svg
|
||||
v-else-if="record.has_retry"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 flex-shrink-0"
|
||||
title="此请求发生了亲和缓存重试"
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M21 21v-5h-5" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-4 w-[80px]">
|
||||
<span
|
||||
v-if="record.api_format"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full border border-border/60 text-[10px] font-medium whitespace-nowrap text-muted-foreground"
|
||||
:title="record.api_format"
|
||||
<TableCell
|
||||
class="py-4 w-[80px]"
|
||||
:title="getApiFormatTooltip(record)"
|
||||
>
|
||||
<!-- 有格式转换:两行显示 -->
|
||||
<div
|
||||
v-if="record.api_format && record.has_format_conversion && record.endpoint_api_format"
|
||||
class="flex flex-col text-xs gap-0.5"
|
||||
>
|
||||
{{ formatApiFormat(record.api_format) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ formatApiFormat(record.api_format) }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-3 h-3 text-muted-foreground flex-shrink-0"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-muted-foreground">{{ formatApiFormat(record.endpoint_api_format) }}</span>
|
||||
</div>
|
||||
<!-- 无格式转换:单行显示 -->
|
||||
<span
|
||||
v-else-if="record.api_format"
|
||||
class="text-xs"
|
||||
>{{ formatApiFormat(record.api_format) }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted-foreground text-xs"
|
||||
@@ -685,6 +700,22 @@ function formatApiFormat(format: string): string {
|
||||
return formatMap[format.toUpperCase()] || format
|
||||
}
|
||||
|
||||
// 获取 API 格式的 tooltip(包含转换信息)
|
||||
function getApiFormatTooltip(record: UsageRecord): string {
|
||||
if (!record.api_format) {
|
||||
return ''
|
||||
}
|
||||
const displayFormat = formatApiFormat(record.api_format)
|
||||
|
||||
// 如果发生了格式转换,显示详细信息
|
||||
if (record.has_format_conversion && record.endpoint_api_format) {
|
||||
const endpointDisplayFormat = formatApiFormat(record.endpoint_api_format)
|
||||
return `用户请求格式: ${displayFormat}\n端点原生格式: ${endpointDisplayFormat}\n系统进行了 ${displayFormat} → ${endpointDisplayFormat} 格式转换`
|
||||
}
|
||||
|
||||
return record.api_format
|
||||
}
|
||||
|
||||
// 获取实际使用的模型(优先 target_model,其次 model_version)
|
||||
// 只有当实际模型与请求模型不同时才返回,用于显示映射箭头
|
||||
function getActualModel(record: UsageRecord): string | null {
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface UsageRecord {
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
|
||||
api_format?: string
|
||||
endpoint_api_format?: string // 端点原生格式
|
||||
has_format_conversion?: boolean // 是否发生了格式转换
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
|
||||
Reference in New Issue
Block a user