refactor: 大规模模块拆分与代码精简,新增 ai-pipeline/data-contracts 独立 crate

- 新增 aether-ai-pipeline 和 aether-data-contracts crate,将 pipeline 逻辑与数据契约从 gateway 中解耦
- 重构 admin handlers:拆分单体模块为 auth/billing/endpoint/features/model/observability/provider/system 等独立子模块
- 合并 chat/cli 重复代码路径:精简 conversion、finalize、planner 中的 sync/chat/cli 分支
- 重构 scheduler/executor/data 层,引入 facade 模式降低模块间耦合
- 移除冗余的 intent 模块,将 plan_fallback/policy/stream_path/sync_path 迁移至 executor
- 前端适配:调整 admin API 调用和 provider 模型测试对话框
This commit is contained in:
fawney19
2026-04-07 02:50:19 +08:00
parent 763ff03a7b
commit 5d96d6673b
732 changed files with 28593 additions and 20666 deletions

View File

@@ -349,7 +349,7 @@ export interface AdminApiKey {
is_active: boolean
is_standalone: boolean // 是否为独立余额Key
total_requests?: number
total_tokens?: number
total_tokens?: number | null
total_cost_usd?: number
rate_limit?: number | null // null = 跟随系统默认0 = 不限制
allowed_providers?: string[] | null // 允许的提供商列表
@@ -360,6 +360,7 @@ export interface AdminApiKey {
expires_at?: string
created_at: string
updated_at?: string
wallet?: BillingSummary | null
}
export interface CreateStandaloneApiKeyRequest {
@@ -501,6 +502,7 @@ export const adminApi = {
skip?: number
limit?: number
is_active?: boolean
include_usage_summary?: boolean
}): Promise<AdminApiKeysResponse> {
const response = await apiClient.get<AdminApiKeysResponse>('/api/admin/api-keys', {
params

View File

@@ -75,6 +75,6 @@ export async function deleteEndpoint(endpointId: string): Promise<{ message: str
export async function getDefaultBodyRules(apiFormat: string, providerType?: string): Promise<{ api_format: string; body_rules: BodyRule[] }> {
const params: Record<string, string> = {}
if (providerType) params.provider_type = providerType
const response = await client.get(`/api/admin/endpoints/defaults/${encodeURIComponent(apiFormat)}/body-rules`, { params })
const response = await client.get(`/api/admin/endpoints/defaults/${apiFormat}/body-rules`, { params })
return response.data
}

View File

@@ -194,6 +194,7 @@ export interface TestModelFailoverRequest {
provider_id: string
mode: 'global' | 'direct'
model_name: string
failover_models?: string[]
api_format?: string
endpoint_id?: string
message?: string
@@ -239,7 +240,14 @@ export async function testModelFailover(
data: TestModelFailoverRequest,
options: { signal?: AbortSignal } = {}
): Promise<TestModelFailoverResponse> {
const response = await client.post('/api/admin/provider-query/test-model-failover', data, {
const normalizedModelName = typeof data.model_name === 'string' ? data.model_name.trim() : ''
const failoverModels = Array.isArray(data.failover_models) && data.failover_models.length > 0
? data.failover_models
: (normalizedModelName ? [normalizedModelName] : undefined)
const response = await client.post('/api/admin/provider-query/test-model-failover', {
...data,
...(failoverModels ? { failover_models: failoverModels } : {}),
}, {
timeout: 10 * 60 * 1000,
signal: options.signal,
})

View File

@@ -53,6 +53,14 @@ export function useModelTest(options: UseModelTestOptions) {
return `provider-test-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
}
function resultHasTraceContext(result: TestModelFailoverResponse): boolean {
if (result.success) return true
if (Array.isArray(result.attempts) && result.attempts.length > 0) return true
if (typeof result.total_attempts === 'number' && result.total_attempts > 0) return true
if (typeof result.total_candidates === 'number' && result.total_candidates > 0) return true
return false
}
async function pollTestTrace(reqId: string, token: number) {
try {
const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false })
@@ -90,7 +98,6 @@ export function useModelTest(options: UseModelTestOptions) {
requestId.value = reqId
testTrace.value = null
const token = ++tracePollToken
void pollTestTrace(reqId, token)
tracePollTimer = setInterval(() => {
void pollTestTrace(reqId, token)
}, pollInterval)
@@ -137,6 +144,7 @@ export function useModelTest(options: UseModelTestOptions) {
provider_id: providerId(),
mode: params.mode,
model_name: params.modelName,
failover_models: [params.modelName],
api_format: params.apiFormat,
endpoint_id: params.endpointId,
...(normalizedMessage ? { message: normalizedMessage } : {}),
@@ -148,9 +156,14 @@ export function useModelTest(options: UseModelTestOptions) {
signal: abortController.signal,
})
const keepTraceContext = resultHasTraceContext(result)
if (result.success) {
await refreshTraceSnapshot(reqId)
stopPolling({ clearState: false })
if (keepTraceContext) {
await refreshTraceSnapshot(reqId)
stopPolling({ clearState: false })
} else {
stopPolling()
}
testResult.value = result
const successAttempt = result.attempts.find(a => a.status === 'success')
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
@@ -162,8 +175,12 @@ export function useModelTest(options: UseModelTestOptions) {
return
}
await refreshTraceSnapshot(reqId)
stopPolling({ clearState: false })
if (keepTraceContext) {
await refreshTraceSnapshot(reqId)
stopPolling({ clearState: false })
} else {
stopPolling()
}
const handled = params.onFailure?.(result)
if (!handled) {
testResult.value = result

View File

@@ -205,7 +205,7 @@
请求体
</Button>
<Button
v-if="isFixedProvider"
v-if="isFixedProvider && hasDefaultBodyRules(endpoint.api_format)"
variant="ghost"
size="sm"
class="h-7 text-xs px-2"
@@ -1240,11 +1240,21 @@ const deleteConfirmDescription = computed(() => {
return `确定要删除 ${formatLabel} 端点吗?关联密钥将移除对该 API 格式的支持。`
})
function defaultBodyRulesCacheKey(apiFormat: string): string {
const providerType = (props.provider?.provider_type || '').toLowerCase()
return providerType ? `${apiFormat}:${providerType}` : apiFormat
}
function hasDefaultBodyRules(apiFormat: string): boolean {
const cacheKey = defaultBodyRulesCacheKey(apiFormat)
if (!defaultBodyRulesLoaded.value[cacheKey]) return false
return (defaultBodyRulesByFormat.value[cacheKey]?.length || 0) > 0
}
async function loadDefaultBodyRulesForFormat(apiFormat: string, force = false): Promise<BodyRule[]> {
if (!apiFormat) return []
const providerType = (props.provider?.provider_type || '').toLowerCase()
// 缓存 key 需要包含 provider_type不同类型的 provider 有不同的默认规则
const cacheKey = providerType ? `${apiFormat}:${providerType}` : apiFormat
const cacheKey = defaultBodyRulesCacheKey(apiFormat)
if (!force && defaultBodyRulesLoaded.value[cacheKey]) {
return defaultBodyRulesByFormat.value[cacheKey] || []
}
@@ -2222,7 +2232,7 @@ watch(() => props.endpoints, (endpoints) => {
}
}
const newFormats = localEndpoints.value
.filter(e => e.api_format && !defaultBodyRulesLoaded.value[e.api_format])
.filter(e => e.api_format && !defaultBodyRulesLoaded.value[defaultBodyRulesCacheKey(e.api_format)])
.map(e => ({ api_format: e.api_format }) as ProviderEndpoint)
if (newFormats.length) {
void preloadDefaultBodyRules(newFormats)

View File

@@ -499,7 +499,7 @@
v-else-if="!showTraceTimeline"
class="py-4 text-center text-sm text-muted-foreground"
>
没有可用的候选进行测试
{{ resultEmptyMessage }}
</div>
<div
@@ -734,19 +734,33 @@ const dialogDescription = computed(() => {
return ''
})
const resultAttempts = computed(() => props.result?.attempts ?? [])
const hasEffectiveModel = computed(() => {
if (!props.result) return false
return props.result.attempts.some(attempt => attempt.effective_model && attempt.effective_model !== props.result?.model)
return resultAttempts.value.some(
attempt => attempt.effective_model && attempt.effective_model !== props.result?.model,
)
})
const showEndpointColumn = computed(() => {
if (!props.result) return false
if (props.mode === 'direct') return true
const formats = new Set(props.result.attempts.map(attempt => attempt.endpoint_api_format))
const formats = new Set(resultAttempts.value.map(attempt => attempt.endpoint_api_format))
return formats.size > 1
})
const resultAttempts = computed(() => props.result?.attempts ?? [])
const resultEmptyMessage = computed(() => {
if (!props.result) return '没有可用的候选进行测试'
if (typeof props.result.error === 'string' && props.result.error.trim()) {
return props.result.error.trim()
}
const rawResult = props.result as TestModelFailoverResponse & { message?: string }
if (typeof rawResult.message === 'string' && rawResult.message.trim()) {
return rawResult.message.trim()
}
return '没有可用的候选进行测试'
})
const showAllAttempts = ref(false)
const inspectionTab = ref<'request-headers' | 'request-body' | 'response-headers' | 'response-body'>('request-body')
const selectedInspectionKey = ref<string | null>(null)

View File

@@ -103,7 +103,10 @@
>
<span class="flex items-center gap-1">
<span class="font-medium text-foreground">ID:</span>
<span class="font-mono">{{ detail.request_id || detail.id }}</span>
<span
class="font-mono"
:title="fullRequestId"
>{{ displayRequestId }}</span>
</span>
<span class="opacity-40">|</span>
<span>{{ formatDateTime(detail.created_at) }}</span>
@@ -698,6 +701,7 @@ import TabsContent from '@/components/ui/tabs-content.vue'
import { Copy, Check, Maximize2, Minimize2, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
import { dashboardApi, type RequestDetail } from '@/api/dashboard'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { formatShortRequestId } from '@/utils/format'
import { log } from '@/utils/logger'
// 子组件
@@ -770,6 +774,9 @@ let bodyLoadRequestId = 0
let loadDetailInFlight = false
let timelineMountTimer: ReturnType<typeof setTimeout> | null = null
const fullRequestId = computed(() => detail.value?.request_id || detail.value?.id || '-')
const displayRequestId = computed(() => formatShortRequestId(fullRequestId.value))
// 监听标签页切换
watch(activeTab, (newTab) => {
if (!['request-headers', 'response-headers'].includes(newTab) && viewMode.value === 'compare') {

View File

@@ -181,3 +181,16 @@ export function isRateLimitInherited(rateLimit?: number | null): boolean {
export function isRateLimitUnlimited(rateLimit?: number | null): boolean {
return rateLimit === 0
}
export function formatShortRequestId(value: string | null | undefined): string {
const trimmed = value?.trim()
if (!trimmed) return '-'
if (trimmed.length <= 12) return trimmed
const uuidLike = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)
if (uuidLike) {
return trimmed.slice(0, 8)
}
return `${trimmed.slice(0, 6)}...${trimmed.slice(-4)}`
}

View File

@@ -221,7 +221,7 @@
请求: <span class="font-medium text-foreground">{{ (apiKey.total_requests || 0).toLocaleString() }}</span>
</div>
<div class="text-muted-foreground">
Tokens: <span class="font-medium text-foreground">{{ formatTokens(apiKey.total_tokens || 0) }}</span>
Tokens: <span class="font-medium text-foreground">{{ formatApiKeyTotalTokens(apiKey) }}</span>
</div>
<div class="flex items-center gap-1 text-muted-foreground">
<span>限速:</span>
@@ -464,7 +464,7 @@
Tokens
</div>
<div class="font-semibold text-foreground">
{{ formatTokens(apiKey.total_tokens || 0) }}
{{ formatApiKeyTotalTokens(apiKey) }}
</div>
</div>
<div class="col-span-2 rounded-lg border border-border/50 bg-background/70 p-2.5">
@@ -644,7 +644,7 @@ import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
import type { AdminWallet } from '@/api/admin-wallets'
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
@@ -781,40 +781,42 @@ onMounted(async () => {
await refreshApiKeys()
})
async function fetchApiKeyWalletMap(): Promise<Record<string, AdminWallet>> {
const wallets = await adminWalletApi.listAllWallets({ owner_type: 'api_key' })
return wallets
.filter((wallet) => !!wallet.api_key_id)
.reduce<Record<string, AdminWallet>>((acc, wallet) => {
acc[wallet.api_key_id as string] = wallet
return acc
}, {})
function buildAdminWalletFromApiKey(apiKey: AdminApiKey): AdminWallet | null {
if (!apiKey.wallet?.id) {
return null
}
return {
...apiKey.wallet,
id: apiKey.wallet.id,
user_id: null,
api_key_id: apiKey.id,
owner_type: 'api_key',
owner_name: apiKey.name || apiKey.key_display || null,
created_at: apiKey.created_at || apiKey.wallet.updated_at || '',
}
}
async function loadApiKeyWallets() {
try {
apiKeyWalletMap.value = await fetchApiKeyWalletMap()
} catch (err: unknown) {
log.error('加载独立 Key 钱包失败:', err)
}
function buildApiKeyWalletMap(items: AdminApiKey[]): Record<string, AdminWallet> {
return items.reduce<Record<string, AdminWallet>>((acc, apiKey) => {
const wallet = buildAdminWalletFromApiKey(apiKey)
if (wallet) {
acc[apiKey.id] = wallet
}
return acc
}, {})
}
async function refreshApiKeys() {
loading.value = true
try {
const [response, walletMap] = await Promise.all([
adminApi.getAllApiKeys({
skip: skip.value,
limit: limit.value
}),
fetchApiKeyWalletMap().catch((err: unknown) => {
log.error('加载独立 Key 钱包失败:', err)
return apiKeyWalletMap.value
})
])
const response = await adminApi.getAllApiKeys({
skip: skip.value,
limit: limit.value
})
apiKeys.value = response.api_keys
total.value = response.total
apiKeyWalletMap.value = walletMap
apiKeyWalletMap.value = buildApiKeyWalletMap(response.api_keys)
} catch (err: unknown) {
log.error('加载独立Keys失败:', err)
error(parseApiError(err, '加载独立 Keys 失败'))
@@ -913,6 +915,13 @@ function getApiKeyWalletStatus(apiKeyId: string): string | null {
return getApiKeyWallet(apiKeyId)?.status ?? null
}
function formatApiKeyTotalTokens(apiKey: AdminApiKey): string {
if (apiKey.total_tokens == null) {
return '未统计'
}
return formatTokens(apiKey.total_tokens)
}
function formatWalletAmount(value: number | null, nullLabel = '无限制'): string {
if (value == null) {
return nullLabel
@@ -1064,7 +1073,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models
}
const { message: _, wallet: __, ...updated } = await adminApi.updateApiKey(data.id, updateData)
const { message: _, ...updated } = await adminApi.updateApiKey(data.id, updateData)
// 局部更新:合并字段,避免覆盖丢失列表已有信息
const index = apiKeys.value.findIndex(k => k.id === data.id)
if (index !== -1) {
@@ -1072,8 +1081,8 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
...apiKeys.value[index],
...updated,
}
apiKeyWalletMap.value = buildApiKeyWalletMap(apiKeys.value)
}
await loadApiKeyWallets()
success('API Key 更新成功')
} else {
// 创建