feat(accounts): support upstream API key accounts (#107)

Add Responses-compatible upstream accounts with isolated credentials, transport,
model catalogs, atomic settings updates, and account management controls.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-16 18:04:41 +08:00
committed by GitHub
co-authored by Zyy Codex
parent 52d24d29c7
commit 8d9aaf0bbd
70 changed files with 2632 additions and 311 deletions
@@ -585,6 +585,7 @@ pub struct CredentialDeletionResult {
pub struct RotateCredential {
pub mutation: CredentialMutation,
pub provider_material: ProviderDocument,
pub settings: Option<super::accounts::UpdateAccount>,
}
/// Provider 校验手工轮换材料时所需的非事务输入。
@@ -664,6 +665,7 @@ impl fmt::Debug for PreparedCredentialRotation {
#[derive(Debug, Clone, PartialEq)]
pub struct CredentialRotationCommit {
pub prepared: PreparedCredentialRotationFacts,
pub settings: Option<super::accounts::UpdateAccount>,
}
impl fmt::Debug for RotateCredential {
@@ -672,6 +674,7 @@ impl fmt::Debug for RotateCredential {
.debug_struct("RotateCredential")
.field("mutation", &self.mutation)
.field("provider_material", &self.provider_material)
.field("settings", &self.settings)
.finish()
}
}
@@ -170,6 +170,14 @@ pub trait ProviderAdmin: Send + Sync {
command: PrepareCredentialRefresh,
) -> Result<PreparedCredentialRotation, ProviderAdminError>;
/// 可公开的账号连接设置;实现只能显式投影非敏感字段,不能返回凭据原文。
async fn account_configuration(
&self,
_account_id: &ProviderAccountId,
) -> Result<Option<ProviderDocument>, ProviderAdminError> {
Ok(None)
}
async fn quota(
&self,
request: ProviderQuotaRequest,
@@ -17,8 +17,8 @@ use crate::{
AdminError, MutationContext,
accounts::{
AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery,
AccountPageItem, AccountUpdateResult, AccountUsageWindowQuery, AccountsUpdateResult,
BatchUpdateAccounts, UpdateAccount,
AccountPageItem, AccountUpdateResult, AccountUsage, AccountUsageWindowQuery,
AccountsUpdateResult, BatchUpdateAccounts, UpdateAccount,
},
observability::TimeRange,
provider_credentials::{
@@ -79,6 +79,13 @@ pub trait AccountsService: Send + Sync {
command: BatchUpdateAccounts,
) -> Result<AccountsUpdateResult, AdminError>;
async fn account_configuration(
&self,
_account_id: &ProviderAccountId,
) -> Result<Option<crate::model::provider_credentials::ProviderDocument>, AdminError> {
Ok(None)
}
async fn quota(
&self,
account_id: &ProviderAccountId,
@@ -250,6 +257,37 @@ impl DefaultAccountsService {
Ok(())
}
async fn load_api_key_usage(
&self,
accounts: &[AccountPageItem],
) -> Result<BTreeMap<String, AccountUsage>, AdminError> {
let now = Utc::now();
// API Key 没有套餐周期;本地累计直接查询账号创建后仍保留的请求记录。
let windows = accounts
.iter()
.filter(|item| item.account.authentication_kind == "api_key")
.map(|item| AccountUsageWindowQuery {
account_id: item.account.id.clone(),
key: "account-lifetime".to_owned(),
range: TimeRange {
start: item.account.created_at,
end: now,
},
})
.collect::<Vec<_>>();
if windows.is_empty() {
return Ok(BTreeMap::new());
}
Ok(self
.accounts
.load_account_usage_by_windows(&windows)
.await
.map_err(|error| map_store_error(error, "API Key account usage"))?
.into_iter()
.map(|result| (result.account_id, result.usage))
.collect())
}
async fn load_directory_item(
&self,
account_id: &ProviderAccountId,
@@ -287,9 +325,15 @@ impl DefaultAccountsService {
std::slice::from_mut(&mut quota),
)
.await?;
let usage = quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone());
let usage = self
.load_api_key_usage(std::slice::from_ref(&stored))
.await?
.remove(&stored.account.id)
.or_else(|| {
quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone())
});
Ok(AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
stored.account.provider_kind.as_str(),
@@ -377,14 +421,17 @@ impl AccountsService for DefaultAccountsService {
.collect::<Result<Vec<_>, AdminError>>()?;
self.attach_quota_local_usage(&page.items, &mut quotas)
.await?;
let mut api_key_usage = self.load_api_key_usage(&page.items).await?;
let items = page
.items
.into_iter()
.zip(quotas)
.map(|(mut item, quota)| {
let usage = quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone());
let usage = api_key_usage.remove(&item.account.id).or_else(|| {
quota
.usage_window()
.and_then(|(window, _)| window.local_usage.clone())
});
AccountDirectoryItem {
plan_type_display: self.providers.resolve_account_plan(
item.account.provider_kind.as_str(),
@@ -581,6 +628,17 @@ impl AccountsService for DefaultAccountsService {
Ok(result)
}
async fn account_configuration(
&self,
account_id: &ProviderAccountId,
) -> Result<Option<crate::model::provider_credentials::ProviderDocument>, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.account_configuration(account_id)
.await
.map_err(|error| map_provider_error(error, "provider account configuration"))
}
async fn quota(
&self,
account_id: &ProviderAccountId,
@@ -345,12 +345,19 @@ async fn commit_authorization(
async fn commit_credential_rotation(
accounts: &dyn AccountStore,
prepared: PreparedCredentialRotation,
settings: Option<crate::model::accounts::UpdateAccount>,
context: &MutationContext,
resource: &'static str,
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_rotation(CredentialRotationCommit { prepared: facts }, context)
.commit_credential_rotation(
CredentialRotationCommit {
prepared: facts,
settings,
},
context,
)
.await
{
Ok(result) => {
@@ -372,7 +379,13 @@ async fn commit_credential_refresh(
) -> Result<CredentialMutationResult, AdminError> {
let (facts, guard) = prepared.into_parts();
match accounts
.commit_credential_refresh(CredentialRotationCommit { prepared: facts }, context)
.commit_credential_refresh(
CredentialRotationCommit {
prepared: facts,
settings: None,
},
context,
)
.await
{
Ok(result) => {
@@ -189,6 +189,17 @@ impl OpenAiService for DefaultOpenAiService {
) -> Result<CredentialMutationResult, AdminError> {
let context = command.mutation.context;
let account_id = command.mutation.account_id;
if command
.settings
.as_ref()
.is_some_and(|settings| settings.account_id != account_id.as_str())
{
return Err(AdminError::invalid("凭据和账号设置的目标不一致"));
}
let disable_account = command
.settings
.as_ref()
.is_some_and(|settings| !settings.enabled);
let details = required_credential(
self.accounts.as_ref(),
self.provider.provider_kind(),
@@ -209,10 +220,14 @@ impl OpenAiService for DefaultOpenAiService {
let result = commit_credential_rotation(
self.accounts.as_ref(),
prepared,
command.settings,
&context,
"OpenAI credential rotation",
)
.await?;
if disable_account {
self.provider.account_unavailable(&account_id).await;
}
self.provider
.account_facts_changed(std::slice::from_ref(&result.account_id))
.await;
@@ -1956,6 +1956,52 @@ async fn quota_forecast_mid_cycle_sampling_accepts_small_reset_jitter_but_not_a_
);
}
#[tokio::test]
async fn api_key_list_and_detail_should_accumulate_local_usage_without_subscription_windows() {
let provider = FakeProviderAdmin::new("openai", events());
let mut account = account_record("openai");
account.authentication_kind = "api_key".to_owned();
account.created_at = Utc::now() - TimeDelta::days(60);
let added_at = account.created_at;
let store = FakeAccountStore::with_account(account, events());
store.set_quota_window_usage(vec![AccountUsageWindowResult {
account_id: "acct_test".to_owned(),
key: "account-lifetime".to_owned(),
usage: quota_local_usage("acct_test", 4_330_000),
}]);
let services = accounts_service(provider, store.clone()).await;
let page = services
.accounts()
.list(AccountListQuery {
page: 1,
page_size: gateway_admin::model::PageSize::new(20).unwrap(),
provider_kind: None,
group_filter: None,
search: None,
status: None,
sort: None,
})
.await
.unwrap();
assert!(page.items[0].quota.windows.is_empty());
assert_eq!(
page.items[0].usage.as_ref().unwrap().total_tokens,
Some(4_330_000)
);
let queries = store.quota_window_queries();
assert_eq!(queries.len(), 1);
assert_eq!(queries[0].range.start, added_at);
assert!(queries[0].range.end > added_at + TimeDelta::days(59));
let detail = services
.accounts()
.quota(&ProviderAccountId::new("acct_test").unwrap(), false)
.await
.unwrap();
assert_eq!(detail.usage, page.items[0].usage);
assert!(detail.quota.windows.is_empty());
assert_eq!(store.quota_window_queries()[0].range.start, added_at);
}
#[tokio::test]
async fn accounts_list_should_attach_local_usage_to_quota_windows() {
let provider = FakeProviderAdmin::new("openai", events());
@@ -319,9 +319,13 @@ impl AccountDeletionRequest {
pub struct RotateAccountRequest {
pub provider: String,
pub account_id: String,
pub access_token: String,
pub access_token: Option<String>,
pub refresh_token: Option<String>,
pub id_token: Option<String>,
pub base_url: Option<String>,
pub api_key: Option<String>,
pub transport: Option<String>,
pub settings: Option<UpdateAccountRequest>,
}
impl RotateAccountRequest {
@@ -330,11 +334,40 @@ impl RotateAccountRequest {
return Err(WireValidationError::new("provider"));
}
require_account_id(&self.account_id, "accountId")?;
validate_oauth_material(
&self.access_token,
self.refresh_token.as_deref(),
self.id_token.as_deref(),
)
if let Some(settings) = &self.settings {
settings.validate()?;
if settings.account_id != self.account_id {
return Err(WireValidationError::new("settings.accountId"));
}
}
if let Some(base_url) = &self.base_url {
if self.access_token.is_some()
|| self.refresh_token.is_some()
|| self.id_token.is_some()
|| base_url.is_empty()
|| base_url.len() > 2048
|| !matches!(self.transport.as_deref(), Some("http" | "prefer_websocket"))
|| self.api_key.as_ref().is_some_and(|key| {
key.is_empty()
|| key.len() > 16 * 1024
|| !key.bytes().all(|byte| byte.is_ascii_graphic())
})
{
return Err(WireValidationError::new("credential"));
}
Ok(())
} else {
if self.api_key.is_some() || self.transport.is_some() {
return Err(WireValidationError::new("credential"));
}
validate_oauth_material(
self.access_token
.as_deref()
.ok_or_else(|| WireValidationError::new("accessToken"))?,
self.refresh_token.as_deref(),
self.id_token.as_deref(),
)
}
}
pub(super) fn into_command(
@@ -343,16 +376,34 @@ impl RotateAccountRequest {
) -> Result<RotateCredential, WireValidationError> {
self.validate()?;
let mut material = Map::new();
material.insert("access_token".to_owned(), Value::String(self.access_token));
material.insert(
"refresh_token".to_owned(),
self.refresh_token.map_or(Value::Null, Value::String),
);
material.insert(
"id_token".to_owned(),
self.id_token.map_or(Value::Null, Value::String),
);
if let Some(base_url) = self.base_url {
material.insert("base_url".to_owned(), Value::String(base_url));
material.insert(
"transport".to_owned(),
self.transport.map_or(Value::Null, Value::String),
);
if let Some(key) = self.api_key {
material.insert("api_key".to_owned(), Value::String(key));
}
} else {
material.insert(
"access_token".to_owned(),
self.access_token.map_or(Value::Null, Value::String),
);
material.insert(
"refresh_token".to_owned(),
self.refresh_token.map_or(Value::Null, Value::String),
);
material.insert(
"id_token".to_owned(),
self.id_token.map_or(Value::Null, Value::String),
);
}
Ok(RotateCredential {
settings: self
.settings
.map(UpdateAccountRequest::into_command)
.transpose()?,
mutation: CredentialMutation {
context,
account_id: ProviderAccountId::new(self.account_id)
@@ -120,8 +120,15 @@ where
.quota(&account_id, false)
.await
.map_err(map_service_error)?;
let data = AccountQuotaData {
let configuration = state
.admin_services()
.accounts()
.account_configuration(&account_id)
.await
.map_err(map_service_error)?;
let data = AccountDetailData {
account: account_view(result, Utc::now()),
credential_configuration: configuration.map(provider_document_value),
};
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
@@ -71,6 +71,10 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
let added_at = china_rfc3339(&account.created_at);
let updated_at = china_rfc3339(&account.updated_at);
let usage_period = quota.usage_window().map(|(_, period)| period);
let mut usage = account_usage_view(usage, usage_period, now);
if account.authentication_kind == "api_key" {
usage.window_label_display = "通用额度".to_owned();
}
let (quota, refresh_token_expires_at) = account_quota_view(quota, rate_limited_until, now);
AccountView {
id: account.id.clone(),
@@ -121,7 +125,7 @@ pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> Ac
updated_at,
updated_at_display: china_datetime(&account.updated_at),
quota,
usage: account_usage_view(usage, usage_period, now),
usage,
}
}
@@ -684,6 +684,14 @@ pub struct AccountQuotaData {
pub account: AccountView,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountDetailData {
pub account: AccountView,
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_configuration: Option<serde_json::Value>,
}
/// 个人资料、累计统计与订阅的统一响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -599,6 +599,44 @@ mod actions {
);
}
#[test]
fn api_key_rotation_settings_must_target_the_same_account_and_remain_valid() {
let mut request = json!({
"provider": "openai",
"accountId": "acct_api",
"baseUrl": "https://api.example.invalid/v1",
"transport": "http",
"settings": {
"accountId": "acct_api",
"enabled": true,
"concurrencyLimit": null,
"weight": 1,
"groupIds": []
}
});
serde_json::from_value::<RotateAccountRequest>(request.clone())
.expect("decode combined save")
.validate()
.expect("blank replacement key preserves the existing key");
request["settings"]["accountId"] = json!("acct_other");
assert_eq!(
serde_json::from_value::<RotateAccountRequest>(request.clone())
.unwrap()
.validate()
.unwrap_err()
.field(),
"settings.accountId"
);
request["settings"]["accountId"] = json!("acct_api");
request["settings"]["concurrencyLimit"] = json!(0);
assert!(
serde_json::from_value::<RotateAccountRequest>(request)
.unwrap()
.validate()
.is_err()
);
}
#[test]
fn credential_recovery_requests_should_not_accept_client_revision_fences() {
let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({
@@ -762,11 +762,8 @@ impl ProviderAccount {
last_error_message: Option<String>,
) -> Self {
self.enabled = enabled;
self.credential_state = if self.upstream_user_id.is_some() {
credential_state
} else {
CredentialState::Unknown
};
// 凭据是否可用由 Provider 判断;API Key 等认证不要求上游用户身份。
self.credential_state = credential_state;
self.quota = quota;
self.last_error_reason = last_error_reason;
self.last_error_message = last_error_message;
@@ -1051,10 +1051,7 @@ impl ExecutionService for DefaultExecutionService {
continue;
}
if seen.insert(model.clone()) {
result.push(PublicModelDescriptor::Native {
model,
payload: source.payload.clone(),
});
result.push(source.content.for_public_model(model));
}
}
for model in client.snapshot.public_models_for_provider(kind) {
@@ -1073,10 +1070,7 @@ impl ExecutionService for DefaultExecutionService {
}
if let Some(source) = by_id.get(target.as_str()) {
seen.insert(model.clone());
result.push(PublicModelDescriptor::Native {
model,
payload: source.payload.clone(),
});
result.push(source.content.for_public_model(model));
}
}
}
@@ -11,14 +11,20 @@ use super::{
ModelCapabilities, ModelPresentation, PublicModelId, PublicModelProfile, UpstreamModelId,
};
/// Provider 原生客户端目录条目;Core 只解释模型标识,不解释协议正文。
/// Provider 客户端目录条目;缺少原生协议正文时使用已编译的通用画像。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelDescriptor {
pub model: UpstreamModelId,
pub payload: RawJsonPayload,
pub content: ProviderModelContent,
}
/// 对外目录保留原生正文;只有没有原生目录的 Provider 才使用通用画像适配。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProviderModelContent {
Native(RawJsonPayload),
Adapted(ModelPresentation),
}
/// 对外目录按条目保留原生正文或使用通用画像适配。
#[derive(Debug, Clone)]
pub enum PublicModelDescriptor {
Native {
@@ -28,6 +34,20 @@ pub enum PublicModelDescriptor {
Adapted(PublicModelProfile),
}
impl ProviderModelContent {
pub(crate) fn for_public_model(&self, model: PublicModelId) -> PublicModelDescriptor {
match self {
Self::Native(payload) => PublicModelDescriptor::Native {
model,
payload: payload.clone(),
},
Self::Adapted(presentation) => {
PublicModelDescriptor::Adapted(PublicModelProfile::new(model, presentation.clone()))
}
}
}
}
/// Provider 实时目录编译后的单模型能力。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelCapabilities {
@@ -10,7 +10,8 @@ pub use crate::account::scope::{
pub use crate::identity::ProviderKind;
pub use catalog::{
ProviderCatalogGeneration, ProviderCatalogPort, ProviderCatalogUnavailable,
ProviderModelCapabilities, ProviderModelDescriptor, PublicModelDescriptor,
ProviderModelCapabilities, ProviderModelContent, ProviderModelDescriptor,
PublicModelDescriptor,
};
pub use snapshot::RuntimeSnapshot;
@@ -323,13 +323,13 @@ fn rate_limited_projection_carries_only_the_active_cooldown_deadline() {
}
#[test]
fn account_without_upstream_identity_should_stay_unknown_and_not_schedulable() {
fn account_without_upstream_identity_preserves_provider_credential_state() {
let account = ProviderAccount::new(
ProviderAccountId::new("acct_pending_identity").expect("valid account"),
ProviderKind::new("openai").expect("valid provider"),
"pending identity".to_owned(),
None,
"oauth".to_owned(),
"api_key".to_owned(),
CredentialRevision::new(1).expect("valid revision"),
Some(SystemTime::now() + Duration::from_secs(3600)),
)
@@ -346,7 +346,18 @@ fn account_without_upstream_identity_should_stay_unknown_and_not_schedulable() {
account.credential_state(),
account.status_projection(SystemTime::now(), None).status
),
(CredentialState::Unknown, AccountStatus::Error)
(CredentialState::Ready, AccountStatus::Normal)
);
let pending = account.with_account_facts(
true,
CredentialState::Unknown,
QuotaState::unknown(),
None,
None,
);
assert_eq!(
pending.status_projection(SystemTime::now(), None).status,
AccountStatus::Error
);
}
@@ -1045,13 +1045,15 @@ impl Provider for NativeCatalogProvider {
.into_iter()
.map(|id| gateway_core::routing::ProviderModelDescriptor {
model: UpstreamModelId::new(id).expect("id"),
payload: RawJsonPayload::new(
"codex",
serde_json::to_vec(&json!({"slug":id,"future":null}))
.expect("JSON")
.into(),
)
.expect("payload"),
content: gateway_core::routing::ProviderModelContent::Native(
RawJsonPayload::new(
"codex",
serde_json::to_vec(&json!({"slug":id,"future":null}))
.expect("JSON")
.into(),
)
.expect("payload"),
),
})
.collect(),
))
@@ -281,6 +281,7 @@ impl PgAdminAccountStore {
async fn commit_prepared_rotation(
&self,
prepared: PreparedCredentialRotationFacts,
settings: Option<UpdateAccount>,
context: &MutationContext,
action: &str,
) -> AdminStoreResult<CredentialMutationResult> {
@@ -288,9 +289,24 @@ impl PgAdminAccountStore {
let scope = ProviderAccountAdminScope {
provider_kind: prepared.provider_kind.as_str().to_owned(),
};
let mut changed_fields = vec!["credentials".to_owned()];
if let Some(settings) = &settings {
changed_fields
.extend(["enabled", "concurrency_limit", "weight", "groups"].map(str::to_owned));
if settings.model_access.is_some() {
changed_fields.push("model_access".to_owned());
}
if settings.outbound_proxy.is_some() {
changed_fields.push("outbound_proxy".to_owned());
}
if settings.notes.is_some() {
changed_fields.push("notes".to_owned());
}
}
let rotation = self
.accounts
.rotate_provider_account(RotateProviderAccount {
settings,
scope,
profile: UpdateProviderAccount {
id: account_id.as_str().to_owned(),
@@ -313,7 +329,7 @@ impl PgAdminAccountStore {
action,
"provider_account",
account_id.as_str(),
vec!["credentials".to_owned()],
changed_fields,
),
})
.await
@@ -637,7 +653,7 @@ impl AccountStore for PgAdminAccountStore {
"reauthorization cannot change account settings",
));
}
self.commit_prepared_rotation(prepared, context, "reauthorize")
self.commit_prepared_rotation(prepared, None, context, "reauthorize")
.await
}
}
@@ -648,8 +664,13 @@ impl AccountStore for PgAdminAccountStore {
command: CredentialRotationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
self.commit_prepared_rotation(command.prepared, context, "rotate_credential")
.await
self.commit_prepared_rotation(
command.prepared,
command.settings,
context,
"rotate_credential",
)
.await
}
async fn commit_credential_refresh(
@@ -657,7 +678,14 @@ impl AccountStore for PgAdminAccountStore {
command: CredentialRotationCommit,
context: &MutationContext,
) -> AdminStoreResult<CredentialMutationResult> {
self.commit_prepared_rotation(command.prepared, context, "refresh_credential")
if command.settings.is_some() {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Invalid,
ENTITY,
"credential refresh cannot change account settings",
));
}
self.commit_prepared_rotation(command.prepared, None, context, "refresh_credential")
.await
}
@@ -184,16 +184,16 @@ impl ProviderAccountStore for PgProviderAccountRepository {
has_refresh_token = $7, access_token_expires_at = $8,
next_refresh_at = $9,
credential_state = case
when $10::text is not null and enabled and upstream_user_id is not null
when $10::text is not null and enabled
then $10 else credential_state end,
credential_observed_at = case
when $10::text is not null and enabled and upstream_user_id is not null
when $10::text is not null and enabled
then $11 else credential_observed_at end,
last_error_reason = case
when $10::text is not null and enabled and upstream_user_id is not null
when $10::text is not null and enabled
then $12 else last_error_reason end,
last_error_message = case
when $10::text is not null and enabled and upstream_user_id is not null
when $10::text is not null and enabled
then $13 else last_error_message end,
updated_at = greatest(now(), coalesce($11, now()))
where id = $1 and credential_revision = $2
@@ -128,11 +128,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
async fn insert_provider_account(&self, account: NewProviderAccount) -> StoreResult<()> {
account.validate()?;
let credential_state = if account.upstream_user_id.is_some() {
account.credential_state
} else {
CredentialState::Unknown
};
let credential_state = account.credential_state;
let mut transaction = self
.pool
.begin()
@@ -255,19 +251,19 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
let result = sqlx::query(
"update provider_accounts
set credential_state = case
when enabled and upstream_user_id is not null then $3
when enabled then $3
else credential_state
end,
credential_observed_at = case
when enabled and upstream_user_id is not null then $4
when enabled then $4
else credential_observed_at
end,
last_error_reason = case
when enabled and upstream_user_id is not null then $5
when enabled then $5
else last_error_reason
end,
last_error_message = case
when enabled and upstream_user_id is not null then $6
when enabled then $6
else last_error_message
end,
updated_at = case when enabled then greatest(now(), $4) else updated_at end
@@ -543,6 +539,14 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
return Err(invalid("rotated profile and credential account IDs differ"));
}
validate_credential_update(&command.credential)?;
if let Some(settings) = &command.settings {
if settings.account_id != command.profile.id {
return Err(invalid(
"rotated credential and account settings IDs differ",
));
}
validate_batch_update_group_ids(&settings.group_ids)?;
}
let mut transaction = self
.pool
.begin()
@@ -558,6 +562,30 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
&command.credential,
)
.await?;
// 凭据 CAS、普通设置和审计共享事务,任何设置失败都回滚凭据更新。
if let Some(settings) = &command.settings {
let ids = std::slice::from_ref(&settings.account_id);
update_provider_accounts_scheduling_in_transaction(
&mut transaction,
ids,
Some(settings.enabled),
Some(settings.concurrency_limit),
Some(settings.weight),
settings.model_access.as_ref(),
settings.outbound_proxy.as_ref(),
)
.await?;
replace_account_group_assignments_in_transaction(
&mut transaction,
ids,
&settings.group_ids,
)
.await?;
if let Some(notes) = settings.notes.as_deref() {
update_provider_account_notes_in_transaction(&mut transaction, ids, notes)
.await?;
}
}
append_admin_audit_event_in_transaction(
&mut transaction,
command.audit,
@@ -637,7 +665,7 @@ impl ProviderAccountAdminRepository for PgProviderAccountRepository {
let recovered = sqlx::query_scalar::<_, String>(
"update provider_accounts
set enabled = true,
credential_state = 'ready',
credential_state = case when credential_state = 'unknown' then 'unknown' else 'ready' end,
credential_observed_at = now(),
access_token_expires_at = case
when access_token_expires_at <= now() then null
@@ -767,11 +795,7 @@ pub(crate) async fn upsert_provider_account_in_transaction(
account: &NewProviderAccount,
) -> StoreResult<String> {
account.validate()?;
let credential_state = if account.upstream_user_id.is_some() {
account.credential_state
} else {
CredentialState::Unknown
};
let credential_state = account.credential_state;
let proxy_id = match account.outbound_proxy.as_ref() {
Some(proxy) => Some(
super::super::proxies::ensure_proxy_for_url(transaction, proxy, None)
@@ -877,6 +901,7 @@ pub(crate) async fn rotate_provider_account_in_transaction(
let upstream_user_id = replacement_identity.map(ProviderAccountIdentity::upstream_user_id);
let upstream_account_id =
replacement_identity.and_then(ProviderAccountIdentity::upstream_account_id);
// 事务时间可能早于应用写入的额度观测,保留既有时间下界,避免轮换破坏时间约束。
let next = sqlx::query_scalar::<_, i64>(
"update provider_accounts
set name = $4,
@@ -891,7 +916,7 @@ pub(crate) async fn rotate_provider_account_in_transaction(
upstream_account_id = case when $11::boolean then $13::text else upstream_account_id end,
credential_state = case
when not enabled then credential_state
when coalesce($12::text, upstream_user_id) is not null then 'ready'
when $11::boolean or credential_state <> 'unknown' then 'ready'
else 'unknown'
end,
credential_observed_at = case
@@ -900,7 +925,7 @@ pub(crate) async fn rotate_provider_account_in_transaction(
end,
last_error_reason = case when enabled then null else last_error_reason end,
last_error_message = case when enabled then null else last_error_message end,
updated_at = now()
updated_at = greatest(now(), updated_at)
where id = $1 and provider_kind = $2
and credential_revision = $3
returning credential_revision",
@@ -295,6 +295,7 @@ impl ImportProviderAccounts {
#[derive(Clone)]
pub struct RotateProviderAccount {
pub settings: Option<gateway_admin::model::accounts::UpdateAccount>,
pub scope: ProviderAccountAdminScope,
pub profile: UpdateProviderAccount,
pub replacement_identity: Option<ProviderAccountIdentity>,
@@ -306,6 +307,7 @@ impl fmt::Debug for RotateProviderAccount {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RotateProviderAccount")
.field("settings", &self.settings)
.field("scope", &self.scope)
.field("profile", &self.profile)
.field("replacement_identity", &self.replacement_identity)
@@ -164,6 +164,7 @@ async fn provider_account_should_allow_missing_upstream_user_id() {
let mut pending = account("acct_pending_identity", "unused");
pending.upstream_user_id = None;
pending.email = None;
pending.authentication_kind = "api_key".to_owned();
pending.credential_state = CredentialState::Ready;
repository
.insert_provider_account(pending)
@@ -180,7 +181,7 @@ async fn provider_account_should_allow_missing_upstream_user_id() {
stored.summary.upstream_user_id,
stored.summary.credential_state,
),
(None, CredentialState::Unknown)
(None, CredentialState::Ready)
);
database.close().await;
@@ -2198,6 +2199,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
};
let wrong_scope_error = repository
.rotate_provider_account(RotateProviderAccount {
settings: None,
scope: wrong_scope,
profile: profile("acct_admin_a", "wrong scope"),
replacement_identity: None,
@@ -2227,6 +2229,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
let rotation = repository
.rotate_provider_account(RotateProviderAccount {
settings: None,
scope: scope.clone(),
profile: profile("acct_admin_a", "rotated account"),
replacement_identity: Some(ProviderAccountIdentity::new(
@@ -2259,6 +2262,7 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
let identity_conflict = repository
.rotate_provider_account(RotateProviderAccount {
settings: None,
scope: scope.clone(),
profile: profile("acct_admin_a", "must roll back"),
replacement_identity: Some(ProviderAccountIdentity::new(
@@ -2343,6 +2347,137 @@ async fn provider_account_admin_mutations_are_scoped_audited_and_atomic() {
database.close().await;
}
#[tokio::test]
async fn credential_rotation_and_settings_share_one_transaction() {
use gateway_admin::model::proxies::AccountProxySelection;
use gateway_core::account::{AccountConcurrencyLimit, AccountWeight};
const ACCOUNT_ID: &str = "acct_combined_save";
const GROUP_ID: &str = "grp_00000000000000000000000000000091";
let Some(database) = TestDatabase::create("combined_account_save").await else {
return;
};
let repository = PgProviderAccountRepository::new(database.pool.clone());
repository
.insert_provider_account(account(ACCOUNT_ID, "combined-save-user"))
.await
.expect("seed account");
sqlx::query(
"insert into account_groups (id, name, color, created_at, updated_at)
values ($1, 'Combined save', '#2563EBFF', now(), now())",
)
.bind(GROUP_ID)
.execute(&database.pool)
.await
.expect("seed group");
let settings = UpdateAccount {
account_id: ACCOUNT_ID.to_owned(),
notes: Some("统一保存".to_owned()),
enabled: false,
concurrency_limit: Some(AccountConcurrencyLimit::new(3).unwrap()),
weight: AccountWeight::new(7).unwrap(),
group_ids: vec![AccountGroupId::new(GROUP_ID).unwrap()],
model_access: None,
outbound_proxy: Some(AccountProxySelection::Direct),
};
let scope = ProviderAccountAdminScope {
provider_kind: "openai".to_owned(),
};
let result = repository
.rotate_provider_account(RotateProviderAccount {
scope: scope.clone(),
profile: profile(ACCOUNT_ID, "combined save"),
replacement_identity: None,
credential: credential_update(ACCOUNT_ID, 1, "combined-secret"),
settings: Some(settings.clone()),
audit: audit("audit_combined_save", "rotate", ACCOUNT_ID),
})
.await
.expect("save credentials and settings");
assert_eq!(result.config_revision.get(), 2);
assert_eq!(result.credential_revision.get(), 2);
let before: serde_json::Value =
sqlx::query_scalar("select to_jsonb(account) from provider_accounts account where id = $1")
.bind(ACCOUNT_ID)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(
before["provider_credentials_json"]["access_token"],
"combined-secret"
);
assert_eq!(before["enabled"], false);
assert_eq!(before["concurrency_limit"], 3);
assert_eq!(before["weight"], 7);
assert_eq!(before["notes"], "统一保存");
assert_eq!(
account_group_ids(&database.pool, ACCOUNT_ID).await,
[GROUP_ID]
);
for (case, expected_revision, invalid_settings) in [
(
"missing_group",
2,
UpdateAccount {
enabled: true,
group_ids: vec![
AccountGroupId::new("grp_00000000000000000000000000000092").unwrap(),
],
..settings.clone()
},
),
(
"missing_proxy",
2,
UpdateAccount {
outbound_proxy: Some(AccountProxySelection::Saved("missing_proxy".to_owned())),
..settings.clone()
},
),
(
"stale_credential",
1,
UpdateAccount {
enabled: true,
notes: Some("must not persist".to_owned()),
..settings.clone()
},
),
] {
repository
.rotate_provider_account(RotateProviderAccount {
scope: scope.clone(),
profile: profile(ACCOUNT_ID, "must not persist"),
replacement_identity: None,
credential: credential_update(ACCOUNT_ID, expected_revision, "must-not-persist"),
settings: Some(invalid_settings),
audit: audit(&format!("audit_{case}"), "rotate", ACCOUNT_ID),
})
.await
.expect_err("failed combined save must roll back all mutations");
let after: serde_json::Value = sqlx::query_scalar(
"select to_jsonb(account) from provider_accounts account where id = $1",
)
.bind(ACCOUNT_ID)
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(after, before, "{case}");
assert_eq!(
account_group_ids(&database.pool, ACCOUNT_ID).await,
[GROUP_ID]
);
assert_eq!(current_revision(&database.pool).await, 2);
let audit_count: i64 = sqlx::query_scalar("select count(*) from admin_audit_events")
.fetch_one(&database.pool)
.await
.unwrap();
assert_eq!(audit_count, 1, "{case}");
}
database.close().await;
}
#[tokio::test]
async fn provider_account_import_and_reauthorization_preserve_existing_memberships() {
const GROUP_ID: &str = "grp_00000000000000000000000000000091";
@@ -2422,6 +2557,7 @@ async fn provider_account_import_and_reauthorization_preserve_existing_membershi
let revision_before_reauthorization = current_revision(&database.pool).await;
let reauthorized = repository
.rotate_provider_account(RotateProviderAccount {
settings: None,
scope,
profile: profile("acct_grouped_import", "reauthorized"),
replacement_identity: None,
@@ -2480,7 +2616,8 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
})
.await
.expect("import exhausted account");
let observed_at = SystemTime::now();
// 模拟应用与数据库的微小时钟偏差,轮换不能把更新时间倒退到已有额度观测之前。
let observed_at = SystemTime::now() + Duration::from_secs(3);
repository
.apply_quota_access(QuotaAccessChange {
account_id: ProviderAccountId::new("acct_rotation_quota").expect("account ID"),
@@ -2492,6 +2629,7 @@ async fn verified_credential_rotation_preserves_quota_exhaustion() {
repository
.rotate_provider_account(RotateProviderAccount {
settings: None,
scope,
profile: profile("acct_rotation_quota", "reauthorized account"),
replacement_identity: None,
@@ -2637,6 +2775,7 @@ async fn disabled_account_preserves_user_state_during_refresh_writes() {
.expect("disabled state write is a no-op");
repository
.rotate_provider_account(RotateProviderAccount {
settings: None,
scope,
profile: profile(account_id.as_str(), "refreshed disabled account"),
replacement_identity: None,
+83 -5
View File
@@ -328,6 +328,10 @@ impl ProviderAdmin for OpenAiAdminProvider {
if pending.provider_kind() != &self.provider_kind {
return Err(provider_admin_error(ProviderAdminErrorKind::Invalid));
}
if let gateway_admin::model::provider_credentials::AuthorizationMutationTarget::Reauthorize { account_id } = pending.target()
&& self.account(account_id).await?.authentication_kind() != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH {
return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported));
}
let started = self
.oauth
.start_authorization(StartCodexOAuthAuthorization { mutation: pending })
@@ -412,6 +416,17 @@ impl ProviderAdmin for OpenAiAdminProvider {
return Err(provider_admin_error(ProviderAdminErrorKind::Conflict)
.with_public_message("账号凭据已被更新,请刷新账号列表后重试"));
}
if current.account.authentication_kind()
== crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY
{
let prepared = CodexCredentialAdmin
.prepare_api_key_rotation(
current,
Value::Object(command.provider_material.into_provider_data().into_inner()),
)
.map_err(map_credential_admin_error)?;
return prepared_rotation(prepared, command.account.provider_kind);
}
let mut secret = rotation_secret(command.provider_material)?;
if secret.id_token.is_none() {
let runtime = CodexCredentialCodec::decode(&current.credential)
@@ -433,6 +448,10 @@ impl ProviderAdmin for OpenAiAdminProvider {
&self,
command: PrepareCredentialRefresh,
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
if command.account.authentication_kind != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH
{
return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported));
}
validate_account_record(&command.account, &self.provider_kind)?;
let account_id = ProviderAccountId::new(command.account.id.clone())
.map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?;
@@ -453,6 +472,36 @@ impl ProviderAdmin for OpenAiAdminProvider {
prepared_rotation(prepared, command.account.provider_kind)
}
async fn account_configuration(
&self,
account_id: &ProviderAccountId,
) -> Result<Option<ProviderDocument>, ProviderAdminError> {
let account = self.account(account_id).await?;
if account.authentication_kind() != crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY {
return Ok(None);
}
let current = self
.accounts
.load_current_credential(account_id)
.await
.map_err(map_store_error)?;
let crate::credential::CodexCredentialData::ApiKey(data) =
CodexCredentialCodec::decode_complete(&current.credential)
.map_err(|_| provider_admin_error(ProviderAdminErrorKind::Invalid))?
else {
return Err(provider_admin_error(ProviderAdminErrorKind::Invalid));
};
let value = serde_json::to_value(data.configuration())
.map_err(|_| provider_admin_error(ProviderAdminErrorKind::Internal))?;
let object = value
.as_object()
.cloned()
.ok_or_else(|| provider_admin_error(ProviderAdminErrorKind::Internal))?;
Ok(Some(ProviderDocument::new(
gateway_core::account::OpaqueProviderData::new(object),
)))
}
async fn quota(
&self,
request: ProviderQuotaRequest,
@@ -463,6 +512,12 @@ impl ProviderAdmin for OpenAiAdminProvider {
rolling_usage: _,
} = request;
let mut account = self.account(&account_id).await?;
if account.authentication_kind() == crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY {
if refresh {
return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported));
}
return Ok(project_quota(None, &account));
}
let snapshot = if refresh {
let snapshot = Some(
self.quota
@@ -517,7 +572,11 @@ impl ProviderAdmin for OpenAiAdminProvider {
&self,
account_id: &ProviderAccountId,
) -> Result<Option<ProviderSubscription>, ProviderAdminError> {
self.account(account_id).await?;
if self.account(account_id).await?.authentication_kind()
!= crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH
{
return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported));
}
self.profile_statistics
.subscription(account_id)
.await
@@ -538,7 +597,11 @@ impl ProviderAdmin for OpenAiAdminProvider {
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, ProviderAdminError> {
self.account(account_id).await?;
if self.account(account_id).await?.authentication_kind()
!= crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH
{
return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported));
}
let statistics = self
.profile_statistics
.profile_statistics(account_id)
@@ -551,7 +614,11 @@ impl ProviderAdmin for OpenAiAdminProvider {
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderProfileAvatar, ProviderAdminError> {
self.account(account_id).await?;
if self.account(account_id).await?.authentication_kind()
!= crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH
{
return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported));
}
self.profile_statistics
.profile_avatar(account_id)
.await
@@ -563,7 +630,11 @@ impl ProviderAdmin for OpenAiAdminProvider {
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderResetCredits, ProviderAdminError> {
self.account(account_id).await?;
if self.account(account_id).await?.authentication_kind()
!= crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH
{
return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported));
}
self.quota
.list_reset_credits(account_id)
.await
@@ -575,7 +646,14 @@ impl ProviderAdmin for OpenAiAdminProvider {
&self,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, ProviderAdminError> {
self.account(&command.account_id).await?;
if self
.account(&command.account_id)
.await?
.authentication_kind()
!= crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH
{
return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported));
}
self.quota
.consume_reset_credit(
&command.account_id,
+2 -19
View File
@@ -5,7 +5,7 @@ use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use url::{Host, Url};
use url::Url;
use crate::credential::CodexQuotaRefreshPolicy;
use crate::transport::profile::{
@@ -146,24 +146,7 @@ impl Default for CodexApiConfig {
impl CodexApiConfig {
fn validate(&self) -> Result<(), OpenAiConfigError> {
let url = Url::parse(&self.base_url)
.map_err(|_| OpenAiConfigError::InvalidField("openai.api.base_url"))?;
// 上游地址只接受 https;明文 http 仅放行本机回环(本地联调),
// 避免把上游指向内网明文服务。凭据/查询串会污染端点拼接,一并拒绝。
let is_loopback_host = match url.host() {
Some(Host::Domain("localhost")) => true,
Some(Host::Ipv4(address)) => address.is_loopback(),
Some(Host::Ipv6(address)) => address.is_loopback(),
Some(Host::Domain(_)) | None => false,
};
let is_loopback_http = url.scheme() == "http" && is_loopback_host;
let is_secure_public = url.scheme() == "https" && url.host_str().is_some();
if !(is_loopback_http || is_secure_public)
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
if !crate::transport::valid_upstream_base_url(&self.base_url) {
return Err(OpenAiConfigError::InvalidField("openai.api.base_url"));
}
Ok(())
@@ -24,6 +24,7 @@ use serde::Serialize;
use serde_json::Value;
use thiserror::Error;
use super::api_key::{ApiKeyCredentialData, ApiKeyTransport, CODEX_AUTHENTICATION_KIND_API_KEY};
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
use super::security::CodexCredentialCodec;
use super::token_client::{
@@ -128,13 +129,19 @@ struct ParsedCodexImportAccount {
outbound_proxy: Option<gateway_core::account::OutboundProxy>,
}
struct ParsedCodexAuthentication {
#[derive(Debug)]
enum ParsedCodexAuthentication {
OAuth(ParsedOAuthAuthentication),
ApiKey(ApiKeyCredentialData),
}
struct ParsedOAuthAuthentication {
access_token: Option<String>,
refresh_token: Option<String>,
id_token: Option<String>,
}
impl fmt::Debug for ParsedCodexAuthentication {
impl fmt::Debug for ParsedOAuthAuthentication {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("OAuth")
@@ -188,7 +195,7 @@ impl fmt::Debug for ExportManagedCodexCredential {
#[serde(rename_all = "camelCase")]
pub struct CodexCprExportDocument {
source_format: &'static str,
accounts: Vec<CodexCprOAuthExportAccount>,
accounts: Vec<CodexCprExportAccount>,
}
impl CodexCprExportDocument {
@@ -235,6 +242,24 @@ struct CodexCprExportCommon {
outbound_proxy_url: Option<String>,
}
#[derive(Serialize)]
#[serde(untagged)]
enum CodexCprExportAccount {
OAuth(CodexCprOAuthExportAccount),
ApiKey(CodexCprApiKeyExportAccount),
}
#[derive(Serialize)]
struct CodexCprApiKeyExportAccount {
#[serde(flatten)]
common: CodexCprExportCommon,
provider: &'static str,
authentication_kind: &'static str,
base_url: String,
api_key: String,
transport: ApiKeyTransport,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CodexCprOAuthExportAccount {
@@ -383,6 +408,94 @@ impl CodexCredentialAdminError {
pub struct CodexCredentialAdmin;
impl CodexCredentialAdmin {
fn prepare_api_key(
&self,
account_id: String,
name: String,
data: ApiKeyCredentialData,
) -> Result<NewProviderAccount, CodexCredentialAdminError> {
if name.trim().is_empty() {
return Err(CodexCredentialAdminError::InvalidInput);
}
let credential = CodexCredentialCodec::encode_complete(CodexCredentialData::ApiKey(data))
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let account = ProviderAccount::new(
ProviderAccountId::new(account_id)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?,
ProviderKind::new(PROVIDER_NAME)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?,
name,
None,
CODEX_AUTHENTICATION_KIND_API_KEY.to_owned(),
CredentialRevision::new(1).map_err(|_| CodexCredentialAdminError::InvalidCredential)?,
None,
)
.with_account_facts(
true,
CredentialState::Ready,
QuotaState::unknown(),
None,
None,
);
Ok(NewProviderAccount {
account,
credential,
model_access: None,
})
}
pub(crate) fn prepare_api_key_rotation(
&self,
current: LoadedCredential,
material: Value,
) -> Result<PreparedCodexCredentialRotation, CodexCredentialAdminError> {
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Rotation {
base_url: String,
transport: ApiKeyTransport,
api_key: Option<String>,
}
let rotation: Rotation = serde_json::from_value(material)
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let CodexCredentialData::ApiKey(mut data) =
CodexCredentialCodec::decode_complete(&current.credential)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?
else {
return Err(CodexCredentialAdminError::InvalidCredential);
};
data.base_url = rotation.base_url;
data.transport = rotation.transport;
if let Some(api_key) = rotation.api_key {
data.api_key = api_key;
}
let credential = CodexCredentialCodec::encode_complete(CodexCredentialData::ApiKey(data))
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?;
let profile = ProviderAccountUpdate {
account_id: current.account.id().clone(),
name: current.account.name().to_owned(),
email: None,
plan_type: None,
};
let credential = CredentialCasUpdate::new(
current.account.id().clone(),
current.account.revision(),
profile.clone(),
credential,
false,
None,
None,
)
.map_err(|_| CodexCredentialAdminError::InvalidCredential)?
.with_account_state(CredentialState::Ready, SystemTime::now(), None, None);
Ok(PreparedCodexCredentialRotation {
profile,
credential,
replacement_identity: None,
refresh_guards: None,
})
}
pub fn prepare_import(
&self,
input: ImportCodexOAuthCredential,
@@ -530,13 +643,27 @@ impl CodexCredentialAdmin {
.map(|proxy| proxy.expose_url().to_owned()),
};
let exported = match data {
CodexCredentialData::ApiKey(data) => {
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_API_KEY {
return Err(CodexCredentialAdminError::InvalidCredential);
}
CodexCprExportAccount::ApiKey(CodexCprApiKeyExportAccount {
common,
provider: PROVIDER_NAME,
authentication_kind: CODEX_AUTHENTICATION_KIND_API_KEY,
base_url: data.base_url,
api_key: data.api_key,
transport: data.transport,
})
}
CodexCredentialData::OAuth(data) => {
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH
|| account.has_refresh_token() != data.refresh_token.is_some()
{
return Err(CodexCredentialAdminError::InvalidCredential);
}
CodexCprOAuthExportAccount {
CodexCprExportAccount::OAuth(CodexCprOAuthExportAccount {
common,
access_token: data.access_token,
refresh_token: data.refresh_token,
@@ -545,7 +672,7 @@ impl CodexCredentialAdmin {
.access_token_expires_at()
.map(DateTime::<Utc>::from)
.map(|value| value.to_rfc3339()),
}
})
}
};
accounts.push(exported);
@@ -862,14 +989,32 @@ impl CodexCredentialAdminService {
let mut accounts = Vec::with_capacity(candidates.len());
for candidate in candidates {
let account_id = format!("acct_{}", uuid::Uuid::now_v7().simple());
let authentication = match candidate.authentication {
ParsedCodexAuthentication::ApiKey(data) => {
let mut prepared = CodexCredentialAdmin.prepare_api_key(
account_id,
candidate
.name
.unwrap_or_else(|| "OpenAI API Key".to_owned()),
data,
)?;
prepared.model_access = candidate.model_access;
prepared.account = prepared
.account
.with_outbound_proxy(candidate.outbound_proxy);
accounts.push(prepared);
continue;
}
ParsedCodexAuthentication::OAuth(authentication) => authentication,
};
let typed_account_id = ProviderAccountId::new(account_id.clone())
.map_err(|_| CodexCredentialAdminError::InvalidInput)?;
let (mut secret, mut access_token_expires_at) = self
.resolve_import_tokens(
&typed_account_id,
candidate.authentication.access_token.clone(),
candidate.authentication.refresh_token.clone(),
candidate.authentication.id_token.clone(),
authentication.access_token.clone(),
authentication.refresh_token.clone(),
authentication.id_token.clone(),
candidate.outbound_proxy.as_ref(),
)
.await?;
@@ -1086,10 +1231,10 @@ fn parse_import_document(
.unwrap_or(payload);
let mut accounts = Vec::new();
for value in import_account_values(payload)? {
if !is_openai_oauth_candidate(value) {
if !is_codex_import_candidate(value) {
continue;
}
let mut account = parse_oauth_import_account(value)?;
let mut account = parse_codex_import_account(value)?;
account.outbound_proxy = if ["outboundProxyUrl", "outbound_proxy_url", "proxy_key"]
.iter()
.any(|field| value.get(field).is_some())
@@ -1103,7 +1248,7 @@ fn parse_import_document(
Ok(accounts)
}
fn parse_oauth_import_account(
fn parse_codex_import_account(
value: &Value,
) -> Result<ParsedCodexImportAccount, CodexCredentialAdminError> {
let credentials = value.get("credentials").unwrap_or(value);
@@ -1115,7 +1260,11 @@ fn parse_oauth_import_account(
.map_err(|_| CodexCredentialAdminError::InvalidInput)?,
name: first_string(value, &["name", "label"]),
email: first_string(value, &["email"]).or_else(|| first_string(credentials, &["email"])),
authentication: parse_oauth_import_tokens(value)?,
authentication: if is_api_key_import(value) {
ParsedCodexAuthentication::ApiKey(parse_api_key_import(value)?)
} else {
ParsedCodexAuthentication::OAuth(parse_oauth_import_tokens(value)?)
},
outbound_proxy: None,
})
}
@@ -1214,7 +1363,7 @@ fn import_account_values(payload: &Value) -> Result<Vec<&Value>, CodexCredential
fn parse_oauth_import_tokens(
value: &Value,
) -> Result<ParsedCodexAuthentication, CodexCredentialAdminError> {
) -> Result<ParsedOAuthAuthentication, CodexCredentialAdminError> {
let mut access_token = None;
let mut refresh_token = None;
let mut id_token = None;
@@ -1247,14 +1396,14 @@ fn parse_oauth_import_tokens(
if access_token.is_none() && refresh_token.is_none() {
return Err(CodexCredentialAdminError::InvalidCredential);
}
Ok(ParsedCodexAuthentication {
Ok(ParsedOAuthAuthentication {
access_token,
refresh_token,
id_token,
})
}
fn is_openai_oauth_candidate(value: &Value) -> bool {
fn is_codex_import_candidate(value: &Value) -> bool {
let Some(account) = value.as_object() else {
return false;
};
@@ -1272,6 +1421,8 @@ fn is_openai_oauth_candidate(value: &Value) -> bool {
kind.eq_ignore_ascii_case("openai")
|| kind.eq_ignore_ascii_case("codex")
|| kind.eq_ignore_ascii_case("oauth")
|| kind.eq_ignore_ascii_case("api_key")
|| kind.eq_ignore_ascii_case("apikey")
})
}
@@ -1282,3 +1433,113 @@ fn first_string(value: &Value, keys: &[&str]) -> Option<String> {
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
fn is_api_key_import(value: &Value) -> bool {
value
.get("authentication_kind")
.or_else(|| value.get("type"))
.and_then(Value::as_str)
.is_some_and(|kind| matches!(kind, "api_key" | "apikey"))
}
fn parse_api_key_import(value: &Value) -> Result<ApiKeyCredentialData, CodexCredentialAdminError> {
let credentials = value.get("credentials").unwrap_or(value);
let external = value.get("type").and_then(Value::as_str) == Some("apikey");
let mut base_url = first_string(credentials, &["base_url"])
.or_else(|| external.then(|| "https://api.openai.com/v1".to_owned()))
.ok_or(CodexCredentialAdminError::InvalidInput)?;
if external {
if !crate::transport::valid_upstream_base_url(&base_url) {
return Err(CodexCredentialAdminError::InvalidInput);
}
// sub2api 接受服务根、版本前缀或完整 Responses 端点;仅在导入边界归一化。
base_url = base_url.trim_end_matches('/').to_owned();
if let Some(prefix) = base_url.strip_suffix("/responses") {
base_url = prefix.to_owned();
} else if !sub2api_version_suffix(&base_url) {
base_url.push_str("/v1");
}
// 不静默丢失会改变协议、身份请求头或模型映射的外部设置。
for field in [
"model_mapping",
"compact_model_mapping",
"custom_headers",
"header_overrides",
"force_model",
"responses_websockets",
"api_base_urls",
] {
if credentials.get(field).is_some_and(external_option_enabled) {
return Err(CodexCredentialAdminError::InvalidInput);
}
}
if credentials
.get("api_protocol")
.and_then(Value::as_str)
.is_some_and(|protocol| !protocol.is_empty() && protocol != "responses")
{
return Err(CodexCredentialAdminError::InvalidInput);
}
if value
.get("extra")
.and_then(Value::as_object)
.is_some_and(|extra| {
extra.iter().any(|(key, value)| {
(key.starts_with("openai_") || key == "header_overrides")
&& external_option_enabled(value)
})
})
{
return Err(CodexCredentialAdminError::InvalidInput);
}
}
let data = ApiKeyCredentialData {
schema_version: 1,
installation_id: uuid::Uuid::new_v4().to_string(),
base_url,
api_key: first_string(credentials, &["api_key"])
.ok_or(CodexCredentialAdminError::InvalidCredential)?,
transport: credentials
.get("transport")
.map(|v| serde_json::from_value(v.clone()))
.transpose()
.map_err(|_| CodexCredentialAdminError::InvalidInput)?
.unwrap_or_default(),
};
if !data.validate() {
return Err(CodexCredentialAdminError::InvalidCredential);
}
Ok(data)
}
fn external_option_enabled(value: &Value) -> bool {
match value {
Value::Null | Value::Bool(false) => false,
Value::Object(value) => !value.is_empty(),
Value::Array(value) => !value.is_empty(),
Value::String(value) => !matches!(value.as_str(), "" | "off"),
_ => true,
}
}
fn sub2api_version_suffix(base_url: &str) -> bool {
let Some(segment) = base_url.rsplit('/').next() else {
return false;
};
let segment = segment.to_ascii_lowercase();
let Some(version) = segment.strip_prefix('v') else {
return false;
};
let digits = version.bytes().take_while(u8::is_ascii_digit).count();
if digits == 0 {
return false;
}
let suffix = &version[digits..];
suffix.is_empty()
|| ["alpha", "beta", "preview"]
.iter()
.any(|prefix| suffix.starts_with(prefix))
|| suffix.strip_prefix('.').is_some_and(|minor| {
!minor.is_empty() && minor.bytes().all(|byte| byte.is_ascii_digit())
})
}
@@ -0,0 +1,87 @@
//! API Key 账号的凭据合同;地址与传输策略随凭据 revision 一起更新。
use std::fmt;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
pub const CODEX_AUTHENTICATION_KIND_API_KEY: &str = "api_key";
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApiKeyTransport {
#[default]
Http,
PreferWebsocket,
}
/// 可在管理端展示的上游设置,不包含密钥。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApiKeyConfiguration {
pub base_url: String,
#[serde(default)]
pub transport: ApiKeyTransport,
}
impl ApiKeyConfiguration {
pub(crate) fn validate(&self) -> bool {
self.base_url.len() <= 2048
&& !self.base_url.chars().any(char::is_control)
&& crate::transport::valid_upstream_base_url(&self.base_url)
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ApiKeyCredentialData {
pub schema_version: u32,
pub installation_id: String,
pub api_key: String,
pub base_url: String,
#[serde(default)]
pub transport: ApiKeyTransport,
}
impl ApiKeyCredentialData {
pub fn configuration(&self) -> ApiKeyConfiguration {
ApiKeyConfiguration {
base_url: self.base_url.clone(),
transport: self.transport,
}
}
pub(crate) fn validate(&self) -> bool {
self.schema_version == 1
&& self.configuration().validate()
&& !self.api_key.is_empty()
&& self.api_key.len() <= 16 * 1024
&& self.api_key.bytes().all(|byte| byte.is_ascii_graphic())
}
}
impl fmt::Debug for ApiKeyCredentialData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ApiKeyCredentialData")
.field("schema_version", &self.schema_version)
.field("configuration", &self.configuration())
.field("api_key", &"<redacted>")
.finish()
}
}
pub struct ApiKeyAuthentication {
pub configuration: ApiKeyConfiguration,
pub secret: SecretString,
}
impl fmt::Debug for ApiKeyAuthentication {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("ApiKeyAuthentication")
.field("configuration", &self.configuration)
.field("secret", &"<redacted>")
.finish()
}
}
@@ -6,6 +6,7 @@ use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, SystemTime};
use chrono::{DateTime, Utc};
use futures::{StreamExt, stream};
use gateway_core::account::{
CredentialRevision, CredentialState, OpaqueProviderData, ProviderAccount, ProviderAccountId,
};
@@ -31,7 +32,8 @@ const PLAN_CATALOG_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
const MAX_CATALOG_FETCH_ATTEMPTS: usize = 3;
const MAX_PLAN_CATALOG_MODELS: usize = 2_048;
const MAX_CLIENT_CATALOGS: usize = 32;
const CLIENT_CATALOG_TIMEOUT: Duration = Duration::from_secs(15);
const MODEL_CATALOG_TIMEOUT: Duration = Duration::from_secs(15);
const MAX_CONCURRENT_API_CATALOGS: usize = 4;
const CLIENT_CATALOG_ERROR_TTL: Duration = Duration::from_secs(5);
/// OpenAI 以套餐划分的模型目录作用域。
@@ -41,6 +43,15 @@ pub struct CodexCatalogScope(ProviderCatalogScope);
impl CodexCatalogScope {
/// 从账号已验证的套餐事实构造目录作用域。
pub fn for_account(account: &ProviderAccount) -> Result<Self, CodexCredentialCatalogError> {
if account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_API_KEY {
return ProviderCatalogScope::new(format!(
"account:{}:{}",
account.id(),
account.revision().get()
))
.map(Self)
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData);
}
let plan = account
.plan_type()
.map(str::trim)
@@ -265,6 +276,47 @@ impl CodexCredentialCatalogService {
accounts
.retain(|account| scope.allows(account.id()) && eligible_catalog_account(account, now));
accounts.sort_by(|left, right| left.id().cmp(right.id()));
let mut adapted = BTreeMap::new();
let (api_accounts, mut accounts): (Vec<_>, Vec<_>) =
accounts.into_iter().partition(|account| {
account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_API_KEY
});
let mut catalogs = stream::iter(api_accounts)
.map(|account| async move {
let result = self.cached_or_refresh_account_catalog(&account).await;
(account, result)
})
.buffer_unordered(MAX_CONCURRENT_API_CATALOGS);
// 所有 API 账号共享等待预算;慢上游不能逐个耗尽超时或阻塞 OAuth 目录。
let deadline = Instant::now() + MODEL_CATALOG_TIMEOUT;
while let Ok(Some((account, result))) =
tokio::time::timeout_at(deadline, catalogs.next()).await
{
if let Ok(catalog) = result {
for id in catalog.models() {
if account.model_access().allows(id) {
let model = gateway_core::routing::UpstreamModelId::new(id.clone())
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
adapted
.entry(id.clone())
.or_insert(ProviderModelDescriptor {
model,
content: gateway_core::routing::ProviderModelContent::Adapted(
gateway_core::routing::ModelPresentation::new(
Some(id.clone()),
None,
)
.with_agent_tools(true, false),
),
});
}
}
}
}
drop(catalogs);
accounts.retain(|account| {
account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_OAUTH
});
let mut last_error = CodexCredentialCatalogError::NoEligibleCredential;
for account in accounts.iter().take(MAX_CATALOG_FETCH_ATTEMPTS) {
let profile = self.profile.snapshot();
@@ -310,25 +362,21 @@ impl CodexCredentialCatalogService {
self.base_url.clone(),
CodexWireProfileState::new(profile),
);
let result = tokio::time::timeout(
CLIENT_CATALOG_TIMEOUT,
self.fetch_account_models(&client, account, Some(client_version)),
)
.await
.map_err(|_| CodexCredentialCatalogError::Upstream {
detail: "client model catalog timed out".to_owned(),
})
.and_then(|result| result)
.map(|fetched| {
fetched
.models
.into_iter()
.map(|model| ProviderModelDescriptor {
model: model.request_model().clone(),
payload: model.document().clone(),
})
.collect()
});
let result = self
.fetch_account_models(&client, account, Some(client_version))
.await
.map(|fetched| {
fetched
.models
.into_iter()
.map(|model| ProviderModelDescriptor {
model: model.request_model().clone(),
content: gateway_core::routing::ProviderModelContent::Native(
model.document().clone(),
),
})
.collect()
});
CachedClientCatalog {
completed_at: Instant::now(),
result,
@@ -336,11 +384,24 @@ impl CodexCredentialCatalogService {
})
.await;
match &result.result {
Ok(models) => return Ok(models.clone()),
Ok(models) => {
for model in models {
adapted.remove(model.model.as_str());
}
return Ok(models
.iter()
.cloned()
.chain(adapted.into_values())
.collect());
}
Err(error) => last_error = error.clone(),
}
}
Err(last_error)
if adapted.is_empty() {
Err(last_error)
} else {
Ok(adapted.into_values().collect())
}
}
#[must_use]
@@ -473,8 +534,15 @@ impl CodexCredentialCatalogService {
let mut union_order = Vec::new();
let mut scope_models = BTreeMap::new();
let mut etags = Vec::new();
let mut last_error = None;
for (scope, candidates) in groups {
let fetched = self.fetch_scope_models(&client, candidates).await?;
let fetched = match self.fetch_scope_models(&client, candidates).await {
Ok(fetched) => fetched,
Err(error) => {
last_error = Some(error);
continue;
}
};
let entitlement = model_ids(&fetched.models);
self.replace_plan_catalog(&CodexPlanCatalog::new(
scope.clone(),
@@ -489,12 +557,24 @@ impl CodexCredentialCatalogService {
union_order.push(entry.key().clone());
entry.insert(model.clone());
}
Entry::Occupied(_) => {}
Entry::Occupied(mut entry) => {
// 原生目录含完整能力证据,同名 API ID 不能覆盖它,也不能跨来源拼字段。
if model.document().protocol() == "codex"
&& entry.get().document().protocol() != "codex"
{
entry.insert(model.clone());
}
}
}
}
scope_models.insert(scope, entitlement);
etags.extend(fetched.etag);
}
if union.is_empty()
&& let Some(error) = last_error
{
return Err(error);
}
let observed_at = SystemTime::now();
let snapshot = CodexCredentialCatalogSnapshot {
observed_at,
@@ -562,38 +642,45 @@ impl CodexCredentialCatalogService {
account: &ProviderAccount,
client_version: Option<&str>,
) -> Result<FetchedAccountModels, CodexCredentialCatalogError> {
let credential = self
.repository
.load_runtime_credential(account)
.await
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
let request_id = format!("catalog_{}", Uuid::now_v7().simple());
let authorization = credential
.authentication
.authorization_header()
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
let result = client
.for_account(account)
.map_err(|error| CodexCredentialCatalogError::Upstream {
tokio::time::timeout(MODEL_CATALOG_TIMEOUT, async {
let credential = self
.repository
.load_runtime_credential(account)
.await
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
let request_id = format!("catalog_{}", Uuid::now_v7().simple());
let authorization = credential
.authentication
.authorization_header()
.map_err(|_| CodexCredentialCatalogError::InvalidCredentialData)?;
let result = client
.for_account(account)
.map_err(|error| CodexCredentialCatalogError::Upstream {
detail: error.to_string(),
})?
.with_authentication(&credential.authentication)
.fetch_models_with_context(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
account.upstream_account_id(),
&request_id,
None,
),
client_version,
)
.await;
let snapshot = result.map_err(|error| CodexCredentialCatalogError::Upstream {
detail: error.to_string(),
})?
.fetch_models_with_context(
CodexRequestContext::auxiliary(
authorization.expose_secret(),
account.upstream_account_id(),
&request_id,
None,
),
client_version,
)
.await;
let snapshot = result.map_err(|error| CodexCredentialCatalogError::Upstream {
detail: error.to_string(),
})?;
Ok(FetchedAccountModels {
models: snapshot.models().to_vec(),
etag: snapshot.etag().map(str::to_owned),
})?;
Ok(FetchedAccountModels {
models: snapshot.models().to_vec(),
etag: snapshot.etag().map(str::to_owned),
})
})
.await
.map_err(|_| CodexCredentialCatalogError::Upstream {
detail: "model catalog timed out".to_owned(),
})?
}
pub fn invalidate(&self) -> Result<(), CodexCredentialCatalogError> {
@@ -2,6 +2,7 @@
mod admin;
mod affinity;
mod api_key;
mod catalog;
mod cookie;
mod oauth;
@@ -23,6 +24,11 @@ pub(crate) use affinity::{
pub(crate) use oauth::oauth_owner_ref;
pub(crate) use types::parse_access_token_expiration;
pub use api_key::{
ApiKeyAuthentication, ApiKeyConfiguration, ApiKeyCredentialData, ApiKeyTransport,
CODEX_AUTHENTICATION_KIND_API_KEY,
};
pub use admin::{
CodexCprExportDocument, CodexCredentialAdmin, CodexCredentialAdminError,
CodexCredentialAdminService, ExportManagedCodexCredential, ImportCodexOAuthCredential,
@@ -685,7 +685,10 @@ impl CodexCredentialQuotaService {
}
pub async fn synchronize(&self) -> Result<CodexQuotaSyncSummary, CodexCredentialQuotaError> {
let accounts = self.repository.list_for_provider().await?;
let mut accounts = self.repository.list_for_provider().await?;
accounts.retain(|account| {
account.authentication_kind() == crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH
});
let mut summary = CodexQuotaSyncSummary::default();
let now = SystemTime::now();
let initial = self.initial_quota_sync_accounts(&accounts, now).await?;
@@ -863,7 +866,9 @@ impl CodexCredentialQuotaService {
account: &ProviderAccount,
rate_limits: &[ParsedRateLimits],
) -> Result<bool, CodexCredentialQuotaError> {
if rate_limits.is_empty() {
if account.authentication_kind() != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH
|| rate_limits.is_empty()
{
return Ok(false);
}
let has_quota_facts = rate_limits.iter().any(|observation| {
@@ -1056,6 +1061,9 @@ impl CodexCredentialQuotaService {
.await?
.filter(|account| account.provider().as_str() == "openai")
.ok_or(CodexCredentialQuotaError::NotFound)?;
if account.authentication_kind() != crate::credential::CODEX_AUTHENTICATION_KIND_OAUTH {
return Err(CodexCredentialQuotaError::NotFound);
}
let observed_at = SystemTime::now();
if !access_token_is_current(&account, observed_at) {
return Err(CodexCredentialQuotaError::CredentialRefreshRequired);
@@ -75,7 +75,12 @@ impl CodexCredentialRepository {
next_refresh_at,
)
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?
.with_account_state(CredentialState::Ready, SystemTime::now(), None, None);
.with_account_state(
oauth_account_state(account, CredentialState::Ready),
SystemTime::now(),
None,
None,
);
cas_revision(self.store.compare_and_swap_credential(update).await?)
}
@@ -146,6 +151,10 @@ impl CodexCredentialRepository {
if loaded.account.provider().as_str() != PROVIDER_NAME {
return Err(CredentialRepositoryError::InvalidCredentialData);
}
let data = CodexCredentialCodec::decode_complete(&loaded.credential)?;
if data.authentication_kind() != loaded.account.authentication_kind() {
return Err(CredentialRepositoryError::InvalidCredentialData);
}
CodexCredentialCodec::decode(&loaded.credential).map_err(Into::into)
}
@@ -230,6 +239,7 @@ impl CodexCredentialRepository {
if !account.enabled() {
return Ok(());
}
let credential_state = oauth_account_state(account, credential_state);
let message = message.filter(|value| !value.trim().is_empty());
let error_reason = if credential_state == CredentialState::Ready {
message.as_ref().and(error_reason)
@@ -295,3 +305,14 @@ impl From<CodexCredentialDataError> for CredentialRepositoryError {
Self::InvalidCredentialData
}
}
/// OAuth 未取得用户身份时保留未验证状态;通用账号层不解释认证类型。
fn oauth_account_state(account: &ProviderAccount, observed: CredentialState) -> CredentialState {
if account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_OAUTH
&& account.upstream_user_id().is_none()
{
CredentialState::Unknown
} else {
observed
}
}
@@ -1,5 +1,6 @@
//! Codex 明文 credential JSON 的 schema 校验与日志脱敏边界。
use super::api_key::ApiKeyAuthentication;
use gateway_core::account::PlaintextCredential;
use secrecy::{ExposeSecret, SecretString};
use serde_json::Value;
@@ -40,11 +41,16 @@ impl std::fmt::Debug for CodexRuntimeCredential {
pub enum CodexRuntimeAuthentication {
OAuth(CodexOAuthSecret),
ApiKey(ApiKeyAuthentication),
}
impl CodexRuntimeAuthentication {
pub fn authorization_header(&self) -> Result<SecretString, CodexCredentialDataError> {
match self {
Self::ApiKey(auth) => Ok(SecretString::from(format!(
"Bearer {}",
auth.secret.expose_secret()
))),
Self::OAuth(secret) => Ok(SecretString::from(format!(
"Bearer {}",
secret.access_token.expose_secret()
@@ -56,6 +62,7 @@ impl CodexRuntimeAuthentication {
pub const fn oauth(&self) -> Option<&CodexOAuthSecret> {
match self {
Self::OAuth(secret) => Some(secret),
Self::ApiKey(_) => None,
}
}
}
@@ -64,6 +71,7 @@ impl std::fmt::Debug for CodexRuntimeAuthentication {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OAuth(secret) => secret.fmt(formatter),
Self::ApiKey(auth) => auth.fmt(formatter),
}
}
}
@@ -166,6 +174,17 @@ impl CodexCredentialCodec {
validate(&data)?;
let (authentication, principal, installation_id, cookies, oauth_client_id, oauth_scope) =
match data {
CodexCredentialData::ApiKey(data) => (
CodexRuntimeAuthentication::ApiKey(ApiKeyAuthentication {
configuration: data.configuration(),
secret: SecretString::from(data.api_key),
}),
None,
data.installation_id,
Vec::new(),
None,
None,
),
CodexCredentialData::OAuth(data) => (
CodexRuntimeAuthentication::OAuth(CodexOAuthSecret {
access_token: SecretString::from(data.access_token),
@@ -220,13 +239,25 @@ impl CodexCredentialCodec {
(CodexCredentialData::OAuth(incoming), CodexCredentialData::OAuth(existing)) => {
incoming.installation_id = existing.installation_id;
}
(CodexCredentialData::ApiKey(incoming), CodexCredentialData::ApiKey(existing)) => {
incoming.installation_id = existing.installation_id;
}
_ => return Err(CodexCredentialDataError::Invalid),
}
Self::encode_complete(incoming)
}
}
fn validate(data: &CodexCredentialData) -> Result<(), CodexCredentialDataError> {
if let CodexCredentialData::ApiKey(data) = data {
return if data.validate() && valid_installation_id(&data.installation_id) {
Ok(())
} else {
Err(CodexCredentialDataError::Invalid)
};
}
let (installation_id, cookies) = match data {
CodexCredentialData::ApiKey(_) => return Err(CodexCredentialDataError::Invalid),
CodexCredentialData::OAuth(data) => {
if data.schema_version != CODEX_CREDENTIAL_SCHEMA_VERSION {
return Err(CodexCredentialDataError::Invalid);
@@ -101,6 +101,8 @@ pub(crate) struct SelectCodexProviderEndpointCredential<'a> {
}
struct CredentialSelectionInput<'a> {
requires_websocket: bool,
oauth_only: bool,
request_url: &'a Url,
attempt: &'a AttemptContext,
session_affinity_key: Option<&'a ProviderSessionAffinityKey>,
@@ -297,6 +299,8 @@ impl CodexCredentialSelector {
request: &SelectCodexCredential<'_>,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let input = CredentialSelectionInput {
requires_websocket: false,
oauth_only: false,
request_url: request.request_url,
attempt: request.attempt,
session_affinity_key: request.session_affinity_key,
@@ -315,8 +319,12 @@ impl CodexCredentialSelector {
request: &SelectCodexCredential<'_>,
cyber_policy_session_key: Option<&ProviderSessionAffinityKey>,
session_affinity_observation: Option<&CodexSessionAffinity>,
requires_websocket: bool,
oauth_only: bool,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let input = CredentialSelectionInput {
requires_websocket,
oauth_only,
request_url: request.request_url,
attempt: request.attempt,
session_affinity_key: request.session_affinity_key,
@@ -339,6 +347,8 @@ impl CodexCredentialSelector {
request: &SelectCodexProviderEndpointCredential<'_>,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let input = CredentialSelectionInput {
requires_websocket: false,
oauth_only: true,
request_url: request.request_url,
attempt: request.attempt,
session_affinity_key: request.session_affinity.map(CodexSessionAffinity::key),
@@ -369,6 +379,8 @@ impl CodexCredentialSelector {
.into_iter()
.filter(|account| {
account.provider() == &self.provider_kind
&& (!request.oauth_only
|| account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH)
&& (diagnostic
|| request
.attempt
@@ -387,11 +399,32 @@ impl CodexCredentialSelector {
let observed_support = self
.catalog
.observed_model_support(account, upstream_model);
matches!(observed_support, Ok(None | Some(true)))
if account.authentication_kind()
== super::CODEX_AUTHENTICATION_KIND_API_KEY
{
matches!(observed_support, Ok(Some(true)))
} else {
matches!(observed_support, Ok(None | Some(true)))
}
}
})
})
.collect::<Vec<_>>();
let mut eligible = Vec::with_capacity(accounts.len());
for account in accounts {
if request.requires_websocket
&& account.authentication_kind() == super::CODEX_AUTHENTICATION_KIND_API_KEY
{
let runtime = self.repository.load_runtime_credential(&account).await?;
if !matches!(runtime.authentication, CodexRuntimeAuthentication::ApiKey(ref auth)
if auth.configuration.transport == super::ApiKeyTransport::PreferWebsocket)
{
continue;
}
}
eligible.push(account);
}
let accounts = eligible;
if model_access_rejected > 0 && request.attempt.trace().is_enabled() {
request.attempt.trace().record(
"account.model_access",
@@ -1151,6 +1184,12 @@ impl CodexCredentialSelector {
response_origin: &Url,
headers: &[String],
) -> Result<CodexCookieCaptureOutcome, CredentialSelectionError> {
if account.authentication_kind() != CODEX_AUTHENTICATION_KIND_OAUTH {
return Ok(CodexCookieCaptureOutcome {
credential_revision: None,
rejected: headers.len(),
});
}
let parsed = self.cookie_policy.parse_response_headers(
account.id().as_str(),
account.revision().get(),
@@ -1165,7 +1204,12 @@ impl CodexCredentialSelector {
});
}
let mut data = self.repository.load_complete_data(account).await?;
let cookies = data.cookies_mut();
let Some(cookies) = data.cookies_mut() else {
return Ok(CodexCookieCaptureOutcome {
credential_revision: None,
rejected: headers.len(),
});
};
for input in parsed.inputs {
let scope = self.cookie_policy.validate_capture(
&input.response_origin,
@@ -1238,10 +1282,13 @@ impl CodexCredentialSelector {
if data.cookies().is_empty() {
return Ok(());
}
let Some(cookies) = data.cookies_mut() else {
return Ok(());
};
match recovery {
CookieRecovery::ExpireAt(expires_at) => {
let expires_at = chrono::DateTime::<chrono::Utc>::from(expires_at);
for cookie in data.cookies_mut() {
for cookie in cookies {
cookie.expires_at = Some(
cookie
.expires_at
@@ -1249,7 +1296,7 @@ impl CodexCredentialSelector {
);
}
}
CookieRecovery::Clear => data.cookies_mut().clear(),
CookieRecovery::Clear => cookies.clear(),
}
self.repository.compare_and_swap_data(account, data).await?;
Ok(())
@@ -322,11 +322,12 @@ impl fmt::Debug for CodexOAuthCredentialData {
}
}
/// OpenAI Provider 的规范化 OAuth 凭据形态。
/// OpenAI Provider 的规范化凭据形态。
#[derive(Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum CodexCredentialData {
OAuth(CodexOAuthCredentialData),
ApiKey(super::api_key::ApiKeyCredentialData),
}
impl CodexCredentialData {
@@ -334,6 +335,7 @@ impl CodexCredentialData {
pub const fn authentication_kind(&self) -> &'static str {
match self {
Self::OAuth(_) => CODEX_AUTHENTICATION_KIND_OAUTH,
Self::ApiKey(_) => super::api_key::CODEX_AUTHENTICATION_KIND_API_KEY,
}
}
@@ -341,6 +343,7 @@ impl CodexCredentialData {
pub fn installation_id(&self) -> &str {
match self {
Self::OAuth(data) => &data.installation_id,
Self::ApiKey(data) => &data.installation_id,
}
}
@@ -348,12 +351,14 @@ impl CodexCredentialData {
pub fn cookies(&self) -> &[CodexCookie] {
match self {
Self::OAuth(data) => &data.cookies,
Self::ApiKey(_) => &[],
}
}
pub fn cookies_mut(&mut self) -> &mut Vec<CodexCookie> {
pub fn cookies_mut(&mut self) -> Option<&mut Vec<CodexCookie>> {
match self {
Self::OAuth(data) => &mut data.cookies,
Self::OAuth(data) => Some(&mut data.cookies),
Self::ApiKey(_) => None,
}
}
@@ -361,12 +366,14 @@ impl CodexCredentialData {
pub fn oauth(&self) -> Option<&CodexOAuthCredentialData> {
match self {
Self::OAuth(data) => Some(data),
Self::ApiKey(_) => None,
}
}
pub fn oauth_mut(&mut self) -> Option<&mut CodexOAuthCredentialData> {
match self {
Self::OAuth(data) => Some(data),
Self::ApiKey(_) => None,
}
}
@@ -381,6 +388,7 @@ impl fmt::Debug for CodexCredentialData {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OAuth(data) => data.fmt(formatter),
Self::ApiKey(data) => data.fmt(formatter),
}
}
}
@@ -198,6 +198,8 @@ pub(super) struct ColdJsonResponse {
#[derive(Clone, Serialize, Deserialize)]
pub(super) struct OpenAiSessionState {
pub(super) account_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) credential_revision: Option<u64>,
pub(super) conversation_id: Option<String>,
#[serde(default)]
pub(super) turn_state: Option<String>,
@@ -216,6 +218,7 @@ pub(super) enum OpenAiContinuationScope {
pub(super) struct OpenAiSessionCapture {
pub(super) account_id: String,
pub(super) credential_revision: Option<u64>,
pub(super) conversation_id: Option<String>,
pub(super) turn_state: Option<String>,
pub(super) client_turn_id: Option<String>,
@@ -261,6 +264,7 @@ fn encode_openai_session_capture(
};
encode_openai_session_state(OpenAiSessionState {
account_id: capture.account_id.clone(),
credential_revision: capture.credential_revision,
conversation_id: capture.conversation_id.clone(),
turn_state: capture.turn_state.clone(),
client_turn_id: capture.client_turn_id.clone(),
@@ -388,6 +388,12 @@ impl Provider for CodexProvider {
let cyber_policy_session_key =
derive_codex_cyber_policy_session_key(&upstream_request, context.client_api_key_ref());
let requires_websocket = transport_requirement(&upstream_request).requires_websocket()
|| (context.continuation_attempt() == ContinuationAttempt::Native
&& (previous_session.as_ref().is_some_and(|state| {
state.continuation_scope == OpenAiContinuationScope::ConnectionLocal
}) || matches!(context.continuation(), Some(ContinuationBinding::Pinned(binding))
if binding.scope() == NativeContinuationScope::ConnectionLocal)));
let selection_started_at = Instant::now();
let lease = self
.selector
@@ -400,6 +406,9 @@ impl Provider for CodexProvider {
},
cyber_policy_session_key.as_ref(),
session_affinity.as_ref(),
requires_websocket,
upstream_request.responses_lite.is_some()
|| upstream_request.memgen_request.is_some(),
)
.await
.map_err(map_selection_error)?;
@@ -407,6 +416,13 @@ impl Provider for CodexProvider {
u64::try_from(selection_started_at.elapsed().as_millis()).unwrap_or(u64::MAX);
let lease = Arc::new(lease);
// 首字计时的起点:账号选择完成之后、上游建立之前。
if previous_session.as_ref().is_some_and(|state| {
state
.credential_revision
.is_some_and(|revision| revision != lease.account().revision().get())
}) {
return Err(continuation_replay_required_error("scope_unavailable"));
}
let output_started_at = Instant::now();
let provider_kind = ProviderKind::new(PROVIDER_NAME)
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
@@ -498,7 +514,19 @@ impl Provider for CodexProvider {
account_scope,
);
let requirement = transport_requirement(&upstream_request);
let requested_transport = selected_transport(&upstream_request);
let api_http = matches!(lease.authentication(), crate::credential::CodexRuntimeAuthentication::ApiKey(auth)
if auth.configuration.transport == crate::credential::ApiKeyTransport::Http);
if api_http && requirement.requires_websocket() {
return Err(provider_error(
ProviderErrorKind::Unsupported,
UpstreamSendState::NotSent,
));
}
let requested_transport = if api_http {
CodexProviderTransport::HttpOnly
} else {
selected_transport(&upstream_request)
};
let session_http_fallback = requirement.allows_pre_send_http_fallback()
&& session_affinity
.as_ref()
@@ -527,6 +555,11 @@ impl Provider for CodexProvider {
let session_capture =
(!continuation_requested || previous_session.is_some()).then(|| OpenAiSessionCapture {
account_id: lease.account_id().as_str().to_owned(),
credential_revision: matches!(
lease.authentication(),
crate::credential::CodexRuntimeAuthentication::ApiKey(_)
)
.then_some(lease.account().revision().get()),
conversation_id: upstream_request.local_conversation_id.clone(),
turn_state: upstream_request.turn_state.clone(),
client_turn_id: upstream_request.client_turn_id.clone(),
@@ -543,9 +576,13 @@ impl Provider for CodexProvider {
AttemptTransport::Default | AttemptTransport::Fallback => 0,
};
let events = cold_response_stream(ColdResponse {
client: self.client.for_account(lease.account()).map_err(|_| {
provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
})?,
client: self
.client
.for_account(lease.account())
.map_err(|_| {
provider_error(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
})?
.with_authentication(lease.authentication()),
response_origin: self.responses_url.clone(),
request: upstream_request,
upstream_model: upstream_model.clone(),
@@ -19,13 +19,14 @@ const MAX_SERVICE_TIER_ID_BYTES: usize = 64;
const MAX_ETAG_BYTES: usize = 256;
/// 上游目录对一项能力给出的明确证据。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum CodexCatalogCapabilityEvidence {
/// 上游明确声明原生支持。
DeclaredNative,
/// 上游明确声明不支持。
DeclaredUnsupported,
/// 上游没有提供可依赖的声明。
#[default]
Unknown,
}
@@ -40,7 +41,7 @@ impl CodexCatalogCapabilityEvidence {
}
/// Codex 目录中允许进入控制面的能力证据。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CodexCatalogCapabilities {
responses_api: CodexCatalogCapabilityEvidence,
reasoning: CodexCatalogCapabilityEvidence,
@@ -110,7 +111,7 @@ impl CodexCatalogCapabilities {
}
/// Codex 目录中明确声明的模型限制。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CodexCatalogLimits {
context_window_tokens: Option<NonZeroU64>,
max_context_window_tokens: Option<NonZeroU64>,
@@ -131,7 +132,7 @@ impl CodexCatalogLimits {
}
/// Codex 目录中允许持久化的原始元数据白名单。
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CodexCatalogMetadata {
description: Option<String>,
priority: Option<i32>,
@@ -585,3 +586,66 @@ fn validate_etag(value: &str) -> Result<String, CodexModelCatalogError> {
}
Ok(value.to_owned())
}
/// 标准 API 模型 ID 遵循核心 ID 合同,不套用 Codex slug 语法或推断原生画像。
pub(crate) fn parse_api_model_catalog(
body: &[u8],
etag: Option<&str>,
) -> Result<CodexModelCatalogSnapshot, CodexModelCatalogError> {
#[derive(Deserialize)]
struct ApiModels {
data: Vec<ApiModel>,
}
#[derive(Deserialize)]
struct ApiModel {
id: String,
display_name: Option<String>,
}
if body.len() > MAX_CODEX_MODEL_CATALOG_BYTES {
return Err(CodexModelCatalogError::ResponseTooLarge);
}
let wire: ApiModels =
serde_json::from_slice(body).map_err(|_| CodexModelCatalogError::InvalidWire)?;
if wire.data.is_empty() {
return Err(CodexModelCatalogError::EmptySnapshot);
}
if wire.data.len() > MAX_CATALOG_MODELS {
return Err(CodexModelCatalogError::TooManyModels);
}
let mut seen = BTreeSet::new();
let mut models = Vec::with_capacity(wire.data.len());
for item in wire.data {
if item.id.trim() != item.id {
return Err(CodexModelCatalogError::InvalidModelSlug);
}
let request_model =
UpstreamModelId::new(item.id).map_err(|_| CodexModelCatalogError::InvalidModelSlug)?;
let display_name = item
.display_name
.unwrap_or_else(|| request_model.as_str().to_owned());
validate_public_text(&display_name, MAX_DISPLAY_NAME_BYTES, false)?;
let document = RawJsonPayload::new(
"openai",
Bytes::from(
serde_json::to_vec(&serde_json::json!({"id": request_model.as_str()}))
.map_err(|_| CodexModelCatalogError::InvalidWire)?,
),
)
.map_err(|_| CodexModelCatalogError::InvalidWire)?;
if !seen.insert(request_model.clone()) {
return Err(CodexModelCatalogError::DuplicateModelSlug);
}
models.push(CodexCatalogModel {
document,
request_model,
display_name,
capabilities: CodexCatalogCapabilities::default(),
limits: CodexCatalogLimits::default(),
metadata: CodexCatalogMetadata::default(),
});
}
Ok(CodexModelCatalogSnapshot {
models,
etag: etag.map(validate_etag).transpose()?,
})
}
@@ -642,12 +642,28 @@ pub struct CodexBackendJsonResponse {
// CodexBackendClient
// ---------------------------------------------------------------------------
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum OpenAiUpstreamProtocol {
Codex,
ResponsesApi,
}
impl OpenAiUpstreamProtocol {
pub(super) const fn responses_path(self) -> &'static str {
match self {
Self::Codex => super::endpoints::CODEX_RESPONSES_PATH,
Self::ResponsesApi => "/responses",
}
}
}
/// Codex HTTP/SSE 上游客户端。
#[derive(Clone)]
pub struct CodexBackendClient {
pub(super) client: Client,
pub(super) direct_client: Client,
pub(super) base_url: String,
pub(super) protocol: OpenAiUpstreamProtocol,
pub(super) profile: CodexWireProfileState,
pub(super) websocket_pool: Option<Arc<CodexWebSocketPool>>,
pub(super) websocket_origin_breaker: WebSocketOriginBreaker,
@@ -657,6 +673,22 @@ pub struct CodexBackendClient {
}
impl CodexBackendClient {
pub(crate) fn with_authentication(
mut self,
authentication: &crate::credential::CodexRuntimeAuthentication,
) -> Self {
if let crate::credential::CodexRuntimeAuthentication::ApiKey(auth) = authentication {
self.base_url = auth.configuration.base_url.trim_end_matches('/').to_owned();
self.protocol = OpenAiUpstreamProtocol::ResponsesApi;
self.websocket_origin_key = format!(
"{}:{}",
websocket_origin_key(&self.base_url),
self.egress_key
);
}
self
}
pub fn for_account(
&self,
account: &gateway_core::account::ProviderAccount,
@@ -664,6 +696,11 @@ impl CodexBackendClient {
let mut client = self.clone();
client.outbound_proxy = account.outbound_proxy().cloned();
client.egress_key = egress_key(account.id().as_str(), account.outbound_proxy());
if account.authentication_kind() == crate::credential::CODEX_AUTHENTICATION_KIND_API_KEY {
client
.egress_key
.push_str(&format!(":revision:{}", account.revision().get()));
}
client.websocket_origin_key = format!(
"{}:{}",
websocket_origin_key(&self.base_url),
@@ -60,6 +60,7 @@ impl CodexBackendClient {
outbound_proxy: None,
egress_key: String::new(),
base_url,
protocol: OpenAiUpstreamProtocol::Codex,
profile,
websocket_pool: None,
websocket_origin_breaker: WebSocketOriginBreaker::default(),
@@ -87,7 +88,7 @@ impl CodexBackendClient {
) -> CodexClientResult<CodexBackendStreamingResponse> {
let headers = self.request_headers_for_http_response(upstream_request, context)?;
let headers_started_at = Instant::now();
// 与官方 Codex app-server 一致:/v1/responses 请求体默认 zstd 压缩。
// OAuth 请求遵循 Codex 压缩合同;API Key 上游使用普通 JSON。
// Codex 上游只交付 SSE;即使下游请求 `stream: false`,也要上游流式执行,
// 再由 API 层收集 canonical events 并返回完整 JSON。不能把下游的传输偏好
// 直接透传给 Codex,否则上游会以 400 拒绝非流式请求。
@@ -95,7 +96,7 @@ impl CodexBackendClient {
upstream_body.insert("stream".to_owned(), serde_json::Value::Bool(true));
let body =
serde_json::to_vec(&upstream_body).map_err(CodexClientError::RequestBodyEncode)?;
let endpoint = endpoint_url(&self.base_url, CODEX_RESPONSES_PATH);
let endpoint = endpoint_url(&self.base_url, self.protocol.responses_path());
let trace = context
.trace
.cloned()
@@ -111,16 +112,15 @@ impl CodexBackendClient {
.map(|(name, value)| (name.as_str(), value.as_bytes())),
);
trace.capture("upstream.request.body", &body);
let body = zstd::stream::encode_all(std::io::Cursor::new(body), 3)
.map_err(CodexClientError::RequestCompression)?;
let response = self
.client
.post(endpoint)
.headers(headers)
.header(CONTENT_ENCODING, HeaderValue::from_static("zstd"))
.body(body)
.send()
.await?;
let mut outbound = self.client.post(endpoint).headers(headers);
let body = if self.protocol == OpenAiUpstreamProtocol::Codex {
outbound = outbound.header(CONTENT_ENCODING, HeaderValue::from_static("zstd"));
zstd::stream::encode_all(std::io::Cursor::new(body), 3)
.map_err(CodexClientError::RequestCompression)?
} else {
body
};
let response = outbound.body(body).send().await?;
let upstream_headers_ms = elapsed_duration_millis(headers_started_at.elapsed());
let http_version = http_version_name(response.version()).to_string();
let status = response.status();
@@ -251,8 +251,9 @@ impl CodexBackendClient {
let websocket_request = websocket_upstream_request(request);
let headers = self.request_headers_for_websocket_response(&websocket_request, context)?;
let mut websocket_create = CodexWebSocketConnection::responses_create_request(
let mut websocket_create = CodexWebSocketConnection::responses_create_request_for_path(
&self.base_url,
self.protocol.responses_path(),
&generate_key(),
websocket_header_pairs(&headers),
&websocket_request,
@@ -501,19 +502,23 @@ impl CodexBackendClient {
context: CodexRequestContext<'_>,
client_version: Option<&str>,
) -> CodexClientResult<CodexModelCatalogSnapshot> {
let endpoint = endpoint_url(&self.base_url, "codex/models");
let path = match self.protocol {
OpenAiUpstreamProtocol::Codex => "codex/models",
OpenAiUpstreamProtocol::ResponsesApi => "models",
};
let profile = self.profile.snapshot();
let headers = self.model_request_headers(&profile, context)?;
let response = self
let mut request = self
.client
.get(endpoint)
.query(&[(
.get(endpoint_url(&self.base_url, path))
.headers(headers);
if self.protocol == OpenAiUpstreamProtocol::Codex {
request = request.query(&[(
"client_version",
client_version.unwrap_or(profile.codex_version.as_str()),
)])
.headers(headers)
.send()
.await?;
)]);
}
let response = request.send().await?;
let status = response.status();
let diagnostics = response_meta::diagnostics(Some(status.as_u16()), response.headers());
let set_cookie_headers = response_meta::set_cookie_headers(response.headers());
@@ -540,7 +545,12 @@ impl CodexBackendClient {
send_phase: CodexUpstreamSendPhase::AfterPayload,
});
}
Ok(parse_codex_model_catalog(&body, etag.as_deref())?)
Ok(match self.protocol {
OpenAiUpstreamProtocol::Codex => parse_codex_model_catalog(&body, etag.as_deref())?,
OpenAiUpstreamProtocol::ResponsesApi => {
super::catalog::parse_api_model_catalog(&body, etag.as_deref())?
}
})
}
}
@@ -27,6 +27,24 @@ pub fn endpoint_url(base_url: &str, endpoint_path: &str) -> String {
)
}
/// 上游启动配置与账号地址共用同一校验,HTTP 仅允许本机联调。
pub(crate) fn valid_upstream_base_url(value: &str) -> bool {
let Ok(url) = url::Url::parse(value) else {
return false;
};
let loopback = match url.host() {
Some(url::Host::Domain("localhost")) => true,
Some(url::Host::Ipv4(address)) => address.is_loopback(),
Some(url::Host::Ipv6(address)) => address.is_loopback(),
_ => false,
};
((url.scheme() == "https" && url.host_str().is_some()) || (url.scheme() == "http" && loopback))
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none()
}
/// 返回与 base path 对应的唯一 usage endpoint。
pub fn usage_endpoint_url(base_url: &str) -> String {
account_endpoint_url(base_url, "usage")
@@ -8,7 +8,8 @@ use reqwest::header::{
};
use super::client::{
CodexBackendClient, CodexClientResult, CodexRequestContext, openai_subagent_from_metadata,
CodexBackendClient, CodexClientResult, CodexRequestContext, OpenAiUpstreamProtocol,
openai_subagent_from_metadata,
};
use super::profile::{CodexResidency, CodexWireProfile};
use super::protocol::responses::CodexResponsesRequest;
@@ -87,6 +88,11 @@ impl CodexBackendClient {
profile: &CodexWireProfile,
context: CodexRequestContext<'_>,
) -> CodexClientResult<HeaderMap> {
if self.protocol == OpenAiUpstreamProtocol::ResponsesApi {
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, HeaderValue::from_str(context.authorization)?);
return Ok(headers);
}
let mut headers =
build_codex_model_headers(profile, context.authorization, context.account_id)?;
insert_optional_header(&mut headers, "cookie", context.cookie_header)?;
@@ -114,11 +120,13 @@ impl CodexBackendClient {
let mut headers = self.response_headers(request, context)?;
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
insert_optional_protocol_header(
&mut headers,
X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER,
request.responses_lite.as_deref(),
);
if self.protocol == OpenAiUpstreamProtocol::Codex {
insert_optional_protocol_header(
&mut headers,
X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER,
request.responses_lite.as_deref(),
);
}
Ok(headers)
}
@@ -142,6 +150,15 @@ impl CodexBackendClient {
context: CodexRequestContext<'_>,
) -> CodexClientResult<HeaderMap> {
let mut headers = self.model_request_headers(&self.profile.snapshot(), context)?;
if self.protocol == OpenAiUpstreamProtocol::ResponsesApi {
// 上游身份只来自该账号;客户端的 OAuth 画像与 Cookie 不跨域转发。
headers.insert(
HeaderName::from_static("x-client-request-id"),
HeaderValue::from_str(context.request_id)?,
);
append_passthrough_headers(&mut headers, request, self.protocol);
return Ok(headers);
}
headers.insert(
HeaderName::from_static("x-client-request-id"),
HeaderValue::from_str(context.request_id)?,
@@ -181,9 +198,26 @@ impl CodexBackendClient {
None => format!("model={}", request.model()),
};
insert_optional_protocol_header(&mut headers, "x-codex-routing-hint", Some(&routing_hint));
for name in request.passthrough_headers.keys() {
// 身份与传输字段只由画像/正文生成;其余协议头保留原始多值字节。
if matches!(
append_passthrough_headers(&mut headers, request, self.protocol);
Ok(headers)
}
}
fn append_passthrough_headers(
headers: &mut HeaderMap,
request: &CodexResponsesRequest,
protocol: OpenAiUpstreamProtocol,
) {
for name in request.passthrough_headers.keys() {
// 身份与传输字段只由画像/正文生成;其余协议头保留原始多值字节。
if (protocol == OpenAiUpstreamProtocol::ResponsesApi
&& (name.as_str().starts_with("x-codex-")
|| name.as_str().starts_with("x-openai-internal-")
|| matches!(
name.as_str(),
"session-id" | "thread-id" | "x-openai-actor-authorization"
)))
|| matches!(
name.as_str(),
"originator"
| "user-agent"
@@ -203,15 +237,14 @@ impl CodexBackendClient {
| "x-oai-is"
| "x-oai-is-update"
| X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER
) {
continue;
}
headers.remove(name);
for value in request.passthrough_headers.get_all(name) {
headers.append(name.clone(), value.clone());
}
)
{
continue;
}
headers.remove(name);
for value in request.passthrough_headers.get_all(name) {
headers.append(name.clone(), value.clone());
}
Ok(headers)
}
}
@@ -18,6 +18,7 @@ mod response_meta;
pub(crate) mod session;
pub mod subscription;
mod time;
pub(crate) use endpoints::valid_upstream_base_url;
pub mod tls;
pub mod usage;
pub mod websocket;
@@ -76,8 +76,29 @@ impl CodexWebSocketConnection {
business_headers: Vec<(String, String)>,
request: &CodexResponsesRequest,
) -> Result<CodexWebSocketRequest, serde_json::Error> {
Self::responses_create_request_for_path(
base_url,
CODEX_RESPONSES_PATH,
websocket_key,
business_headers,
request,
)
}
pub(crate) fn responses_create_request_for_path(
base_url: &str,
path: &str,
websocket_key: &str,
business_headers: Vec<(String, String)>,
request: &CodexResponsesRequest,
) -> Result<CodexWebSocketRequest, serde_json::Error> {
let mut connection = Self::responses(base_url, websocket_key, business_headers);
let endpoint = crate::transport::endpoint_url(base_url, path);
connection.endpoint = endpoint
.replacen("https://", "wss://", 1)
.replacen("http://", "ws://", 1);
Ok(CodexWebSocketRequest {
connection: Self::responses(base_url, websocket_key, business_headers),
connection,
payload_text: websocket_response_create_payload_text(request)?,
continuation: WebSocketContinuationRequirement::from_request(request),
})
@@ -1991,3 +1991,78 @@ mod errors {
}
}
}
#[tokio::test]
async fn api_key_admin_exposes_only_configuration_and_preserves_key_when_rotating_address() {
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key(
"acct_api_admin",
"https://first.example/v1".to_owned(),
provider_openai::credential::ApiKeyTransport::Http,
)
.await;
let account = store.account("acct_api_admin").unwrap();
let config = valid_config();
let bundle = provider_openai::initialize(
config.config.clone(),
provider_ports_with(Arc::clone(&store), Arc::new(TestOAuthPending::default())),
)
.await
.unwrap();
let admin = bundle.admin_provider();
let configuration = admin
.account_configuration(account.id())
.await
.unwrap()
.unwrap();
let configuration = configuration.expose_to_provider().expose_to_provider();
assert_eq!(configuration.len(), 2);
assert_eq!(
configuration.get("base_url"),
Some(&json!("https://first.example/v1"))
);
assert!(!configuration.contains_key("api_key"));
let prepared = admin
.prepare_rotation(PrepareCredentialRotation {
account: account_record(&account),
provider_material: ProviderDocument::new(OpaqueProviderData::new(
json!({"base_url":"https://second.example/root", "transport":"prefer_websocket"})
.as_object()
.unwrap()
.clone(),
)),
})
.await
.unwrap();
let material = prepared
.facts()
.provider_material
.expose_to_provider()
.expose_to_provider();
assert_eq!(material.get("api_key"), Some(&json!("sk-api-test-only")));
assert_eq!(
material.get("base_url"),
Some(&json!("https://second.example/root"))
);
assert!(!prepared.facts().has_refresh_token);
assert_eq!(prepared.facts().account_id, *account.id());
assert_eq!(
admin
.prepare_refresh(PrepareCredentialRefresh {
account: account_record(&account)
})
.await
.unwrap_err()
.kind(),
ProviderAdminErrorKind::Unsupported
);
assert_eq!(
admin.subscription(account.id()).await.unwrap_err().kind(),
ProviderAdminErrorKind::Unsupported
);
assert_eq!(
admin.reset_credits(account.id()).await.unwrap_err().kind(),
ProviderAdminErrorKind::Unsupported
);
}
@@ -837,3 +837,116 @@ async fn pat_import_uses_account_proxy_without_direct_fallback() {
);
}
}
#[tokio::test]
async fn api_key_import_export_preserves_target_without_oauth_exchange() {
use gateway_core::account::LoadedCredential;
use provider_openai::credential::{
CodexCredentialAdmin, CodexCredentialData, ExportManagedCodexCredential,
};
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
Arc::new(TestLeaseCoordinator::default()),
runtime_policy(),
);
let imported = service.prepare_import_document(serde_json::json!({
"provider": "openai", "authentication_kind": "api_key", "name": "relay", "base_url": "https://relay.example/custom/v2", "api_key": "sk-test-only"
})).await.expect("import API account").into_accounts().pop().expect("one account");
assert_eq!(imported.account.authentication_kind(), "api_key");
assert!(!imported.account.has_refresh_token());
assert_eq!(imported.account.upstream_account_id(), None);
assert!(
!format!(
"{:?}",
CodexCredentialCodec::decode(&imported.credential).unwrap()
)
.contains("sk-test-only")
);
let now = Utc::now();
let document = CodexCredentialAdmin
.format_cpr_export(vec![ExportManagedCodexCredential {
current: LoadedCredential {
account: imported.account,
credential: imported.credential,
},
added_at: now,
updated_at: now,
}])
.expect("export")
.into_json()
.expect("JSON");
let restored = service
.prepare_import_document(document)
.await
.expect("reimport")
.into_accounts()
.pop()
.unwrap();
let CodexCredentialData::ApiKey(data) =
CodexCredentialCodec::decode_complete(&restored.credential).unwrap()
else {
panic!("API credential")
};
assert_eq!(data.base_url, "https://relay.example/custom/v2");
assert_eq!(data.api_key, "sk-test-only");
assert_eq!(
data.transport,
provider_openai::credential::ApiKeyTransport::Http
);
}
#[tokio::test]
async fn api_key_import_rejects_unsafe_urls_and_empty_keys() {
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
Arc::new(TestLeaseCoordinator::default()),
runtime_policy(),
);
for (url, key) in [
("http://remote.example/v1", "sk-test"),
("https://user:password@example.com", "sk-test"),
("https://example.com?token=secret", "sk-test"),
("https://example.com/#fragment", "sk-test"),
("https://example.com", ""),
("https://example.com", "sk-test\r\nx-header: value"),
] {
assert!(service.prepare_import_document(serde_json::json!({"authentication_kind":"api_key","base_url":url,"api_key":key})).await.is_err());
}
let prepared = service.prepare_import_document(serde_json::json!({"platform":"openai","type":"apikey","credentials":{"base_url":"https://example.com","api_key":"sk-test"}})).await.unwrap();
let value = prepared.accounts()[0].credential.expose_to_provider();
assert_eq!(
value.get("base_url"),
Some(&serde_json::json!("https://example.com/v1"))
);
}
#[tokio::test]
async fn sub2api_api_key_import_preserves_versioned_and_explicit_responses_paths() {
let service = CodexCredentialAdminService::new(
Arc::new(UnusedRefresher),
Arc::new(TestLeaseCoordinator::default()),
runtime_policy(),
);
for (base, expected) in [
("https://example.com", "https://example.com/v1"),
(
"https://example.com/custom/v4",
"https://example.com/custom/v4",
),
(
"https://example.com/custom/responses",
"https://example.com/custom",
),
("https://example.com/v1beta", "https://example.com/v1beta"),
] {
let prepared = service.prepare_import_document(serde_json::json!({"platform":"openai","type":"apikey","credentials":{"base_url":base,"api_key":"sk-test","model_mapping":{}}})).await.unwrap();
assert_eq!(
prepared.accounts()[0]
.credential
.expose_to_provider()
.get("base_url"),
Some(&serde_json::json!(expected))
);
}
assert!(service.prepare_import_document(serde_json::json!({"platform":"openai","type":"apikey","credentials":{"api_key":"sk-test"},"extra":{"openai_api_key_responses_websockets_v2_mode":"http_bridge"}})).await.is_err());
}
@@ -169,8 +169,11 @@ async fn client_catalog_is_account_and_version_scoped_and_keeps_original_objects
.client_model_catalog(&client_scope(&accounts), version)
.await
.expect("catalog");
let document: serde_json::Value =
serde_json::from_slice(models[0].payload.body()).expect("document");
let document: serde_json::Value = serde_json::from_slice(match &models[0].content {
gateway_core::routing::ProviderModelContent::Native(payload) => payload.body(),
_ => panic!("expected native model"),
})
.expect("document");
assert_eq!(
document,
serde_json::json!({
@@ -752,3 +755,317 @@ fn invalid_response_etag_is_rejected_without_touching_the_catalog() {
assert!(service.cached().expect("cache read").is_none());
assert_eq!(service.catalog_generation().get(), 0);
}
#[tokio::test]
async fn api_key_catalogs_are_isolated_and_join_oauth_without_claiming_native_metadata() {
use provider_openai::credential::{ApiKeyTransport, CodexCatalogScope};
let oauth = MockServer::start().await;
let first = MockServer::start().await;
let second = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
let oauth_account = seed_account(&store, "acct_oauth").await;
store
.seed_api_key("acct_first", first.uri(), ApiKeyTransport::Http)
.await;
store
.seed_api_key("acct_second", second.uri(), ApiKeyTransport::Http)
.await;
let a = store.account("acct_first").unwrap();
let b = store.account("acct_second").unwrap();
assert_ne!(
CodexCatalogScope::for_account(&a).unwrap(),
CodexCatalogScope::for_account(&b).unwrap()
);
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"models":[{"slug":"oauth-model","display_name":"OAuth model"}]}),
))
.mount(&oauth)
.await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"data":[{"id":"api-first"}]})),
)
.mount(&first)
.await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"data":[{"id":"api-second"}]})),
)
.mount(&second)
.await;
let service = service_with_catalog_cache(&store, oauth.uri(), catalog_cache());
service.synchronize().await.unwrap();
assert_eq!(
service.cached_account_models(&a).unwrap().unwrap(),
["api-first"]
);
assert_eq!(
service.cached_account_models(&b).unwrap().unwrap(),
["api-second"]
);
let combined = service
.client_model_catalog(&client_scope(&[oauth_account, a.clone()]), "1.0.0")
.await
.unwrap();
assert_eq!(
combined
.iter()
.map(|entry| entry.model.as_str())
.collect::<Vec<_>>(),
["oauth-model", "api-first"]
);
assert!(matches!(
combined[0].content,
gateway_core::routing::ProviderModelContent::Native(_)
));
assert!(matches!(
combined[1].content,
gateway_core::routing::ProviderModelContent::Adapted(_)
));
first.reset().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(503))
.mount(&first)
.await;
service.invalidate().unwrap();
service
.synchronize()
.await
.expect("failed upstream must not hide other accounts");
assert_eq!(
service.cached_account_models(&b).unwrap().unwrap(),
["api-second"]
);
}
#[tokio::test]
async fn api_key_catalog_accepts_namespaced_model_ids_without_inventing_capabilities() {
use provider_openai::credential::ApiKeyTransport;
use provider_openai::transport::CodexCatalogCapabilityEvidence;
let upstream = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key("acct_api", upstream.uri(), ApiKeyTransport::Http)
.await;
let ids = [
"gpt-5.4",
"ft:gpt-4.1-mini:example:custom:abc",
"vendor/model",
];
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": ids.map(|id| serde_json::json!({"id": id}))
})))
.mount(&upstream)
.await;
let service = service_with_catalog_cache(&store, upstream.uri(), catalog_cache());
let snapshot = service.synchronize().await.expect("standard API IDs");
assert_eq!(
snapshot
.models()
.iter()
.map(|model| model.request_model().as_str())
.collect::<Vec<_>>(),
ids
);
for model in snapshot.models() {
assert_eq!(
model.capabilities().reasoning(),
CodexCatalogCapabilityEvidence::Unknown
);
assert_eq!(model.limits().context_window_tokens(), None);
}
let account = store.account("acct_api").expect("API account");
let models = service
.client_model_catalog(&client_scope(&[account]), "1.0.0")
.await
.expect("client catalog");
assert_eq!(
models
.iter()
.map(|model| model.model.as_str())
.collect::<std::collections::BTreeSet<_>>(),
ids.into_iter().collect()
);
}
#[tokio::test]
async fn api_key_catalog_rejects_invalid_ids_and_duplicates() {
use provider_openai::credential::ApiKeyTransport;
let upstream = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key("acct_api", upstream.uri(), ApiKeyTransport::Http)
.await;
let service = service_with_catalog_cache(&store, upstream.uri(), catalog_cache());
for invalid in [
String::new(),
"__reserved".to_owned(),
"model\nname".to_owned(),
" model".to_owned(),
"x".repeat(257),
"valid-model".to_owned(),
] {
upstream.reset().await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id":"valid-model"}, {"id":invalid}]
})))
.mount(&upstream)
.await;
assert!(
service.synchronize().await.is_err(),
"invalid entry accepted: {invalid:?}"
);
assert!(service.cached().expect("cache").is_none());
}
}
#[tokio::test]
async fn api_key_catalog_does_not_replace_native_metadata_for_shared_models() {
use gateway_core::routing::ProviderModelContent;
use provider_openai::credential::ApiKeyTransport;
let oauth = MockServer::start().await;
let upstream = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
let oauth_account = seed_account(&store, "acct_oauth").await;
Mock::given(method("GET")).and(path("/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"models":[{
"slug":"gpt-5.4", "display_name":"Official Model", "context_window":272000,
"input_modalities":["text","image"], "supported_reasoning_levels":[{"effort":"high"}]
}]})))
.mount(&oauth).await;
let service = service_with_catalog_cache(&store, oauth.uri(), catalog_cache());
let before = service.synchronize().await.expect("OAuth directory");
store
.seed_api_key("acct_api", upstream.uri(), ApiKeyTransport::Http)
.await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"data":[{"id":"gpt-5.4"}]})),
)
.mount(&upstream)
.await;
service.invalidate().expect("invalidate");
let after = service.synchronize().await.expect("mixed directory");
assert_eq!(
after.models(),
before.models(),
"preserve the complete native model"
);
let api_account = store.account("acct_api").expect("API account");
assert_eq!(
service.cached_account_models(&api_account).unwrap(),
service.cached_account_models(&oauth_account).unwrap()
);
let mixed = service
.client_model_catalog(
&client_scope(&[oauth_account, api_account.clone()]),
"1.0.0",
)
.await
.expect("mixed client catalog");
assert_eq!(mixed.len(), 1);
let ProviderModelContent::Native(payload) = &mixed[0].content else {
panic!("native metadata expected")
};
assert_eq!(payload, before.models()[0].document());
let api_only = service
.client_model_catalog(&client_scope(&[api_account]), "1.0.0")
.await
.expect("API client catalog");
assert!(matches!(
api_only[0].content,
ProviderModelContent::Adapted(_)
));
}
#[tokio::test]
async fn slow_api_key_catalogs_share_a_deadline_and_preserve_healthy_catalogs() {
use provider_openai::credential::ApiKeyTransport;
use std::time::Duration;
let upstream = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
let mut accounts = vec![seed_account(&store, "acct_oauth").await];
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"models":[{"slug":"oauth-model","display_name":"OAuth model"}]}),
))
.mount(&upstream)
.await;
Mock::given(method("GET"))
.and(path("/fast/models"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"data":[{"id":"api-model"}]})),
)
.mount(&upstream)
.await;
Mock::given(method("GET"))
.and(path("/slow/models"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"data":[{"id":"slow-model"}]}))
.set_delay(Duration::from_secs(60)),
)
.mount(&upstream)
.await;
store
.seed_api_key(
"acct_fast",
format!("{}/fast", upstream.uri()),
ApiKeyTransport::Http,
)
.await;
accounts.push(store.account("acct_fast").expect("fast API account"));
for index in 0..6 {
let id = format!("acct_slow_{index}");
store
.seed_api_key(
&id,
format!("{}/slow", upstream.uri()),
ApiKeyTransport::Http,
)
.await;
accounts.push(store.account(&id).expect("slow API account"));
}
let service = service_with_catalog_cache(&store, upstream.uri(), catalog_cache());
let scope = client_scope(&accounts);
let slow_id = ProviderAccountId::new("acct_slow_0").expect("account ID");
let (client, refresh) = tokio::time::timeout(Duration::from_secs(17), async {
tokio::join!(
service.client_model_catalog(&scope, "1.0.0"),
service.refresh_account_catalog(&slow_id)
)
})
.await
.expect("catalog deadline must bound both client reads and account refreshes");
let models = client.expect("healthy catalogs remain available");
assert_eq!(
models
.iter()
.map(|model| model.model.as_str())
.collect::<Vec<_>>(),
["oauth-model", "api-model"]
);
assert!(matches!(
refresh,
Err(CodexCredentialCatalogError::Upstream { .. })
));
}
@@ -6900,7 +6900,11 @@ async fn provider_compiles_catalog_presentation_for_codex_models() {
.expect("native catalog")
.expect("supported");
let original: Value = serde_json::from_slice(OFFICIAL_FIXTURE).expect("fixture");
let document: Value = serde_json::from_slice(native[0].payload.body()).expect("document");
let document: Value = serde_json::from_slice(match &native[0].content {
gateway_core::routing::ProviderModelContent::Native(payload) => payload.body(),
_ => panic!("expected native model"),
})
.expect("document");
assert_eq!(document, original["models"][0]);
assert!(
server
@@ -7519,3 +7523,183 @@ async fn connection_limit_payload_survives_exhausted_retry_budget() {
Some("req-limit-final")
);
}
#[tokio::test]
async fn api_key_default_http_uses_own_prefix_plain_json_and_only_own_authentication() {
for prefix in ["", "/v1", "/custom/v2"] {
let upstream = MockServer::start().await;
let oauth = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key(
"acct_provider_contract",
format!("{}{prefix}", upstream.uri()),
provider_openai::credential::ApiKeyTransport::Http,
)
.await;
Mock::given(method("GET"))
.and(path(format!("{prefix}/models")))
.and(header("authorization", "Bearer sk-api-test-only"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"data":[{"id":"gpt-5.4"}]})),
)
.expect(1)
.mount(&upstream)
.await;
Mock::given(method("POST"))
.and(path(format!("{prefix}/responses")))
.and(header("authorization", "Bearer sk-api-test-only"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"),
)
.expect(1)
.mount(&upstream)
.await;
let (provider, quota) = provider_and_quota_with_affinity_and_base_url_and_leases(
&store,
Arc::new(MemorySessionAffinity::default()),
oauth.uri(),
Arc::new(TestLeaseCoordinator::default()),
DEFAULT_STREAM_MAX_RETRIES as u32,
);
provider
.query_model_capabilities()
.await
.expect("standard API catalog");
let catalog = provider
.query_client_model_catalog(&contract_account_scope(), "codex", "1.0.0")
.await
.unwrap()
.unwrap();
assert_eq!(catalog.len(), 1);
assert!(matches!(
catalog[0].content,
gateway_core::routing::ProviderModelContent::Adapted(_)
));
let mut stream = provider
.execute(
planned_request("openai", generate_operation()),
context("req_api_http", CancellationToken::new()),
)
.await
.expect("scheduled API account");
while let Some(event) = stream.next().await {
event.expect("completed API response");
}
quota.synchronize().await.expect("skip API account quota");
assert!(oauth.received_requests().await.unwrap().is_empty());
let requests = upstream.received_requests().await.unwrap();
let request = requests
.iter()
.find(|request| request.method == "POST")
.unwrap();
assert!(!request.headers.contains_key("content-encoding"));
for header in [
"cookie",
"chatgpt-account-id",
"originator",
"version",
"x-codex-routing-hint",
] {
assert!(!request.headers.contains_key(header), "unexpected {header}");
}
let body: Value = serde_json::from_slice(&request.body).expect("ordinary JSON");
assert_eq!(body["stream"], true);
assert_eq!(body["model"], "gpt-5.4");
}
}
#[tokio::test]
async fn api_key_http_account_is_rejected_before_websocket_warmup_or_old_revision_continuation() {
let upstream = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key(
"acct_provider_contract",
upstream.uri(),
provider_openai::credential::ApiKeyTransport::Http,
)
.await;
let provider = provider(&store);
let warmup = Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object(
"openai",
json!({"model":"gpt-5.4","input":[],"store":false,"generate":false})
.as_object()
.unwrap()
.clone(),
)
.unwrap(),
));
assert!(
provider
.execute(
planned_request("openai", warmup),
diagnostic_context("req_api_warmup", "acct_provider_contract")
)
.await
.is_err()
);
let Operation::Generate(generate) = generate_operation() else {
panic!("generate")
};
let stale = generate.with_provider_session_state(ProviderSessionState::new("openai", json!({"account_id":"acct_provider_contract","conversation_id":"old","credential_revision":9,"continuation_scope":"persisted"}).as_object().unwrap().clone()).unwrap());
assert!(
provider
.execute(
planned_request("openai", Operation::Generate(stale)),
diagnostic_context("req_api_stale", "acct_provider_contract")
)
.await
.is_err()
);
assert!(upstream.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn api_key_websocket_uses_api_path_and_bearer_without_oauth_identity() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}/custom/v2", listener.local_addr().unwrap());
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key(
"acct_provider_contract",
base,
provider_openai::credential::ApiKeyTransport::PreferWebsocket,
)
.await;
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket =
crate::transport::accept_codex_test_websocket_with(stream, |request, _| {
assert_eq!(request.uri().path(), "/custom/v2/responses");
assert_eq!(
request.headers()["authorization"],
"Bearer sk-api-test-only"
);
assert!(!request.headers().contains_key("chatgpt-account-id"));
assert!(!request.headers().contains_key("cookie"));
assert!(!request.headers().contains_key("originator"));
})
.await;
let frame = websocket.next().await.unwrap().unwrap();
let payload: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap();
assert_eq!(payload["type"], "response.create");
websocket.send(Message::Text(json!({"type":"response.completed","response":{"id":"resp_api_ws","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}).to_string().into())).await.unwrap();
});
let provider = provider(&store);
let mut stream = provider
.execute(
planned_request("openai", generate_operation()),
diagnostic_context("req_api_ws", "acct_provider_contract"),
)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.expect("API WebSocket completion");
}
timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap();
}
@@ -62,6 +62,49 @@ impl MemoryAccountStore {
.expect("seed test OAuth credential");
}
pub(crate) async fn seed_api_key(
&self,
id: &str,
base_url: String,
transport: provider_openai::credential::ApiKeyTransport,
) {
let credential = provider_openai::credential::CodexCredentialCodec::encode_complete(
provider_openai::credential::CodexCredentialData::ApiKey(
provider_openai::credential::ApiKeyCredentialData {
schema_version: 1,
installation_id: uuid::Uuid::new_v4().to_string(),
base_url,
api_key: "sk-api-test-only".to_owned(),
transport,
},
),
)
.expect("API Key schema");
let account = ProviderAccount::new(
ProviderAccountId::new(id).expect("id"),
ProviderKind::new("openai").expect("kind"),
id.to_owned(),
None,
"api_key".to_owned(),
CredentialRevision::new(1).expect("revision"),
None,
)
.with_account_facts(
true,
CredentialState::Ready,
QuotaState::unknown(),
None,
None,
);
self.create_account(NewProviderAccount {
account,
credential,
model_access: None,
})
.await
.expect("seed API account");
}
pub(crate) fn account(&self, id: &str) -> Option<ProviderAccount> {
let id = ProviderAccountId::new(id).ok()?;
self.accounts
+47 -6
View File
@@ -166,12 +166,15 @@ Responses 不透传下游的逐跳头、反代元数据(如 `cf-*`、`x-forwar
`cdn-loop`)以及 `Accept-Encoding` / `Content-Encoding`。链路元数据和编解码能力
由各段传输层独立管理;其余业务扩展头继续透传,不使用固定业务头白名单。
此规则同时适用于上游 HTTP 和 WebSocket,不影响上游响应的 `cf-ray` 等诊断信息。
API Key 上游还会移除 Cookie、ChatGPT 账号身份、`x-codex-*`、`x-openai-internal-*`、会话/线程身份头及
`X-OpenAI-Actor-Authorization`,避免把 OAuth 或网关托管身份传给第三方 API。
Responses WebSocket 仅接受文本 `response.create`,同一连接串行执行。当前响应期间收到的后续业务帧
留在有界接收队列中,待当前响应完成终结和写出后再逐条校验、准入与执行,不因请求提前到达而断开。
接收队列容量为 32 个事件,超载仍关闭连接;Ping/Pong、客户端关闭和服务关闭不等待队列中的请求执行。
客户端使用 HTTP/SSE 时,OpenAI Provider 仍可能选择上游 WebSocket。
OAuth 账号在客户端使用 HTTP/SSE 时仍可能选择上游 WebSocket。API Key 账号默认使用 HTTP/SSE,
可在账号上配置 `prefer_websocket`;必须依赖 WS 的预热、非持久化新链和连接内续接不使用 HTTP-only 账号。
客户端配置的 `supports_websockets` 只控制第一段连接,不是服务端传输策略开关。
上游在响应终态前发送 Close 1000 仍属于失败,不能按“正常关闭”计为成功。
@@ -318,7 +321,7 @@ Token 明细、费用明细、用时/首字与状态。Token 和费用复用现
| `POST` | `/api/admin/accounts/import` | `{ provider, data, settings?, outboundProxyId? }` | 导入或按上游身份更新账号,可同时应用调度、分组设置与默认代理 |
| `POST` | `/api/admin/accounts/refresh` | `{ accountId }` | 手工刷新 OAuth credential(`idToken` / `accessToken` / `refreshToken`),不刷新额度 |
| `POST` | `/api/admin/accounts/recover` | `{ accountId }` | 管理员显式清除该账号的本地错误/额度/cooldown 事实并重新启用,不访问上游 |
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 手工替换 OpenAI OAuth token |
| `POST` | `/api/admin/accounts/rotate` | OpenAI rotation 字段 | 更新指定 OpenAI 账号的 OAuth token 或 API Key 上游设置 |
| `POST` | `/api/admin/accounts/update` | `{ accountId, enabled, concurrencyLimit, weight, groupIds, notes?, modelAccess?, outboundProxyId?, outboundProxyUrl? }` | 一次更新账号备注、调度状态、并发上限(`null` 表示继承运行参数)、权重(1–100)、所属分组与出站代理 |
| `POST` | `/api/admin/accounts/batch-update` | `{ accountIds, enabled?, concurrencyLimit?, weight?, groupIds?, modelAccess?, outboundProxyId?, outboundProxyUrl? }` | 一次事务更新所选账号;仅修改提供的字段,至少提供一项修改 |
| `POST` | `/api/admin/accounts/delete` | `{ provider, accountIds }` | 批量删除 1–200 个账号 |
@@ -455,7 +458,7 @@ OAuth 等待回调期间不持有保护;提交仍拒绝已删除、连接配
导入的 `data` 必须是 JSON object,Admin API 请求上限为 64 MiB;Provider 可以收紧限制,
当前 xAI 导入上限为 16 MiB。内部 schema 由目标 Provider 独占解释:
- OpenAI 接受单账号 OAuth 文档、`accounts` 数组(最多 200 项)、CPR 账号 bundle 和含代理引用的 sub2api 导出;
- OpenAI 接受单账号 OAuth 或 API Key 文档、`accounts` 数组(最多 200 项)、CPR 账号 bundle 和含代理引用的 sub2api 导出;
- OpenAI OAuth token 字段接受 `accessToken`、`refreshToken`、`idToken`,以及官方
`auth.json` 中的 `access_token`、`refresh_token`、`id_token`,可以嵌套在 `tokens` 等账号 object 内;
每项至少包含 AT 或 RT。仅含 `OPENAI_API_KEY` 的客户端代理配置不是 OAuth 账号导入材料;
@@ -465,7 +468,38 @@ OAuth 等待回调期间不持有保护;提交仍拒绝已删除、连接配
- xAI 从单账号 object 或 `accounts` 数组中提取 OAuth token;并发、优先级等字段不参与认证;
- xAI 批量导入逐条独立校验:失败条目跳过并记录日志,不中断其余条目,仅当没有任何条目成功时整个导入才报错;
- xAI API Key 不是受支持的账号 credential;
- 导入不会只凭文件外形写入账号;目标 Provider 使用认证材料完成必要的 token exchange 或已认证账号资料补全。
- OAuth 导入由目标 Provider 完成必要的 token exchange 或身份投影。API Key 导入校验凭据格式与地址,不调用 OAuth 或 ChatGPT 身份接口;可用性由模型目录和连接测试验证。
API Key 账号使用以下独立凭据形态:
```json
{
"provider": "openai",
"data": {
"authentication_kind": "api_key",
"name": "团队上游",
"base_url": "https://api.example.com/v1",
"api_key": "<upstream-api-key>",
"transport": "http"
}
}
```
`base_url` 是 API 前缀,追加 `responses` 和 `models`,不会自动补 `/v1`;支持根路径和自定义前缀。
地址仅允许 HTTPS,HTTP 只允许本机回环;拒绝 URL 中的认证信息、查询串和 fragment。
`transport` 可省略(默认 `http`)或设为 `prefer_websocket`。API Key 使用 Bearer 认证与普通 JSON,
不携带 OAuth Cookie 或 ChatGPT 身份。上游须提供标准 `/models` 列表,模型目录按账号和凭据版本隔离。
API Key 每次导入创建独立账号;更新已有账号使用 `rotate`。导出会显式包含密钥,沿用敏感导出的确认合同。
sub2api 的 `platform=openai`、`type=apikey` 使用 `credentials.base_url` / `credentials.api_key`;
导入时按其端点规则将服务根、版本前缀或完整 `/responses` 地址转换为 API 前缀,缺省地址为官方 `/v1`。
非空模型映射、请求头覆盖、其他协议和启用的 `extra.openai_*` 设置尚未适配,返回输入错误,需先移除并在本项目重新配置。
API Key 账号支持 Responses、模型目录、连接测试和使用统计中的本地用量。OAuth 刷新/重新授权、ChatGPT 额度、个人资料、订阅、重置卡
及 Images、standalone Search、Responses Lite / compact 专用入口不对这类账号开放。
客户端的 `image_generation` 和 `X-OpenAI-Actor-Authorization` 声明不改变账号的实际能力。
账号列表和详情的 API Key `usage` 汇总该账号创建后仍保留的本地请求记录,`windowLabelDisplay` 为 `通用额度`;
日志清理会影响累计范围,不代表上游余额。OAuth 账号仍按实际周/月额度窗口统计。
批量导入 AT / RT 使用 `accounts` JSON 数组,最多 200 项,不接受纯文本 token 列表。例如:
@@ -488,7 +522,7 @@ RT-only 使用同一形状,只提交 `refreshToken`。不得把真实 token
`weight` 为 1–100,`groupIds` 为完整分组集合。设置应用于本次导入的全部账号,包括匹配到的已有账号,
与凭据在同一事务内提交;分组不存在时整次回滚。省略 `settings` 时新账号使用默认设置并保持未分组,
已有账号保留原有分组、权重与并发设置。可选 `notes` 与编辑备注使用相同的校验和清空语义,省略或 `null` 保留已有备注;
管理端新建表单留空时省略 `notes`。重新授权不接受 `settings`,普通 credential refresh/rotation 也保留账号设置。
管理端新建表单留空时省略 `notes`。重新授权不接受 `settings`,credential refresh 和未携带 `settings` 的 rotation 保留账号设置。
账号列表的每个 item 返回轻量 `groups: [{ id, name, enabled }]`。
@@ -509,6 +543,13 @@ OpenAI rotation 请求字段为:
}
```
API Key rotation 使用 `{ provider: "openai", accountId, baseUrl, transport, apiKey?, settings? }`。
省略 `apiKey` 保留当前密钥,空字符串无效;账号 ID 和认证类型不能通过轮换转换。
rotation 可选携带 `settings`,字段与 `POST /api/admin/accounts/update` 相同,其中 `accountId` 必须与外层一致。
凭据与设置在同一事务中保存,任一校验或持久化失败均不落库;省略 `settings` 保留现有分组、调度等设置。
`GET /api/admin/accounts/detail` 对 API Key 账号额外返回 `credentialConfiguration: { base_url, transport }`,不回显密钥。
更新会推进凭据 revision 并失效目录与连接;旧版本会话不可静默续接到新上游。
OAuth start 使用:
```json
@@ -544,7 +585,7 @@ OAuth start 使用:
`refreshToken`。刷新响应中的三个 token 字段均按官方语义独立轮换:返回新值时替换,省略时分别保留
现值。重新授权也保留这些回调保护,但只轮换目标账号的 token。回调地址只承载 `code`/`state`,
不以 host/path 形式作为拒绝条件。
- 账号文件导入和 OAuth complete(包括重新授权)在 credential 提交后后台尝试一次额度观测,不等待
- OAuth 账号文件导入和 OAuth complete(包括重新授权)在 credential 提交后后台尝试一次额度观测,不等待
观测完成才返回成功。观测失败只记录告警,不回滚已提交的账号;手工或后台 RT 刷新只更新 token,
不隐式等同于手工额度刷新,也不更新既有账号资料或 OAuth principal。xAI 导入与 OAuth complete
使用相同的提交后观察流程。
+7
View File
@@ -186,6 +186,13 @@ Images 与 standalone Search 是 OpenAI Provider 自有端点:两者都不参
Core 只理解 `Operation`、能力要求、Provider 候选、稳定错误和 canonical event,不读取 Provider SDK
类型。Provider 独占 credential schema、OAuth、账号选择、模型目录、额度投影和上游 transport。
OpenAI 的 OAuth 与 API Key 共用现有账号和事务。API Key 的 Base URL、密钥和传输策略属于 Provider 凭据 JSON,
随 credential revision 更新;普通详情只投影非敏感连接设置。API Key 目录按账号和凭据版本隔离,标准 API 模型列表
通过通用画像输出客户端目录,OAuth 原生对象保留。通用账号层按 Provider 提交的 credential state 调度,不以是否存在
上游用户 ID 推断可用性;OAuth 未完成身份投影时由 Provider 保持 `unknown`。状态恢复和未补齐身份的凭据轮换保留 `unknown`。
API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS pool 与 continuation 按凭据版本隔离。
- OpenAI 是透明边界。Responses 请求保留未知字段和字段顺序;SSE、WebSocket、Images 与 standalone
Search 的业务正文按原始字节转发。canonical facts 从同一数据旁路提取,只用于路由、观测和计费。
- Responses 的业务扩展头保留原始多值字节;传输与反代请求头分类由 `gateway-protocol` 统一定义,
+22
View File
@@ -571,3 +571,25 @@ export function completeAccountOAuth(data: AccountOAuthCompleteParam) {
data,
})
}
export interface ApiKeyConfiguration {
base_url: string
transport: 'http' | 'prefer_websocket'
}
export function getAccountDetail(data: AccountIdParam, options: RequestOptions = {}) {
return request<{ account: Account, credentialConfiguration?: ApiKeyConfiguration }>({
url: '/api/admin/accounts/detail',
method: 'GET',
params: data,
...options,
})
}
export function updateAccountApiKey(data: { accountId: string, baseUrl: string, transport: ApiKeyConfiguration['transport'], apiKey?: string, settings?: AccountUpdateParam }) {
return request<{ accountId: string }>({
url: '/api/admin/accounts/rotate',
method: 'POST',
data: { provider: 'openai', ...data },
})
}
@@ -56,6 +56,6 @@ const boxClasses = computed(() => [
:class="!indeterminate && model ? 'opacity-100' : 'opacity-0'"
/>
</span>
<span v-if="showLabel" class="text-cp leading-none font-emphasis">{{ label }}</span>
<span v-if="showLabel" class="min-w-0 text-cp leading-none font-emphasis"><slot name="label">{{ label }}</slot></span>
</label>
</template>
@@ -30,10 +30,7 @@ const props = withDefaults(
const model = defineModel<string>({ required: true })
const activeIndex = computed(() => {
const index = props.options.findIndex(option => option.value === model.value)
return index >= 0 ? index : 0
})
const activeIndex = computed(() => props.options.findIndex(option => option.value === model.value))
const optionCount = computed(() => clamp(props.options.length, 1, Number.POSITIVE_INFINITY))
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${optionCount.value}, minmax(0, 1fr))`,
@@ -104,7 +101,7 @@ async function handleKeydown(event: KeyboardEvent, index: number) {
:aria-disabled="disabled || undefined"
>
<span
v-if="options.length > 0"
v-if="activeIndex >= 0"
class="pointer-events-none absolute inset-y-0.75 left-0.75 rounded-cp bg-cp-bg-container shadow-cp-tertiary transition-transform duration-200 ease-out motion-reduce:transition-none"
:style="indicatorStyle"
/>
@@ -123,7 +120,7 @@ async function handleKeydown(event: KeyboardEvent, index: number) {
:aria-checked="model === option.value"
:aria-label="display === 'icon' ? option.label : undefined"
:title="display === 'icon' ? option.label : undefined"
:tabindex="model === option.value || (activeIndex === index && !model) ? 0 : -1"
:tabindex="model === option.value || (activeIndex < 0 && index === 0) ? 0 : -1"
:disabled="disabled || option.disabled"
@click="selectOption(index)"
@keydown="handleKeydown($event, index)"
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { ApiKeyAccountForm } from '../utils/upstreamApiKey'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
defineProps<{ disabled?: boolean, editing?: boolean }>()
const form = defineModel<ApiKeyAccountForm>({ required: true })
const transportOptions = [
{ label: 'HTTP / SSE', value: 'http' },
{ label: 'WS 优先', value: 'prefer_websocket' },
]
</script>
<template>
<div class="grid gap-4">
<BaseFormItem v-if="!editing" label="账号名称" required>
<BaseInput v-model="form.name" placeholder="输入上游名称" :disabled="disabled" />
</BaseFormItem>
<BaseFormItem label="Base URL" required>
<template #extra>
<span class="text-cp-xs text-cp-text-quaternary" title="填写 API 前缀,接口路径会自动追加">API 前缀</span>
</template>
<BaseInput v-model="form.base_url" type="url" placeholder="https://api.example.com/v1" :disabled="disabled" autocomplete="off" />
</BaseFormItem>
<BaseFormItem label="API Key" :required="!editing">
<BaseInput v-model="form.apiKey" type="password" :placeholder="editing ? '留空保留当前密钥' : '输入上游密钥'" :disabled="disabled" autocomplete="new-password" />
</BaseFormItem>
<BaseFormItem label="传输方式">
<BaseSegmented v-model="form.transport" label="传输方式" :options="transportOptions" :disabled="disabled" title="WS 优先需要上游支持 Responses WebSocket" />
</BaseFormItem>
</div>
</template>
@@ -2,6 +2,7 @@
import type { AccountCreateProvider } from './model'
import { Openai, Xai } from '@boxicons/vue'
import { LayoutGrid } from '@lucide/vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers'
withDefaults(
@@ -35,22 +36,22 @@ const providers = [
icon: Xai,
},
]
function selectProvider(value: string) {
const provider = providers.find(provider => provider.value === value)
if (provider)
emit('select', provider.value)
}
</script>
<template>
<div class="grid grid-cols-3 gap-3" role="group" aria-label="选择账号平台">
<button
v-for="provider in providers"
:key="provider.value"
type="button"
class="flex min-w-0 cursor-pointer flex-col items-center gap-3 rounded-cp border-0 px-2 py-4 text-cp font-medium outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-cp-control-outline disabled:cursor-not-allowed disabled:opacity-55 motion-reduce:transition-none"
:class="selected === provider.value ? 'bg-cp-primary-container text-cp-primary-on-container' : 'bg-cp-fill-quaternary text-cp-text-secondary hover:bg-cp-fill-tertiary hover:text-cp-text'"
:disabled="disabled"
:aria-pressed="selected === provider.value"
@click="emit('select', provider.value)"
>
<component :is="provider.icon" :width="24" :height="24" aria-hidden="true" />
<span>{{ provider.label }}</span>
</button>
</div>
<BaseSegmented
:model-value="selected ?? ''"
label="选择账号平台"
:options="providers"
:disabled="disabled"
display="icon"
class="w-31"
@update:model-value="selectProvider"
/>
</template>
@@ -10,6 +10,7 @@ import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import { accountModelAccessError } from '../../utils/modelAccess'
import { parseAccountSchedulingForm } from '../../utils/schedulingForm'
import AccountApiKeyFields from '../AccountApiKeyFields.vue'
import AccountImportFields from './AccountImportFields.vue'
import AccountOAuthFields from './AccountOAuthFields.vue'
import AccountSetupFields from './AccountSetupFields.vue'
@@ -47,9 +48,9 @@ const mode = computed({
},
})
const importText = computed({
get: () => form.value.mode === 'oauth' ? '' : form.value.importTexts[form.value.mode],
get: () => form.value.mode === 'oauth' || form.value.mode === 'api_key' ? '' : form.value.importTexts[form.value.mode],
set: (value: string) => {
if (form.value.mode !== 'oauth')
if (form.value.mode !== 'oauth' && form.value.mode !== 'api_key')
form.value.importTexts[form.value.mode] = value
},
})
@@ -109,6 +110,7 @@ function continueToImport() {
:disabled="busy"
@regenerate="emit('generateOauth')"
/>
<AccountApiKeyFields v-else-if="mode === 'api_key'" v-model="form.apiKey" :disabled="busy" />
<AccountImportFields
v-else
:key="mode"
@@ -1,13 +1,16 @@
import type { ApiKeyAccountForm } from '../../utils/upstreamApiKey'
import type { AccountModelAccess } from '@/api'
import { accountModelAccessError } from '../../utils/modelAccess'
import { parseAccountSchedulingForm } from '../../utils/schedulingForm'
import { emptyApiKeyAccountForm } from '../../utils/upstreamApiKey'
export type AccountCreateProvider = 'batch' | 'openai' | 'xai'
export type AccountImportMode = 'oauth' | 'access_token' | 'refresh_token' | 'json'
export type AccountImportInputMode = Exclude<AccountImportMode, 'oauth'>
export type AccountImportMode = 'oauth' | 'api_key' | 'access_token' | 'refresh_token' | 'json'
export type AccountImportInputMode = Exclude<AccountImportMode, 'oauth' | 'api_key'>
export interface AccountCreateForm {
provider: AccountCreateProvider | ''
apiKey: ApiKeyAccountForm
notes: string
enabled: boolean
concurrencyLimit: string
@@ -27,6 +30,7 @@ export interface AccountCreateForm {
export function emptyAccountCreateForm(): AccountCreateForm {
return {
provider: '',
apiKey: emptyApiKeyAccountForm(),
notes: '',
enabled: true,
concurrencyLimit: '',
@@ -1,5 +1,6 @@
import type { AccountRow } from '../../constants'
import type { AccountCreateForm, AccountCreateProvider } from './model'
import { apiKeyAccountError } from '../../utils/upstreamApiKey'
interface AccountCreatePresentationInput {
form: AccountCreateForm
@@ -12,6 +13,7 @@ interface AccountCreatePresentationInput {
const modeOptions = {
openai: [
{ label: 'OAuth', value: 'oauth' },
{ label: 'API Key', value: 'api_key' },
{ label: 'AT', value: 'access_token' },
{ label: 'RT', value: 'refresh_token' },
{ label: '账号文件', value: 'json' },
@@ -78,6 +80,8 @@ function resolveModal(
description = '通过浏览器授权导入 xAI 账号'
else if (provider === 'xai')
description = '粘贴或上传 xAI 账号文件,匹配已有账号时更新凭据'
else if (input.form.mode === 'api_key')
description = '接入 OpenAI Responses 兼容上游'
else if (input.form.mode === 'oauth')
description = '通过浏览器授权导入 OpenAI 账号'
else if (input.form.mode === 'access_token')
@@ -143,7 +147,7 @@ function resolveImportInput(
}
return {
label: '账号文件',
placeholder: '粘贴包含 accessToken 或 refreshToken(可含 idToken)的 JSON 内容',
placeholder: '粘贴 OAuth 或 API Key 账号 JSON 内容',
uploadable: true,
}
}
@@ -168,6 +172,8 @@ function canSubmit(
) {
if (!provider || input.saving || input.oauthLoading)
return false
if (input.form.mode === 'api_key')
return !apiKeyAccountError(input.form.apiKey)
if (input.form.mode !== 'oauth')
return input.form.importTexts[input.form.mode].trim().length > 0
return Boolean(
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import type { ApiKeyAccountForm } from '../utils/upstreamApiKey'
import type { AccountGroup, AccountModelAccess } from '@/api'
import BaseButton from '@/components/base/BaseButton.vue'
@@ -7,6 +8,7 @@ import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountApiKeyFields from './AccountApiKeyFields.vue'
import AccountIdentityCell from './AccountIdentityCell.vue'
import AccountPlanBadge from './AccountPlanBadge.vue'
import AccountSettingsFields from './AccountSettingsFields.vue'
@@ -16,6 +18,8 @@ defineProps<{
groups: AccountGroup[]
groupsLoading: boolean
saving: boolean
configurationLoading: boolean
configurationReady: boolean
}>()
const emit = defineEmits<{
@@ -23,6 +27,7 @@ const emit = defineEmits<{
}>()
const open = defineModel<boolean>({ required: true })
const apiKey = defineModel<ApiKeyAccountForm>('apiKey', { required: true })
const notes = defineModel<string>('notes', { required: true })
const enabled = defineModel<boolean>('enabled', { required: true })
const concurrencyLimit = defineModel<string>('concurrencyLimit', { required: true })
@@ -50,7 +55,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
size="lg"
/>
<div class="flex shrink-0 items-center gap-3">
<AccountPlanBadge :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="sm" />
<AccountPlanBadge :authentication-kind="account.authenticationKind" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="sm" />
<ProviderIconGroup
:provider="account.provider"
:authentication-kind="account.authenticationKind"
@@ -58,6 +63,19 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
</div>
</div>
<section v-if="account.authenticationKind === 'api_key'" class="grid gap-4">
<h3 class="m-0 text-cp font-heavy text-cp-text">
上游连接
</h3>
<p v-if="configurationLoading" role="status" class="m-0 text-cp-sm text-cp-text-secondary">
正在读取上游设置…
</p>
<p v-else-if="!configurationReady" role="alert" class="m-0 text-cp-sm text-cp-error">
上游设置读取失败,请关闭后重试
</p>
<AccountApiKeyFields v-else v-model="apiKey" editing :disabled="saving" />
</section>
<AccountSettingsFields
v-model:enabled="enabled"
v-model:concurrency-limit="concurrencyLimit"
@@ -91,7 +109,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
<BaseButton
variant="primary"
:loading="saving"
:disabled="!account || groupsLoading"
:disabled="!account || groupsLoading || (account.authenticationKind === 'api_key' && !configurationReady)"
@click="emit('save')"
>
保存更改
@@ -8,7 +8,7 @@ import AccountPlanBadge from './AccountPlanBadge.vue'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
type AccountIdentity = Pick<AccountRow, 'id' | 'email' | 'planType' | 'planTypeDisplay'>
& Partial<Pick<AccountRow, 'accountId' | 'notes'>>
& Partial<Pick<AccountRow, 'accountId' | 'notes' | 'name' | 'authenticationKind'>>
const props = withDefaults(
defineProps<{
@@ -31,6 +31,8 @@ const props = withDefaults(
)
const emailText = computed(() => {
if (props.account.authenticationKind === 'api_key' && props.account.name)
return props.account.name
const email = props.account.email?.trim()
if (email)
return email
@@ -42,11 +44,11 @@ const emailText = computed(() => {
const visibleNotes = computed(() => props.showNotes ? props.account.notes : undefined)
const displayTitle = computed(() =>
visibleNotes.value || props.titleMode === 'email' ? emailText.value : emailText.value.split('@')[0],
visibleNotes.value || props.titleMode === 'email' || props.account.authenticationKind === 'api_key' ? emailText.value : emailText.value.split('@')[0],
)
const secondaryText = computed(() =>
props.titleMode === 'email' ? null : emailText.value,
props.titleMode === 'email' || props.account.authenticationKind === 'api_key' ? null : emailText.value,
)
const initial = computed(() => displayTitle.value.slice(0, 1).toUpperCase())
@@ -88,7 +90,7 @@ const avatarToneClass = computed(() => {
:class="metaGapClass"
>
<slot name="meta" />
<AccountPlanBadge v-if="showPlan" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" :size="metaSize" />
<AccountPlanBadge v-if="showPlan" :authentication-kind="account.authenticationKind" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" :size="metaSize" />
</span>
</div>
<div
@@ -97,9 +99,9 @@ const avatarToneClass = computed(() => {
:class="metaGapClass"
>
<slot name="meta" />
<AccountPlanBadge v-if="showPlan" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" :size="metaSize" />
<AccountPlanBadge v-if="showPlan" :authentication-kind="account.authenticationKind" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" :size="metaSize" />
</div>
<AccountNotesPopover v-else-if="visibleNotes" :notes="visibleNotes" class="mt-1" />
<AccountNotesPopover v-else-if="visibleNotes" :notes="visibleNotes" class="mt-0.5" />
<div v-else-if="secondaryText" class="truncate font-emphasis" :class="secondaryClass">
{{ secondaryText }}
</div>
@@ -9,6 +9,7 @@ import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import { useRequestState } from '@/composables/useRequestState'
import { accountModelAccessError, accountModelIdError } from '../utils/modelAccess'
@@ -135,27 +136,31 @@ watch(search, () => {
<p v-if="inputError" class="m-0 text-cp-xs text-cp-error-text" role="alert">
{{ inputError }}
</p>
<div v-if="models.length" class="grid max-h-60 grid-cols-2 gap-2 overflow-y-auto p-1 -m-1 sm:grid-cols-3" role="group" aria-label="选择模型" :aria-busy="loading || undefined">
<BaseCheckbox
v-for="item in models"
:key="item.id"
class="min-h-11 min-w-0 rounded-cp px-3 py-2.5 wrap-anywhere transition-colors duration-150 motion-reduce:transition-none"
:class="model?.models.includes(item.id) ? 'bg-cp-primary-container text-cp-primary-on-container' : 'bg-cp-fill-quaternary text-cp-text-secondary hover:bg-cp-fill-tertiary hover:text-cp-text'"
:model-value="model?.models.includes(item.id) ?? false"
:label="item.id"
:title="item.unavailable && accountId ? '当前目录未返回' : undefined"
show-label
:disabled="disabled"
@update:model-value="select(item.id, $event)"
/>
</div>
<BaseScrollbar v-if="models.length" max-height="15rem" class="min-w-0 -m-1">
<div class="grid grid-cols-2 gap-2 p-1 sm:grid-cols-3" role="group" aria-label="选择模型" :aria-busy="loading || undefined">
<BaseCheckbox
v-for="item in models"
:key="item.id"
class="min-h-11 min-w-0 rounded-cp px-3 py-2.5 transition-colors duration-150 motion-reduce:transition-none"
:class="model?.models.includes(item.id) ? 'bg-cp-primary-container text-cp-primary-on-container' : 'bg-cp-fill-quaternary text-cp-text-secondary hover:bg-cp-fill-tertiary hover:text-cp-text'"
:model-value="model?.models.includes(item.id) ?? false"
:label="item.id"
:title="item.unavailable && accountId ? `${item.id}(当前目录未返回)` : item.id"
show-label
:disabled="disabled"
@update:model-value="select(item.id, $event)"
>
<template #label>
<span class="block truncate">{{ item.id }}</span>
</template>
</BaseCheckbox>
</div>
</BaseScrollbar>
<p v-else-if="loading" class="m-0 py-4 text-center text-cp-sm text-cp-text-tertiary" role="status">
加载模型中…
</p>
<BaseEmpty v-else-if="!error" :title="search ? '无匹配模型' : '暂无模型'" :icon="search ? Search : undefined" size="sm" surface="none" />
<p v-if="error" class="m-0 text-cp-xs text-cp-error-text" role="alert">
模型列表加载失败,请重试
</p>
<BaseEmpty v-if="error" title="模型列表加载失败" description="请刷新后重试" size="sm" surface="none" role="alert" />
</template>
</div>
</template>
@@ -6,6 +6,7 @@ import { stableVisualIndex } from '../utils/visualTone'
const props = withDefaults(
defineProps<{
planType?: string | null
authenticationKind?: string
planTypeDisplay: string
size?: 'xs' | 'sm' | 'md'
}>(),
@@ -28,7 +29,8 @@ const fallbackPalettes = [
'bg-cp-orange-container text-cp-orange-on-container',
] as const
const rawPlanType = computed(() => props.planType?.trim() || '')
const rawPlanType = computed(() => props.authenticationKind === 'api_key' ? 'api' : props.planType?.trim() || '')
const displayText = computed(() => props.authenticationKind === 'api_key' ? 'API' : props.planTypeDisplay)
const sizeClass = computed(() => {
if (props.size === 'xs')
@@ -54,6 +56,6 @@ const paletteClass = computed(() => {
:class="[sizeClass, paletteClass]"
:title="rawPlanType || undefined"
>
<span>{{ planTypeDisplay }}</span>
<span>{{ displayText }}</span>
</span>
</template>
@@ -3,6 +3,7 @@ import type { AccountRow } from '../../constants'
import { RefreshCw, UserRound } from '@lucide/vue'
import { computed, shallowRef } from 'vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants'
import AccountPlanBadge from '../AccountPlanBadge.vue'
@@ -34,10 +35,11 @@ const profileOpen = shallowRef(false)
账号额度
</h3>
<p
v-if="account.authenticationKind !== 'api_key'"
class="m-0 mt-1 flex min-w-0 items-center gap-1.5 text-cp-xs font-emphasis text-cp-text-secondary"
>
<span>{{ account.provider === 'xai' ? 'xAI 用量窗口' : 'Codex 额度' }}</span>
<template v-if="account.provider === 'openai'">
<template v-if="account.provider === 'openai' && account.authenticationKind === 'oauth'">
<span>·</span>
<AccountPlanBadge :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="sm" />
</template>
@@ -45,9 +47,9 @@ const profileOpen = shallowRef(false)
<span>最近刷新: {{ account.quota.refreshedAtDisplay }}</span>
</p>
</div>
<div class="flex shrink-0 items-center gap-0.5">
<div v-if="account.authenticationKind !== 'api_key'" class="flex shrink-0 items-center gap-0.5">
<BaseIconButton
v-if="account.provider === 'openai'"
v-if="account.provider === 'openai' && account.authenticationKind === 'oauth'"
label="查看个人信息"
size="sm"
variant="ghost"
@@ -57,7 +59,7 @@ const profileOpen = shallowRef(false)
<UserRound class="size-3.5" />
</BaseIconButton>
<AccountResetCredits
v-if="account.provider === 'openai'"
v-if="account.provider === 'openai' && account.authenticationKind === 'oauth'"
:account="account"
@account-updated="emit('accountUpdated', $event)"
/>
@@ -77,7 +79,10 @@ const profileOpen = shallowRef(false)
</div>
</div>
<div class="grid min-h-0 gap-3">
<div v-if="account.authenticationKind === 'api_key'" class="grid flex-1 place-items-center">
<BaseEmpty title="暂不支持查询上游额度" surface="none" />
</div>
<div v-else class="grid min-h-0 gap-3">
<AccountQuotaPanelEntry
v-for="entry in quotaEntries"
:key="entry.key"
@@ -91,7 +96,7 @@ const profileOpen = shallowRef(false)
</section>
<AccountProfileModal
v-if="account.provider === 'openai'"
v-if="account.provider === 'openai' && account.authenticationKind === 'oauth'"
v-model="profileOpen"
:account="account"
/>
@@ -30,7 +30,20 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length
<template>
<div class="box-border grid min-h-16.5 w-full min-w-0 content-center gap-1.5 py-1.5">
<template v-if="summaryEntries.length > 0">
<template v-if="account.authenticationKind === 'api_key'">
<span
class="flex min-w-0 items-baseline gap-1 font-mono tabular-nums"
title="本地累计总 Token"
>
<strong class="truncate text-cp-xs font-heavy text-cp-text">{{ account.usage.totalTokensDisplay }}</strong>
<span class="shrink-0 text-[9px] font-emphasis tracking-[0.02em] text-cp-text-quaternary">Tokens</span>
</span>
<div class="grid min-w-0 gap-1.5">
<span class="text-[10px] leading-3 font-bold text-cp-text-quaternary">{{ account.usage.windowLabelDisplay }}</span>
<div class="h-1 w-full rounded-full bg-cp-success" title="上游额度未提供;绿色条不表示剩余额度" aria-hidden="true" />
</div>
</template>
<template v-else-if="summaryEntries.length > 0">
<div
v-if="hasUsage"
class="flex min-w-0 items-baseline justify-between gap-2 leading-none"
@@ -65,6 +65,7 @@ const emit = defineEmits<{
测试连接
</BaseMenuItem>
<BaseMenuItem
v-if="account.authenticationKind === 'oauth'"
:loading="refreshing"
:disabled="refreshing"
@click.stop="(close(), emit('refresh', account.id))"
@@ -77,7 +78,7 @@ const emit = defineEmits<{
</template>
刷新令牌
</BaseMenuItem>
<BaseMenuItem @click.stop="(close(), emit('reauthorize', account))">
<BaseMenuItem v-if="account.authenticationKind === 'oauth'" @click.stop="(close(), emit('reauthorize', account))">
<template #icon>
<KeyRound class="size-3.5 text-cp-text-quaternary" />
</template>
@@ -87,6 +87,7 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
模型使用排行
</h3>
<BaseIconButton
v-if="account.authenticationKind !== 'api_key'"
label="预测周/月额度"
size="sm"
aria-haspopup="dialog"
@@ -136,6 +137,7 @@ const modelUsageColumns = defineTableColumns<AccountModelUsage>([
</section>
<AccountQuotaForecastModal
v-if="account.authenticationKind !== 'api_key'"
v-model="forecastOpen"
:account="account"
@account-updated="emit('accountUpdated', $event)"
@@ -1,12 +1,14 @@
import type { Ref } from 'vue'
import type { AccountModelAccess, getAccounts } from '@/api'
import type { AccountModelAccess, ApiKeyConfiguration, getAccounts } from '@/api'
import { computed, ref, shallowRef, watch } from 'vue'
import { updateAccount } from '@/api'
import { getAccountDetail, updateAccount, updateAccountApiKey } from '@/api'
import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useRequestState } from '@/composables/useRequestState'
import { accountModelAccessError } from '../utils/modelAccess'
import { concurrencyLimitInput, parseAccountSchedulingForm } from '../utils/schedulingForm'
import { apiKeyAccountError, emptyApiKeyAccountForm } from '../utils/upstreamApiKey'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
@@ -27,6 +29,32 @@ export function useAccountEditor(options: {
const selectedGroupIds = ref<string[]>([])
const saveAction = useAsyncAction()
const saving = saveAction.loading
const apiKey = ref(emptyApiKeyAccountForm())
const configurationRequest = useRequestState()
const configurationLoading = configurationRequest.loading
const configurationReady = shallowRef(false)
const savedConfiguration = shallowRef<ApiKeyConfiguration>()
async function loadConfiguration(accountId: string) {
const requestId = configurationRequest.start()
try {
const detail = await getAccountDetail({ accountId }, { signal: configurationRequest.signal })
if (!configurationRequest.isCurrent(requestId))
return
if (!detail.credentialConfiguration)
throw new Error('该账号没有 API Key 上游设置')
apiKey.value = { ...emptyApiKeyAccountForm(), ...detail.credentialConfiguration }
savedConfiguration.value = detail.credentialConfiguration
configurationReady.value = true
}
catch (error) {
configurationRequest.fail(requestId, error)
}
finally {
configurationRequest.finish(requestId)
}
}
const editingAccount = computed(() => {
const accountId = editingAccountId.value
return accountId
@@ -35,6 +63,7 @@ export function useAccountEditor(options: {
})
function open(account: AccountRow) {
configurationRequest.invalidate()
editingAccountId.value = account.id
notes.value = account.notes ?? ''
proxyMode.value = 'preserve'
@@ -44,13 +73,28 @@ export function useAccountEditor(options: {
weight.value = String(account.weight)
modelAccess.value = { ...account.modelAccess, models: [...account.modelAccess.models] }
selectedGroupIds.value = account.groups.map(group => group.id)
apiKey.value = emptyApiKeyAccountForm()
savedConfiguration.value = undefined
configurationReady.value = false
showEditModal.value = true
if (account.authenticationKind === 'api_key')
void loadConfiguration(account.id)
}
async function save() {
const accountId = editingAccountId.value
if (!accountId || saving.value)
return
const isApiKey = editingAccount.value?.authenticationKind === 'api_key'
if (isApiKey) {
if (!configurationReady.value)
return
const error = apiKeyAccountError(apiKey.value, true)
if (error) {
toast.warning(error)
return
}
}
const modelError = accountModelAccessError(modelAccess.value)
if (modelError) {
toast.warning(modelError)
@@ -67,7 +111,7 @@ export function useAccountEditor(options: {
}
await saveAction.run(async () => {
await updateAccount({
const settings = {
accountId,
notes: notes.value,
outboundProxyId: proxyMode.value === 'preserve' ? undefined : proxyMode.value === 'direct' ? '' : proxyId.value.trim(),
@@ -76,7 +120,18 @@ export function useAccountEditor(options: {
weight: scheduling.values.weight,
modelAccess: modelAccess.value,
groupIds: [...new Set(selectedGroupIds.value)],
})
}
const connectionChanged = isApiKey && (
apiKey.value.apiKey !== ''
|| apiKey.value.base_url.trim() !== savedConfiguration.value?.base_url
|| apiKey.value.transport !== savedConfiguration.value?.transport
)
if (connectionChanged) {
await updateAccountApiKey({ accountId, baseUrl: apiKey.value.base_url.trim(), transport: apiKey.value.transport, apiKey: apiKey.value.apiKey || undefined, settings })
}
else {
await updateAccount(settings)
}
showEditModal.value = false
await Promise.all([options.reloadAccounts(), options.reloadGroups()])
toast.success('账号已更新')
@@ -86,6 +141,10 @@ export function useAccountEditor(options: {
watch([showEditModal, saving], ([open, isSaving]) => {
if (open || isSaving)
return
configurationRequest.invalidate()
apiKey.value = emptyApiKeyAccountForm()
savedConfiguration.value = undefined
configurationReady.value = false
editingAccountId.value = null
notes.value = ''
proxyMode.value = 'preserve'
@@ -98,6 +157,9 @@ export function useAccountEditor(options: {
})
return {
apiKey,
configurationLoading,
configurationReady,
showEditModal,
editingAccount,
notes,
@@ -12,6 +12,7 @@ import { errorMessage } from '@/utils/async'
import { isRecord } from '@/utils/object'
import { formatProviderLabel, isSupportedProvider } from '@/utils/providers'
import { accountImportSettings, accountProxyError, emptyAccountCreateForm } from '../components/AccountCreateModal/model'
import { apiKeyAccountError, emptyApiKeyAccountForm } from '../utils/upstreamApiKey'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
type ImportProvider = 'openai' | 'xai'
@@ -139,7 +140,7 @@ export function useAccountOnboarding(options: {
}
function openReauthorizeAccount(account: AccountRow) {
if (account.provider !== 'openai' && account.provider !== 'xai')
if (account.authenticationKind !== 'oauth' || (account.provider !== 'openai' && account.provider !== 'xai'))
return
reauthorizingAccount.value = account
createForm.value = {
@@ -165,6 +166,21 @@ export function useAccountOnboarding(options: {
const mode = createForm.value.mode
if (mode === 'oauth')
throw new Error('请选择凭据导入方式')
if (mode === 'api_key') {
if (provider !== 'openai')
throw new Error('当前平台不支持 API Key 账号')
const form = createForm.value.apiKey
const error = apiKeyAccountError(form)
if (error)
throw new Error(error)
await importAccounts({
provider,
settings: accountImportSettings(createForm.value),
outboundProxyId: createForm.value.proxyMode === 'proxy' ? createForm.value.proxyId.trim() : undefined,
data: { provider, authentication_kind: 'api_key', name: form.name.trim(), base_url: form.base_url.trim(), api_key: form.apiKey, transport: form.transport },
})
return 'API Key 账号已添加'
}
const documents = accountImportDocuments(
provider,
mode,
@@ -226,6 +242,7 @@ export function useAccountOnboarding(options: {
createForm.value = {
...createForm.value,
mode: createForm.value.provider === 'batch' ? 'json' : 'oauth',
apiKey: emptyApiKeyAccountForm(),
importTexts: { access_token: '', refresh_token: '', json: '' },
oauthFlowId: '',
oauthAuthUrl: '',
+7 -1
View File
@@ -145,6 +145,9 @@ const {
})
const {
apiKey: editingApiKey,
configurationLoading,
configurationReady,
showEditModal,
editingAccount,
notes: editingNotes,
@@ -262,7 +265,7 @@ const {
</template>
<template #planType="{ row }">
<AccountPlanBadge :plan-type="row.planType" :plan-type-display="row.planTypeDisplay" />
<AccountPlanBadge :authentication-kind="row.authenticationKind" :plan-type="row.planType" :plan-type-display="row.planTypeDisplay" />
</template>
<template #usage="{ row }">
@@ -354,6 +357,7 @@ const {
<AccountEditModal
v-model="showEditModal"
v-model:api-key="editingApiKey"
v-model:notes="editingNotes"
v-model:enabled="schedulingEnabled"
v-model:concurrency-limit="editingConcurrencyLimit"
@@ -362,6 +366,8 @@ const {
v-model:proxy-mode="editingProxyMode"
v-model:proxy-id="editingProxyId"
v-model:selected-group-ids="editingGroupIds"
:configuration-loading="configurationLoading"
:configuration-ready="configurationReady"
:account="editingAccount"
:groups="groups"
:groups-loading="groupsLoading"
@@ -0,0 +1,31 @@
import type { ApiKeyConfiguration } from '@/api'
export interface ApiKeyAccountForm extends ApiKeyConfiguration {
name: string
apiKey: string
}
export function emptyApiKeyAccountForm(): ApiKeyAccountForm {
return { name: '', base_url: '', apiKey: '', transport: 'http' }
}
export function apiKeyAccountError(form: ApiKeyAccountForm, editing = false): string | undefined {
if (!editing && !form.name.trim())
return '请输入账号名称'
if (form.base_url.trim().length > 2048)
return '上游 API 地址不能超过 2048 个字符'
try {
const url = new URL(form.base_url)
const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(url.hostname)
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash)
return '请输入不含认证、查询参数或片段的 HTTPS 地址'
}
catch {
return '请输入完整的上游 API 地址'
}
if (!editing && !form.apiKey)
return '请输入 API Key'
if (form.apiKey && (!/^[\x21-\x7E]+$/.test(form.apiKey) || form.apiKey.length > 16384))
return 'API Key 不能包含空格或控制字符'
return undefined
}
@@ -65,7 +65,7 @@ const avatarToneClass = computed(() =>
<strong class="min-w-0 truncate text-cp leading-[1.15] font-heavy text-cp-text">
{{ displayTitle }}
</strong>
<AccountPlanBadge :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="xs" />
<AccountPlanBadge :authentication-kind="account.authenticationKind" :plan-type="account.planType" :plan-type-display="account.planTypeDisplay" size="xs" />
</span>
<span
class="mt-0.5 block min-w-0 truncate font-mono text-cp-xs font-emphasis text-cp-text-quaternary"