mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// 创建
|
||||
|
||||
Reference in New Issue
Block a user