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:
fawney19
2026-01-28 00:04:47 +08:00
parent 3c0bf5fdae
commit c732a03263
23 changed files with 652 additions and 97 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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 初始化
}
// 提取自定义模型(不在全局模型和上游模型中的)

View File

@@ -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('密钥已添加', '成功')
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加

View File

@@ -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,
}

View File

@@ -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 {

View File

@@ -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