feat: paginate provider keys from backend

This commit is contained in:
fawney19
2026-05-16 12:52:49 +08:00
parent b6d74249a4
commit a7fdce493b
9 changed files with 347 additions and 88 deletions

View File

@@ -12,6 +12,38 @@ use axum::{
}; };
use serde_json::json; use serde_json::json;
fn parse_provider_keys_page_param(raw: Option<String>) -> Result<usize, String> {
match raw {
None => Ok(1),
Some(value) => {
let parsed = value
.parse::<usize>()
.map_err(|_| "page must be an integer between 1 and 10000".to_string())?;
if (1..=10_000).contains(&parsed) {
Ok(parsed)
} else {
Err("page must be an integer between 1 and 10000".to_string())
}
}
}
}
fn parse_provider_keys_page_size_param(raw: Option<String>) -> Result<usize, String> {
match raw {
None => Ok(20),
Some(value) => {
let parsed = value
.parse::<usize>()
.map_err(|_| "page_size must be an integer between 1 and 1000".to_string())?;
if (1..=1000).contains(&parsed) {
Ok(parsed)
} else {
Err("page_size must be an integer between 1 and 1000".to_string())
}
}
}
}
pub(super) async fn maybe_handle( pub(super) async fn maybe_handle(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>, request_context: &AdminRequestContext<'_>,
@@ -141,6 +173,47 @@ pub(super) async fn maybe_handle(
.into_response(), .into_response(),
)); ));
}; };
let page_param = query_param_value(request_context.query_string(), "page");
let page_size_param = query_param_value(request_context.query_string(), "page_size");
if page_param.is_some() || page_size_param.is_some() {
let page = match parse_provider_keys_page_param(page_param) {
Ok(value) => value,
Err(detail) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
));
}
};
let page_size = match parse_provider_keys_page_size_param(page_size_param) {
Ok(value) => value,
Err(detail) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail })),
)
.into_response(),
));
}
};
return Ok(Some(
match state
.build_admin_provider_keys_page_payload(&provider_id, page, page_size)
.await
{
Some(payload) => Json(payload).into_response(),
None => (
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": format!("Provider {provider_id} 不存在") })),
)
.into_response(),
},
));
}
let skip = query_param_value(request_context.query_string(), "skip") let skip = query_param_value(request_context.query_string(), "skip")
.and_then(|value| value.parse::<usize>().ok()) .and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0); .unwrap_or(0);

View File

@@ -1,5 +1,6 @@
pub(crate) use self::{ pub(crate) use self::{
create::build_admin_create_provider_key_record, payload::build_admin_provider_keys_payload, create::build_admin_create_provider_key_record,
payload::{build_admin_provider_keys_page_payload, build_admin_provider_keys_payload},
update::build_admin_update_provider_key_record, update::build_admin_update_provider_key_record,
}; };

View File

@@ -3,14 +3,15 @@ use crate::provider_key_auth::provider_key_effective_api_formats;
use aether_data_contracts::repository::provider_catalog::{ use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogKeyListOrder, ProviderCatalogKeyListQuery, ProviderCatalogKeyListOrder, ProviderCatalogKeyListQuery,
}; };
use serde_json::{json, Value};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) async fn build_admin_provider_keys_payload( async fn build_admin_provider_key_items_payload(
state: &AdminAppState<'_>, state: &AdminAppState<'_>,
provider_id: &str, provider_id: &str,
skip: usize, skip: usize,
limit: usize, limit: usize,
) -> Option<serde_json::Value> { ) -> Option<(Vec<Value>, usize)> {
if !state.has_provider_catalog_data_reader() { if !state.has_provider_catalog_data_reader() {
return None; return None;
} }
@@ -40,8 +41,7 @@ pub(crate) async fn build_admin_provider_keys_payload(
.ok() .ok()
.map(|duration| duration.as_secs()) .map(|duration| duration.as_secs())
.unwrap_or(0); .unwrap_or(0);
Some(serde_json::Value::Array( let items = key_page
key_page
.items .items
.into_iter() .into_iter()
.map(|key| { .map(|key| {
@@ -54,6 +54,34 @@ pub(crate) async fn build_admin_provider_keys_payload(
now_unix_secs, now_unix_secs,
) )
}) })
.collect(), .collect();
)) Some((items, key_page.total))
}
pub(crate) async fn build_admin_provider_keys_payload(
state: &AdminAppState<'_>,
provider_id: &str,
skip: usize,
limit: usize,
) -> Option<Value> {
let (items, _) =
build_admin_provider_key_items_payload(state, provider_id, skip, limit).await?;
Some(Value::Array(items))
}
pub(crate) async fn build_admin_provider_keys_page_payload(
state: &AdminAppState<'_>,
provider_id: &str,
page: usize,
page_size: usize,
) -> Option<Value> {
let skip = page.saturating_sub(1).saturating_mul(page_size);
let (items, total) =
build_admin_provider_key_items_payload(state, provider_id, skip, page_size).await?;
Some(json!({
"total": total,
"page": page,
"page_size": page_size,
"keys": items,
}))
} }

View File

@@ -70,6 +70,21 @@ impl<'a> AdminAppState<'a> {
.await .await
} }
pub(crate) async fn build_admin_provider_keys_page_payload(
&self,
provider_id: &str,
page: usize,
page_size: usize,
) -> Option<serde_json::Value> {
crate::handlers::admin::provider::write::keys::build_admin_provider_keys_page_payload(
self,
provider_id,
page,
page_size,
)
.await
}
pub(crate) fn build_admin_reveal_key_payload( pub(crate) fn build_admin_reveal_key_payload(
&self, &self,
key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey, key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,

View File

@@ -118,6 +118,90 @@ async fn gateway_handles_admin_provider_keys_locally_with_trusted_admin_principa
upstream_handle.abort(); upstream_handle.abort();
} }
#[tokio::test]
async fn gateway_handles_admin_provider_keys_page_locally_with_total() {
let upstream_hits = Arc::new(Mutex::new(0usize));
let upstream_hits_clone = Arc::clone(&upstream_hits);
let upstream = Router::new().route(
"/api/admin/endpoints/providers/provider-openai/keys",
any(move |_request: Request| {
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
async move {
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
(StatusCode::OK, Body::from("unexpected upstream hit"))
}
}),
);
let mut key_a = sample_key(
"key-openai-a",
"provider-openai",
"openai:chat",
"sk-test-a",
);
key_a.internal_priority = 10;
key_a.created_at_unix_ms = Some(1_711_000_000);
let mut key_b = sample_key(
"key-openai-b",
"provider-openai",
"openai:chat",
"sk-test-b",
);
key_b.internal_priority = 20;
key_b.created_at_unix_ms = Some(1_711_100_000);
let mut key_c = sample_key(
"key-openai-c",
"provider-openai",
"openai:chat",
"sk-test-c",
);
key_c.internal_priority = 30;
key_c.created_at_unix_ms = Some(1_711_200_000);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider("provider-openai", "openai", 10)],
vec![],
vec![key_a, key_b, key_c],
));
let (_upstream_url, upstream_handle) = start_server(upstream).await;
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
provider_catalog_repository,
)),
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let response = reqwest::Client::new()
.get(format!(
"{gateway_url}/api/admin/endpoints/providers/provider-openai/keys?page=2&page_size=1"
))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.send()
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let payload: serde_json::Value = response.json().await.expect("json body should parse");
assert_eq!(payload["total"], 3);
assert_eq!(payload["page"], 2);
assert_eq!(payload["page_size"], 1);
let items = payload["keys"].as_array().expect("keys should be an array");
assert_eq!(items.len(), 1);
assert_eq!(items[0]["id"], "key-openai-b");
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
upstream_handle.abort();
}
#[tokio::test] #[tokio::test]
async fn gateway_admin_provider_keys_prefers_upstream_plan_type_over_auth_config() { async fn gateway_admin_provider_keys_prefers_upstream_plan_type_over_auth_config() {
let upstream_hits = Arc::new(Mutex::new(0usize)); let upstream_hits = Arc::new(Mutex::new(0usize));

View File

@@ -109,6 +109,31 @@ export async function batchDeleteEndpointKeys(ids: string[]): Promise<BatchDelet
/** /**
* 获取 Provider 的所有 Keys * 获取 Provider 的所有 Keys
*/ */
export interface ProviderKeysPageResponse {
total: number
page: number
page_size: number
keys: EndpointAPIKey[]
}
export interface ProviderKeysPageQuery {
page?: number
page_size?: number
}
export async function getProviderKeysPage(
providerId: string,
params: ProviderKeysPageQuery = {},
): Promise<ProviderKeysPageResponse> {
const page = params.page ?? 1
const pageSize = params.page_size ?? 20
const response = await client.get<ProviderKeysPageResponse>(
`/api/admin/endpoints/providers/${providerId}/keys`,
{ params: { page, page_size: pageSize } },
)
return response.data
}
export async function getProviderKeys(providerId: string): Promise<EndpointAPIKey[]> { export async function getProviderKeys(providerId: string): Promise<EndpointAPIKey[]> {
// 后端默认 limit=100这里主动分页拉取避免账号数 >100 时前端被截断 // 后端默认 limit=100这里主动分页拉取避免账号数 >100 时前端被截断
const pageSize = 1000 const pageSize = 1000

View File

@@ -260,10 +260,8 @@
<div <div
v-else-if="allKeys.length > 0" v-else-if="allKeys.length > 0"
ref="keysListRef"
class="divide-y divide-border/40" class="divide-y divide-border/40"
:class="shouldPaginateKeys && 'flex flex-col'" :class="shouldPaginateKeys && 'flex flex-col'"
:style="keysFixedHeight ? { minHeight: keysFixedHeight + 'px' } : undefined"
> >
<div <div
v-for="({ key, endpoint }, localIdx) in paginatedKeys" v-for="({ key, endpoint }, localIdx) in paginatedKeys"
@@ -1037,8 +1035,8 @@
variant="ghost" variant="ghost"
size="sm" size="sm"
class="h-6 px-2 text-xs" class="h-6 px-2 text-xs"
:disabled="currentKeyPage <= 1" :disabled="loadingProviderKeys || currentKeyPage <= 1"
@click="currentKeyPage--" @click="goToKeyPage(currentKeyPage - 1)"
> >
</Button> </Button>
@@ -1047,8 +1045,8 @@
variant="ghost" variant="ghost"
size="sm" size="sm"
class="h-6 px-2 text-xs" class="h-6 px-2 text-xs"
:disabled="currentKeyPage >= totalKeyPages" :disabled="loadingProviderKeys || currentKeyPage >= totalKeyPages"
@click="currentKeyPage++" @click="goToKeyPage(currentKeyPage + 1)"
> >
</Button> </Button>
@@ -1219,7 +1217,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, nextTick } from 'vue' import { ref, watch, computed, nextTick } from 'vue'
import { useSmartPagination } from '@/composables/useSmartPagination'
import { import {
Plus, Plus,
Key, Key,
@@ -1278,7 +1275,7 @@ import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { import {
deleteEndpointKey, deleteEndpointKey,
recoverKeyHealth, recoverKeyHealth,
getProviderKeys, getProviderKeysPage,
updateProviderKey, updateProviderKey,
revealEndpointKey, revealEndpointKey,
exportKey, exportKey,
@@ -1362,7 +1359,9 @@ const loadingProviderModels = ref(false)
const loadingProviderMappingPreview = ref(false) const loadingProviderMappingPreview = ref(false)
let providerLoadRequestId = 0 let providerLoadRequestId = 0
let endpointsLoadRequestId = 0 let endpointsLoadRequestId = 0
let keysLoadRequestId = 0
let mappingPreviewLoadRequestId = 0 let mappingPreviewLoadRequestId = 0
const PROVIDER_KEYS_PAGE_SIZE = 20
// 系统级格式转换配置 // 系统级格式转换配置
const systemFormatConversionEnabled = ref(false) const systemFormatConversionEnabled = ref(false)
@@ -1455,34 +1454,9 @@ const hasBlockingDialogOpen = computed(() =>
modelMappingTabRef.value?.dialogOpen modelMappingTabRef.value?.dialogOpen
) )
// 所有密钥的扁平列表(带端点信息) // 当前后端分页页内的密钥列表。key 通过 api_formats 字段确定支持的格式endpoint 可能为 undefined。
// key 通过 api_formats 字段确定支持的格式endpoint 可能为 undefined
const allKeys = computed(() => { const allKeys = computed(() => {
const result: { key: EndpointAPIKey; endpoint?: ProviderEndpointWithKeys }[] = [] return providerKeys.value.map(key => ({ key, endpoint: undefined as ProviderEndpointWithKeys | undefined }))
const seenKeyIds = new Set<string>()
// 1. 先添加 Provider 级别的 keys
for (const key of providerKeys.value) {
if (!seenKeyIds.has(key.id)) {
seenKeyIds.add(key.id)
// key 没有关联特定 endpoint
result.push({ key, endpoint: undefined })
}
}
// 2. 再遍历所有端点的 keys历史数据
for (const endpoint of endpoints.value) {
if (endpoint.keys) {
for (const key of endpoint.keys) {
if (!seenKeyIds.has(key.id)) {
seenKeyIds.add(key.id)
result.push({ key, endpoint })
}
}
}
}
return result
}) })
const availableKeyApiFormats = computed(() => { const availableKeyApiFormats = computed(() => {
@@ -1544,23 +1518,33 @@ function syncCurrentSelections(
} }
} }
// ===== 账号列表智能分页 ===== // ===== 账号列表后端分页 =====
const keysListRef = ref<HTMLElement | null>(null) const providerKeysTotal = ref(0)
const { const currentKeyPage = ref(1)
currentPage: currentKeyPage, const keyPageSize = ref(PROVIDER_KEYS_PAGE_SIZE)
totalPages: totalKeyPages, const totalKeyPages = computed(() => Math.max(1, Math.ceil(providerKeysTotal.value / keyPageSize.value)))
shouldPaginate: shouldPaginateKeys, const shouldPaginateKeys = computed(() => totalKeyPages.value > 1)
paginatedItems: paginatedKeys, const paginatedKeys = computed(() => allKeys.value)
fixedHeight: keysFixedHeight,
getGlobalIndex: getGlobalKeyIndex, function getGlobalKeyIndex(localIdx: number): number {
reset: resetKeysPagination, return localIdx
} = useSmartPagination(allKeys, keysListRef) }
async function goToKeyPage(page: number) {
const nextPage = Math.min(Math.max(page, 1), totalKeyPages.value)
if (nextPage === currentKeyPage.value && providerKeys.value.length > 0) return
await loadProviderKeysPage(nextPage)
}
// 合并监听 providerId 和 open避免同一 tick 内两个 watcher 都触发导致重复请求 // 合并监听 providerId 和 open避免同一 tick 内两个 watcher 都触发导致重复请求
watch( watch(
[() => props.providerId, () => props.open], [() => props.providerId, () => props.open],
async ([newId, newOpen], [_oldId, oldOpen]) => { async ([newId, newOpen], [_oldId, oldOpen]) => {
if (newOpen && newId) { if (newOpen && newId) {
if (!oldOpen || provider.value?.id !== newId) {
currentKeyPage.value = 1
providerKeysTotal.value = 0
}
const hasInitialProvider = props.initialProvider?.id === newId const hasInitialProvider = props.initialProvider?.id === newId
if (hasInitialProvider) { if (hasInitialProvider) {
provider.value = props.initialProvider provider.value = props.initialProvider
@@ -1582,6 +1566,7 @@ watch(
// 使在途请求失效,避免关闭后旧响应回写 // 使在途请求失效,避免关闭后旧响应回写
providerLoadRequestId += 1 providerLoadRequestId += 1
endpointsLoadRequestId += 1 endpointsLoadRequestId += 1
keysLoadRequestId += 1
mappingPreviewLoadRequestId += 1 mappingPreviewLoadRequestId += 1
// 停止倒计时定时器 // 停止倒计时定时器
@@ -1591,6 +1576,9 @@ watch(
provider.value = null provider.value = null
endpoints.value = [] endpoints.value = []
providerKeys.value = [] // 清空 Provider 级别的 keys providerKeys.value = [] // 清空 Provider 级别的 keys
providerKeysTotal.value = 0
currentKeyPage.value = 1
keyPageSize.value = PROVIDER_KEYS_PAGE_SIZE
providerModels.value = [] providerModels.value = []
providerMappingPreview.value = null providerMappingPreview.value = null
loadingProviderEndpoints.value = false loadingProviderEndpoints.value = false
@@ -1598,9 +1586,6 @@ watch(
loadingProviderModels.value = false loadingProviderModels.value = false
loadingProviderMappingPreview.value = false loadingProviderMappingPreview.value = false
// 重置分页状态
resetKeysPagination()
// 重置所有对话框状态 // 重置所有对话框状态
endpointDialogOpen.value = false endpointDialogOpen.value = false
keyFormDialogOpen.value = false keyFormDialogOpen.value = false
@@ -1851,11 +1836,14 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
if (keyInList) { if (keyInList) {
keyInList.oauth_expires_at = refreshedExpiresAt keyInList.oauth_expires_at = refreshedExpiresAt
} }
// 只重新加载 keys 数据,避免整个表格刷新 // 只重新加载当前 keys ,避免整个表格刷新
if (props.providerId) { if (props.providerId) {
const freshKeys = await getProviderKeys(props.providerId).catch(() => null) const freshPage = await getProviderKeysPage(props.providerId, {
if (freshKeys) { page: currentKeyPage.value,
const mergedKeys = freshKeys.map((item) => { page_size: keyPageSize.value,
}).catch(() => null)
if (freshPage) {
const mergedKeys = freshPage.keys.map((item) => {
if (item.id !== key.id) return item if (item.id !== key.id) return item
if (refreshedExpiresAt == null) return item if (refreshedExpiresAt == null) return item
if (typeof item.oauth_expires_at === 'number' && item.oauth_expires_at >= refreshedExpiresAt) { if (typeof item.oauth_expires_at === 'number' && item.oauth_expires_at >= refreshedExpiresAt) {
@@ -1864,6 +1852,9 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
return { ...item, oauth_expires_at: refreshedExpiresAt } return { ...item, oauth_expires_at: refreshedExpiresAt }
}) })
providerKeys.value = mergedKeys providerKeys.value = mergedKeys
providerKeysTotal.value = freshPage.total
currentKeyPage.value = freshPage.page
keyPageSize.value = freshPage.page_size
syncCurrentSelections(endpoints.value, mergedKeys) syncCurrentSelections(endpoints.value, mergedKeys)
refreshedKey = mergedKeys.find(item => item.id === key.id) ?? null refreshedKey = mergedKeys.find(item => item.id === key.id) ?? null
} }
@@ -3507,6 +3498,43 @@ async function loadProvider() {
} }
} }
async function loadProviderKeysPage(page = currentKeyPage.value) {
if (!props.providerId) return
const providerId = props.providerId
const requestId = ++keysLoadRequestId
loadingProviderKeys.value = true
try {
const result = await getProviderKeysPage(providerId, {
page,
page_size: keyPageSize.value,
})
if (requestId !== keysLoadRequestId || props.providerId !== providerId) return
const nextTotalPages = Math.max(1, Math.ceil(result.total / result.page_size))
if (result.keys.length === 0 && result.total > 0 && result.page > nextTotalPages) {
await loadProviderKeysPage(nextTotalPages)
return
}
providerKeys.value = result.keys
providerKeysTotal.value = result.total
currentKeyPage.value = Math.min(result.page, nextTotalPages)
keyPageSize.value = result.page_size
syncCurrentSelections(endpoints.value, result.keys)
} catch (err: unknown) {
if (requestId !== keysLoadRequestId || props.providerId !== providerId) return
providerKeys.value = []
providerKeysTotal.value = 0
syncCurrentSelections(endpoints.value, [])
showError(parseApiError(err, '加载密钥失败'), '错误')
} finally {
if (requestId === keysLoadRequestId) {
loadingProviderKeys.value = false
}
}
}
// 加载端点列表 // 加载端点列表
async function loadEndpoints() { async function loadEndpoints() {
if (!props.providerId) return if (!props.providerId) return
@@ -3546,18 +3574,7 @@ async function loadEndpoints() {
} }
}) })
const providerKeysPromise = getProviderKeys(providerId) const providerKeysPromise = loadProviderKeysPage(currentKeyPage.value)
.catch(() => [])
.then((providerKeysResult) => {
if (requestId !== endpointsLoadRequestId) return
providerKeys.value = providerKeysResult
syncCurrentSelections(endpoints.value, providerKeysResult)
})
.finally(() => {
if (requestId === endpointsLoadRequestId) {
loadingProviderKeys.value = false
}
})
const modelsPromise = getProviderModels(providerId) const modelsPromise = getProviderModels(providerId)
.catch(() => []) .catch(() => [])

View File

@@ -370,6 +370,7 @@ import { normalizeApiFormatAlias } from '@/api/endpoints/types/api-format'
import { import {
buildDefaultModelTestRequestHeaders, buildDefaultModelTestRequestHeaders,
buildDefaultModelTestRequestBody, buildDefaultModelTestRequestBody,
isModelTestableApiFormat,
isModelTestableEndpoint, isModelTestableEndpoint,
parseModelTestRequestHeadersDraft, parseModelTestRequestHeadersDraft,
parseModelTestRequestBodyDraft, parseModelTestRequestBodyDraft,
@@ -436,7 +437,14 @@ const testRequestBodyResetValue = ref('')
const mappingTestEndpoints = ref<ProviderEndpoint[] | null>(null) const mappingTestEndpoints = ref<ProviderEndpoint[] | null>(null)
const providerKeysState = computed(() => props.providerKeys ?? []) const providerKeysState = computed(() => props.providerKeys ?? [])
const activeEndpoints = computed(() => (props.endpoints ?? []) const activeEndpoints = computed(() => (props.endpoints ?? [])
.filter(endpoint => isModelTestableEndpoint(endpoint, providerKeysState.value))) .filter(endpoint => {
if (typeof endpoint.active_keys === 'number') {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& endpoint.active_keys > 0
}
return isModelTestableEndpoint(endpoint, providerKeysState.value)
}))
const selectableTestEndpoints = computed(() => mappingTestEndpoints.value ?? activeEndpoints.value) const selectableTestEndpoints = computed(() => mappingTestEndpoints.value ?? activeEndpoints.value)
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value)) const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error) const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
@@ -448,9 +456,9 @@ const isLoading = computed(() => Boolean(props.loading) || localLoading.value)
const models = computed(() => props.models ?? []) const models = computed(() => props.models ?? [])
const aliasMappingPreview = computed(() => props.mappingPreview ?? null) const aliasMappingPreview = computed(() => props.mappingPreview ?? null)
// 是否有 key 配置了自动获取上游模型 // 后端分页下当前页不一定包含 auto_fetch key有活跃 key 时允许弹窗尝试拉取上游模型
const hasAutoFetchKey = computed(() => { const hasAutoFetchKey = computed(() => {
return providerKeysState.value.some(k => k.auto_fetch_models) return providerKeysState.value.some(k => k.auto_fetch_models) || props.provider.active_keys > 0
}) })
// 展开状态 // 展开状态

View File

@@ -266,6 +266,7 @@ import ModelTestDialog from './ModelTestDialog.vue'
import { import {
buildDefaultModelTestRequestHeaders, buildDefaultModelTestRequestHeaders,
buildDefaultModelTestRequestBody, buildDefaultModelTestRequestBody,
isModelTestableApiFormat,
isModelTestableEndpoint, isModelTestableEndpoint,
listModelTestMappedModelOptions, listModelTestMappedModelOptions,
normalizeModelTestMappedModelSelection, normalizeModelTestMappedModelSelection,
@@ -307,7 +308,14 @@ const testRequestBodyResetValue = ref('')
const selectedTestMappedModelName = ref<string | null>(null) const selectedTestMappedModelName = ref<string | null>(null)
const isPoolManagedProvider = computed(() => Boolean(props.provider.pool_advanced)) const isPoolManagedProvider = computed(() => Boolean(props.provider.pool_advanced))
const activeEndpoints = computed(() => (props.endpoints ?? []) const activeEndpoints = computed(() => (props.endpoints ?? [])
.filter(endpoint => isModelTestableEndpoint(endpoint, props.providerKeys ?? []))) .filter(endpoint => {
if (typeof endpoint.active_keys === 'number') {
return endpoint.is_active !== false
&& isModelTestableApiFormat(endpoint.api_format)
&& endpoint.active_keys > 0
}
return isModelTestableEndpoint(endpoint, props.providerKeys ?? [])
}))
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value)) const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error) const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value)) const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))