mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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(¤t.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(¤t.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,
|
||||
|
||||
@@ -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(¤t.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
@@ -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
|
||||
使用相同的提交后观察流程。
|
||||
|
||||
@@ -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` 统一定义,
|
||||
|
||||
@@ -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>
|
||||
+16
-15
@@ -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: '',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user