fix(providers): 修复 Key 表单状态同步与 streaming 候选状态码处理

前端:
- KeyAllowedModelsEditDialog 同时监听 open 和 apiKey 变化,避免切换 key 时状态未刷新
- KeyFormDialog 新增 api_formats 过滤与默认值逻辑,可用格式变化时自动同步表单
- ProviderDetailDrawer 合并 provider 和 endpoint 的 api_formats 传递给 Key 表单,
  数据刷新后同步 currentEndpoint 和 editingKey 引用

后端:
- mark_candidate_streaming 移除 status_code 参数,streaming 阶段不再提前写入状态码
- 简化 active_requests 中 streaming 请求的完成判断,不再依赖 status_code 条件
This commit is contained in:
fawney19
2026-03-07 17:11:19 +08:00
parent 239238fe47
commit a00e137ffc
6 changed files with 154 additions and 51 deletions

View File

@@ -717,49 +717,44 @@ function parseAllowedModels(allowed: AllowedModels): string[] {
return [...allowed] return [...allowed]
} }
// 监听对话框打开 async function initializeDialogState(apiKey: EndpointAPIKey) {
watch(() => props.open, async (open) => { loadingCancelled = false
if (open && props.apiKey) {
loadingCancelled = false
const parsed = parseAllowedModels(props.apiKey.allowed_models ?? null) const parsed = parseAllowedModels(apiKey.allowed_models ?? null)
selectedModels.value = [...parsed] selectedModels.value = [...parsed]
initialSelectedModels.value = [...parsed] initialSelectedModels.value = [...parsed]
// 加载锁定的模型 const locked = apiKey.locked_models ?? []
const locked = props.apiKey.locked_models ?? [] lockedModels.value = [...locked]
lockedModels.value = [...locked] initialLockedModels.value = [...locked]
initialLockedModels.value = [...locked]
searchQuery.value = '' searchQuery.value = ''
upstreamModels.value = [] upstreamModels.value = []
upstreamModelsLoaded.value = false upstreamModelsLoaded.value = false
allCustomModels.value = [] allCustomModels.value = []
// 自动获取模式下展开上游模型,其他收缩;非自动获取模式下全部展开 if (apiKey.auto_fetch_models) {
if (props.apiKey.auto_fetch_models) { collapsedGroups.value = new Set(['global', 'custom'])
collapsedGroups.value = new Set(['global', 'custom']) } else {
} else { collapsedGroups.value = new Set()
collapsedGroups.value = new Set() }
}
// 加载该 Provider 已关联模型 await loadProviderModels()
await loadProviderModels()
// 自动获取模式下,获取上游模型用于显示(但选中状态使用已保存的 allowed_models if (apiKey.auto_fetch_models) {
if (props.apiKey.auto_fetch_models) { await fetchUpstreamModels()
await fetchUpstreamModels() }
// 注意:不再将所有上游模型自动标记为选中
// 因为后端有过滤规则,实际保存的 allowed_models 是过滤后的结果
// selectedModels 已在上面从 props.apiKey.allowed_models 初始化
}
// 提取自定义模型(不在提供商模型和上游模型中的) const upstreamModelIdsSet = new Set(upstreamModels.value.map(m => m.id))
const upstreamModelIdsSet = new Set(upstreamModels.value.map(m => m.id)) allCustomModels.value = selectedModels.value.filter(m =>
// 自定义模型是用户手动添加的、不在已知模型列表中的 !providerModelNamesSet.value.has(m) && !upstreamModelIdsSet.has(m)
allCustomModels.value = selectedModels.value.filter(m => )
!providerModelNamesSet.value.has(m) && !upstreamModelIdsSet.has(m) }
)
// 监听对话框打开 / 当前 key 变化
watch([() => props.open, () => props.apiKey], async ([open, apiKey]) => {
if (open && apiKey) {
await initializeDialogState(apiKey)
} else { } else {
loadingCancelled = true loadingCancelled = true
} }

View File

@@ -377,6 +377,32 @@ function normalizeApiFormat(format: string): string {
return String(format || '').trim().toLowerCase() return String(format || '').trim().toLowerCase()
} }
function getAvailableApiFormatSet(): Set<string> {
return new Set(props.availableApiFormats.map(normalizeApiFormat))
}
function filterAvailableApiFormats(formats: string[]): string[] {
const availableFormatSet = getAvailableApiFormatSet()
if (availableFormatSet.size === 0) {
return []
}
return formats.filter(format => availableFormatSet.has(normalizeApiFormat(format)))
}
function getDefaultApiFormats(): string[] {
const endpointFormat = props.endpoint?.api_format
if (endpointFormat) {
const endpointFormats = filterAvailableApiFormats([endpointFormat])
if (endpointFormats.length > 0) {
return endpointFormats
}
}
const firstAvailableFormat = sortApiFormats(props.availableApiFormats)[0]
return firstAvailableFormat ? [firstAvailableFormat] : []
}
// 按 provider/auth_type 过滤后的可用 API 格式列表 // 按 provider/auth_type 过滤后的可用 API 格式列表
const visibleApiFormats = computed(() => { const visibleApiFormats = computed(() => {
const sorted = sortApiFormats(props.availableApiFormats) const sorted = sortApiFormats(props.availableApiFormats)
@@ -486,6 +512,29 @@ watch(
{ immediate: true } { immediate: true }
) )
watch(
[() => props.availableApiFormats, () => props.open, () => props.editingKey],
([, open, editingKey]) => {
if (!open) {
return
}
const filtered = filterAvailableApiFormats(form.value.api_formats)
if (filtered.length !== form.value.api_formats.length) {
form.value.api_formats = [...filtered]
return
}
if (!editingKey && form.value.api_formats.length === 0) {
const defaults = getDefaultApiFormats()
if (defaults.length > 0) {
form.value.api_formats = defaults
}
}
},
{ deep: true, immediate: true }
)
// 加载能力列表 // 加载能力列表
async function loadCapabilities() { async function loadCapabilities() {
try { try {
@@ -540,7 +589,7 @@ function resetForm() {
api_key: '', api_key: '',
auth_type: defaultAuthType, auth_type: defaultAuthType,
auth_config_text: '', auth_config_text: '',
api_formats: [], // 默认不选中任何格式 api_formats: getDefaultApiFormats(),
rate_multipliers: {}, rate_multipliers: {},
internal_priority: 10, internal_priority: 10,
rpm_limit: undefined, rpm_limit: undefined,
@@ -573,7 +622,7 @@ function loadKeyData() {
auth_type: props.editingKey.auth_type === 'service_account' ? 'service_account' : 'api_key', auth_type: props.editingKey.auth_type === 'service_account' ? 'service_account' : 'api_key',
auth_config_text: '', // auth_config 不返回给前端,编辑时需要重新输入 auth_config_text: '', // auth_config 不返回给前端,编辑时需要重新输入
api_formats: props.editingKey.api_formats?.length > 0 api_formats: props.editingKey.api_formats?.length > 0
? [...props.editingKey.api_formats] ? filterAvailableApiFormats(props.editingKey.api_formats)
: [], // 编辑模式下保持原有选择,不默认全选 : [], // 编辑模式下保持原有选择,不默认全选
rate_multipliers: { ...(props.editingKey.rate_multipliers || {}) }, rate_multipliers: { ...(props.editingKey.rate_multipliers || {}) },
internal_priority: props.editingKey.internal_priority ?? 10, internal_priority: props.editingKey.internal_priority ?? 10,

View File

@@ -936,7 +936,7 @@
:editing-key="editingKey" :editing-key="editingKey"
:provider-id="provider ? provider.id : null" :provider-id="provider ? provider.id : null"
:provider-type="provider?.provider_type || null" :provider-type="provider?.provider_type || null"
:available-api-formats="provider?.api_formats || []" :available-api-formats="availableKeyApiFormats"
@close="keyFormDialogOpen = false" @close="keyFormDialogOpen = false"
@saved="handleKeyChanged" @saved="handleKeyChanged"
/> />
@@ -1258,6 +1258,65 @@ const allKeys = computed(() => {
return result return result
}) })
const availableKeyApiFormats = computed(() => {
const formatSet = new Set<string>()
for (const format of provider.value?.api_formats || []) {
if (format) {
formatSet.add(format)
}
}
for (const endpoint of endpoints.value) {
if (endpoint.api_format) {
formatSet.add(endpoint.api_format)
}
}
return sortApiFormats([...formatSet])
})
function syncCurrentSelections(
nextEndpoints: ProviderEndpointWithKeys[] = endpoints.value,
nextProviderKeys: EndpointAPIKey[] = providerKeys.value
) {
if (currentEndpoint.value) {
currentEndpoint.value = nextEndpoints.find(endpoint => endpoint.id === currentEndpoint.value?.id) ?? null
}
if (!editingKey.value) {
return
}
const latestKeys: EndpointAPIKey[] = []
const seenKeyIds = new Set<string>()
for (const key of nextProviderKeys) {
if (!seenKeyIds.has(key.id)) {
seenKeyIds.add(key.id)
latestKeys.push(key)
}
}
for (const endpoint of nextEndpoints) {
for (const key of endpoint.keys || []) {
if (!seenKeyIds.has(key.id)) {
seenKeyIds.add(key.id)
latestKeys.push(key)
}
}
}
const latestEditingKey = latestKeys.find(key => key.id === editingKey.value?.id) || null
editingKey.value = latestEditingKey
if (!latestEditingKey) {
keyFormDialogOpen.value = false
keyPermissionsDialogOpen.value = false
oauthKeyEditDialogOpen.value = false
}
}
// ===== 账号列表智能分页 ===== // ===== 账号列表智能分页 =====
const keysListRef = ref<HTMLElement | null>(null) const keysListRef = ref<HTMLElement | null>(null)
const { const {
@@ -1558,6 +1617,7 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
const freshKeys = await getProviderKeys(props.providerId).catch(() => null) const freshKeys = await getProviderKeys(props.providerId).catch(() => null)
if (freshKeys) { if (freshKeys) {
providerKeys.value = freshKeys providerKeys.value = freshKeys
syncCurrentSelections(endpoints.value, freshKeys)
} }
} }
// Antigravitytoken 刷新后可能完成了账号激活,触发配额获取 // Antigravitytoken 刷新后可能完成了账号激活,触发配额获取
@@ -2640,7 +2700,7 @@ async function loadEndpoints() {
providerKeys.value = providerKeysResult providerKeys.value = providerKeysResult
providerModels.value = modelsResult providerModels.value = modelsResult
// 按 API 格式排序 // 按 API 格式排序
endpoints.value = endpointsList.sort((a, b) => { const sortedEndpoints = endpointsList.sort((a, b) => {
const aIdx = API_FORMAT_ORDER.indexOf(a.api_format) const aIdx = API_FORMAT_ORDER.indexOf(a.api_format)
const bIdx = API_FORMAT_ORDER.indexOf(b.api_format) const bIdx = API_FORMAT_ORDER.indexOf(b.api_format)
if (aIdx === -1 && bIdx === -1) return 0 if (aIdx === -1 && bIdx === -1) return 0
@@ -2648,6 +2708,8 @@ async function loadEndpoints() {
if (bIdx === -1) return -1 if (bIdx === -1) return -1
return aIdx - bIdx return aIdx - bIdx
}) })
endpoints.value = sortedEndpoints
syncCurrentSelections(sortedEndpoints, providerKeysResult)
} catch (err: unknown) { } catch (err: unknown) {
if (requestId !== endpointsLoadRequestId) return if (requestId !== endpointsLoadRequestId) return
showError(parseApiError(err, '加载端点失败'), '错误') showError(parseApiError(err, '加载端点失败'), '错误')

View File

@@ -114,7 +114,6 @@ class RequestCandidateService:
def mark_candidate_streaming( def mark_candidate_streaming(
db: Session, db: Session,
candidate_id: str, candidate_id: str,
status_code: int = 200,
concurrent_requests: int | None = None, concurrent_requests: int | None = None,
) -> None: ) -> None:
""" """
@@ -123,18 +122,19 @@ class RequestCandidateService:
用于流式请求:连接建立成功后,流开始传输时调用。 用于流式请求:连接建立成功后,流开始传输时调用。
此时请求尚未完成,需要等流传输完毕后再调用 mark_candidate_success。 此时请求尚未完成,需要等流传输完毕后再调用 mark_candidate_success。
注意streaming 阶段不设置 status_code最终状态码由
mark_candidate_success / mark_candidate_failed 在流结束时写入。
Args: Args:
db: 数据库会话 db: 数据库会话
candidate_id: 候选ID candidate_id: 候选ID
status_code: HTTP 状态码(通常是 200
concurrent_requests: 并发请求数 concurrent_requests: 并发请求数
""" """
candidate = db.query(RequestCandidate).filter(RequestCandidate.id == candidate_id).first() candidate = db.query(RequestCandidate).filter(RequestCandidate.id == candidate_id).first()
if candidate: if candidate:
candidate.status = "streaming" candidate.status = "streaming"
candidate.status_code = status_code
candidate.concurrent_requests = concurrent_requests candidate.concurrent_requests = concurrent_requests
# streaming 状态不设置 finished_at因为请求还在进行中 # streaming 状态不设置 finished_at 和 status_code,因为请求还在进行中
db.commit() db.commit()
@staticmethod @staticmethod

View File

@@ -197,7 +197,6 @@ class RequestExecutor:
RequestCandidateService.mark_candidate_streaming( RequestCandidateService.mark_candidate_streaming(
db=self.db, db=self.db,
candidate_id=candidate_id, candidate_id=candidate_id,
status_code=200,
concurrent_requests=key_rpm_count, concurrent_requests=key_rpm_count,
) )
else: else:

View File

@@ -24,7 +24,7 @@ class UsageActiveRequestsMixin:
通过 RequestCandidate 表判断哪些请求实际已成功完成: 通过 RequestCandidate 表判断哪些请求实际已成功完成:
1. status='success' 且 stream_completed=True正常完成 1. status='success' 且 stream_completed=True正常完成
2. status='streaming' 且 status_code=200Provider 已返回成功,流因重启中断) 2. status='streaming'Provider 已返回成功响应头,流因重启中断)
""" """
if not request_ids: if not request_ids:
return set() return set()
@@ -35,15 +35,13 @@ class UsageActiveRequestsMixin:
db.query( db.query(
RequestCandidate.request_id, RequestCandidate.request_id,
RequestCandidate.status, RequestCandidate.status,
RequestCandidate.status_code,
RequestCandidate.extra_data, RequestCandidate.extra_data,
) )
.filter( .filter(
RequestCandidate.request_id.in_(request_ids), RequestCandidate.request_id.in_(request_ids),
or_( or_(
RequestCandidate.status == "success", RequestCandidate.status == "success",
(RequestCandidate.status == "streaming") RequestCandidate.status == "streaming",
& (RequestCandidate.status_code == 200),
), ),
) )
.all() .all()
@@ -53,7 +51,7 @@ class UsageActiveRequestsMixin:
extra_data = c.extra_data or {} extra_data = c.extra_data or {}
if c.status == "success" and extra_data.get("stream_completed", False): if c.status == "success" and extra_data.get("stream_completed", False):
completed.add(c.request_id) completed.add(c.request_id)
elif c.status == "streaming" and c.status_code == 200: elif c.status == "streaming":
completed.add(c.request_id) completed.add(c.request_id)
return completed return completed
@@ -111,7 +109,7 @@ class UsageActiveRequestsMixin:
清理超时的 pending/streaming 请求 清理超时的 pending/streaming 请求
将超过指定时间仍处于 pending 或 streaming 状态的请求标记为 failed 或恢复为 completed。 将超过指定时间仍处于 pending 或 streaming 状态的请求标记为 failed 或恢复为 completed。
会检查 RequestCandidate 表,如果 Provider 已返回成功status_code=200 会检查 RequestCandidate 表,如果 Provider 已返回成功响应status=streaming 或 stream_completed
则恢复为 completed 而非标记为 failed同时同步更新 candidate 状态。 则恢复为 completed 而非标记为 failed同时同步更新 candidate 状态。
Args: Args: