mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor: 拆分 gateway 单体为独立 crate,新增 systemd 部署方案
将 gateway 内部的 model-fetch、provider-transport、scheduler-core、 usage-runtime、video-tasks-core 模块提取为独立 crate;重构 gateway 内部模块结构(state/router/cache/data/query 等);移除大量遗留模块 文件;新增 systemd 二进制部署骨架及相关文档;更新前端 usage 相关 API 和组件。
This commit is contained in:
@@ -89,6 +89,7 @@ export interface RefundCompleteRequest {
|
||||
export const adminWalletApi = {
|
||||
async listWallets(params?: {
|
||||
status?: string
|
||||
owner_type?: 'user' | 'api_key'
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<AdminWalletListResponse> {
|
||||
@@ -98,6 +99,7 @@ export const adminWalletApi = {
|
||||
|
||||
async listAllWallets(params?: {
|
||||
status?: string
|
||||
owner_type?: 'user' | 'api_key'
|
||||
}): Promise<AdminWallet[]> {
|
||||
const items: AdminWallet[] = []
|
||||
const limit = 200
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import apiClient from './client'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { BillingSummary } from './auth'
|
||||
|
||||
// LDAP 配置导出结构
|
||||
@@ -589,11 +590,13 @@ export const adminApi = {
|
||||
async updateSystemConfig(
|
||||
key: string,
|
||||
value: unknown,
|
||||
description?: string
|
||||
description?: string,
|
||||
requestConfig?: AxiosRequestConfig,
|
||||
): Promise<{ key: string; value: unknown; description?: string }> {
|
||||
const response = await apiClient.put<{ key: string; value: unknown; description?: string }>(
|
||||
`/api/admin/system/configs/${key}`,
|
||||
{ value, description }
|
||||
{ value, description },
|
||||
requestConfig,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { EndpointAPIKey, AllowedModels } from './types'
|
||||
// Re-export types for convenience
|
||||
export type { EndpointAPIKey, AllowedModels }
|
||||
|
||||
interface KeyRequestOptions {
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 能力定义类型
|
||||
*/
|
||||
@@ -179,9 +183,14 @@ export async function updateProviderKey(
|
||||
model_include_patterns: string[] // 模型包含规则
|
||||
model_exclude_patterns: string[] // 模型排除规则
|
||||
proxy: import('./types').ProxyConfig | null // Key 级别代理配置
|
||||
}>
|
||||
}>,
|
||||
requestOptions?: KeyRequestOptions,
|
||||
): Promise<EndpointAPIKey> {
|
||||
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)
|
||||
const response = await client.put(
|
||||
`/api/admin/endpoints/keys/${keyId}`,
|
||||
data,
|
||||
requestOptions,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import type {
|
||||
ProxyConfig,
|
||||
} from './types'
|
||||
|
||||
interface ProviderRequestOptions {
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Providers 摘要(分页)
|
||||
*/
|
||||
@@ -73,9 +77,10 @@ export async function updateProvider(
|
||||
claude_code_advanced: ClaudeCodeAdvancedConfig | null
|
||||
pool_advanced: PoolAdvancedConfig | null
|
||||
failover_rules: FailoverRulesConfig | null
|
||||
}>
|
||||
}>,
|
||||
requestOptions?: ProviderRequestOptions,
|
||||
): Promise<ProviderWithEndpointsSummary> {
|
||||
const response = await client.patch(`/api/admin/providers/${providerId}`, data)
|
||||
const response = await client.patch(`/api/admin/providers/${providerId}`, data, requestOptions)
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,73 @@ export interface UsageFilters {
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
function normalizeActivityHeatmapResponse(payload: unknown): ActivityHeatmap {
|
||||
const today = new Date()
|
||||
const endDate = today.toISOString().slice(0, 10)
|
||||
const start = new Date(today)
|
||||
start.setUTCDate(start.getUTCDate() - 364)
|
||||
const startDate = start.toISOString().slice(0, 10)
|
||||
|
||||
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
||||
const candidate = payload as Partial<ActivityHeatmap>
|
||||
if (Array.isArray(candidate.days)) {
|
||||
return {
|
||||
start_date: typeof candidate.start_date === 'string' ? candidate.start_date : startDate,
|
||||
end_date: typeof candidate.end_date === 'string' ? candidate.end_date : endDate,
|
||||
total_days: typeof candidate.total_days === 'number' ? candidate.total_days : candidate.days.length,
|
||||
max_requests: typeof candidate.max_requests === 'number' ? candidate.max_requests : 0,
|
||||
days: candidate.days,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const grouped = new Map<string, { requests: number; total_tokens: number; total_cost: number; actual_total_cost?: number }>()
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const raw = item as Record<string, unknown>
|
||||
const date = typeof raw.date === 'string' ? raw.date : ''
|
||||
if (!date) continue
|
||||
grouped.set(date, {
|
||||
requests: typeof raw.requests === 'number'
|
||||
? raw.requests
|
||||
: typeof raw.request_count === 'number'
|
||||
? raw.request_count
|
||||
: 0,
|
||||
total_tokens: typeof raw.total_tokens === 'number' ? raw.total_tokens : 0,
|
||||
total_cost: typeof raw.total_cost === 'number' ? raw.total_cost : 0,
|
||||
actual_total_cost: typeof raw.actual_total_cost === 'number' ? raw.actual_total_cost : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const days: ActivityHeatmap['days'] = []
|
||||
let maxRequests = 0
|
||||
const cursor = new Date(start)
|
||||
while (cursor <= today) {
|
||||
const date = cursor.toISOString().slice(0, 10)
|
||||
const existing = grouped.get(date)
|
||||
const requests = existing?.requests ?? 0
|
||||
maxRequests = Math.max(maxRequests, requests)
|
||||
days.push({
|
||||
date,
|
||||
requests,
|
||||
total_tokens: existing?.total_tokens ?? 0,
|
||||
total_cost: existing?.total_cost ?? 0,
|
||||
actual_total_cost: existing?.actual_total_cost,
|
||||
})
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1)
|
||||
}
|
||||
|
||||
return {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
total_days: days.length,
|
||||
max_requests: maxRequests,
|
||||
days,
|
||||
}
|
||||
}
|
||||
|
||||
export const usageApi = {
|
||||
async getUsageRecords(filters?: UsageFilters): Promise<{
|
||||
records: UsageRecord[]
|
||||
@@ -219,6 +286,7 @@ export const usageApi = {
|
||||
first_byte_time_ms: number | null
|
||||
provider?: string | null
|
||||
api_key_name?: string | null
|
||||
provider_key_name?: string | null
|
||||
api_format?: string | null
|
||||
endpoint_api_format?: string | null
|
||||
has_format_conversion?: boolean | null
|
||||
@@ -238,8 +306,8 @@ export const usageApi = {
|
||||
return cachedRequest(
|
||||
'admin-usage-activity-heatmap',
|
||||
async () => {
|
||||
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
|
||||
return response.data
|
||||
const response = await apiClient.get<ActivityHeatmap | unknown[]>('/api/admin/usage/heatmap')
|
||||
return normalizeActivityHeatmapResponse(response.data)
|
||||
},
|
||||
60000
|
||||
)
|
||||
|
||||
@@ -538,6 +538,7 @@ const loadingKeys = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const SAVE_CONCURRENCY = 6
|
||||
const PRIORITY_REQUEST_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
let originalProviderPriorityById = new Map<string, number>()
|
||||
let originalPoolPriorityByProviderId = new Map<string, number | null>()
|
||||
@@ -761,6 +762,11 @@ function snapshotKeyBaseline() {
|
||||
)
|
||||
}
|
||||
|
||||
function snapshotCurrentPriorityBaseline() {
|
||||
snapshotProviderBaseline(sortedProviders.value)
|
||||
snapshotKeyBaseline()
|
||||
}
|
||||
|
||||
function arePriorityMapsEqual(
|
||||
left: Record<string, number> | undefined,
|
||||
right: Record<string, number> | undefined,
|
||||
@@ -1035,7 +1041,9 @@ async function loadKeysByFormat() {
|
||||
try {
|
||||
loadingKeys.value = true
|
||||
const { default: client } = await import('@/api/client')
|
||||
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format')
|
||||
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format', {
|
||||
timeout: PRIORITY_REQUEST_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
// 每个格式独立管理优先级,额外做一次前端归一化兜底,避免历史数据导致重复格式/脏键
|
||||
const data: Record<string, KeyWithMeta[]> = {}
|
||||
@@ -1475,7 +1483,11 @@ async function save() {
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length > 0) {
|
||||
providerTasks.push(() => updateProvider(provider.id, payload))
|
||||
providerTasks.push(() => updateProvider(
|
||||
provider.id,
|
||||
payload,
|
||||
{ timeout: PRIORITY_REQUEST_TIMEOUT_MS },
|
||||
))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1487,7 +1499,11 @@ async function save() {
|
||||
priorityByFormat,
|
||||
))
|
||||
.map(([keyId, priorityByFormat]) => () =>
|
||||
updateProviderKey(keyId, { global_priority_by_format: priorityByFormat })
|
||||
updateProviderKey(
|
||||
keyId,
|
||||
{ global_priority_by_format: priorityByFormat },
|
||||
{ timeout: PRIORITY_REQUEST_TIMEOUT_MS },
|
||||
)
|
||||
)
|
||||
|
||||
await runTasksWithConcurrency([...providerTasks, ...keyTasks])
|
||||
@@ -1497,16 +1513,16 @@ async function save() {
|
||||
await adminApi.updateSystemConfig(
|
||||
'provider_priority_mode',
|
||||
newMode,
|
||||
'Provider/Key 优先级策略:provider(提供商优先模式) 或 global_key(全局Key优先模式)'
|
||||
'Provider/Key 优先级策略:provider(提供商优先模式) 或 global_key(全局Key优先模式)',
|
||||
{ timeout: PRIORITY_REQUEST_TIMEOUT_MS },
|
||||
)
|
||||
await adminApi.updateSystemConfig(
|
||||
'scheduling_mode',
|
||||
schedulingMode.value,
|
||||
'调度模式:cache_affinity(缓存亲和模式) 或 load_balance(负载均衡模式) 或 fixed_order(固定顺序模式)'
|
||||
'调度模式:cache_affinity(缓存亲和模式) 或 load_balance(负载均衡模式) 或 fixed_order(固定顺序模式)',
|
||||
{ timeout: PRIORITY_REQUEST_TIMEOUT_MS },
|
||||
)
|
||||
|
||||
await loadAllProviders()
|
||||
await loadKeysByFormat()
|
||||
snapshotCurrentPriorityBaseline()
|
||||
|
||||
success('优先级已保存')
|
||||
emit('saved')
|
||||
|
||||
@@ -264,9 +264,9 @@
|
||||
:response-time-ms="record.response_time_ms ?? null"
|
||||
/></span>
|
||||
<span
|
||||
v-else-if="record.response_time_ms != null"
|
||||
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
|
||||
class="tabular-nums"
|
||||
>{{ record.first_byte_time_ms != null ? (record.first_byte_time_ms / 1000).toFixed(1) + '/' : '' }}{{ (record.response_time_ms / 1000).toFixed(1) }}s</span>
|
||||
>{{ record.first_byte_time_ms != null ? (record.first_byte_time_ms / 1000).toFixed(1) + '/' : '' }}{{ record.response_time_ms != null ? (record.response_time_ms / 1000).toFixed(1) : '-' }}{{ record.response_time_ms != null ? 's' : '' }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="tabular-nums"
|
||||
@@ -419,11 +419,11 @@
|
||||
<div class="flex flex-col text-xs gap-0.5">
|
||||
<span>{{ record.provider }}</span>
|
||||
<span
|
||||
v-if="record.api_key_name"
|
||||
v-if="record.provider_key_name || record.api_key_name"
|
||||
class="text-muted-foreground truncate"
|
||||
:title="record.api_key_name"
|
||||
:title="record.provider_key_name || record.api_key_name"
|
||||
>
|
||||
{{ record.api_key_name }}
|
||||
{{ record.provider_key_name || record.api_key_name }}
|
||||
<span
|
||||
v-if="record.rate_multiplier && record.rate_multiplier !== 1.0"
|
||||
class="text-foreground/60"
|
||||
@@ -608,7 +608,7 @@
|
||||
</div>
|
||||
<!-- 已完成状态:首字 + 总耗时 -->
|
||||
<div
|
||||
v-else-if="record.response_time_ms != null"
|
||||
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
|
||||
class="flex flex-col items-end text-xs gap-0.5"
|
||||
>
|
||||
<span
|
||||
@@ -619,7 +619,14 @@
|
||||
v-else
|
||||
class="text-muted-foreground"
|
||||
>-</span>
|
||||
<span class="text-muted-foreground tabular-nums">{{ (record.response_time_ms / 1000).toFixed(2) }}s</span>
|
||||
<span
|
||||
v-if="record.response_time_ms != null"
|
||||
class="text-muted-foreground tabular-nums"
|
||||
>{{ (record.response_time_ms / 1000).toFixed(2) }}s</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted-foreground"
|
||||
>-</span>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
|
||||
@@ -395,6 +395,7 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
endpoint_api_format: existing.endpoint_api_format || record.endpoint_api_format,
|
||||
has_format_conversion: existing.has_format_conversion ?? record.has_format_conversion,
|
||||
api_key_name: existing.api_key_name || record.api_key_name,
|
||||
provider_key_name: existing.provider_key_name || record.provider_key_name,
|
||||
rate_multiplier: existing.rate_multiplier ?? record.rate_multiplier,
|
||||
target_model: existing.target_model || record.target_model
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseResponse, renderResponse } from '../registry'
|
||||
|
||||
describe('Conversation stream compatibility', () => {
|
||||
it('parses raw OpenAI chat SSE text from stored usage records', () => {
|
||||
const requestBody = {
|
||||
model: 'gpt-5.4',
|
||||
stream: true,
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
],
|
||||
}
|
||||
const rawSse = [
|
||||
'data: {"id":"chatcmpl_123","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}',
|
||||
'',
|
||||
'data: {"id":"chatcmpl_123","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"Hello from stream"},"finish_reason":null}]}',
|
||||
'',
|
||||
'data: {"id":"chatcmpl_123","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const parsed = parseResponse(rawSse, requestBody, 'openai:chat')
|
||||
expect(parsed.apiFormat).toBe('openai')
|
||||
expect(parsed.isStream).toBe(true)
|
||||
expect(parsed.messages).toHaveLength(1)
|
||||
expect(parsed.messages[0]?.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: 'Hello from stream',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders raw OpenAI CLI SSE text from stored usage records', () => {
|
||||
const requestBody = {
|
||||
model: 'gpt-5.4',
|
||||
stream: true,
|
||||
input: 'Hello',
|
||||
}
|
||||
const rawSse = [
|
||||
'event: response.created',
|
||||
'data: {"type":"response.created","response":{"id":"resp_123","object":"response","model":"gpt-5.4","status":"in_progress"}}',
|
||||
'',
|
||||
'event: response.output_text.delta',
|
||||
'data: {"type":"response.output_text.delta","delta":"Hello from CLI stream"}',
|
||||
'',
|
||||
'event: response.completed',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_123","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello from CLI stream"}]}]}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
const rendered = renderResponse(rawSse, requestBody, 'openai:cli')
|
||||
expect(rendered.error).toBeUndefined()
|
||||
expect(rendered.isStream).toBe(true)
|
||||
expect(rendered.blocks).toHaveLength(1)
|
||||
expect(rendered.blocks[0]).toMatchObject({
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
})
|
||||
|
||||
const firstBlock = rendered.blocks[0]
|
||||
if (!firstBlock || firstBlock.type !== 'message') {
|
||||
throw new Error('expected first render block to be message')
|
||||
}
|
||||
|
||||
expect(firstBlock.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
content: 'Hello from CLI stream',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
* 解析器注册表和统一入口
|
||||
*/
|
||||
|
||||
import type { ApiFormat, ApiFormatParser, ParsedConversation } from './types'
|
||||
import type { ApiFormat, ApiFormatParser, ParsedConversation, StreamResponseBody } from './types'
|
||||
import { createEmptyConversation, isStreamResponse } from './types'
|
||||
import type { RenderResult } from './render'
|
||||
import { createEmptyRenderResult } from './render'
|
||||
@@ -10,6 +10,67 @@ import { claudeParser } from './claude'
|
||||
import { openaiParser } from './openai'
|
||||
import { geminiParser } from './gemini'
|
||||
|
||||
function parseRawSseResponse(responseBody: unknown): StreamResponseBody | null {
|
||||
if (typeof responseBody !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const text = responseBody.trim()
|
||||
if (!text.includes('data:')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const chunks: unknown[] = []
|
||||
const eventBlocks = text.split(/\r?\n\r?\n/)
|
||||
|
||||
for (const block of eventBlocks) {
|
||||
if (!block.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const dataLines = block
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.startsWith('data:'))
|
||||
.map(line => line.slice(5).trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const payload = dataLines.join('\n')
|
||||
if (payload === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
chunks.push(JSON.parse(payload))
|
||||
} catch {
|
||||
// 保持原始数据不变,交给现有解析器兜底
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
stream: true,
|
||||
},
|
||||
chunks,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeResponseBody(responseBody: unknown): unknown {
|
||||
if (isStreamResponse(responseBody)) {
|
||||
return responseBody
|
||||
}
|
||||
|
||||
return parseRawSseResponse(responseBody) ?? responseBody
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析器注册表
|
||||
*/
|
||||
@@ -84,7 +145,11 @@ export function parseRequest(
|
||||
return createEmptyConversation('unknown', '无请求体')
|
||||
}
|
||||
|
||||
const parser = parserRegistry.detectParser(requestBody, responseBody, formatHint)
|
||||
const parser = parserRegistry.detectParser(
|
||||
requestBody,
|
||||
normalizeResponseBody(responseBody),
|
||||
formatHint
|
||||
)
|
||||
if (!parser) {
|
||||
return createEmptyConversation('unknown', '无法识别的 API 格式')
|
||||
}
|
||||
@@ -104,17 +169,18 @@ export function parseResponse(
|
||||
return createEmptyConversation('unknown', '无响应体')
|
||||
}
|
||||
|
||||
const parser = parserRegistry.detectParser(requestBody, responseBody, formatHint)
|
||||
const normalizedResponseBody = normalizeResponseBody(responseBody)
|
||||
const parser = parserRegistry.detectParser(requestBody, normalizedResponseBody, formatHint)
|
||||
if (!parser) {
|
||||
return createEmptyConversation('unknown', '无法识别的 API 格式')
|
||||
}
|
||||
|
||||
// 判断是否为流式响应
|
||||
if (isStreamResponse(responseBody)) {
|
||||
return parser.parseStreamResponse(responseBody.chunks || [])
|
||||
if (isStreamResponse(normalizedResponseBody)) {
|
||||
return parser.parseStreamResponse(normalizedResponseBody.chunks || [])
|
||||
}
|
||||
|
||||
return parser.parseResponse(responseBody)
|
||||
return parser.parseResponse(normalizedResponseBody)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +191,7 @@ export function detectApiFormat(
|
||||
responseBody: unknown,
|
||||
hint?: string
|
||||
): ApiFormat {
|
||||
return parserRegistry.detectFormat(requestBody, responseBody, hint)
|
||||
return parserRegistry.detectFormat(requestBody, normalizeResponseBody(responseBody), hint)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,7 +206,11 @@ export function renderRequest(
|
||||
return createEmptyRenderResult('无请求体')
|
||||
}
|
||||
|
||||
const parser = parserRegistry.detectParser(requestBody, responseBody, formatHint)
|
||||
const parser = parserRegistry.detectParser(
|
||||
requestBody,
|
||||
normalizeResponseBody(responseBody),
|
||||
formatHint
|
||||
)
|
||||
if (!parser) {
|
||||
return createEmptyRenderResult('无法识别的 API 格式')
|
||||
}
|
||||
@@ -160,10 +230,11 @@ export function renderResponse(
|
||||
return createEmptyRenderResult('无响应体')
|
||||
}
|
||||
|
||||
const parser = parserRegistry.detectParser(requestBody, responseBody, formatHint)
|
||||
const normalizedResponseBody = normalizeResponseBody(responseBody)
|
||||
const parser = parserRegistry.detectParser(requestBody, normalizedResponseBody, formatHint)
|
||||
if (!parser) {
|
||||
return createEmptyRenderResult('无法识别的 API 格式')
|
||||
}
|
||||
|
||||
return parser.renderResponse(responseBody)
|
||||
return parser.renderResponse(normalizedResponseBody)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface UsageRecord {
|
||||
} | null
|
||||
provider?: string // 仅管理员可见
|
||||
api_key_name?: string
|
||||
provider_key_name?: string | null
|
||||
rate_multiplier?: number
|
||||
model: string
|
||||
target_model?: string | null // 映射后的目标模型名(若无映射则为空)
|
||||
|
||||
@@ -781,41 +781,46 @@ onMounted(async () => {
|
||||
await refreshApiKeys()
|
||||
})
|
||||
|
||||
async function loadApiKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminApi.getAllApiKeys({
|
||||
skip: skip.value,
|
||||
limit: limit.value
|
||||
})
|
||||
apiKeys.value = response.api_keys
|
||||
total.value = response.total
|
||||
} catch (err: unknown) {
|
||||
log.error('加载独立Keys失败:', err)
|
||||
error(parseApiError(err, '加载独立 Keys 失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
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
|
||||
}, {})
|
||||
}
|
||||
|
||||
async function loadApiKeyWallets() {
|
||||
try {
|
||||
const wallets = await adminWalletApi.listAllWallets()
|
||||
apiKeyWalletMap.value = wallets
|
||||
.filter((wallet) => wallet.owner_type === 'api_key' && !!wallet.api_key_id)
|
||||
.reduce<Record<string, AdminWallet>>((acc, wallet) => {
|
||||
acc[wallet.api_key_id as string] = wallet
|
||||
return acc
|
||||
}, {})
|
||||
apiKeyWalletMap.value = await fetchApiKeyWalletMap()
|
||||
} catch (err: unknown) {
|
||||
log.error('加载独立 Key 钱包失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshApiKeys() {
|
||||
// 先拉取 Key 列表,再拉钱包,避免并发请求导致新钱包映射短暂缺失。
|
||||
await loadApiKeys()
|
||||
await loadApiKeyWallets()
|
||||
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
|
||||
})
|
||||
])
|
||||
apiKeys.value = response.api_keys
|
||||
total.value = response.total
|
||||
apiKeyWalletMap.value = walletMap
|
||||
} catch (err: unknown) {
|
||||
log.error('加载独立Keys失败:', err)
|
||||
error(parseApiError(err, '加载独立 Keys 失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div
|
||||
v-if="!loading && filteredGlobalModels.length > 0"
|
||||
v-if="!loading && paginatedGlobalModels.length > 0"
|
||||
class="xl:hidden divide-y divide-border/40"
|
||||
>
|
||||
<div
|
||||
@@ -294,9 +294,9 @@
|
||||
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-if="!loading && filteredGlobalModels.length > 0"
|
||||
v-if="!loading && totalGlobalModels > 0"
|
||||
:current="catalogCurrentPage"
|
||||
:total="filteredGlobalModels.length"
|
||||
:total="totalGlobalModels"
|
||||
:page-size="catalogPageSize"
|
||||
cache-key="model-management-page-size"
|
||||
@update:current="catalogCurrentPage = $event"
|
||||
@@ -508,7 +508,14 @@
|
||||
<!-- 模型列表 -->
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<template v-if="filteredBatchManageModels.length > 0">
|
||||
<div
|
||||
v-if="batchManageLoading"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
|
||||
<template v-else-if="filteredBatchManageModels.length > 0">
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10"
|
||||
>
|
||||
@@ -569,7 +576,7 @@
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="filteredBatchManageModels.length === 0"
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
||||
>
|
||||
<p class="text-sm">
|
||||
@@ -611,7 +618,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { ref, computed, onBeforeUnmount, watch } from 'vue'
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
@@ -701,12 +708,14 @@ const editingModel = ref<GlobalModelResponse | null>(null)
|
||||
|
||||
// 数据
|
||||
const globalModels = ref<GlobalModelResponse[]>([])
|
||||
const providers = ref<ProviderWithEndpointsSummary[]>([])
|
||||
const GLOBAL_MODELS_FETCH_PAGE_SIZE = 1000
|
||||
const totalGlobalModels = ref(0)
|
||||
const batchManageModels = ref<GlobalModelResponse[]>([])
|
||||
const batchManageLoading = ref(false)
|
||||
const GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE = 1000
|
||||
let globalModelsRequestId = 0
|
||||
let modelSelectionRequestId = 0
|
||||
let modelProvidersRequestId = 0
|
||||
let providersRequestId = 0
|
||||
let batchManageModelsRequestId = 0
|
||||
let providerOptionsRequest: Promise<void> | null = null
|
||||
|
||||
// 模型目录分页
|
||||
@@ -988,15 +997,6 @@ async function saveBatchProviderChanges() {
|
||||
const filteredGlobalModels = computed(() => {
|
||||
let result = globalModels.value
|
||||
|
||||
// 搜索(支持空格分隔的多关键词 AND 搜索)
|
||||
if (searchQuery.value) {
|
||||
const keywords = searchQuery.value.toLowerCase().split(/\s+/).filter(k => k.length > 0)
|
||||
result = result.filter(m => {
|
||||
const searchableText = `${m.name} ${m.display_name || ''}`.toLowerCase()
|
||||
return keywords.every(keyword => searchableText.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
// 能力筛选
|
||||
if (capabilityFilters.value.streaming) {
|
||||
result = result.filter(m => m.config?.streaming !== false)
|
||||
@@ -1018,20 +1018,55 @@ const filteredGlobalModels = computed(() => {
|
||||
})
|
||||
|
||||
// 模型目录分页计算
|
||||
const paginatedGlobalModels = computed(() => {
|
||||
const start = (catalogCurrentPage.value - 1) * catalogPageSize.value
|
||||
const end = start + catalogPageSize.value
|
||||
return filteredGlobalModels.value.slice(start, end)
|
||||
const paginatedGlobalModels = computed(() => filteredGlobalModels.value)
|
||||
|
||||
watch(searchQuery, () => {
|
||||
catalogCurrentPage.value = 1
|
||||
})
|
||||
|
||||
// 搜索或筛选变化时重置到第一页
|
||||
watch([searchQuery, capabilityFilters], () => {
|
||||
watch(catalogPageSize, () => {
|
||||
catalogCurrentPage.value = 1
|
||||
}, { deep: true })
|
||||
})
|
||||
|
||||
const globalModelsQueryParams = computed(() => ({
|
||||
skip: Math.max(0, (catalogCurrentPage.value - 1) * catalogPageSize.value),
|
||||
limit: catalogPageSize.value,
|
||||
search: searchQuery.value.trim() || undefined,
|
||||
}))
|
||||
|
||||
let modelSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function loadGlobalModels() {
|
||||
const requestId = ++globalModelsRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await listGlobalModels(globalModelsQueryParams.value)
|
||||
if (requestId !== globalModelsRequestId) return
|
||||
|
||||
const pageModels = response.models || []
|
||||
const total = typeof response.total === 'number' ? response.total : pageModels.length
|
||||
const totalPages = Math.max(1, Math.ceil(total / Math.max(catalogPageSize.value, 1)))
|
||||
if (total > 0 && catalogCurrentPage.value > totalPages) {
|
||||
catalogCurrentPage.value = totalPages
|
||||
return
|
||||
}
|
||||
|
||||
globalModels.value = pageModels
|
||||
totalGlobalModels.value = total
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== globalModelsRequestId) return
|
||||
log.error('加载模型失败:', err)
|
||||
showError(parseApiError(err, '加载模型失败'), '加载模型失败')
|
||||
} finally {
|
||||
if (requestId === globalModelsRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBatchManageModels() {
|
||||
const requestId = ++batchManageModelsRequestId
|
||||
batchManageLoading.value = true
|
||||
try {
|
||||
const allModels: GlobalModelResponse[] = []
|
||||
let skip = 0
|
||||
@@ -1040,7 +1075,7 @@ async function loadGlobalModels() {
|
||||
while (true) {
|
||||
const response = await listGlobalModels({
|
||||
skip,
|
||||
limit: GLOBAL_MODELS_FETCH_PAGE_SIZE,
|
||||
limit: GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE,
|
||||
})
|
||||
if (expectedTotal === null && typeof response.total === 'number') {
|
||||
expectedTotal = response.total
|
||||
@@ -1051,22 +1086,22 @@ async function loadGlobalModels() {
|
||||
if (expectedTotal !== null && allModels.length >= expectedTotal) {
|
||||
break
|
||||
}
|
||||
if (pageModels.length < GLOBAL_MODELS_FETCH_PAGE_SIZE) {
|
||||
if (pageModels.length < GLOBAL_MODELS_BATCH_FETCH_PAGE_SIZE) {
|
||||
break
|
||||
}
|
||||
|
||||
skip += pageModels.length
|
||||
}
|
||||
|
||||
if (requestId !== globalModelsRequestId) return
|
||||
globalModels.value = allModels
|
||||
if (requestId !== batchManageModelsRequestId) return
|
||||
batchManageModels.value = allModels
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== globalModelsRequestId) return
|
||||
log.error('加载模型失败:', err)
|
||||
if (requestId !== batchManageModelsRequestId) return
|
||||
log.error('加载批量管理模型失败:', err)
|
||||
showError(parseApiError(err, '加载模型失败'), '加载模型失败')
|
||||
} finally {
|
||||
if (requestId === globalModelsRequestId) {
|
||||
loading.value = false
|
||||
if (requestId === batchManageModelsRequestId) {
|
||||
batchManageLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1246,8 +1281,8 @@ function closeBatchAddProvidersDialog() {
|
||||
// 批量管理全局模型 - 过滤
|
||||
const filteredBatchManageModels = computed(() => {
|
||||
const query = batchManageSearchQuery.value.toLowerCase().trim()
|
||||
if (!query) return globalModels.value
|
||||
return globalModels.value.filter(m => {
|
||||
if (!query) return batchManageModels.value
|
||||
return batchManageModels.value.filter(m => {
|
||||
const searchableText = `${m.name} ${m.display_name || ''}`.toLowerCase()
|
||||
return searchableText.includes(query)
|
||||
})
|
||||
@@ -1260,7 +1295,7 @@ function hasNoPrice(m: GlobalModelResponse): boolean {
|
||||
}
|
||||
|
||||
const batchManageShortcuts = computed(() => {
|
||||
const models = globalModels.value
|
||||
const models = batchManageModels.value
|
||||
const defs: { label: string; description: string; filter: (m: GlobalModelResponse) => boolean }[] = [
|
||||
{ label: '无提供商', description: '没有关联任何提供商的模型', filter: m => (m.provider_count || 0) === 0 },
|
||||
{ label: '无活跃提供商', description: '有提供商但没有活跃提供商的模型', filter: m => (m.active_provider_count || 0) === 0 && (m.provider_count || 0) > 0 },
|
||||
@@ -1273,7 +1308,7 @@ const batchManageShortcuts = computed(() => {
|
||||
|
||||
// 批量管理 - 应用快捷选中
|
||||
function applyBatchManageShortcut(filter: (m: GlobalModelResponse) => boolean) {
|
||||
const matchedIds = globalModels.value.filter(filter).map(m => m.id)
|
||||
const matchedIds = batchManageModels.value.filter(filter).map(m => m.id)
|
||||
selectedBatchManageModelIds.value = new Set(matchedIds)
|
||||
}
|
||||
|
||||
@@ -1313,6 +1348,7 @@ function openBatchManageDialog() {
|
||||
batchManageSearchQuery.value = ''
|
||||
selectedBatchManageModelIds.value = new Set()
|
||||
batchManageDialogOpen.value = true
|
||||
loadBatchManageModels()
|
||||
}
|
||||
|
||||
// 确认批量删除模型
|
||||
@@ -1344,7 +1380,7 @@ async function confirmBatchDeleteModels() {
|
||||
}
|
||||
|
||||
selectedBatchManageModelIds.value = new Set()
|
||||
await loadGlobalModels()
|
||||
await Promise.all([loadGlobalModels(), loadBatchManageModels()])
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '批量删除失败'), '错误')
|
||||
} finally {
|
||||
@@ -1491,30 +1527,24 @@ async function refreshData() {
|
||||
await loadGlobalModels()
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
const requestId = ++providersRequestId
|
||||
try {
|
||||
const nextProviders = (await getProvidersSummary({ page_size: 9999 })).items
|
||||
if (requestId !== providersRequestId) return
|
||||
providers.value = nextProviders
|
||||
} catch (err: unknown) {
|
||||
if (requestId !== providersRequestId) return
|
||||
showError(parseApiError(err, '加载 Provider 列表失败'), '加载 Provider 列表失败')
|
||||
watch(globalModelsQueryParams, (newParams, oldParams) => {
|
||||
if (modelSearchDebounceTimer) clearTimeout(modelSearchDebounceTimer)
|
||||
const isSearchOnly = newParams.search !== oldParams?.search
|
||||
&& newParams.skip === oldParams?.skip
|
||||
&& newParams.limit === oldParams?.limit
|
||||
if (isSearchOnly) {
|
||||
modelSearchDebounceTimer = setTimeout(loadGlobalModels, 300)
|
||||
} else {
|
||||
loadGlobalModels()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
refreshData(),
|
||||
loadProviders(),
|
||||
])
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (modelSearchDebounceTimer) clearTimeout(modelSearchDebounceTimer)
|
||||
globalModelsRequestId += 1
|
||||
batchManageModelsRequestId += 1
|
||||
modelSelectionRequestId += 1
|
||||
modelProvidersRequestId += 1
|
||||
providersRequestId += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -416,6 +416,11 @@ async function pollActiveRequests() {
|
||||
if ('api_key_name' in update) {
|
||||
record.api_key_name = typeof update.api_key_name === 'string' ? update.api_key_name : undefined
|
||||
}
|
||||
if ('provider_key_name' in update) {
|
||||
record.provider_key_name = typeof update.provider_key_name === 'string'
|
||||
? update.provider_key_name
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user