feat(provider): 增加 Claude Code 适配器、高级配置能力与 OAuth 账号类型统一解析

- 新增 Claude Code provider adapter (context, envelope, plugin, constants)
- 扩展 provider admin 路由,支持 Claude Code 高级配置 (CRUD)
- 统一 OAuth 账号类型解析逻辑,前后端对齐
- 重构 BatchAssignModelsDialog / ModelMappingDialog,简化组件逻辑
- handler 基类增强: request_builder 支持 Claude Code 信封格式
- CLI stream/sync mixin 适配 Claude Code 流式与同步模式
- 扩展 candidate builder / failover / scheduler 对 Claude Code 的支持
- 前端增加请求时间线可视化 (HorizontalRequestTimeline)
- 补充 Claude Code envelope / runtime controls / distributed sessions 等测试

Closes #183
Closes #185

Co-Authored-By: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
fawney19
2026-02-27 13:54:46 +08:00
parent f2f2a2dbc4
commit 579b5e4623
55 changed files with 3106 additions and 652 deletions

View File

@@ -7,3 +7,4 @@ export * from './health'
export * from './models'
export * from './adaptive'
export * from './global-models'
export * from './pool'

View File

@@ -1,5 +1,10 @@
import client from '../client'
import type { ProviderWithEndpointsSummary, ProxyConfig } from './types'
import type {
ClaudeCodeAdvancedConfig,
PoolAdvancedConfig,
ProviderWithEndpointsSummary,
ProxyConfig,
} from './types'
/**
* 获取 Providers 摘要(包含 Endpoints 统计)
@@ -41,6 +46,8 @@ export async function updateProvider(
max_probe_interval_minutes: number
enable_format_conversion: boolean // 是否允许格式转换(提供商级别开关)
is_active: boolean
claude_code_advanced: ClaudeCodeAdvancedConfig | null
pool_advanced: PoolAdvancedConfig | null
}>
): Promise<ProviderWithEndpointsSummary> {
const response = await client.patch(`/api/admin/providers/${providerId}`, data)
@@ -68,6 +75,8 @@ export async function createProvider(
stream_first_byte_timeout?: number | null
request_timeout?: number | null
proxy?: ProxyConfig | null
claude_code_advanced?: ClaudeCodeAdvancedConfig | null
pool_advanced?: PoolAdvancedConfig | null
}
): Promise<{ id: string; name: string; message?: string }> {
const response = await client.post('/api/admin/providers/', data)

View File

@@ -442,6 +442,30 @@ export interface PublicEndpointStatusMonitorResponse {
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
export interface ClaudeCodeAdvancedConfig {
// 会话数量控制null/undefined 表示不限制
max_sessions?: number | null
session_idle_timeout_minutes?: number | null
// TLS 指纹模拟(模拟 Node.js/Claude Code 客户端指纹)
enable_tls_fingerprint?: boolean
// 会话 ID 伪装(固定 metadata.user_id 中 session 片段)
session_id_masking_enabled?: boolean
}
export interface PoolAdvancedConfig {
sticky_session_ttl_seconds?: number | null
load_threshold_percent?: number | null
lru_enabled?: boolean
cost_window_seconds?: number | null
cost_limit_per_key_tokens?: number | null
cost_soft_threshold_percent?: number | null
rate_limit_cooldown_seconds?: number | null
overload_cooldown_seconds?: number | null
proactive_refresh_seconds?: number | null
health_policy_enabled?: boolean
unschedulable_rules?: Array<Record<string, unknown>> | null
}
export interface ProviderWithEndpointsSummary {
id: string
name: string
@@ -475,6 +499,8 @@ export interface ProviderWithEndpointsSummary {
unhealthy_endpoints: number
api_formats: string[]
endpoint_health_details: EndpointHealthDetail[]
claude_code_advanced?: ClaudeCodeAdvancedConfig | null
pool_advanced?: PoolAdvancedConfig | null
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter
created_at: string

View File

@@ -19,35 +19,9 @@
class="pl-8 h-9"
/>
</div>
<button
v-if="upstreamModelsLoaded"
type="button"
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
title="刷新上游模型"
:disabled="fetchingUpstreamModels"
@click="fetchUpstreamModels(true)"
>
<RefreshCw
class="w-4 h-4"
:class="{ 'animate-spin': fetchingUpstreamModels }"
/>
</button>
<button
v-else-if="!fetchingUpstreamModels"
type="button"
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
title="从提供商获取模型"
@click="fetchUpstreamModels()"
>
<Zap class="w-4 h-4" />
</button>
<Loader2
v-else
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
/>
</div>
<!-- 单列模型列表 -->
<!-- 模型列表 -->
<div class="border rounded-lg overflow-hidden">
<div class="max-h-96 overflow-y-auto">
<div
@@ -58,17 +32,12 @@
</div>
<template v-else>
<!-- 全局模型 -->
<div v-if="filteredGlobalModels.length > 0 || !upstreamModelsLoaded">
<!-- 全局模型列表 -->
<div v-if="filteredGlobalModels.length > 0">
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
@click="toggleGroupCollapse('global')"
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10"
>
<div class="flex items-center gap-2">
<ChevronDown
class="w-4 h-4 transition-transform shrink-0"
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
/>
<span class="text-xs font-medium">全局模型</span>
<span class="text-xs text-muted-foreground">({{ filteredGlobalModels.length }})</span>
</div>
@@ -81,16 +50,7 @@
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
</button>
</div>
<div
v-show="!collapsedGroups.has('global')"
class="space-y-1 p-2"
>
<div
v-if="filteredGlobalModels.length === 0"
class="py-4 text-center text-xs text-muted-foreground"
>
暂无可用全局模型
</div>
<div class="space-y-1 p-2">
<div
v-for="model in filteredGlobalModels"
:key="model.id"
@@ -118,85 +78,17 @@
</div>
</div>
<!-- 上游模型组 -->
<div v-if="filteredUpstreamModels.length > 0">
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
@click="toggleGroupCollapse('upstream')"
>
<div class="flex items-center gap-2">
<ChevronDown
class="w-4 h-4 transition-transform shrink-0"
:class="collapsedGroups.has('upstream') ? '-rotate-90' : ''"
/>
<span class="text-xs font-medium">上游模型</span>
<span class="text-xs text-muted-foreground">({{ filteredUpstreamModels.length }})</span>
</div>
<button
type="button"
class="text-xs text-primary hover:underline shrink-0"
@click.stop="toggleAllUpstreamModels"
>
{{ isAllUpstreamModelsSelected ? '取消全选' : '全选' }}
</button>
</div>
<div
v-show="!collapsedGroups.has('upstream')"
class="space-y-1 p-2"
>
<div
v-for="model in filteredUpstreamModels"
:key="model.id"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
@click="toggleUpstreamModelSelection(model.id)"
>
<div
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="isUpstreamModelSelected(model.id) ? 'bg-primary border-primary' : ''"
>
<Check
v-if="isUpstreamModelSelected(model.id)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium truncate">
{{ model.id }}
</p>
<span
v-for="fmt in model.api_formats"
:key="fmt"
class="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0"
>
{{ formatApiFormat(fmt) }}
</span>
</div>
<p
v-if="model.owned_by"
class="text-xs text-muted-foreground truncate"
>
{{ model.owned_by }}
</p>
</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div
v-if="filteredGlobalModels.length === 0 && filteredUpstreamModels.length === 0"
v-if="filteredGlobalModels.length === 0"
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
>
<Layers class="w-10 h-10 mb-2 opacity-30" />
<p class="text-sm">
{{ searchQuery ? '无匹配结果' : '暂无可用模型' }}
{{ searchQuery ? '无匹配结果' : '暂无可用全局模型' }}
</p>
<p
v-if="!upstreamModelsLoaded"
class="text-xs mt-1"
>
点击上方按钮从上游获取模型
<p class="text-xs mt-1">
请先前往"模型目录"页面创建全局模型
</p>
</div>
</template>
@@ -234,7 +126,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Layers, Loader2, ChevronDown, Zap, RefreshCw, Search, Check } from 'lucide-vue-next'
import { Layers, Loader2, Search, Check } from 'lucide-vue-next'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
@@ -249,11 +141,8 @@ import {
getProviderModels,
batchAssignModelsToProvider,
deleteModel,
importModelsFromUpstream,
type Model
} from '@/api/endpoints'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
const props = defineProps<{
open: boolean
@@ -266,32 +155,22 @@ const emit = defineEmits<{
'changed': []
}>()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
const { error: showError, success } = useToast()
const { confirmWarning } = useConfirm()
// 状态
const loadingGlobalModels = ref(false)
const fetchingUpstreamModels = ref(false)
const upstreamModelsLoaded = ref(false)
const saving = ref(false)
// 数据
const allGlobalModels = ref<GlobalModelResponse[]>([])
const existingModels = ref<Model[]>([])
const upstreamModels = ref<UpstreamModel[]>([])
// 选择状态(本地状态,保存时才提交)
const selectedGlobalModelIds = ref<Set<string>>(new Set())
const selectedUpstreamModelIds = ref<Set<string>>(new Set())
// 初始状态(用于计算变更)
const initialGlobalModelIds = ref<Set<string>>(new Set())
const initialUpstreamModelNames = ref<Set<string>>(new Set())
// 折叠状态
const collapsedGroups = ref<Set<string>>(new Set())
// 搜索状态
const searchQuery = ref('')
@@ -305,18 +184,6 @@ const existingGlobalModelIds = computed(() => {
)
})
// 已关联的上游模型名称集合
const existingUpstreamModelNames = computed(() => {
const names = new Set<string>()
for (const m of existingModels.value) {
names.add(m.provider_model_name)
for (const mapping of m.provider_model_mappings ?? []) {
if (mapping.name) names.add(mapping.name)
}
}
return names
})
// 过滤后的全局模型
const filteredGlobalModels = computed(() => {
const query = searchQuery.value.toLowerCase().trim()
@@ -328,59 +195,17 @@ const filteredGlobalModels = computed(() => {
})
})
// 过滤后的上游模型(后端已按 id 聚合)
const filteredUpstreamModels = computed(() => {
if (!upstreamModelsLoaded.value) return []
const query = searchQuery.value.toLowerCase().trim()
let models = upstreamModels.value
if (query) {
models = models.filter(m => m.id.toLowerCase().includes(query))
}
// 按 id 排序
return [...models].sort((a, b) => a.id.localeCompare(b.id))
})
// 上游模型是否全选
const isAllUpstreamModelsSelected = computed(() => {
if (filteredUpstreamModels.value.length === 0) return false
return filteredUpstreamModels.value.every(m => selectedUpstreamModelIds.value.has(m.id))
})
// 全选/取消全选上游模型
function toggleAllUpstreamModels() {
const allIds = filteredUpstreamModels.value.map(m => m.id)
if (isAllUpstreamModelsSelected.value) {
// 取消全选
for (const id of allIds) {
selectedUpstreamModelIds.value.delete(id)
}
} else {
// 全选
for (const id of allIds) {
selectedUpstreamModelIds.value.add(id)
}
}
}
// 检查全局模型是否已选中
function isGlobalModelSelected(globalModelId: string): boolean {
return selectedGlobalModelIds.value.has(globalModelId)
}
// 检查上游模型是否已选中
function isUpstreamModelSelected(modelId: string): boolean {
return selectedUpstreamModelIds.value.has(modelId)
}
// 全局模型是否全选
const isAllGlobalModelsSelected = computed(() => {
if (filteredGlobalModels.value.length === 0) return false
return filteredGlobalModels.value.every(m => isGlobalModelSelected(m.id))
})
// 检查全局模型是否已选中
function isGlobalModelSelected(globalModelId: string): boolean {
return selectedGlobalModelIds.value.has(globalModelId)
}
// 计算待添加的全局模型
const globalModelsToAdd = computed(() => {
const toAdd: string[] = []
@@ -403,42 +228,16 @@ const globalModelsToRemove = computed(() => {
return toRemove
})
// 计算待添加的上游模型
const upstreamModelsToAdd = computed(() => {
const toAdd: string[] = []
for (const id of selectedUpstreamModelIds.value) {
if (!initialUpstreamModelNames.value.has(id)) {
toAdd.push(id)
}
}
return toAdd
})
// 计算待移除的上游模型
const upstreamModelsToRemove = computed(() => {
const toRemove: string[] = []
for (const id of initialUpstreamModelNames.value) {
if (!selectedUpstreamModelIds.value.has(id)) {
toRemove.push(id)
}
}
return toRemove
})
// 是否有变更
const hasChanges = computed(() => {
return globalModelsToAdd.value.length > 0 ||
globalModelsToRemove.value.length > 0 ||
upstreamModelsToAdd.value.length > 0 ||
upstreamModelsToRemove.value.length > 0
globalModelsToRemove.value.length > 0
})
// 待变更数量
const pendingChangesCount = computed(() => {
return globalModelsToAdd.value.length +
globalModelsToRemove.value.length +
upstreamModelsToAdd.value.length +
upstreamModelsToRemove.value.length
globalModelsToRemove.value.length
})
// 切换全局模型选择
@@ -451,26 +250,14 @@ function toggleGlobalModelSelection(id: string) {
selectedGlobalModelIds.value = new Set(selectedGlobalModelIds.value)
}
// 切换上游模型选择
function toggleUpstreamModelSelection(id: string) {
if (selectedUpstreamModelIds.value.has(id)) {
selectedUpstreamModelIds.value.delete(id)
} else {
selectedUpstreamModelIds.value.add(id)
}
selectedUpstreamModelIds.value = new Set(selectedUpstreamModelIds.value)
}
// 全选/取消全选全局模型
function toggleAllGlobalModels() {
const allIds = filteredGlobalModels.value.map(m => m.id)
if (isAllGlobalModelsSelected.value) {
// 取消全选
for (const id of allIds) {
selectedGlobalModelIds.value.delete(id)
}
} else {
// 全选
for (const id of allIds) {
selectedGlobalModelIds.value.add(id)
}
@@ -478,16 +265,6 @@ function toggleAllGlobalModels() {
selectedGlobalModelIds.value = new Set(selectedGlobalModelIds.value)
}
// 切换折叠状态
function toggleGroupCollapse(group: string) {
if (collapsedGroups.value.has(group)) {
collapsedGroups.value.delete(group)
} else {
collapsedGroups.value.add(group)
}
collapsedGroups.value = new Set(collapsedGroups.value)
}
// 处理关闭
async function handleClose() {
if (hasChanges.value) {
@@ -530,23 +307,6 @@ async function handleSave() {
}
}
// 移除上游模型
for (const modelId of upstreamModelsToRemove.value) {
const existingModel = existingModels.value.find(m =>
m.provider_model_name === modelId ||
m.provider_model_mappings?.some(mapping => mapping.name === modelId)
)
if (existingModel) {
hasAnyOperation = true
try {
await deleteModel(props.providerId, existingModel.id)
totalSuccess++
} catch (err: unknown) {
allErrors.push(parseApiError(err, '移除失败'))
}
}
}
// 添加全局模型
if (globalModelsToAdd.value.length > 0) {
hasAnyOperation = true
@@ -561,20 +321,6 @@ async function handleSave() {
}
}
// 添加上游模型
if (upstreamModelsToAdd.value.length > 0) {
hasAnyOperation = true
try {
const result = await importModelsFromUpstream(props.providerId, upstreamModelsToAdd.value)
totalSuccess += result.success.length
if (result.errors.length > 0) {
allErrors.push(...result.errors.map(e => e.error))
}
} catch (err: unknown) {
allErrors.push(parseApiError(err, '导入上游模型失败'))
}
}
if (totalSuccess > 0) {
success(`成功处理 ${totalSuccess} 个模型`)
}
@@ -587,7 +333,6 @@ async function handleSave() {
emit('update:open', false)
} catch (err: unknown) {
showError(parseApiError(err, '保存失败'), '错误')
// 即使出错,如果已执行过操作,也通知父组件刷新数据
if (hasAnyOperation) {
emit('changed')
}
@@ -596,52 +341,28 @@ async function handleSave() {
}
}
// 从已有数据同步选择状态(全局模型)
// 从已有数据同步选择状态
function syncGlobalModelSelection() {
const globalIds = [...existingGlobalModelIds.value].filter((id): id is string => id !== undefined)
selectedGlobalModelIds.value = new Set(globalIds)
initialGlobalModelIds.value = new Set(globalIds)
}
// 从已有数据同步选择状态(上游模型)
function syncUpstreamModelSelection() {
// 只同步当前已加载的上游模型中,与已关联模型匹配的部分
const selected = new Set<string>()
for (const model of upstreamModels.value) {
if (existingUpstreamModelNames.value.has(model.id)) {
selected.add(model.id)
}
}
selectedUpstreamModelIds.value = selected
initialUpstreamModelNames.value = new Set(selected)
}
// 监听打开状态
watch(() => props.open, async (isOpen) => {
if (isOpen && props.providerId) {
await loadData()
} else {
// 重置状态
upstreamModels.value = []
upstreamModelsLoaded.value = false
collapsedGroups.value = new Set()
searchQuery.value = ''
selectedGlobalModelIds.value = new Set()
selectedUpstreamModelIds.value = new Set()
initialGlobalModelIds.value = new Set()
initialUpstreamModelNames.value = new Set()
}
})
// 加载数据
async function loadData() {
await Promise.all([loadGlobalModels(), loadExistingModels()])
// 同步全局模型选择状态
syncGlobalModelSelection()
// 初始折叠状态
collapsedGroups.value = new Set()
}
// 加载全局模型列表
@@ -665,26 +386,4 @@ async function loadExistingModels() {
showError(parseApiError(err, '加载已关联模型失败'), '错误')
}
}
// 从提供商获取模型
async function fetchUpstreamModels(forceRefresh = false) {
try {
fetchingUpstreamModels.value = true
const result = await fetchCachedModels(props.providerId, undefined, forceRefresh)
if (result) {
if (result.error) {
showError(result.error, '错误')
} else {
upstreamModels.value = result.models
upstreamModelsLoaded.value = true
// 同步上游模型选择状态
syncUpstreamModelSelection()
// 全部折叠
collapsedGroups.value = new Set(['global', 'upstream'])
}
}
} finally {
fetchingUpstreamModels.value = false
}
}
</script>

View File

@@ -63,33 +63,6 @@
>
已选 {{ selectedNames.length }}
</span>
<!-- 刷新上游模型按钮 -->
<button
v-if="upstreamModelsLoaded"
type="button"
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
:disabled="fetchingUpstreamModels"
title="刷新上游模型"
@click="fetchUpstreamModels()"
>
<RefreshCw
class="w-4 h-4"
:class="{ 'animate-spin': fetchingUpstreamModels }"
/>
</button>
<button
v-else-if="!fetchingUpstreamModels"
type="button"
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
title="从提供商获取模型"
@click="fetchUpstreamModels()"
>
<Zap class="w-4 h-4" />
</button>
<Loader2
v-else
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
/>
</div>
<!-- 模型列表 -->
@@ -124,22 +97,14 @@
<!-- 自定义映射名称 -->
<div v-if="customNames.length > 0">
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
@click="toggleGroupCollapse('custom')"
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20"
>
<div class="flex items-center gap-2">
<ChevronDown
class="w-4 h-4 transition-transform shrink-0"
:class="collapsedGroups.has('custom') ? '-rotate-90' : ''"
/>
<span class="text-xs font-medium">自定义模型</span>
<span class="text-xs text-muted-foreground">({{ customNames.length }})</span>
</div>
</div>
<div
v-show="!collapsedGroups.has('custom')"
class="space-y-1 p-2"
>
<div class="space-y-1 p-2">
<div
v-for="name in sortedCustomNames"
:key="name"
@@ -160,52 +125,6 @@
</div>
</div>
<!-- 上游模型 -->
<template v-if="filteredUpstreamModels.length > 0">
<div
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
@click="toggleGroupCollapse('upstream')"
>
<div class="flex items-center gap-2">
<ChevronDown
class="w-4 h-4 transition-transform shrink-0"
:class="collapsedGroups.has('upstream') ? '-rotate-90' : ''"
/>
<span class="text-xs font-medium">上游模型</span>
<span class="text-xs text-muted-foreground">({{ upstreamModelNames.length }})</span>
</div>
<button
type="button"
class="text-xs text-primary hover:underline"
@click.stop="toggleAllUpstreamModels"
>
{{ isAllUpstreamModelsSelected ? '取消全选' : '全选' }}
</button>
</div>
<div
v-show="!collapsedGroups.has('upstream')"
class="space-y-1 p-2"
>
<div
v-for="name in filteredUpstreamModels"
:key="name"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
@click="toggleName(name)"
>
<div
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
:class="selectedNames.includes(name) ? 'bg-primary border-primary' : ''"
>
<Check
v-if="selectedNames.includes(name)"
class="w-3 h-3 text-primary-foreground"
/>
</div>
<span class="text-sm font-mono truncate flex-1">{{ name }}</span>
</div>
</div>
</template>
<!-- 空状态 -->
<div
v-if="showEmptyState"
@@ -248,7 +167,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Tag, Loader2, Plus, Search, Check, ChevronDown, RefreshCw, Zap } from 'lucide-vue-next'
import { Tag, Loader2, Plus, Search, Check } from 'lucide-vue-next'
import {
Button,
Input,
@@ -265,16 +184,14 @@ import { parseApiError } from '@/utils/errorParser'
import {
type Model,
type ProviderModelAlias,
type UpstreamModel,
} from '@/api/endpoints'
import { updateModel } from '@/api/endpoints/models'
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
export interface AliasGroup {
model: Model
/** @deprecated 作用域功能已废弃,将在后续版本移除 */
/** @deprecated */
apiFormatsKey: string
/** @deprecated 作用域功能已废弃,将在后续版本移除 */
/** @deprecated */
apiFormats: string[]
aliases: ProviderModelAlias[]
}
@@ -282,12 +199,11 @@ export interface AliasGroup {
const props = defineProps<{
open: boolean
providerId: string
/** @deprecated 作用域功能已废弃,此 prop 将在后续版本移除 */
/** @deprecated */
providerApiFormats?: string[]
models: Model[]
editingGroup?: AliasGroup | null
preselectedModelId?: string | null
hasAutoFetchKey?: boolean
}>()
const emit = defineEmits<{
@@ -296,23 +212,14 @@ const emit = defineEmits<{
}>()
const { error: showError, success: showSuccess } = useToast()
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
// 状态
const submitting = ref(false)
const loadingModels = ref(false)
const fetchingUpstreamModels = ref(false)
const upstreamModelsLoaded = ref(false)
// 搜索
const searchQuery = ref('')
// 折叠状态
const collapsedGroups = ref<Set<string>>(new Set())
// 上游模型
const upstreamModels = ref<UpstreamModel[]>([])
// 表单数据
const formData = ref<{
modelId: string
@@ -326,26 +233,9 @@ const selectedNames = ref<string[]>([])
// 自定义名称列表(手动添加的)
const allCustomNames = ref<string[]>([])
// 所有已知名称集合
const allKnownNames = computed(() => {
const set = new Set<string>()
upstreamModels.value.forEach(m => set.add(m.id))
return set
})
// 上游模型名称列表(去重后)
const upstreamModelNames = computed(() => {
const names = new Set<string>()
upstreamModels.value.forEach(m => {
names.add(m.id)
})
return Array.from(names).sort()
})
// 自定义名称列表(排除上游模型中已有的)
// 自定义名称列表
const customNames = computed(() => {
const upstreamSet = new Set(upstreamModelNames.value)
return allCustomNames.value.filter(name => !upstreamSet.has(name))
return allCustomNames.value
})
// 排序后的自定义名称
@@ -369,31 +259,14 @@ const sortedCustomNames = computed(() => {
const canAddAsCustom = computed(() => {
const search = searchQuery.value.trim()
if (!search) return false
// 已经选中了就不显示
if (selectedNames.value.includes(search)) return false
// 已经在自定义列表中就不显示
if (allCustomNames.value.includes(search)) return false
// 精确匹配上游模型就不显示
if (upstreamModelNames.value.includes(search)) return false
return true
})
// 过滤后的上游模型
const filteredUpstreamModels = computed(() => {
if (!searchQuery.value.trim()) return upstreamModelNames.value
const query = searchQuery.value.toLowerCase()
return upstreamModelNames.value.filter(name => name.toLowerCase().includes(query))
})
// 空状态判断
const showEmptyState = computed(() => {
return filteredUpstreamModels.value.length === 0 && customNames.value.length === 0
})
// 上游模型是否全选
const isAllUpstreamModelsSelected = computed(() => {
if (filteredUpstreamModels.value.length === 0) return false
return filteredUpstreamModels.value.every(name => selectedNames.value.includes(name))
return customNames.value.length === 0
})
// 切换名称选中状态
@@ -411,73 +284,17 @@ function addCustomName() {
const name = searchQuery.value.trim()
if (name && !selectedNames.value.includes(name)) {
selectedNames.value.push(name)
if (!allKnownNames.value.has(name) && !allCustomNames.value.includes(name)) {
if (!allCustomNames.value.includes(name)) {
allCustomNames.value.push(name)
}
searchQuery.value = ''
}
}
// 全选/取消全选上游模型
function toggleAllUpstreamModels() {
const allNames = filteredUpstreamModels.value
if (isAllUpstreamModelsSelected.value) {
selectedNames.value = selectedNames.value.filter(name => !allNames.includes(name))
} else {
allNames.forEach(name => {
if (!selectedNames.value.includes(name)) {
selectedNames.value.push(name)
}
})
}
}
// 切换折叠状态
function toggleGroupCollapse(group: string) {
if (collapsedGroups.value.has(group)) {
collapsedGroups.value.delete(group)
} else {
collapsedGroups.value.add(group)
}
collapsedGroups.value = new Set(collapsedGroups.value)
}
// 从提供商获取模型(使用缓存)
async function fetchUpstreamModels() {
if (!props.providerId) return
try {
loadingModels.value = true
fetchingUpstreamModels.value = true
const result = await fetchCachedModels(props.providerId)
if (result.models.length > 0) {
upstreamModels.value = result.models
upstreamModelsLoaded.value = true
// 获取上游模型后,将不在上游列表中的已选名称添加到自定义列表
const upstreamIds = new Set(result.models.map(m => m.id))
const customFromSelected = selectedNames.value.filter(name => !upstreamIds.has(name))
// 合并现有自定义名称和从已选中提取的自定义名称
const mergedCustom = new Set([...allCustomNames.value, ...customFromSelected])
allCustomNames.value = Array.from(mergedCustom).filter(name => !upstreamIds.has(name))
}
if (result.error) {
showError(result.error, '获取上游模型失败')
}
} catch (err: unknown) {
showError(parseApiError(err, '获取上游模型列表失败'), '错误')
} finally {
loadingModels.value = false
fetchingUpstreamModels.value = false
}
}
// 监听打开状态
watch(() => props.open, async (isOpen) => {
if (isOpen) {
initForm()
// 只有在有 key 配置了自动获取时才自动加载上游模型
if (props.hasAutoFetchKey) {
await fetchUpstreamModels()
}
}
})
@@ -489,7 +306,6 @@ function initForm() {
}
const existingNames = props.editingGroup.aliases.map(a => a.name)
selectedNames.value = [...existingNames]
// 将已有映射名称添加到自定义列表,使其在列表中可见(可取消选中来移除映射)
allCustomNames.value = [...existingNames]
} else {
formData.value = {
@@ -499,10 +315,6 @@ function initForm() {
allCustomNames.value = []
}
searchQuery.value = ''
upstreamModels.value = []
upstreamModelsLoaded.value = false
// 默认展开所有分组
collapsedGroups.value = new Set()
}
// 处理模型选择变更
@@ -532,7 +344,6 @@ async function handleSubmit() {
const currentAliases = targetModel.provider_model_mappings || []
let newAliases: ProviderModelAlias[]
// 为每个选中的名称创建映射(所有映射使用相同优先级,实现同级负载均衡)
const buildAliases = (names: string[]): ProviderModelAlias[] => {
return names.map((name) => ({
name: name.trim(),

View File

@@ -159,7 +159,7 @@
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs font-medium text-muted-foreground">TOTP Secret (可选)</label>
<label class="text-xs font-medium text-muted-foreground">TOTP Secret (可选, 2FA认证)</label>
<input
v-model="device.totp_secret"
type="text"

View File

@@ -51,7 +51,7 @@
</Button>
</span>
<Popover
v-if="provider.provider_type !== 'custom'"
v-if="provider.pool_advanced"
:open="providerProxyPopoverOpen"
@update:open="handleProviderProxyPopoverToggle"
>
@@ -427,9 +427,9 @@
>
<Shield class="w-3.5 h-3.5" />
</Button>
<!-- 代理节点配置 custom 类型显示 -->
<!-- 代理节点配置号池模式显示 -->
<Popover
v-if="provider.provider_type !== 'custom'"
v-if="provider.pool_advanced"
:open="proxyPopoverOpenKeyId === key.id"
@update:open="(v: boolean) => handleProxyPopoverToggle(key.id, v)"
>

View File

@@ -37,11 +37,14 @@
<SelectValue placeholder="请选择" />
</SelectTrigger>
<SelectContent>
<!-- 新建模式允许自定义CodexKiro Antigravity -->
<!-- 新建模式允许自定义及各反代类型 -->
<template v-if="!isEditMode">
<SelectItem value="custom">
自定义
</SelectItem>
<SelectItem value="claude_code">
ClaudeCode
</SelectItem>
<SelectItem value="codex">
Codex
</SelectItem>
@@ -216,14 +219,15 @@
</div>
</div>
<!-- 格式转换配置 -->
<!-- 功能开关 -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
格式转换
功能开关
</h3>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">保持优先级</span>
<span class="text-sm font-medium">格式转换保持优先级</span>
<p class="text-xs text-muted-foreground">
跨格式请求时保持原优先级排名,不降级到格式匹配的提供商之后
</p>
@@ -233,30 +237,17 @@
@update:model-value="(v: boolean) => form.keep_priority_on_conversion = v"
/>
</div>
</div>
<!-- 代理配置 -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium">
代理配置
</h3>
<div class="flex items-center gap-2">
<Switch
:model-value="form.proxy_enabled"
@update:model-value="handleProxyToggle"
/>
<span class="text-sm text-muted-foreground">启用代理</span>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">号池调度模式</span>
<p class="text-xs text-muted-foreground">
启用后该提供商的密钥将由号池统一调度
</p>
</div>
</div>
<div
v-if="form.proxy_enabled"
class="space-y-1.5 p-3 border rounded-lg bg-muted/50"
>
<Label class="text-xs">代理节点 *</Label>
<ProxyNodeSelect
ref="proxyNodeSelectRef"
v-model="form.proxy_node_id"
<Switch
:model-value="form.pool_mode_enabled"
@update:model-value="(v: boolean) => form.pool_mode_enabled = v"
/>
</div>
</div>
@@ -282,7 +273,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import {
Dialog,
Button,
@@ -301,8 +292,6 @@ import { useFormDialog } from '@/composables/useFormDialog'
import { createProvider, updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
import { parseApiError } from '@/utils/errorParser'
import { parseNumberInput } from '@/utils/form'
import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
const props = defineProps<{
modelValue: boolean
@@ -318,16 +307,6 @@ const emit = defineEmits<{
const { success, error: showError } = useToast()
const loading = ref(false)
const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null)
const proxyNodesStore = useProxyNodesStore()
/** 启用代理时懒加载节点列表 */
function handleProxyToggle(v: boolean) {
form.value.proxy_enabled = v
if (v) {
proxyNodesStore.ensureLoaded()
}
}
// 内部状态
const internalOpen = computed(() => props.modelValue)
@@ -363,9 +342,8 @@ const form = ref({
// 超时配置(秒)
stream_first_byte_timeout: undefined as number | undefined,
request_timeout: undefined as number | undefined,
// 代理配置
proxy_enabled: false,
proxy_node_id: '',
// 号池模式
pool_mode_enabled: false,
})
// 重置表单
@@ -390,9 +368,8 @@ function resetForm() {
// 超时配置
stream_first_byte_timeout: undefined,
request_timeout: undefined,
// 代理配置
proxy_enabled: false,
proxy_node_id: '',
// 号池模式
pool_mode_enabled: false,
}
}
@@ -400,7 +377,6 @@ function resetForm() {
function loadProviderData() {
if (!props.provider) return
const proxy = props.provider.proxy
form.value = {
name: props.provider.name,
provider_type: props.provider.provider_type || 'custom',
@@ -423,14 +399,8 @@ function loadProviderData() {
// 超时配置
stream_first_byte_timeout: props.provider.stream_first_byte_timeout ?? undefined,
request_timeout: props.provider.request_timeout ?? undefined,
// 代理配置
proxy_enabled: proxy?.enabled ?? false,
proxy_node_id: proxy?.node_id || '',
}
// 如果有代理配置,确保加载节点列表(直接调用 store避免 ref 未挂载时静默失败)
if (proxy?.enabled) {
proxyNodesStore.ensureLoaded()
// 号池模式
pool_mode_enabled: !!props.provider.pool_advanced,
}
}
@@ -444,6 +414,13 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
resetForm,
})
// 新建模式下切换 provider_type 时自动设置号池模式:非自定义类型默认开启
watch(() => form.value.provider_type, (newType) => {
if (!isEditMode.value) {
form.value.pool_mode_enabled = newType !== 'custom'
}
})
// 提交表单
const handleSubmit = async () => {
// 月卡类型必须设置周期开始时间
@@ -452,20 +429,8 @@ const handleSubmit = async () => {
return
}
// 启用代理时的验证
if (form.value.proxy_enabled && !form.value.proxy_node_id) {
showError('请选择代理节点', '验证失败')
return
}
loading.value = true
try {
// 构建代理配置
const proxy = form.value.proxy_enabled && form.value.proxy_node_id ? {
node_id: form.value.proxy_node_id,
enabled: true,
} : null
const payload = {
name: form.value.name,
provider_type: form.value.provider_type,
@@ -484,7 +449,9 @@ const handleSubmit = async () => {
// 超时配置null 表示清除,使用全局配置)
stream_first_byte_timeout: form.value.stream_first_byte_timeout ?? null,
request_timeout: form.value.request_timeout ?? null,
proxy,
pool_advanced: form.value.pool_mode_enabled
? (props.provider?.pool_advanced ?? {})
: null,
}
if (isEditMode.value && props.provider) {

View File

@@ -13,3 +13,4 @@ export { default as OAuthKeyEditDialog } from './OAuthKeyEditDialog.vue'
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
export { default as ProviderAuthDialog } from './ProviderAuthDialog.vue'
export { default as PoolStatusCard } from './PoolStatusCard.vue'

View File

@@ -325,7 +325,6 @@
:models="models"
:editing-group="editingGroup"
:preselected-model-id="preselectedModelId"
:has-auto-fetch-key="hasAutoFetchKey"
@saved="onDialogSaved"
/>
@@ -424,11 +423,6 @@ const providerKeysState = computed(() => props.providerKeys ?? [])
// 展开状态
const expandedItems = ref<Set<string>>(new Set())
// 是否有 key 配置了自动获取上游模型
const hasAutoFetchKey = computed(() => {
return providerKeysState.value.some(k => k.auto_fetch_models)
})
// 生成作用域唯一键
function getApiFormatsKey(formats: string[] | undefined): string {
if (!formats || formats.length === 0) return ''

View File

@@ -252,6 +252,62 @@
</span>
</span>
</div>
<div
v-if="currentAttempt.extra_data?.pool_selection"
class="info-item"
>
<span class="info-label">号池调度</span>
<span class="info-value info-value-stacked">
<span class="pool-reason">
<span
class="pool-reason-tag"
:class="'pool-' + currentAttempt.extra_data.pool_selection.reason"
>
{{ poolSelectionLabel(currentAttempt.extra_data.pool_selection.reason) }}
</span>
<span
v-if="currentAttempt.extra_data.pool_selection.cost_soft_threshold"
class="pool-cost-warn"
>接近限额</span>
</span>
<span
v-if="currentAttempt.extra_data.pool_selection.cost_window_usage"
class="text-xs text-muted-foreground"
>
{{ formatNumber(currentAttempt.extra_data.pool_selection.cost_window_usage) }}
<template v-if="currentAttempt.extra_data.pool_selection.cost_limit">
/ {{ formatNumber(currentAttempt.extra_data.pool_selection.cost_limit) }}
</template>
tokens
</span>
</span>
</div>
<div
v-if="currentAttempt.extra_data?.pool_skip"
class="info-item"
>
<span class="info-label">号池跳过</span>
<span class="info-value info-value-stacked">
<span class="pool-skip-type">
{{ poolSkipLabel(currentAttempt.extra_data.pool_skip.type) }}
</span>
<span
v-if="currentAttempt.extra_data.pool_skip.cooldown_reason"
class="text-xs text-muted-foreground"
>
{{ currentAttempt.extra_data.pool_skip.cooldown_reason }}
<template v-if="currentAttempt.extra_data.pool_skip.cooldown_ttl != null">
({{ currentAttempt.extra_data.pool_skip.cooldown_ttl }}s)
</template>
</span>
<span
v-if="currentAttempt.extra_data.pool_skip.cost_window_usage"
class="text-xs text-muted-foreground"
>
{{ formatNumber(currentAttempt.extra_data.pool_skip.cost_window_usage) }} tokens
</span>
</span>
</div>
<div
v-if="mergedCapabilities.length > 0"
class="info-item"
@@ -764,6 +820,25 @@ const formatCapabilityLabel = (cap: string): string => {
return labels[cap] || cap
}
const poolSelectionLabel = (reason: string): string => {
const labels: Record<string, string> = {
sticky: '粘性会话',
lru: 'LRU',
random: '随机',
tiebreak: '随机 (平分)',
}
return labels[reason] || reason
}
const poolSkipLabel = (type: string): string => {
const labels: Record<string, string> = {
cooldown: '冷却中',
cost_exhausted: '额度耗尽',
upstream: '上游跳过',
}
return labels[type] || type
}
// 检查组是否被悬浮
const isGroupHovered = (groupIndex: number) => {
return hoveredGroupIndex.value === groupIndex
@@ -1485,6 +1560,52 @@ const getStatusColorClass = (status: string) => {
gap: 0.375rem;
}
/* 号池调度 */
.pool-reason {
display: flex;
align-items: center;
gap: 0.375rem;
}
.pool-reason-tag {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
font-size: 0.7rem;
font-weight: 500;
border-radius: 4px;
white-space: nowrap;
border: 1px solid hsl(var(--border));
}
.pool-reason-tag.pool-sticky {
color: hsl(var(--chart-4));
border-color: hsl(var(--chart-4) / 0.3);
background: hsl(var(--chart-4) / 0.08);
}
.pool-reason-tag.pool-lru {
color: hsl(var(--chart-2));
border-color: hsl(var(--chart-2) / 0.3);
background: hsl(var(--chart-2) / 0.08);
}
.pool-reason-tag.pool-random,
.pool-reason-tag.pool-tiebreak {
color: hsl(var(--muted-foreground));
}
.pool-cost-warn {
font-size: 0.65rem;
color: hsl(var(--chart-5));
font-weight: 500;
}
.pool-skip-type {
font-weight: 500;
color: hsl(var(--muted-foreground));
}
/* 能力标签 */
.capability-tags {
display: flex;

View File

@@ -370,6 +370,51 @@
</div>
</Card>
<!-- 号池调度摘要 -->
<Card v-if="poolSummary">
<div class="p-3 sm:p-4">
<div class="text-xs text-muted-foreground mb-2 font-medium">
号池调度
</div>
<div class="flex items-center gap-3 flex-wrap text-sm">
<span class="font-mono">
{{ poolSummary.total_keys }} 候选
</span>
<span class="text-muted-foreground">|</span>
<span class="font-mono">
{{ poolSummary.attempted }} 尝试
</span>
<template v-if="poolSummary.skipped_cooldown > 0">
<span class="text-muted-foreground">|</span>
<span class="font-mono text-amber-600 dark:text-amber-400">
{{ poolSummary.skipped_cooldown }} 冷却跳过
</span>
</template>
<template v-if="poolSummary.skipped_cost > 0">
<span class="text-muted-foreground">|</span>
<span class="font-mono text-orange-600 dark:text-orange-400">
{{ poolSummary.skipped_cost }} 成本跳过
</span>
</template>
<template v-if="poolSummary.sticky_session">
<span class="text-muted-foreground">|</span>
<Badge
variant="outline"
class="text-[10px] px-1.5 py-0 h-4"
>
粘性会话
</Badge>
</template>
<template v-if="poolSummary.success_reason">
<span class="text-muted-foreground">|</span>
<span class="text-xs text-muted-foreground">
{{ poolSummary.success_reason }}
</span>
</template>
</div>
</div>
</Card>
<!-- 请求链路追踪卡片 -->
<div v-if="detail.request_id || detail.id">
<HorizontalRequestTimeline
@@ -733,6 +778,13 @@ const isDark = computed(() => {
return document.documentElement.classList.contains('dark')
})
// 号池调度摘要
const poolSummary = computed(() => {
const ps = detail.value?.metadata?.pool_summary as Record<string, unknown> | undefined
if (!ps || !ps.enabled) return null
return ps
})
// 检测是否有提供商请求头
const hasProviderHeaders = computed(() => {
return !!(detail.value?.provider_request_headers &&

View File

@@ -357,6 +357,7 @@ import {
Gauge,
Layers,
FolderTree,
Database,
Box,
LogOut,
SunMoon,
@@ -554,6 +555,7 @@ const navigation = computed(() => {
items: [
{ name: '用户管理', href: '/admin/users', icon: Users },
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
{ name: '号池管理', href: '/admin/pool', icon: Database },
{ name: '模型管理', href: '/admin/models', icon: Layers },
{ name: '独立密钥', href: '/admin/keys', icon: Key },
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },

View File

@@ -160,6 +160,11 @@ const routes: RouteRecordRaw[] = [
name: 'ProviderManagement',
component: () => importWithRetry(() => import('@/views/admin/ProviderManagement.vue'))
},
{
path: 'pool',
name: 'PoolManagement',
component: () => importWithRetry(() => import('@/views/admin/PoolManagement.vue'))
},
{
path: 'models',
name: 'ModelManagement',