feat(keys): support custom keys and search by name

Support optional custom client keys across creation, storage, and authentication. Search keys and request records by name, remove credential and prefix searches, and reject duplicate names during create and update using the existing control-plane transaction lock.

Simplify the request diagnostics panel and use the shared empty state.

Validated with 1,029 backend tests using dedicated PostgreSQL and Redis, strict workspace Clippy, frontend lint/typecheck/build, and browser checks for key creation, name validation, search, diagnostics refresh/export, and mobile layouts.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Yanshan Chen
2026-09-13 03:21:42 +08:00
committed by GitHub
co-authored by Codex
parent 851a50962d
commit 7b03fdc8ab
30 changed files with 796 additions and 145 deletions
@@ -6,7 +6,7 @@ use chrono::{DateTime, Utc};
use gateway_core::{
engine::budget::{ClientBudgetLimits, ClientBudgetStatus},
policy::{ClientApiKeyId, RateLimits},
policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits},
routing::{AccountGroupId, ProviderKind},
};
@@ -144,6 +144,7 @@ impl fmt::Debug for ClientKeySecret {
/// API 提交的 Client Key 创建命令。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreateClientKey {
pub custom_key: Option<PlaintextClientApiKey>,
pub name: String,
pub label: Option<String>,
pub group_ids: Vec<AccountGroupId>,
@@ -44,6 +44,7 @@ pub enum AdminStoreErrorKind {
Invalid,
NotFound,
StaleRevision,
DuplicateName,
Conflict,
Unavailable,
}
@@ -18,7 +18,7 @@ use crate::{
DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey,
},
},
ports::store::ClientKeyStore,
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
};
use super::{map_store_error, publish_committed};
@@ -87,9 +87,13 @@ impl ClientKeyService for DefaultClientKeyService {
) -> Result<CreatedClientKey, AdminError> {
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
.map_err(|_| AdminError::internal("创建 Client API Key ID 失败"))?;
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes));
let plaintext = if let Some(key) = command.custom_key {
key.expose_for_auth().to_owned()
} else {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes))
};
let (config_revision, record) = self
.store
.create_client_key(
@@ -105,7 +109,7 @@ impl ClientKeyService for DefaultClientKeyService {
context,
)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
.map_err(map_client_key_write_error)?;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
Ok(CreatedClientKey {
config_revision,
@@ -123,7 +127,7 @@ impl ClientKeyService for DefaultClientKeyService {
.store
.update_client_key(command, context)
.await
.map_err(|error| map_store_error(error, "client API key"))?;
.map_err(map_client_key_write_error)?;
publish_committed(self.snapshot.as_ref(), config_revision).await?;
Ok(ClientKeyMutation {
config_revision,
@@ -171,6 +175,14 @@ impl ClientKeyService for DefaultClientKeyService {
}
}
fn map_client_key_write_error(error: AdminStoreError) -> AdminError {
match error.kind() {
AdminStoreErrorKind::DuplicateName => AdminError::conflict("名称已存在"),
AdminStoreErrorKind::Conflict => AdminError::conflict("API Key 已存在,请使用其他密钥"),
_ => map_store_error(error, "client API key"),
}
}
fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
let Some(cursor) = &query.cursor else {
return Ok(());
@@ -42,9 +42,9 @@ fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError
let kind = match error.kind() {
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
AdminStoreErrorKind::StaleRevision | AdminStoreErrorKind::Conflict => {
AdminErrorKind::Conflict
}
AdminStoreErrorKind::StaleRevision
| AdminStoreErrorKind::DuplicateName
| AdminStoreErrorKind::Conflict => AdminErrorKind::Conflict,
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
};
tracing::warn!(resource, error_kind = ?error.kind(), "admin store operation failed");
@@ -1,22 +1,31 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use gateway_core::policy::ClientApiKeyId;
use chrono::Utc;
use gateway_core::policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits};
use gateway_admin::{
model::{
AdminErrorKind, MutationContext, Revision,
AdminErrorKind, MutationActor, MutationContext, Revision,
client_keys::{
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPage,
ClientKeyPageSize, ClientKeyRecord, ClientKeySecret, ClientKeySort, ClientKeySortField,
DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
CreateClientKey, DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection,
UpdateClientKey,
},
},
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ClientKeyStore},
};
struct UnusedClientKeyStore;
#[derive(Default)]
struct TestClientKeyStore {
plaintexts: Mutex<Vec<String>>,
create_error: Option<AdminStoreErrorKind>,
update_error: Option<AdminStoreErrorKind>,
}
#[async_trait]
impl ClientKeyStore for UnusedClientKeyStore {
impl ClientKeyStore for TestClientKeyStore {
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
assert_eq!(query.page_size.get(), u16::MAX);
Ok(ClientKeyPage {
@@ -36,10 +45,32 @@ impl ClientKeyStore for UnusedClientKeyStore {
async fn create_client_key(
&self,
_: NewClientKey,
key: NewClientKey,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
Err(unused())
if let Some(kind) = self.create_error {
return Err(AdminStoreError::new(
kind,
"client key",
"test create failure",
));
}
self.plaintexts.lock().unwrap().push(key.plaintext);
let record = ClientKeyRecord {
id: key.id,
name: key.name,
label: key.label,
groups: Vec::new(),
provider_kinds: Vec::new(),
prefix: String::new(),
enabled: true,
limits: key.limits,
budget: Default::default(),
last_used_at: None,
created_at: Utc::now(),
updated_at: Utc::now(),
};
Ok((Revision::new(2).unwrap(), record))
}
async fn update_client_key(
@@ -47,6 +78,13 @@ impl ClientKeyStore for UnusedClientKeyStore {
_: UpdateClientKey,
_: &MutationContext,
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
if let Some(kind) = self.update_error {
return Err(AdminStoreError::new(
kind,
"client key",
"test update failure",
));
}
Err(unused())
}
@@ -70,7 +108,7 @@ impl ClientKeyStore for UnusedClientKeyStore {
#[tokio::test]
async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
let services = super::AdminHarness::new()
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
.client_keys(Arc::new(TestClientKeyStore::default()))
.build()
.await;
let sort = ClientKeySort {
@@ -98,7 +136,7 @@ async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
#[tokio::test]
async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
let services = super::AdminHarness::new()
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
.client_keys(Arc::new(TestClientKeyStore::default()))
.build()
.await;
let page = services
@@ -118,6 +156,105 @@ async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
assert_eq!(page.total, 0);
}
#[tokio::test]
async fn create_preserves_migrated_keys_and_keeps_default_generation() {
let store = Arc::new(TestClientKeyStore::default());
let services = super::AdminHarness::new()
.client_keys(store.clone())
.build()
.await;
for value in [Some("q".to_owned()), Some("legacy+/=:!".repeat(1024)), None] {
let created = services
.client_keys()
.create(&mutation_context(), create_command(value.as_deref()))
.await
.unwrap();
let plaintext = created.secret.expose_for_response();
assert_eq!(store.plaintexts.lock().unwrap().last().unwrap(), plaintext);
if let Some(value) = value {
assert_eq!(plaintext, value);
} else {
assert!(plaintext.starts_with("sk_"));
assert_eq!(plaintext.len(), 46);
}
}
}
#[tokio::test]
async fn duplicate_keys_return_actionable_conflicts_without_disclosing_the_key() {
let services = super::AdminHarness::new()
.client_keys(Arc::new(TestClientKeyStore {
create_error: Some(AdminStoreErrorKind::Conflict),
..Default::default()
}))
.build()
.await;
let key = "legacy-duplicate-must-stay-private";
let error = services
.client_keys()
.create(&mutation_context(), create_command(Some(key)))
.await
.unwrap_err();
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert_eq!(error.message(), "API Key 已存在,请使用其他密钥");
assert!(!format!("{error:?}").contains(key));
}
#[tokio::test]
async fn duplicate_names_report_the_same_actionable_conflict_on_create_and_update() {
let services = super::AdminHarness::new()
.client_keys(Arc::new(TestClientKeyStore {
create_error: Some(AdminStoreErrorKind::DuplicateName),
update_error: Some(AdminStoreErrorKind::DuplicateName),
..Default::default()
}))
.build()
.await;
let created = services
.client_keys()
.create(&mutation_context(), create_command(None))
.await
.unwrap_err();
let updated = services
.client_keys()
.update(
&mutation_context(),
UpdateClientKey {
id: ClientApiKeyId::new("key_existing").unwrap(),
name: "Migration".to_owned(),
label: None,
group_ids: Vec::new(),
limits: RateLimits::unlimited(),
daily_limit_usd: None,
weekly_limit_usd: None,
},
)
.await
.unwrap_err();
for error in [created, updated] {
assert_eq!(error.kind(), AdminErrorKind::Conflict);
assert_eq!(error.message(), "名称已存在");
}
}
fn create_command(key: Option<&str>) -> CreateClientKey {
CreateClientKey {
custom_key: key.map(|value| PlaintextClientApiKey::new(value).unwrap()),
name: "Migration".to_owned(),
label: None,
group_ids: Vec::new(),
limits: RateLimits::unlimited(),
budget: Default::default(),
}
}
fn mutation_context() -> MutationContext {
MutationContext {
actor: MutationActor::System,
request_id: "custom-key-test".to_owned(),
}
}
fn unused() -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Unavailable,
@@ -13,7 +13,7 @@ use gateway_admin::model::client_keys::{
use gateway_core::{
engine::budget::ClientBudgetLimits,
metering::Decimal,
policy::{ClientApiKeyId, RateLimits},
policy::{ClientApiKeyId, PlaintextClientApiKey, RateLimits},
routing::AccountGroupId,
};
use serde::{Deserialize, Serialize};
@@ -70,9 +70,7 @@ impl ListClientKeysQuery {
}
let search = self.search.map(|search| search.trim().to_owned());
if search.as_deref().is_some_and(|search| {
search.len() > MAX_SEARCH_BYTES
|| search.chars().any(char::is_control)
|| contains_client_key_material(search)
search.len() > MAX_SEARCH_BYTES || search.chars().any(char::is_control)
}) {
return Err(WireValidationError::new("search"));
}
@@ -143,9 +141,10 @@ impl ClientKeySort {
}
/// 创建 Client Key 请求。
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[derive(Clone, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateClientKeyRequest {
custom_key: Option<String>,
name: String,
label: Option<String>,
group_ids: Vec<String>,
@@ -155,6 +154,16 @@ pub struct CreateClientKeyRequest {
weekly_limit_usd: Option<String>,
}
impl fmt::Debug for CreateClientKeyRequest {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CreateClientKeyRequest")
.field("name", &self.name)
.field("custom_key", &"[REDACTED]")
.finish_non_exhaustive()
}
}
impl CreateClientKeyRequest {
/// 校验 wire 边界并直接构造管理用例命令。
pub fn into_command(self) -> Result<CreateClientKey, WireValidationError> {
@@ -164,6 +173,12 @@ impl CreateClientKeyRequest {
validate_limit(self.max_concurrency, "maxConcurrency")?;
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
Ok(CreateClientKey {
custom_key: self
.custom_key
.filter(|key| !key.is_empty())
.map(PlaintextClientApiKey::new)
.transpose()
.map_err(|_| WireValidationError::new("customKey"))?,
name: self.name,
label: self.label,
group_ids,
@@ -661,15 +676,6 @@ fn validate_optional_text(
Ok(())
}
fn contains_client_key_material(value: &str) -> bool {
value.as_bytes().windows(46).any(|window| {
&window[..3] == b"sk_"
&& window[3..]
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
})
}
/// 构造固定 GET/POST 且 ID 仅位于 query/body 的 Client API Key 路由。
pub fn router<S>() -> Router<S>
where
@@ -86,7 +86,7 @@ impl ClientApiKeyAuthError {
///
/// # Errors
///
/// Header 缺失、Bearer 语法错误或不是网关 Client Key 前缀时返回稳定错误。
/// Header 缺失、Bearer 语法错误或 Key 不能作为 HTTP Bearer 值时返回稳定错误。
pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAuthError> {
let raw = headers
.get(AUTHORIZATION)
@@ -100,7 +100,7 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu
if token.is_empty() {
return Err(ClientApiKeyAuthError::MalformedAuthorization);
}
if !token.starts_with("sk_") {
if gateway_core::policy::PlaintextClientApiKey::validate(token).is_err() {
return Err(ClientApiKeyAuthError::InvalidKeyFormat);
}
Ok(token)
@@ -10,6 +10,71 @@ use serde_json::json;
use super::{AdminTestFixture, AdminTestState};
#[test]
fn custom_client_keys_preserve_migrated_values_and_redact_debug() {
for value in [
"q".to_owned(),
"sk-old!@/key+=value".to_owned(),
"x".repeat(8192),
] {
let request: CreateClientKeyRequest = serde_json::from_value(json!({
"name": "migration", "customKey": value, "groupIds": [],
"maxConcurrency": 2, "requestsPerMinute": 0
}))
.unwrap();
let debug = format!("{request:?}");
assert!(debug.contains("[REDACTED]"));
let command = request.into_command().unwrap();
assert_eq!(command.custom_key.unwrap().expose_for_auth(), value);
}
let secret = "legacy-key-should-not-leak";
let request: CreateClientKeyRequest = serde_json::from_value(json!({
"name": "migration", "customKey": secret, "groupIds": [],
"maxConcurrency": 0, "requestsPerMinute": 0
}))
.unwrap();
assert!(!format!("{request:?}").contains(secret));
assert!(!format!("{:?}", request.into_command().unwrap()).contains(secret));
}
#[test]
fn custom_client_key_is_optional_and_only_rejects_untransportable_values() {
let payload = json!({"name": "migration", "groupIds": [], "maxConcurrency": 0,
"requestsPerMinute": 0});
for value in [None, Some(json!(null)), Some(json!(""))] {
let mut payload = payload.clone();
if let Some(value) = value {
payload["customKey"] = value;
}
let command = serde_json::from_value::<CreateClientKeyRequest>(payload)
.unwrap()
.into_command()
.unwrap();
assert!(command.custom_key.is_none());
}
for value in [
" key",
"key ",
"key with spaces",
"key\n",
"key\tvalue",
"密钥",
] {
let mut payload = payload.clone();
payload["customKey"] = json!(value);
let error = serde_json::from_value::<CreateClientKeyRequest>(payload)
.unwrap()
.into_command()
.unwrap_err();
assert_eq!(error.field(), "customKey");
assert!(!format!("{error:?}").contains(value));
}
let mut update = payload;
update["id"] = json!("key_existing");
update["customKey"] = json!("replacement-credential");
assert!(serde_json::from_value::<UpdateClientKeyRequest>(update).is_err());
}
#[test]
fn budget_inputs_preserve_decimal_precision_and_omitted_updates() {
let payload = json!({"name": "budget", "groupIds": [], "maxConcurrency": 3,
@@ -63,10 +128,6 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
"search": "a".repeat(257)
}))
.expect("deserialize oversized search");
let secret_search = serde_json::from_value::<ListClientKeysQuery>(json!({
"search": format!("sk_{}", "a".repeat(43))
}))
.expect("deserialize secret search");
let invalid_sort = serde_json::from_value::<ListClientKeysQuery>(json!({
"sortBy": "plaintextKey",
"sortDirection": "asc"
@@ -92,13 +153,6 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
.field(),
"search"
);
assert_eq!(
secret_search
.into_command()
.expect_err("reject full key in search")
.field(),
"search"
);
assert_eq!(
invalid_sort
.into_command()
@@ -140,6 +194,16 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
);
}
#[test]
fn client_key_name_search_does_not_interpret_names_as_credentials() {
let name = format!("sk_{}", "a".repeat(43));
let command = serde_json::from_value::<ListClientKeysQuery>(json!({ "search": name }))
.unwrap()
.into_command()
.unwrap();
assert_eq!(command.search.as_deref(), Some(name.as_str()));
}
#[test]
fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
let valid = serde_json::from_value::<CreateClientKeyRequest>(json!({
@@ -76,10 +76,28 @@ fn bearer_client_api_key_should_reject_empty_bearer_token() {
}
#[test]
fn bearer_client_api_key_should_reject_non_gateway_key_prefix() {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer xai-secret"));
fn bearer_client_api_key_should_accept_migrated_formats_without_rewriting() {
for key in [
"q".to_owned(),
"sk-old/key+value=:!@".to_owned(),
"x".repeat(8192),
] {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {key}")).unwrap(),
);
assert_eq!(bearer_client_api_key(&headers), Ok(key.as_str()));
}
}
#[test]
fn bearer_client_api_key_should_reject_whitespace_inside_tokens() {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_static("Bearer key with spaces"),
);
assert_eq!(
bearer_client_api_key(&headers),
Err(ClientApiKeyAuthError::InvalidKeyFormat)
+17 -2
View File
@@ -53,13 +53,28 @@ impl PlaintextClientApiKey {
///
/// # Errors
///
/// Key 为空、过长或包含控制字符时返回错误。
/// Key 为空或无法作为 HTTP Bearer 值发送时返回错误。
pub fn new(value: impl Into<String>) -> Result<Self, IdentifierError> {
let value = value.into();
validate_text(&value, 512, false, None)?;
Self::validate(&value)?;
Ok(Self(value))
}
/// 迁入的 Key 不限定前缀或长度;保持原值,仅校验 HTTP 可传输的非空可见 ASCII。
///
/// # Errors
///
/// Key 为空或包含空白、控制字符、非 ASCII 字符时返回错误。
pub fn validate(value: &str) -> Result<(), IdentifierError> {
if value.is_empty() {
return Err(IdentifierError::Empty);
}
if !value.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(IdentifierError::InvalidFormat);
}
Ok(())
}
/// 仅借给同步认证器做常量时间比较。
#[must_use]
pub fn expose_for_auth(&self) -> &str {
@@ -57,6 +57,29 @@ fn plaintext_client_key_debug_should_be_redacted() {
assert_eq!(key.expose_for_auth(), "sk_must_not_appear");
}
#[test]
fn migrated_client_keys_keep_their_exact_value_without_prefix_or_length_limits() {
for value in [
"q".to_owned(),
"legacy-!@#$%^&*()[]{}:;,.?/+=~|\\".to_owned(),
"x".repeat(8192),
] {
assert_eq!(plaintext(&value).expose_for_auth(), value);
}
for value in [
"",
" ",
" key",
"key ",
"key\n",
"key\tvalue",
"key\u{7f}",
"密钥",
] {
assert!(PlaintextClientApiKey::new(value).is_err());
}
}
#[test]
fn client_version_should_require_strict_semver() {
assert!(CodexClientVersion::parse("0.152.0").is_ok());
@@ -47,7 +47,6 @@ use crate::{
use super::{ControlPlaneRepository, PgControlPlaneRepository};
const ENTITY: &str = "client API key";
const KEY_LENGTH: usize = 46;
const CLIENT_API_KEY_LAST_USED_FLUSH_DELAY: Duration = Duration::from_secs(1);
const CLIENT_API_KEY_LAST_USED_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
@@ -355,7 +354,8 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository {
query.validate()?;
let total = count_client_api_keys(&self.pool, query.search.as_deref()).await?;
let mut statement = QueryBuilder::<Postgres>::new(
"select k.id, k.name, k.label, left(k.key, 10) as prefix, k.enabled,
"select k.id, k.name, k.label,
left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled,
k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at,
k.updated_at, '[]'::jsonb as groups, '{}'::text[] as provider_kinds
from client_api_keys k
@@ -414,7 +414,8 @@ impl ClientApiKeyRepository for PgClientApiKeyRepository {
async fn get_client_api_key(&self, id: &str) -> StoreResult<Option<ClientApiKeyRecord>> {
require_nonempty(ENTITY, "id", id)?;
let record = sqlx::query(
"select k.id, k.name, k.label, left(k.key, 10) as prefix, k.enabled,
"select k.id, k.name, k.label,
left(k.key, least(10, length(k.key) / 2)) as prefix, k.enabled,
k.max_concurrency, k.requests_per_minute, k.last_used_at, k.created_at,
k.updated_at, coalesce(groups.groups, '[]'::jsonb) as groups,
case
@@ -977,6 +978,7 @@ pub(crate) async fn insert_client_api_key_in_transaction(
key: &NewClientApiKey,
) -> StoreResult<()> {
key.validate()?;
ensure_client_key_name_available(transaction, &key.id, key.name.trim()).await?;
sqlx::query(
"insert into client_api_keys (
id, name, label, key, enabled, max_concurrency, requests_per_minute,
@@ -984,7 +986,7 @@ pub(crate) async fn insert_client_api_key_in_transaction(
) values ($1, $2, $3, $4, true, $5, $6, null, now(), now(), $7::text::numeric, $8::text::numeric)",
)
.bind(&key.id)
.bind(&key.name)
.bind(key.name.trim())
.bind(&key.label)
.bind(&key.key)
.bind(to_i64(key.max_concurrency)?)
@@ -993,7 +995,20 @@ pub(crate) async fn insert_client_api_key_in_transaction(
.bind(key.budget.weekly_usd.canonical())
.execute(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("insert client API key in transaction"))?;
.map_err(|error| {
if error.as_database_error().is_some_and(|database| {
database.is_unique_violation()
&& database.constraint() == Some("client_api_keys_key_sha256_idx")
}) {
StoreError::Conflict {
entity: ENTITY,
id: key.id.clone(),
kind: crate::ConflictKind::InvalidTransition,
}
} else {
postgres_unavailable("insert client API key in transaction")
}
})?;
replace_client_api_key_groups_in_transaction(transaction, &key.id, &key.group_ids).await?;
Ok(())
}
@@ -1003,6 +1018,7 @@ pub(crate) async fn update_client_api_key_in_transaction(
key: &UpdateClientApiKeyDetails,
) -> StoreResult<()> {
key.validate()?;
ensure_client_key_name_available(transaction, &key.id, key.name.trim()).await?;
let result = sqlx::query(
"update client_api_keys
set name = $2, label = $3, max_concurrency = $4,
@@ -1012,7 +1028,7 @@ pub(crate) async fn update_client_api_key_in_transaction(
where id = $1",
)
.bind(&key.id)
.bind(&key.name)
.bind(key.name.trim())
.bind(&key.label)
.bind(to_i64(key.max_concurrency)?)
.bind(to_i64(key.requests_per_minute)?)
@@ -1025,6 +1041,32 @@ pub(crate) async fn update_client_api_key_in_transaction(
replace_client_api_key_groups_in_transaction(transaction, &key.id, &key.group_ids).await
}
async fn ensure_client_key_name_available(
transaction: &mut Transaction<'_, Postgres>,
id: &str,
name: &str,
) -> StoreResult<()> {
// 调用方已通过递增配置版本持有控制面行锁,查重与写入在同一事务内串行执行。
// 不回填历史重名数据;创建和保存时统一校验,更新排除当前记录。
let duplicate: bool = sqlx::query_scalar(
"select exists(select 1 from client_api_keys
where lower(btrim(name)) = lower($1) and id <> $2)",
)
.bind(name)
.bind(id)
.fetch_one(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("check client API key name"))?;
if duplicate {
return Err(StoreError::Conflict {
entity: ENTITY,
id: id.to_owned(),
kind: crate::ConflictKind::DuplicateName,
});
}
Ok(())
}
pub(crate) async fn set_client_api_key_enabled_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
id: &str,
@@ -1119,18 +1161,8 @@ fn validate_group_ids(group_ids: &[String]) -> StoreResult<()> {
}
fn validate_key(key: &str) -> StoreResult<()> {
let valid = key.len() == KEY_LENGTH
&& key.starts_with("sk_")
&& key[3..]
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
if valid {
Ok(())
} else {
Err(invalid(
"key must be sk_ followed by 43 URL-safe characters",
))
}
PlaintextClientApiKey::validate(key)
.map_err(|_| invalid("key must contain nonempty visible ASCII characters"))
}
fn client_secret_from_row(
@@ -1204,9 +1236,6 @@ fn push_client_key_search(statement: &mut QueryBuilder<Postgres>, search: Option
statement.push_bind(prefix.clone());
statement.push(" escape '\\'");
statement.push(" or lower(coalesce(label, '')) like ");
statement.push_bind(prefix.clone());
statement.push(" escape '\\'");
statement.push(" or lower(left(key, 10)) like ");
statement.push_bind(prefix);
statement.push(" escape '\\'");
statement.push(")");
@@ -254,5 +254,6 @@ fn push_prefix_search(statement: &mut QueryBuilder<Postgres>, columns: &[&str],
statement.push_bind(pattern.clone());
statement.push(" escape '\\'");
}
push_client_key_name_search(statement, "mr.client_api_key_ref", pattern);
statement.push(")");
}
@@ -94,22 +94,25 @@ pub(crate) fn push_usage_filter(
query.push(format!(" escape '\\' or {alias}.{column} like "));
query.push_bind(pattern.clone());
}
if value.starts_with("sk_") {
query.push(format!(
" escape '\\' or exists (
select 1 from client_api_keys searched_client_key
where searched_client_key.id = {alias}.client_api_key_ref
and searched_client_key.key like "
));
query.push_bind(pattern);
query.push(" escape '\\')");
} else {
query.push(" escape '\\'");
}
query.push(" escape '\\'");
push_client_key_name_search(query, &format!("{alias}.client_api_key_ref"), pattern);
query.push(")");
}
}
pub(crate) fn push_client_key_name_search(
query: &mut QueryBuilder<Postgres>,
key_ref: &str,
pattern: String,
) {
query.push(format!(
" or exists (select 1 from client_api_keys searched_client_key
where searched_client_key.id = {key_ref} and searched_client_key.name ilike "
));
query.push_bind(pattern);
query.push(" escape '\\')");
}
pub(crate) fn literal_prefix_pattern(value: &str) -> String {
format!(
"{}%",
@@ -13,6 +13,7 @@ pub enum StoreBackend {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConflictKind {
StaleRevision,
DuplicateName,
AlreadyFinalized,
DownstreamAlreadyCommitted,
RequestNotRunning,
@@ -105,6 +106,10 @@ pub(crate) fn admin_store_error(resource: &'static str, error: StoreError) -> Ad
kind: ConflictKind::StaleRevision,
..
} => AdminStoreErrorKind::StaleRevision,
StoreError::Conflict {
kind: ConflictKind::DuplicateName,
..
} => AdminStoreErrorKind::DuplicateName,
StoreError::Conflict { .. } => AdminStoreErrorKind::Conflict,
StoreError::InvalidData { .. } => AdminStoreErrorKind::Invalid,
StoreError::Unavailable { .. } => AdminStoreErrorKind::Unavailable,
@@ -21,8 +21,122 @@ use gateway_store::postgres::{
use super::TestDatabase;
#[tokio::test]
async fn migrated_keys_persist_exactly_and_keep_short_keys_masked() {
use gateway_admin::model::{MutationActor, MutationContext, client_keys::NewClientKey};
use gateway_core::policy::RateLimits;
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
let Some(database) = TestDatabase::create("migrated_keys").await else {
return;
};
let store = PgAdminClientKeyStore::new(database.pool.clone());
let context = MutationContext {
actor: MutationActor::System,
request_id: "migration-test".to_owned(),
};
let long_key: String = (0..160)
.map(|_| uuid::Uuid::new_v4().simple().to_string())
.collect();
let values = [
"q".to_owned(),
"abc".to_owned(),
"sk-legacy/key+value=:!@\\".to_owned(),
long_key,
];
for (index, key) in values.iter().enumerate() {
let id = ClientApiKeyId::new(format!("key_migrated_{index}")).unwrap();
let (_, record) = store
.create_client_key(
NewClientKey {
id: id.clone(),
name: format!("Migrated {index}"),
label: None,
group_ids: Vec::new(),
limits: RateLimits {
max_concurrency: 3,
requests_per_minute: 25,
},
budget: Default::default(),
plaintext: key.clone(),
},
&context,
)
.await
.unwrap();
assert_eq!(record.prefix.len(), 10.min(key.len() / 2));
assert_ne!(record.prefix, *key);
let revealed = store.reveal_client_key(&id).await.unwrap().unwrap();
assert_eq!(revealed.expose_for_response(), key);
}
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
.await
.unwrap();
assert_eq!(snapshot.client_api_keys.len(), values.len());
for key in &values {
let record = snapshot
.client_api_keys
.iter()
.find(|item| item.plaintext_key.expose_for_auth() == key)
.unwrap();
assert_eq!(record.limits.max_concurrency, 3);
}
database.close().await;
}
#[tokio::test]
async fn duplicate_migrated_keys_conflict_atomically_without_extra_audits() {
use gateway_admin::{
model::{MutationActor, MutationContext, client_keys::NewClientKey},
ports::store::AdminStoreErrorKind,
};
use gateway_core::policy::RateLimits;
let Some(database) = TestDatabase::create("duplicate_keys").await else {
return;
};
let store = PgAdminClientKeyStore::new(database.pool.clone());
let context = MutationContext {
actor: MutationActor::System,
request_id: "duplicate-test".to_owned(),
};
let key = "legacy-key-case-sensitive!";
let command = |id: &str| NewClientKey {
id: ClientApiKeyId::new(id).unwrap(),
name: id.to_owned(),
label: None,
group_ids: Vec::new(),
limits: RateLimits::unlimited(),
budget: Default::default(),
plaintext: key.to_owned(),
};
let (first, second) = tokio::join!(
store.create_client_key(command("key_first"), &context),
store.create_client_key(command("key_second"), &context)
);
assert_ne!(first.is_ok(), second.is_ok());
let error = first.err().or_else(|| second.err()).unwrap();
assert_eq!(error.kind(), AdminStoreErrorKind::Conflict);
assert!(!format!("{error:?}").contains(key));
let counts: (i64, i64) = sqlx::query_as(
"select (select count(*) from client_api_keys), (select count(*) from admin_audit_events)",
)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(counts, (1, 1));
let mut different_case = command("key_case_sensitive");
different_case.plaintext = key.to_uppercase();
store
.create_client_key(different_case, &context)
.await
.unwrap();
database.close().await;
}
#[test]
fn client_key_requires_the_frozen_plaintext_format() {
fn generated_client_key_format_remains_valid() {
let key = NewClientApiKey {
budget: Default::default(),
id: "key-1".to_owned(),
@@ -36,6 +150,142 @@ fn client_key_requires_the_frozen_plaintext_format() {
assert!(key.validate().is_ok());
}
#[tokio::test]
async fn client_key_names_are_checked_atomically_on_create_and_rename() {
use gateway_admin::{
model::{
MutationActor, MutationContext,
client_keys::{NewClientKey, UpdateClientKey},
},
ports::store::AdminStoreErrorKind,
};
use gateway_core::policy::RateLimits;
let Some(database) = TestDatabase::create("duplicate_key_names").await else {
return;
};
let store = PgAdminClientKeyStore::new(database.pool.clone());
let context = MutationContext {
actor: MutationActor::System,
request_id: "duplicate-name-test".to_owned(),
};
let create = |id: &str, name: &str| NewClientKey {
id: ClientApiKeyId::new(id).unwrap(),
name: name.to_owned(),
label: None,
group_ids: Vec::new(),
limits: RateLimits::unlimited(),
budget: Default::default(),
plaintext: format!("synthetic-credential-{id}"),
};
let update = |id: ClientApiKeyId, name: &str| UpdateClientKey {
id,
name: name.to_owned(),
label: None,
group_ids: Vec::new(),
limits: RateLimits::unlimited(),
daily_limit_usd: None,
weekly_limit_usd: None,
};
let (first, second) = tokio::join!(
store.create_client_key(create("key_name_first", "Production"), &context),
store.create_client_key(create("key_name_second", " production "), &context),
);
let (winner, conflict) = match (first, second) {
(Ok((_, record)), Err(error)) | (Err(error), Ok((_, record))) => (record, error),
results => panic!("expected one committed create: {results:?}"),
};
assert_eq!(conflict.kind(), AdminStoreErrorKind::DuplicateName);
assert_eq!(winner.name.trim(), winner.name);
let counts: (i64, i64) = sqlx::query_as(
"select (select count(*) from client_api_keys), (select count(*) from admin_audit_events)",
)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(counts, (1, 1));
let (revision, _) = store
.update_client_key(update(winner.id.clone(), "PRODUCTION"), &context)
.await
.unwrap();
let (_, other) = store
.create_client_key(create("key_name_other", "Staging"), &context)
.await
.unwrap();
let error = store
.update_client_key(update(other.id.clone(), " production "), &context)
.await
.unwrap_err();
assert_eq!(error.kind(), AdminStoreErrorKind::DuplicateName);
let unchanged = store.reveal_client_key(&other.id).await.unwrap().unwrap();
assert_eq!(unchanged.record.name, "Staging");
let persisted_revision: i64 =
sqlx::query_scalar("select config_revision from runtime_settings where id = 1")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(persisted_revision as u64, revision.get() + 1);
let (first, second) = tokio::join!(
store.update_client_key(update(winner.id, "Shared"), &context),
store.update_client_key(update(other.id, "SHARED"), &context),
);
assert_ne!(first.is_ok(), second.is_ok());
assert_eq!(
first.err().or_else(|| second.err()).unwrap().kind(),
AdminStoreErrorKind::DuplicateName
);
database.close().await;
}
#[tokio::test]
async fn client_key_search_matches_names_and_labels_but_never_credential_values() {
let Some(database) = TestDatabase::create("key_name_search").await else {
return;
};
let generated = format!("sk_{}", "K".repeat(43));
for (id, name, key) in [
("key_generated", "Production", generated.as_str()),
("key_custom", "生产_%专用", "legacy-token+/123"),
] {
sqlx::query(
"insert into client_api_keys (id, name, label, key, created_at, updated_at)
values ($1, $2, 'backend label', $3, now(), now())",
)
.bind(id)
.bind(name)
.bind(key)
.execute(&database.pool)
.await
.unwrap();
}
let repository = PgClientApiKeyRepository::new(database.pool.clone());
for (search, expected) in [
("PROD", 1),
("生产_%", 1),
("生产X", 0),
("backend", 2),
(generated.as_str(), 0),
(&generated[..10], 0),
("legacy-token+/123", 0),
("legacy-", 0),
] {
let page = repository
.list_client_api_keys(ClientApiKeyListQuery {
cursor: None,
page_size: 10,
search: Some(search.to_owned()),
sort: ClientApiKeySort::default(),
})
.await
.unwrap();
assert_eq!(page.total, expected, "search: {search}");
assert_eq!(page.items.len() as u64, expected);
}
database.close().await;
}
#[tokio::test]
async fn client_key_list_uses_safe_keyset_search_and_filtered_total() {
let Some(database) = TestDatabase::create("client_key_page").await else {
@@ -331,7 +331,7 @@ async fn assert_usage_search_ids(
}
#[tokio::test]
async fn usage_search_should_match_client_api_key_prefix() {
async fn usage_and_error_search_match_key_names_instead_of_credentials() {
let Some(database) = TestDatabase::create("usage_client_api_key_search").await else {
return;
};
@@ -342,7 +342,7 @@ async fn usage_search_should_match_client_api_key_prefix() {
let plaintext_key = format!("sk_{}", "K".repeat(43));
sqlx::query(
"insert into client_api_keys (id, name, key, enabled, created_at, updated_at)
values ('key_observe', 'usage search', $1, true, $2, $2)",
values ('key_observe', 'Production_%专用', $1, true, $2, $2)",
)
.bind(&plaintext_key)
.bind(now)
@@ -352,22 +352,53 @@ async fn usage_search_should_match_client_api_key_prefix() {
let range = ObservabilityRange::new(now - TimeDelta::hours(1), now + TimeDelta::hours(1))
.expect("observability range");
for search in [plaintext_key.as_str(), &plaintext_key[..10]] {
let page = observability_repository(&database.pool)
.list_usage_records(UsageRecordQuery {
range,
filter: UsageRecordFilter {
search: Some(search.to_owned()),
..UsageRecordFilter::default()
},
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
let repository = observability_repository(&database.pool);
for key in [plaintext_key.as_str(), "legacy-key+/123"] {
sqlx::query("update client_api_keys set key = $1 where id = 'key_observe'")
.bind(key)
.execute(&database.pool)
.await
.expect("search usage by client API key");
.unwrap();
for (search, expected) in [
("production", 1),
("PRODUCTION_%", 1),
("ProductionX", 0),
(key, 0),
(&key[..10], 0),
] {
let page = repository
.list_usage_records(UsageRecordQuery {
range,
filter: UsageRecordFilter {
search: Some(search.to_owned()),
..UsageRecordFilter::default()
},
current_page: 1,
page_size: ObservabilityPageSize::new(10).expect("page size"),
})
.await
.expect("search usage by client API key name");
assert_eq!(page.total, 1);
assert_eq!(page.items[0].id, "req_observe_success");
assert_eq!(page.total, expected, "search: {search}");
assert_eq!(page.items.len() as u64, expected);
if expected == 1 {
assert_eq!(page.items[0].id, "req_observe_success");
}
let errors = repository
.list_ops_errors(OpsErrorQuery {
range,
filter: OpsErrorFilter {
search: Some(search.to_owned()),
..Default::default()
},
current_page: 1,
page_size: ObservabilityPageSize::new(10).unwrap(),
})
.await
.unwrap();
assert_eq!(errors.total, expected * 2, "error search: {search}");
assert_eq!(errors.items.len() as u64, expected * 2);
}
}
database.close().await;
}
+1
View File
@@ -3,3 +3,4 @@ b7d3df7b48d835a00a3b74cbefc958a039d38fea5529cdad04bfbc703dca72aa 0001_initial.s
46f493ff410cf77550114542e0418475876ee2b2c975436290f0f72440e098c4 0003_account_outbound_proxy.sql
5e4bfd0b47a4de5e99b7ae1d89e0294de61856a40fba3480572db765147c3b3a 0004_remove_client_charge_reconciliation.sql
2741fabb6ec23e7a19962e364ffdc054f81584c868e24453e33644b96ccca19b 0005_managed_outbound_proxies.sql
393968715af911bb09f6738472678fbedcdc0a0967c39711610f52b9a2994c3f 0006_custom_client_keys.sql
@@ -0,0 +1,17 @@
-- 保留旧 Key,允许迁入不同前缀、长度和标点;约束与 HTTP Bearer 可见字符一致。
alter table client_api_keys
drop constraint client_api_keys_key_ck,
add constraint client_api_keys_key_ck check (
key <> '' and key collate "C" !~ '[^!-~]'
);
-- 可见 ASCII 在 UTF-8 中编码固定,因此这里可声明 immutable。
-- 对完整值建固定大小的摘要索引,避免长 Key 超过 PostgreSQL B-tree 索引项上限。
create function client_api_key_sha256(value text) returns bytea
language sql immutable strict parallel safe
return sha256(convert_to(value, 'UTF8'));
create unique index client_api_keys_key_sha256_idx
on client_api_keys (client_api_key_sha256(key));
alter table client_api_keys drop constraint client_api_keys_key_key;
+38 -4
View File
@@ -14,6 +14,9 @@
Authorization: Bearer sk_...
```
自动生成的 Key 保持 `sk_` 格式;迁入的自定义 Key 使用保存时的原值,不限制前缀或固定长度。
无论格式如何,只有已保存且启用的 Client Key 能通过鉴权。
Codex 原生生图配置还会携带 `X-OpenAI-Actor-Authorization: proxy-managed`。
它仅用于客户端识别服务端托管认证,不能代替 Client Key。网关和 OpenAI Provider 都会过滤该请求头,
上游账号身份只由服务端选中的账号提供;不要把真实账号 token 放进该标记。
@@ -620,12 +623,41 @@ PostgreSQL 或 Redis。管理端只在用户打开弹窗或点击刷新时调用
| `POST` | `/api/admin/client-keys/delete` | `{ id }` | 删除 |
创建字段为 `name`、可选 `label`、`groupIds`、`maxConcurrency`、`requestsPerMinute`、可选
`dailyLimitUsd` 和 `weeklyLimitUsd`,更新请求再增加
`dailyLimitUsd`、`weeklyLimitUsd` 和 `customKey`,更新请求增加
`id`。`groupIds` 必须显式提交:空数组派生 `routingScope: "all"`,非空数组派生
`routingScope: "groups"`。响应同时返回分组引用 `groups`,以及从当前有效账号池派生、仅供展示的
`providerKinds`;Client Key 不再保存 `providerKind`。创建和 reveal 响应会返回完整明文 Key,调用方
必须立即安全保存。
密钥列表的 `search` 仅匹配名称和标签,不匹配密钥值或可见前缀;搜索不区分大小写,使用字面量前缀匹配。
创建和更新时去除名称首尾空白,并按忽略大小写、首尾空格的名称查重,重复返回 `409`。
更新排除当前记录;并发写入复用控制面事务锁,失败不会留下审计或配置版本变更。
历史重名数据不自动改名,已有凭据继续有效;再次保存时需使用未被其他密钥占用的名称。
`customKey` 仅用于创建:省略、`null` 或空字符串时继续自动生成;非空时按原值保存,不追加前缀、
不截断、不修剪空白,也不要求固定长度。Key 须为 HTTP Bearer 可传输的非空可见 ASCII 字符,
支持标点,空格、控制字符和非 ASCII 文本会返回 `400`。重复 Key 返回 `409`,包括并发创建时。
应用不另设 Key 长度上限;创建请求和 `Authorization` 头仍受 Web 服务器及反向代理的通用大小限制。
更新接口不接受 `customKey`,防止修改策略时意外替换正在使用的凭据。
列表仅展示最多前 10 个字符,且至少隐藏一半字符;单字符 Key 的可见前缀为空。
完整值仍只通过创建和显式 reveal 返回,不进入普通 Debug 或审计。
迁移示例(其他创建字段同上):
```json
{
"name": "迁入的客户端",
"customKey": "legacy-platform-key/example+=",
"groupIds": [],
"maxConcurrency": 2,
"requestsPerMinute": 0
}
```
客户端沿用原 Key,将 Base URL 指向本平台即可。分组权限、日/周限额和并发规则按本平台配置执行,
不会导入旧平台的历史用量。部署升级会新增 `0006_custom_client_keys.sql`,保留全部已有 Key,
并以 SHA-256 唯一索引支持长 Key;鉴权仍校验完整原值。
金额字段为非负十进制字符串,最多 10 位整数与 10 位小数,`"0"` 表示不限额。
创建时省略金额字段默认为零;更新时省略或 `null` 保留当前值,修改限额不会清空已用金额。
`maxConcurrency` 和 `requestsPerMinute` 是非负整数,零表示不限。
@@ -805,13 +837,15 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt
request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可取 `model`、`account`、
`apiKey`、`provider`、`transport`、`failureClass`、`status`。
请求记录列表的 `search` 使用区分大小写的字面量前缀匹配,支持请求 ID、Client Key ID / Key 前缀、
账号 ID、账号邮箱与名称、请求 / 上游模型 ID、上游请求 ID。账号邮箱与名称按请求记录的历史快照检索,
请求记录列表的 `search` 使用字面量前缀匹配,支持请求 ID、Client Key ID / 名称、
账号 ID、账号邮箱与名称、请求 / 上游模型 ID、上游请求 ID。密钥名称不区分大小写,其他字段区分大小写。
密钥名称按当前密钥记录检索,改名后使用新名称,删除后仍可按 Client Key ID 查询历史记录。
账号邮箱与名称按请求记录的历史快照检索,
不随当前账号修改或删除而改变;`%`、`_` 和 `\` 均按普通字符处理,不作为搜索通配符。
请求 ID 和上游 ID 继续通过既有字段查询;不增加入口 ID 字段,也不扫描 trace 建立查询映射。
旧响应中只有入口 ID 时仍需结合时间与入口日志定位,不能回填不存在的关联。
管理端搜索完整 `sk_` Client Key 时仅提交其可见前缀,不将完整密钥放入 URL。
请求记录和错误列表支持按密钥名称搜索,不支持密钥值或可见前缀搜索。
错误列表的主动刷新、搜索和平台/时间条件变化会取得新的结束时间;翻页沿用该次查询快照。
已进入模型执行会话、但在首次合法 ProviderStream 建立前失败的请求也进入现有错误及详情查询,
+5 -1
View File
@@ -70,6 +70,10 @@ interface ApiKeyUpdateParam extends ApiKeyWriteParam {
id: string
}
interface ApiKeyCreateParam extends ApiKeyWriteParam {
customKey?: string
}
interface ApiKeyIdParam {
id: string
}
@@ -83,7 +87,7 @@ export function getApiKeys(data: ApiKeyListParams, options: RequestOptions = {})
})
}
export function createApiKey(data: ApiKeyWriteParam, options: RequestOptions = {}) {
export function createApiKey(data: ApiKeyCreateParam, options: RequestOptions = {}) {
return request<ApiKeyCreateResponse>({
url: '/api/admin/client-keys/create',
method: 'POST',
@@ -61,6 +61,21 @@ const title = computed(() => props.editing ? '编辑 API Key' : '创建 API Key'
/>
</BaseFormItem>
<BaseFormItem
v-if="!editing"
label="自定义 Key(可选)"
>
<BaseInput
v-model="form.customKey"
type="password"
autocomplete="new-password"
:spellcheck="false"
aria-label="自定义 Key(可选)"
placeholder="留空自动生成"
:disabled="saving"
/>
</BaseFormItem>
<BaseFormItem label="分组">
<AccountGroupCheckboxGrid
v-model="form.groupIds"
@@ -24,7 +24,7 @@ const search = defineModel<string>('search', { required: true })
aria-label="API Key 筛选与操作"
>
<div class="min-w-0 flex-1 md:w-96 md:flex-none">
<BaseInput v-model="search" placeholder="搜索名称、标签或 ID" class="w-full">
<BaseInput v-model="search" placeholder="搜索名称或标签" aria-label="搜索 API Key 名称或标签" class="w-full">
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
@@ -17,6 +17,7 @@ import { useIdSet } from '@/composables/useIdSet'
type ApiKeyRow = Awaited<ReturnType<typeof getApiKeys>>['items'][number]
export interface ApiKeyFormValue {
customKey: string
name: string
label: string
groupIds: string[]
@@ -61,6 +62,7 @@ export function useApiKeyMutations(options: {
function openEdit(key: ApiKeyRow) {
editingKey.value = key
form.value = {
customKey: '',
name: key.name,
label: key.label ?? '',
groupIds: key.groups.map(group => group.id),
@@ -107,7 +109,10 @@ export function useApiKeyMutations(options: {
await updateApiKey({ id: current.id, ...payload })
}
else {
const result = await createApiKey(payload)
const result = await createApiKey({
...payload,
customKey: form.value.customKey || undefined,
})
createdKey.value = result.plaintextKey
createdKeyName.value = payload.name
}
@@ -139,6 +144,10 @@ export function useApiKeyMutations(options: {
toast.warning('请输入 API Key 名称')
return false
}
if (!editingKey.value && form.value.customKey && !/^[\x21-\x7E]+$/.test(form.value.customKey)) {
toast.warning('自定义 Key 只能包含 HTTP 可传输的可见字符,不能包含空格或换行')
return false
}
for (const [label, value] of [
['最大并发', form.value.maxConcurrency],
['每分钟请求数', form.value.requestsPerMinute],
@@ -285,6 +294,7 @@ export function useApiKeyMutations(options: {
function emptyForm(): ApiKeyFormValue {
return {
customKey: '',
name: '',
label: '',
groupIds: [],
@@ -91,8 +91,8 @@ function upstreamSendStateText(value: string | null | undefined) {
<div class="min-w-0 flex-1">
<BaseInput
v-model="searchQuery"
placeholder="请求 ID、Key ID / 可见前缀或账号"
aria-label="搜索错误:请求 ID、Key ID / 可见前缀或账号"
placeholder="请求 ID、密钥名称或账号"
aria-label="搜索错误:请求 ID、密钥名称或账号"
class="min-w-0 w-full lg:max-w-96"
>
<template #prefix>
@@ -3,6 +3,7 @@ import type { OpsError, OpsErrorMetadata } from '@/api'
import { Download, RefreshCw } from '@lucide/vue'
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useDownload } from '@/composables/useDownload'
@@ -54,46 +55,34 @@ function download() {
<template #icon>
<Download :size="14" />
</template>
导出安全诊断包
导出诊断包
</BaseButton>
</div>
</div>
<p class="mt-1 mb-3 break-all font-mono text-cp-xs leading-relaxed text-cp-text-secondary">
{{ selectedId }}
</p>
<p class="mb-3 text-cp-xs leading-relaxed text-cp-text-secondary">
诊断包 v2 仅含关联字段、错误分类、尝试与时间线阶段;不含错误原文、正文、用户内容或事件 data。版本与环境需另附,分享前请审阅关联 ID。
</p>
<p v-if="!loading && !selectedError" class="text-cp-xs leading-relaxed text-cp-text-secondary">
当前请求未提供错误详情摘要,导出不会沿用其他请求的错误;可结合尝试记录排查。
</p>
<p v-if="loading" role="status" class="text-cp-sm text-cp-text-secondary">
正在加载诊断记录…
</p>
<p v-else-if="error" role="alert" class="text-cp-sm text-cp-error-text">
{{ error }}
<span v-if="selectedError">仍可导出本次错误的分类摘要和关联字段,缺失诊断会在包内标明。</span>
</p>
<template v-else-if="detail">
<p v-if="!detail.attemptsComplete" role="status" class="text-cp-xs leading-relaxed text-cp-warning-text">
尝试记录可能不完整,诊断包会保留此缺口,不能仅按列表条数判断实际尝试次数。
</p>
<div v-if="detail.relatedRequests?.length" class="mb-3 flex flex-wrap gap-2">
<BaseButton v-for="related in detail.relatedRequests" :key="related.requestId" variant="soft" size="sm" class="max-w-full" @click="selectedId = related.requestId">
{{ related.relation === 'recovered_by' ? '查看恢复请求' : '查看先前失败' }} · {{ related.requestId }}
</BaseButton>
</div>
<RequestTransportFailure :events="trace?.events ?? []" :metadata="selectedId === requestId ? metadata : undefined" />
<p v-if="!trace" class="text-cp-sm text-cp-text-secondary">
这条记录没有保存诊断时间线。旧记录无法补回当时未采集的事件。
</p>
<template v-else>
<BaseEmpty v-if="!events.length" title="暂无诊断时间线" size="sm" surface="none" />
<template v-else-if="trace">
<div class="mb-3 grid gap-1 text-cp-xs leading-relaxed">
<p class="m-0 text-cp-text-secondary">
已观测 {{ trace.totalEvents }} 个阶段或事件,展示 {{ events.length }} 条记录。连续增量合并计数;历史事件内容可能未充分脱敏,不会自动导出。
已观测 {{ trace.totalEvents }} 个阶段或事件,展示 {{ events.length }} 条记录
</p>
<p v-if="trace.droppedEvents" role="status" class="m-0 text-cp-warning-text">
达到保存上限,{{ trace.droppedEvents }} 个事件已被淘汰;保留请求开头与最近事件。
已达保存上限,{{ trace.droppedEvents }} 个事件未保留
</p>
</div>
<BaseScrollbar max-height="32rem">
@@ -114,9 +103,6 @@ function download() {
</li>
</ol>
</BaseScrollbar>
<p class="mt-3 mb-0 text-cp-xs leading-relaxed text-cp-text-secondary">
界面中的诊断事实不等于默认导出内容。原始错误或完整报文如确需反馈,请先单独审阅脱敏;可用请求 ID 在服务器日志中检索。
</p>
</template>
</template>
</section>
@@ -19,7 +19,7 @@ const search = defineModel<string>('search', { required: true })
<template>
<div class="flex w-full items-center gap-3" role="group" aria-label="使用记录筛选与操作">
<div class="min-w-0 flex-1 sm:w-96 sm:flex-none">
<BaseInput v-model="search" placeholder="请求、Key ID / 可见前缀、账号或模型" aria-label="搜索使用记录:请求、Key ID / 可见前缀、账号或模型" class="w-full">
<BaseInput v-model="search" placeholder="请求、密钥名称、账号或模型" aria-label="搜索使用记录:请求、密钥名称、账号或模型" class="w-full">
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
@@ -6,7 +6,6 @@ import { computed, onScopeDispose, shallowRef, watch } from 'vue'
import { getOpsErrors } from '@/api'
import { useStablePagedQuery } from '@/composables/useStablePagedQuery'
import { withMinimumDuration } from '@/utils/async'
import { usageSearchParam } from '../utils/search'
interface UseOpsErrorsTableOptions {
timeRangeParams: Readonly<Ref<UsageTimeRangeParams>>
@@ -18,7 +17,7 @@ interface UseOpsErrorsTableOptions {
export function useOpsErrorsTable(options: UseOpsErrorsTableOptions) {
const refreshing = shallowRef(false)
const searchQuery = shallowRef('')
const search = computed(() => usageSearchParam(searchQuery.value))
const search = computed(() => searchQuery.value.trim() || undefined)
let disposed = false
// 时间、平台和搜索共同构成分页快照,避免翻页混入另一组筛选结果。
let tableParams = snapshot()
@@ -11,7 +11,6 @@ import {
getUsageRecordSummary,
} from '@/api'
import { withMinimumDuration } from '@/utils/async'
import { usageSearchParam } from '../utils/search'
interface UseUsageRecordsTableOptions {
timeRangeParams: Readonly<Ref<UsageTimeRangeParams>>
@@ -36,7 +35,7 @@ export function useUsageRecordsTable(options: UseUsageRecordsTableOptions) {
const pageSize = shallowRef(10)
const totalRecords = shallowRef(0)
const searchQuery = shallowRef('')
const search = computed(() => usageSearchParam(searchQuery.value))
const search = computed(() => searchQuery.value.trim() || undefined)
const providerQuery = shallowRef('')
let tableParams = snapshot()
const refreshingList = shallowRef(false)
-10
View File
@@ -1,10 +0,0 @@
const CLIENT_API_KEY_VISIBLE_PREFIX_LENGTH = 10
export function usageSearchParam(value: string) {
const search = value.trim()
if (!search)
return undefined
if (search.startsWith('sk_'))
return search.slice(0, CLIENT_API_KEY_VISIBLE_PREFIX_LENGTH)
return search
}