mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +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:
@@ -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 // 映射后的目标模型名(若无映射则为空)
|
||||
|
||||
Reference in New Issue
Block a user