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:
fawney19
2026-04-05 20:23:16 +08:00
parent cbc811f6ce
commit 763ff03a7b
777 changed files with 42659 additions and 21469 deletions

View File

@@ -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')

View File

@@ -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

View File

@@ -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
}

View File

@@ -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',
})
})
})

View File

@@ -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)
}

View File

@@ -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 // 映射后的目标模型名(若无映射则为空)