mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: paginate provider keys from backend
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,20 +41,47 @@ 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| {
|
let api_formats =
|
||||||
let api_formats =
|
provider_key_effective_api_formats(&key, &provider.provider_type, &endpoints);
|
||||||
provider_key_effective_api_formats(&key, &provider.provider_type, &endpoints);
|
state.build_admin_provider_key_response(
|
||||||
state.build_admin_provider_key_response(
|
&key,
|
||||||
&key,
|
&provider.provider_type,
|
||||||
&provider.provider_type,
|
&api_formats,
|
||||||
&api_formats,
|
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,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(() => [])
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
// 展开状态
|
// 展开状态
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user