mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Fix OAuth token import and table filters
This commit is contained in:
@@ -428,6 +428,11 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request(
|
||||
pub(crate) fn beautify_local_execution_client_error_message(message: &str) -> String {
|
||||
let without_reason_code = strip_parenthesized_reason_code(message);
|
||||
let mut simplified = collapse_whitespace(without_reason_code.as_str());
|
||||
if let Some(unavailable_message) =
|
||||
simplify_all_candidates_skipped_client_error_message(simplified.as_str())
|
||||
{
|
||||
return unavailable_message;
|
||||
}
|
||||
for marker in [
|
||||
"。请检查",
|
||||
"。请确认",
|
||||
@@ -450,6 +455,35 @@ pub(crate) fn beautify_local_execution_client_error_message(message: &str) -> St
|
||||
trim_trailing_message_punctuation(simplified.as_str()).to_string()
|
||||
}
|
||||
|
||||
fn simplify_all_candidates_skipped_client_error_message(message: &str) -> Option<String> {
|
||||
if !message.contains("候选提供商")
|
||||
|| !(message.contains("全部不可用") || message.contains("都不满足本次"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let request_mode = extract_local_execution_request_mode(message)?;
|
||||
if let Some(model) = extract_candidate_supported_model(message) {
|
||||
return Some(format!(
|
||||
"没有可用提供商支持模型 {model} 的{request_mode}请求"
|
||||
));
|
||||
}
|
||||
|
||||
Some(format!("没有可用提供商支持本次{request_mode}请求"))
|
||||
}
|
||||
|
||||
fn extract_local_execution_request_mode(message: &str) -> Option<&str> {
|
||||
let rest = message.get(message.find("本次")? + "本次".len()..)?;
|
||||
let mode = rest.get(..rest.find("请求")?)?.trim();
|
||||
(!mode.is_empty()).then_some(mode)
|
||||
}
|
||||
|
||||
fn extract_candidate_supported_model(message: &str) -> Option<&str> {
|
||||
let rest = message.get(message.find("支持模型 ")? + "支持模型 ".len()..)?;
|
||||
let model = rest.get(..rest.find(" 的")?)?.trim();
|
||||
(!model.is_empty()).then_some(model)
|
||||
}
|
||||
|
||||
fn strip_parenthesized_reason_code(message: &str) -> String {
|
||||
let Some(reason_index) = message.find("原因代码") else {
|
||||
return message.to_string();
|
||||
@@ -1026,6 +1060,12 @@ mod tests {
|
||||
),
|
||||
"请求缺少 model 字段,无法选择上游提供商"
|
||||
);
|
||||
assert_eq!(
|
||||
beautify_local_execution_client_error_message(
|
||||
"找到 1 个支持模型 gpt-5.4 的候选提供商,但本次流式请求全部不可用:provider_quota_blocked 2 次(原因代码: all_candidates_skipped)",
|
||||
),
|
||||
"没有可用提供商支持模型 gpt-5.4 的流式请求"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::super::token_import::build_codex_access_token_import_auth_config;
|
||||
use super::kiro_import::execute_admin_provider_oauth_kiro_batch_import;
|
||||
use super::parse::{
|
||||
apply_admin_provider_oauth_batch_import_hints, extract_admin_provider_oauth_batch_error_detail,
|
||||
@@ -22,12 +23,19 @@ use crate::handlers::admin::provider::oauth::state::{
|
||||
admin_provider_oauth_template, exchange_admin_provider_oauth_refresh_token,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::support::ADMIN_PROVIDER_OAUTH_DATA_UNAVAILABLE_DETAIL;
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminProviderOAuthTemplate};
|
||||
use crate::GatewayError;
|
||||
use aether_admin::provider::oauth::parse_admin_provider_oauth_kiro_batch_import_entries;
|
||||
use serde_json::json;
|
||||
use aether_contracts::ProxySnapshot;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct AdminProviderOAuthResolvedBatchImport {
|
||||
access_token: String,
|
||||
auth_config: Map<String, Value>,
|
||||
expires_at: Option<u64>,
|
||||
}
|
||||
|
||||
pub(super) fn estimate_admin_provider_oauth_batch_import_total(
|
||||
provider_type: &str,
|
||||
raw_credentials: &str,
|
||||
@@ -70,6 +78,90 @@ pub(super) async fn execute_admin_provider_oauth_batch_import_for_provider_type(
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_admin_provider_oauth_batch_import_tokens(
|
||||
state: &AdminAppState<'_>,
|
||||
template: AdminProviderOAuthTemplate,
|
||||
provider_type: &str,
|
||||
entry: &AdminProviderOAuthBatchImportEntry,
|
||||
request_proxy: Option<ProxySnapshot>,
|
||||
) -> Result<AdminProviderOAuthResolvedBatchImport, String> {
|
||||
let refresh_token = entry
|
||||
.refresh_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let access_token = entry
|
||||
.access_token
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
if let Some(refresh_token) = refresh_token {
|
||||
let token_payload = match exchange_admin_provider_oauth_refresh_token(
|
||||
state,
|
||||
template,
|
||||
refresh_token,
|
||||
request_proxy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(payload) => payload,
|
||||
Err(response) => {
|
||||
let detail = extract_admin_provider_oauth_batch_error_detail(response).await;
|
||||
if provider_type.eq_ignore_ascii_case("codex") {
|
||||
if let Some(access_token) = access_token {
|
||||
let (auth_config, expires_at) = build_codex_access_token_import_auth_config(
|
||||
access_token,
|
||||
Some(refresh_token),
|
||||
entry.expires_at,
|
||||
Some(detail.as_str()),
|
||||
);
|
||||
return Ok(AdminProviderOAuthResolvedBatchImport {
|
||||
access_token: access_token.to_string(),
|
||||
auth_config,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Err(format!("Token 验证失败: {detail}"));
|
||||
}
|
||||
};
|
||||
|
||||
let (mut auth_config, access_token, returned_refresh_token, expires_at) =
|
||||
build_provider_oauth_auth_config_from_token_payload(provider_type, &token_payload);
|
||||
let Some(access_token) = access_token else {
|
||||
return Err("Token 刷新返回缺少 access_token".to_string());
|
||||
};
|
||||
|
||||
let refresh_token = returned_refresh_token
|
||||
.or_else(|| Some(refresh_token.to_string()))
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
if let Some(refresh_token) = refresh_token.as_ref() {
|
||||
auth_config.insert("refresh_token".to_string(), json!(refresh_token));
|
||||
}
|
||||
return Ok(AdminProviderOAuthResolvedBatchImport {
|
||||
access_token,
|
||||
auth_config,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(access_token) = access_token {
|
||||
if !provider_type.eq_ignore_ascii_case("codex") {
|
||||
return Err("Access Token 导入仅支持 Codex Provider".to_string());
|
||||
}
|
||||
let (auth_config, expires_at) =
|
||||
build_codex_access_token_import_auth_config(access_token, None, entry.expires_at, None);
|
||||
return Ok(AdminProviderOAuthResolvedBatchImport {
|
||||
access_token: access_token.to_string(),
|
||||
auth_config,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
Err("Refresh Token 或 Access Token 不能为空".to_string())
|
||||
}
|
||||
|
||||
pub(super) async fn execute_admin_provider_oauth_batch_import(
|
||||
state: &AdminAppState<'_>,
|
||||
provider_id: &str,
|
||||
@@ -145,24 +237,22 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
|
||||
let mut failed = 0usize;
|
||||
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
let token_payload = match exchange_admin_provider_oauth_refresh_token(
|
||||
let resolved_import = match resolve_admin_provider_oauth_batch_import_tokens(
|
||||
state,
|
||||
template,
|
||||
entry.refresh_token.as_str(),
|
||||
provider_type,
|
||||
entry,
|
||||
request_proxy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(payload) => payload,
|
||||
Err(response) => {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
failed += 1;
|
||||
results.push(json!({
|
||||
"index": index,
|
||||
"status": "error",
|
||||
"error": format!(
|
||||
"Token 验证失败: {}",
|
||||
extract_admin_provider_oauth_batch_error_detail(response).await
|
||||
),
|
||||
"error": error,
|
||||
"replaced": false,
|
||||
}));
|
||||
maybe_report_admin_provider_oauth_batch_import_progress(
|
||||
@@ -176,34 +266,11 @@ pub(super) async fn execute_admin_provider_oauth_batch_import(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let (mut auth_config, access_token, returned_refresh_token, expires_at) =
|
||||
build_provider_oauth_auth_config_from_token_payload(provider_type, &token_payload);
|
||||
let Some(access_token) = access_token else {
|
||||
failed += 1;
|
||||
results.push(json!({
|
||||
"index": index,
|
||||
"status": "error",
|
||||
"error": "Token 刷新返回缺少 access_token",
|
||||
"replaced": false,
|
||||
}));
|
||||
maybe_report_admin_provider_oauth_batch_import_progress(
|
||||
&mut progress,
|
||||
entries.len(),
|
||||
success,
|
||||
failed,
|
||||
&results,
|
||||
)
|
||||
.await;
|
||||
continue;
|
||||
};
|
||||
|
||||
let refresh_token = returned_refresh_token
|
||||
.or_else(|| Some(entry.refresh_token.clone()))
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
if let Some(refresh_token) = refresh_token.as_ref() {
|
||||
auth_config.insert("refresh_token".to_string(), json!(refresh_token));
|
||||
}
|
||||
let AdminProviderOAuthResolvedBatchImport {
|
||||
access_token,
|
||||
mut auth_config,
|
||||
expires_at,
|
||||
} = resolved_import;
|
||||
apply_admin_provider_oauth_batch_import_hints(provider_type, entry, &mut auth_config);
|
||||
|
||||
let duplicate =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::super::token_import::{import_tokens_from_raw_token, normalize_single_import_tokens};
|
||||
use crate::handlers::admin::provider::oauth::errors::build_internal_control_error_response;
|
||||
use crate::handlers::admin::provider::oauth::state::current_unix_secs;
|
||||
use crate::handlers::admin::provider::oauth::state::{current_unix_secs, json_u64_value};
|
||||
use axum::{
|
||||
body::{to_bytes, Body, Bytes},
|
||||
http,
|
||||
@@ -18,7 +19,9 @@ pub(super) struct AdminProviderOAuthBatchImportRequest {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct AdminProviderOAuthBatchImportEntry {
|
||||
pub refresh_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub access_token: Option<String>,
|
||||
pub expires_at: Option<u64>,
|
||||
pub account_id: Option<String>,
|
||||
pub account_user_id: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
@@ -73,8 +76,11 @@ fn extract_admin_provider_oauth_batch_import_entry(
|
||||
if refresh_token.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let (refresh_token, access_token) = import_tokens_from_raw_token(refresh_token);
|
||||
Some(AdminProviderOAuthBatchImportEntry {
|
||||
refresh_token: refresh_token.to_string(),
|
||||
refresh_token,
|
||||
access_token,
|
||||
expires_at: None,
|
||||
account_id: None,
|
||||
account_user_id: None,
|
||||
plan_type: None,
|
||||
@@ -88,7 +94,19 @@ fn extract_admin_provider_oauth_batch_import_entry(
|
||||
object
|
||||
.get("refresh_token")
|
||||
.or_else(|| object.get("refreshToken")),
|
||||
)?;
|
||||
);
|
||||
let access_token = coerce_admin_provider_oauth_import_str(
|
||||
object
|
||||
.get("access_token")
|
||||
.or_else(|| object.get("accessToken")),
|
||||
);
|
||||
let (refresh_token, access_token) =
|
||||
normalize_single_import_tokens(refresh_token.as_deref(), access_token.as_deref());
|
||||
if refresh_token.is_none() && access_token.is_none() {
|
||||
return None;
|
||||
}
|
||||
let expires_at =
|
||||
json_u64_value(object.get("expires_at").or_else(|| object.get("expiresAt")));
|
||||
let account_id = coerce_admin_provider_oauth_import_str(
|
||||
object
|
||||
.get("account_id")
|
||||
@@ -121,6 +139,8 @@ fn extract_admin_provider_oauth_batch_import_entry(
|
||||
let email = coerce_admin_provider_oauth_import_str(object.get("email"));
|
||||
Some(AdminProviderOAuthBatchImportEntry {
|
||||
refresh_token,
|
||||
access_token,
|
||||
expires_at,
|
||||
account_id,
|
||||
account_user_id,
|
||||
plan_type,
|
||||
@@ -163,13 +183,18 @@ pub(super) fn parse_admin_provider_oauth_batch_import_entries(
|
||||
raw.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||
.map(|refresh_token| AdminProviderOAuthBatchImportEntry {
|
||||
refresh_token: refresh_token.to_string(),
|
||||
account_id: None,
|
||||
account_user_id: None,
|
||||
plan_type: None,
|
||||
user_id: None,
|
||||
email: None,
|
||||
.map(|token| {
|
||||
let (refresh_token, access_token) = import_tokens_from_raw_token(token);
|
||||
AdminProviderOAuthBatchImportEntry {
|
||||
refresh_token,
|
||||
access_token,
|
||||
expires_at: None,
|
||||
account_id: None,
|
||||
account_user_id: None,
|
||||
plan_type: None,
|
||||
user_id: None,
|
||||
email: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -291,3 +316,47 @@ pub(super) fn build_admin_provider_oauth_batch_task_state(
|
||||
"updated_at": updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_admin_provider_oauth_batch_import_entries;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde_json::json;
|
||||
|
||||
fn unsigned_jwt(payload: serde_json::Value) -> String {
|
||||
let header = json!({"alg": "none", "typ": "JWT"});
|
||||
let encode = |value: serde_json::Value| {
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&value).expect("jwt json should serialize"))
|
||||
};
|
||||
format!("{}.{}.signature", encode(header), encode(payload))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_access_token_only_entry() {
|
||||
let entries = parse_admin_provider_oauth_batch_import_entries(
|
||||
r#"[{"accessToken":"at_1","expiresAt":2100000000,"accountId":"acc-1","email":"u@example.com"}]"#,
|
||||
);
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].refresh_token, None);
|
||||
assert_eq!(entries[0].access_token.as_deref(), Some("at_1"));
|
||||
assert_eq!(entries[0].expires_at, Some(2_100_000_000));
|
||||
assert_eq!(entries[0].account_id.as_deref(), Some("acc-1"));
|
||||
assert_eq!(entries[0].email.as_deref(), Some("u@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_plain_jwt_line_as_access_token() {
|
||||
let token = unsigned_jwt(json!({
|
||||
"iss": "https://auth.openai.com",
|
||||
"aud": ["https://api.openai.com/v1"],
|
||||
"exp": 2_000_000_000u64,
|
||||
}));
|
||||
|
||||
let entries = parse_admin_provider_oauth_batch_import_entries(&token);
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].refresh_token, None);
|
||||
assert_eq!(entries[0].access_token.as_deref(), Some(token.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use super::super::super::errors::build_internal_control_error_response;
|
||||
use super::super::super::provisioning::provider_oauth_token_payload_expires_at_unix_secs;
|
||||
use super::super::super::quota::codex::refresh_codex_provider_quota_locally;
|
||||
use super::super::super::runtime::provider_oauth_runtime_endpoint_for_provider;
|
||||
use super::super::super::state::{
|
||||
admin_provider_oauth_template, enrich_admin_provider_oauth_auth_config,
|
||||
is_fixed_provider_type_for_provider_oauth, json_non_empty_string, json_u64_value,
|
||||
is_fixed_provider_type_for_provider_oauth, json_non_empty_string,
|
||||
};
|
||||
use super::shared::{
|
||||
parse_admin_provider_oauth_complete_callback, parse_admin_provider_oauth_complete_request_body,
|
||||
@@ -168,8 +169,8 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let expires_at = json_u64_value(token_payload.get("expires_in"))
|
||||
.map(|expires_in| now_unix_secs.saturating_add(expires_in));
|
||||
let expires_at =
|
||||
provider_oauth_token_payload_expires_at_unix_secs(&token_payload, now_unix_secs);
|
||||
|
||||
let mut auth_config = serde_json::Map::new();
|
||||
auth_config.insert("provider_type".to_string(), json!(provider_type.clone()));
|
||||
|
||||
@@ -12,10 +12,17 @@ use super::super::runtime::{
|
||||
use super::super::state::{
|
||||
admin_provider_oauth_template, build_admin_provider_oauth_backend_unavailable_response,
|
||||
exchange_admin_provider_oauth_refresh_token, is_fixed_provider_type_for_provider_oauth,
|
||||
json_u64_value,
|
||||
};
|
||||
use super::token_import::{
|
||||
build_codex_access_token_import_auth_config, normalize_single_import_tokens,
|
||||
};
|
||||
use crate::handlers::admin::provider::shared::paths::admin_provider_oauth_import_provider_id;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::admin::request::{
|
||||
AdminAppState, AdminProviderOAuthTemplate, AdminRequestContext,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use aether_contracts::ProxySnapshot;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
@@ -25,6 +32,125 @@ use axum::{
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct AdminProviderOAuthSingleImportTokens {
|
||||
access_token: String,
|
||||
auth_config: serde_json::Map<String, serde_json::Value>,
|
||||
expires_at: Option<u64>,
|
||||
}
|
||||
|
||||
fn import_payload_string(
|
||||
payload: &serde_json::Map<String, serde_json::Value>,
|
||||
snake_case: &str,
|
||||
camel_case: &str,
|
||||
) -> Option<String> {
|
||||
payload
|
||||
.get(snake_case)
|
||||
.or_else(|| payload.get(camel_case))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn import_payload_u64(
|
||||
payload: &serde_json::Map<String, serde_json::Value>,
|
||||
snake_case: &str,
|
||||
camel_case: &str,
|
||||
) -> Option<u64> {
|
||||
json_u64_value(payload.get(snake_case).or_else(|| payload.get(camel_case)))
|
||||
}
|
||||
|
||||
async fn resolve_admin_provider_oauth_single_import_tokens(
|
||||
state: &AdminAppState<'_>,
|
||||
template: AdminProviderOAuthTemplate,
|
||||
provider_type: &str,
|
||||
refresh_token: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
imported_expires_at: Option<u64>,
|
||||
request_proxy: Option<ProxySnapshot>,
|
||||
) -> Result<AdminProviderOAuthSingleImportTokens, Response<Body>> {
|
||||
if let Some(refresh_token) = refresh_token
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let token_payload = match state
|
||||
.exchange_admin_provider_oauth_refresh_token(
|
||||
template,
|
||||
refresh_token,
|
||||
request_proxy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(payload) => payload,
|
||||
Err(response) => {
|
||||
if provider_type.eq_ignore_ascii_case("codex") {
|
||||
if let Some(access_token) = access_token
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let (auth_config, expires_at) = build_codex_access_token_import_auth_config(
|
||||
access_token,
|
||||
Some(refresh_token),
|
||||
imported_expires_at,
|
||||
Some("Refresh Token 验证失败,已回退为 Access Token 导入"),
|
||||
);
|
||||
return Ok(AdminProviderOAuthSingleImportTokens {
|
||||
access_token: access_token.to_string(),
|
||||
auth_config,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Err(response);
|
||||
}
|
||||
};
|
||||
|
||||
let (mut auth_config, access_token, returned_refresh_token, expires_at) =
|
||||
build_provider_oauth_auth_config_from_token_payload(provider_type, &token_payload);
|
||||
let Some(access_token) = access_token else {
|
||||
return Err(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"token refresh 返回缺少 access_token",
|
||||
));
|
||||
};
|
||||
let refresh_token = returned_refresh_token
|
||||
.or_else(|| Some(refresh_token.to_string()))
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
if let Some(refresh_token) = refresh_token.as_ref() {
|
||||
auth_config.insert("refresh_token".to_string(), json!(refresh_token));
|
||||
}
|
||||
return Ok(AdminProviderOAuthSingleImportTokens {
|
||||
access_token,
|
||||
auth_config,
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
let Some(access_token) = access_token
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
return Err(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"Refresh Token 或 Access Token 不能为空",
|
||||
));
|
||||
};
|
||||
if !provider_type.eq_ignore_ascii_case("codex") {
|
||||
return Err(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"Access Token 导入仅支持 Codex Provider",
|
||||
));
|
||||
}
|
||||
|
||||
let (auth_config, expires_at) =
|
||||
build_codex_access_token_import_auth_config(access_token, None, imported_expires_at, None);
|
||||
Ok(AdminProviderOAuthSingleImportTokens {
|
||||
access_token: access_token.to_string(),
|
||||
auth_config,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
@@ -54,17 +180,19 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(refresh_token_input) = raw_payload
|
||||
.get("refresh_token")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
let refresh_token_input = import_payload_string(&raw_payload, "refresh_token", "refreshToken");
|
||||
let access_token_input = import_payload_string(&raw_payload, "access_token", "accessToken");
|
||||
let imported_expires_at = import_payload_u64(&raw_payload, "expires_at", "expiresAt");
|
||||
let (refresh_token_input, access_token_input) = normalize_single_import_tokens(
|
||||
refresh_token_input.as_deref(),
|
||||
access_token_input.as_deref(),
|
||||
);
|
||||
if refresh_token_input.is_none() && access_token_input.is_none() {
|
||||
return Ok(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"Refresh Token 不能为空",
|
||||
"Refresh Token 或 Access Token 不能为空",
|
||||
));
|
||||
};
|
||||
}
|
||||
let name = raw_payload
|
||||
.get("name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
@@ -122,32 +250,30 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
|
||||
.await;
|
||||
let key_proxy = provider_oauth_key_proxy_value(proxy_node_id.as_deref());
|
||||
|
||||
let token_payload = match state
|
||||
.exchange_admin_provider_oauth_refresh_token(
|
||||
template,
|
||||
refresh_token_input,
|
||||
request_proxy.clone(),
|
||||
)
|
||||
.await
|
||||
let resolved_import = match resolve_admin_provider_oauth_single_import_tokens(
|
||||
state,
|
||||
template,
|
||||
&provider_type,
|
||||
refresh_token_input.as_deref(),
|
||||
access_token_input.as_deref(),
|
||||
imported_expires_at,
|
||||
request_proxy.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(payload) => payload,
|
||||
Ok(value) => value,
|
||||
Err(response) => return Ok(response),
|
||||
};
|
||||
|
||||
let (mut auth_config, access_token, returned_refresh_token, expires_at) =
|
||||
build_provider_oauth_auth_config_from_token_payload(&provider_type, &token_payload);
|
||||
let Some(access_token) = access_token else {
|
||||
return Ok(build_internal_control_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
"token refresh 返回缺少 access_token",
|
||||
));
|
||||
};
|
||||
let refresh_token = returned_refresh_token
|
||||
.or_else(|| Some(refresh_token_input.to_string()))
|
||||
.filter(|value| !value.trim().is_empty());
|
||||
if let Some(refresh_token) = refresh_token.as_ref() {
|
||||
auth_config.insert("refresh_token".to_string(), json!(refresh_token));
|
||||
}
|
||||
let AdminProviderOAuthSingleImportTokens {
|
||||
access_token,
|
||||
auth_config,
|
||||
expires_at,
|
||||
} = resolved_import;
|
||||
let has_refresh_token = auth_config
|
||||
.get("refresh_token")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
|
||||
let api_formats = provider_oauth_active_api_formats(&endpoints);
|
||||
let duplicate = match state
|
||||
@@ -239,7 +365,11 @@ pub(super) async fn handle_admin_provider_oauth_import_refresh_token(
|
||||
"key_id": persisted_key.id,
|
||||
"provider_type": provider_type,
|
||||
"expires_at": expires_at,
|
||||
"has_refresh_token": refresh_token.is_some(),
|
||||
"has_refresh_token": has_refresh_token,
|
||||
"temporary": auth_config
|
||||
.get("access_token_import_temporary")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
"email": auth_config.get("email").cloned().unwrap_or(serde_json::Value::Null),
|
||||
"replaced": replaced,
|
||||
}))
|
||||
|
||||
@@ -27,6 +27,7 @@ mod kiro;
|
||||
mod refresh;
|
||||
mod start;
|
||||
mod tasks;
|
||||
mod token_import;
|
||||
|
||||
pub(crate) async fn maybe_build_local_admin_provider_oauth_response(
|
||||
state: &AdminAppState<'_>,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
use super::super::provisioning::build_provider_oauth_auth_config_from_token_payload;
|
||||
use super::super::state::json_u64_value;
|
||||
use base64::{
|
||||
engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD},
|
||||
Engine as _,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
fn decode_base64_url_part(value: &str) -> Option<Vec<u8>> {
|
||||
URL_SAFE_NO_PAD
|
||||
.decode(value.as_bytes())
|
||||
.or_else(|_| URL_SAFE.decode(value.as_bytes()))
|
||||
.or_else(|_| {
|
||||
let mut padded = value.to_string();
|
||||
let remainder = padded.len() % 4;
|
||||
if remainder != 0 {
|
||||
padded.extend(std::iter::repeat_n('=', 4 - remainder));
|
||||
}
|
||||
URL_SAFE.decode(padded.as_bytes())
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn decode_unverified_jwt_json_part(part: &str) -> Option<Map<String, Value>> {
|
||||
let bytes = decode_base64_url_part(part)?;
|
||||
serde_json::from_slice::<Value>(&bytes)
|
||||
.ok()?
|
||||
.as_object()
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub(super) fn looks_like_access_token(token: &str) -> bool {
|
||||
let parts = token.trim().split('.').collect::<Vec<_>>();
|
||||
if parts.len() != 3 || parts.iter().any(|part| part.is_empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(header) = decode_unverified_jwt_json_part(parts[0]) else {
|
||||
return false;
|
||||
};
|
||||
let Some(payload) = decode_unverified_jwt_json_part(parts[1]) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let token_type = header
|
||||
.get("typ")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if !token_type.is_empty() && token_type != "jwt" && token_type != "at+jwt" {
|
||||
return false;
|
||||
}
|
||||
|
||||
["exp", "aud", "iss", "scope", "scp"]
|
||||
.iter()
|
||||
.any(|field| payload.contains_key(*field))
|
||||
}
|
||||
|
||||
pub(super) fn normalize_single_import_tokens(
|
||||
refresh_token: Option<&str>,
|
||||
access_token: Option<&str>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
let mut refresh_token = refresh_token
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let mut access_token = access_token
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
if access_token.is_none()
|
||||
&& refresh_token
|
||||
.as_deref()
|
||||
.is_some_and(looks_like_access_token)
|
||||
{
|
||||
access_token = refresh_token.take();
|
||||
}
|
||||
|
||||
(refresh_token, access_token)
|
||||
}
|
||||
|
||||
pub(super) fn import_tokens_from_raw_token(token: &str) -> (Option<String>, Option<String>) {
|
||||
if looks_like_access_token(token) {
|
||||
(None, Some(token.trim().to_string()))
|
||||
} else {
|
||||
(Some(token.trim().to_string()), None)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn decode_access_token_expires_at(access_token: &str) -> Option<u64> {
|
||||
let payload = access_token.trim().split('.').nth(1)?;
|
||||
let claims = decode_unverified_jwt_json_part(payload)?;
|
||||
json_u64_value(claims.get("exp"))
|
||||
}
|
||||
|
||||
pub(super) fn build_codex_access_token_import_auth_config(
|
||||
access_token: &str,
|
||||
refresh_token: Option<&str>,
|
||||
imported_expires_at: Option<u64>,
|
||||
refresh_error: Option<&str>,
|
||||
) -> (Map<String, Value>, Option<u64>) {
|
||||
let token_payload = json!({
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
});
|
||||
let (mut auth_config, _, _, _) =
|
||||
build_provider_oauth_auth_config_from_token_payload("codex", &token_payload);
|
||||
|
||||
let refresh_token = refresh_token
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if let Some(refresh_token) = refresh_token {
|
||||
auth_config.insert("refresh_token".to_string(), json!(refresh_token));
|
||||
}
|
||||
|
||||
auth_config.insert(
|
||||
"access_token_import_temporary".to_string(),
|
||||
json!(refresh_token.is_none()),
|
||||
);
|
||||
|
||||
if let Some(expires_at) = decode_access_token_expires_at(access_token).or(imported_expires_at) {
|
||||
auth_config.insert("expires_at".to_string(), json!(expires_at));
|
||||
}
|
||||
if let Some(refresh_error) = refresh_error
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
auth_config.insert(
|
||||
"refresh_token_import_error".to_string(),
|
||||
json!(refresh_error),
|
||||
);
|
||||
}
|
||||
|
||||
let expires_at = auth_config.get("expires_at").and_then(Value::as_u64);
|
||||
(auth_config, expires_at)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_codex_access_token_import_auth_config, decode_access_token_expires_at,
|
||||
looks_like_access_token, normalize_single_import_tokens,
|
||||
};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde_json::json;
|
||||
|
||||
fn unsigned_jwt(payload: serde_json::Value) -> String {
|
||||
let header = json!({"alg": "none", "typ": "JWT"});
|
||||
let encode = |value: serde_json::Value| {
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&value).expect("jwt json should serialize"))
|
||||
};
|
||||
format!("{}.{}.signature", encode(header), encode(payload))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_plain_jwt_access_token() {
|
||||
let token = unsigned_jwt(json!({
|
||||
"iss": "https://auth.openai.com",
|
||||
"aud": ["https://api.openai.com/v1"],
|
||||
"exp": 2_000_000_000u64,
|
||||
}));
|
||||
|
||||
assert!(looks_like_access_token(&token));
|
||||
let (refresh_token, access_token) = normalize_single_import_tokens(Some(&token), None);
|
||||
assert!(refresh_token.is_none());
|
||||
assert_eq!(access_token.as_deref(), Some(token.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_codex_temporary_auth_config_from_access_token() {
|
||||
let token = unsigned_jwt(json!({
|
||||
"exp": 2_000_000_000u64,
|
||||
"https://api.openai.com/profile": {
|
||||
"email": "u@example.com"
|
||||
},
|
||||
}));
|
||||
|
||||
let (auth_config, expires_at) =
|
||||
build_codex_access_token_import_auth_config(&token, None, None, None);
|
||||
|
||||
assert_eq!(expires_at, Some(2_000_000_000));
|
||||
assert_eq!(decode_access_token_expires_at(&token), Some(2_000_000_000));
|
||||
assert_eq!(auth_config.get("email"), Some(&json!("u@example.com")));
|
||||
assert_eq!(
|
||||
auth_config.get("access_token_import_temporary"),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert!(auth_config.get("refresh_token").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_codex_auth_config_with_imported_expires_at_when_token_has_no_exp() {
|
||||
let token = unsigned_jwt(json!({
|
||||
"iss": "https://auth.openai.com",
|
||||
"aud": ["https://api.openai.com/v1"]
|
||||
}));
|
||||
|
||||
let (auth_config, expires_at) =
|
||||
build_codex_access_token_import_auth_config(&token, None, Some(2_100_000_000), None);
|
||||
|
||||
assert_eq!(expires_at, Some(2_100_000_000));
|
||||
assert_eq!(
|
||||
auth_config.get("expires_at"),
|
||||
Some(&json!(2_100_000_000u64))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,17 @@ use axum::{
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn oauth_invalid_reason_is_account_level_block(reason: Option<&str>) -> bool {
|
||||
let Some(reason) = reason.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return false;
|
||||
};
|
||||
if reason.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX) {
|
||||
return true;
|
||||
}
|
||||
aether_admin::provider::status::resolve_account_status_snapshot(None, None, Some(reason))
|
||||
.blocked
|
||||
}
|
||||
|
||||
pub(crate) fn build_internal_control_error_response(
|
||||
status: http::StatusCode,
|
||||
message: impl Into<String>,
|
||||
@@ -133,22 +144,17 @@ pub(crate) fn merge_provider_oauth_refresh_failure_reason(
|
||||
if current_reason.starts_with(OAUTH_EXPIRED_PREFIX) {
|
||||
return None;
|
||||
}
|
||||
if current_reason.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX) {
|
||||
if let Some((head, _)) = current_reason.split_once("[REFRESH_FAILED]") {
|
||||
return Some(
|
||||
format!("{}\n{}", head.trim_end(), refresh_reason)
|
||||
.trim()
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return Some(format!("{current_reason}\n{refresh_reason}"));
|
||||
if oauth_invalid_reason_is_account_level_block(Some(current_reason)) {
|
||||
return None;
|
||||
}
|
||||
Some(refresh_reason.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize_provider_oauth_refresh_error_message;
|
||||
use super::{
|
||||
merge_provider_oauth_refresh_failure_reason, normalize_provider_oauth_refresh_error_message,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn normalizes_openai_refresh_token_expired_response() {
|
||||
@@ -159,4 +165,22 @@ mod tests {
|
||||
"refresh_token 无效、已过期或已撤销,请重新登录授权"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_failure_does_not_replace_account_level_block() {
|
||||
assert_eq!(
|
||||
merge_provider_oauth_refresh_failure_reason(
|
||||
Some("[ACCOUNT_BLOCK] account has been deactivated"),
|
||||
"[REFRESH_FAILED] Token 续期失败 (401): refresh_token 无效",
|
||||
),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
merge_provider_oauth_refresh_failure_reason(
|
||||
Some("account_banned"),
|
||||
"[REFRESH_FAILED] Token 续期失败 (401): refresh_token 无效",
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::state::{
|
||||
enrich_admin_provider_oauth_auth_config, json_non_empty_string, json_u64_value,
|
||||
decode_jwt_claims, enrich_admin_provider_oauth_auth_config, json_non_empty_string,
|
||||
json_u64_value,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::provider_key_auth::provider_active_api_formats;
|
||||
@@ -27,6 +28,32 @@ pub(crate) fn provider_oauth_active_api_formats(
|
||||
provider_active_api_formats(endpoints)
|
||||
}
|
||||
|
||||
pub(crate) fn provider_oauth_token_payload_expires_at_unix_secs(
|
||||
token_payload: &serde_json::Value,
|
||||
now_unix_secs: u64,
|
||||
) -> Option<u64> {
|
||||
json_u64_value(
|
||||
token_payload
|
||||
.get("expires_in")
|
||||
.or_else(|| token_payload.get("expiresIn")),
|
||||
)
|
||||
.map(|expires_in| now_unix_secs.saturating_add(expires_in))
|
||||
.or_else(|| {
|
||||
json_u64_value(
|
||||
token_payload
|
||||
.get("expires_at")
|
||||
.or_else(|| token_payload.get("expiresAt"))
|
||||
.or_else(|| token_payload.get("expiry"))
|
||||
.or_else(|| token_payload.get("exp")),
|
||||
)
|
||||
})
|
||||
.or_else(|| {
|
||||
let access_token = json_non_empty_string(token_payload.get("access_token"))?;
|
||||
let claims = decode_jwt_claims(&access_token)?;
|
||||
json_u64_value(claims.get("exp"))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_provider_oauth_auth_config_from_token_payload(
|
||||
provider_type: &str,
|
||||
token_payload: &serde_json::Value,
|
||||
@@ -43,8 +70,8 @@ pub(crate) fn build_provider_oauth_auth_config_from_token_payload(
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let expires_at = json_u64_value(token_payload.get("expires_in"))
|
||||
.map(|expires_in| now_unix_secs.saturating_add(expires_in));
|
||||
let expires_at =
|
||||
provider_oauth_token_payload_expires_at_unix_secs(token_payload, now_unix_secs);
|
||||
|
||||
let mut auth_config = serde_json::Map::new();
|
||||
auth_config.insert("provider_type".to_string(), json!(provider_type));
|
||||
@@ -193,3 +220,57 @@ fn provider_oauth_catalog_key_api_formats(
|
||||
Some(json!(api_formats))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::provider_oauth_token_payload_expires_at_unix_secs;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_unsigned_jwt(payload: serde_json::Value) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(payload.to_string());
|
||||
format!("{header}.{payload}.sig")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_payload_expiry_uses_relative_expires_in_aliases() {
|
||||
let payload = json!({
|
||||
"access_token": "opaque-token",
|
||||
"expiresIn": 120,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
provider_oauth_token_payload_expires_at_unix_secs(&payload, 1_000),
|
||||
Some(1_120)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_payload_expiry_uses_absolute_expires_at_aliases() {
|
||||
let payload = json!({
|
||||
"access_token": "opaque-token",
|
||||
"expiresAt": 4_102_444_800u64,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
provider_oauth_token_payload_expires_at_unix_secs(&payload, 1_000),
|
||||
Some(4_102_444_800)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_payload_expiry_falls_back_to_access_token_exp_claim() {
|
||||
let access_token = sample_unsigned_jwt(json!({
|
||||
"exp": 2_000_000_000u64,
|
||||
}));
|
||||
let payload = json!({
|
||||
"access_token": access_token,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
provider_oauth_token_payload_expires_at_unix_secs(&payload, 1_000),
|
||||
Some(2_000_000_000)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,22 @@ use aether_data_contracts::repository::provider_catalog::{
|
||||
use serde_json::json;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn merge_codex_quota_metadata(
|
||||
header_metadata: Option<&serde_json::Value>,
|
||||
body_metadata: &serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
let mut merged = header_metadata
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if let Some(body_object) = body_metadata.as_object() {
|
||||
for (key, value) in body_object {
|
||||
merged.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(merged)
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
@@ -103,7 +119,9 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut metadata_update = parse_codex_usage_headers(&result.headers, now_unix_secs)
|
||||
let header_metadata = parse_codex_usage_headers(&result.headers, now_unix_secs);
|
||||
let mut metadata_update = header_metadata
|
||||
.as_ref()
|
||||
.map(|metadata| json!({ "codex": metadata }));
|
||||
let (mut oauth_invalid_at_unix_secs, mut oauth_invalid_reason) = (None, None);
|
||||
let mut status = "error".to_string();
|
||||
@@ -117,7 +135,13 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
.and_then(|body| body.json_body.as_ref())
|
||||
{
|
||||
if let Some(parsed) = parse_codex_wham_usage_response(body_json, now_unix_secs) {
|
||||
metadata_update = Some(json!({ "codex": parsed }));
|
||||
metadata_update = Some(json!({
|
||||
"codex": merge_codex_quota_metadata(header_metadata.as_ref(), &parsed)
|
||||
}));
|
||||
(oauth_invalid_at_unix_secs, oauth_invalid_reason) =
|
||||
quota_refresh_success_invalid_state(&key);
|
||||
status = "success".to_string();
|
||||
} else if metadata_update.is_some() {
|
||||
(oauth_invalid_at_unix_secs, oauth_invalid_reason) =
|
||||
quota_refresh_success_invalid_state(&key);
|
||||
status = "success".to_string();
|
||||
|
||||
@@ -74,4 +74,29 @@ mod tests {
|
||||
assert_eq!(auth_config.get("plan_type"), Some(&json!("plus")));
|
||||
assert_eq!(auth_config.get("user_id"), Some(&json!("user-2")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_enrichment_extracts_profile_email_from_claims() {
|
||||
let access_token = sample_unsigned_jwt(json!({
|
||||
"https://api.openai.com/profile": {
|
||||
"email": "profile@example.com",
|
||||
"email_verified": true,
|
||||
},
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-profile",
|
||||
},
|
||||
}));
|
||||
let token_payload = json!({
|
||||
"access_token": access_token,
|
||||
});
|
||||
let mut auth_config = serde_json::Map::new();
|
||||
|
||||
enrich_admin_provider_oauth_auth_config("codex", &mut auth_config, &token_payload);
|
||||
|
||||
assert_eq!(
|
||||
auth_config.get("email"),
|
||||
Some(&json!("profile@example.com"))
|
||||
);
|
||||
assert_eq!(auth_config.get("account_id"), Some(&json!("acc-profile")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,11 @@ pub(crate) use self::batch_shared::{
|
||||
AdminPoolBatchImportRequest,
|
||||
};
|
||||
pub(crate) use self::support::{
|
||||
admin_pool_provider_id_from_path, parse_admin_pool_page, parse_admin_pool_page_size,
|
||||
parse_admin_pool_quick_selectors, parse_admin_pool_search, parse_admin_pool_status_filter,
|
||||
AdminPoolResolveSelectionRequest, ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE,
|
||||
admin_pool_provider_id_from_path, parse_admin_pool_key_sort, parse_admin_pool_page,
|
||||
parse_admin_pool_page_size, parse_admin_pool_quick_selectors, parse_admin_pool_search,
|
||||
parse_admin_pool_status_filter, AdminPoolKeySort, AdminPoolKeySortDirection,
|
||||
AdminPoolKeySortField, AdminPoolResolveSelectionRequest,
|
||||
ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE,
|
||||
ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
|
||||
ADMIN_POOL_PROVIDER_CATALOG_WRITER_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
|
||||
@@ -305,17 +305,17 @@ fn admin_pool_quota_window_reset_seconds(
|
||||
window: &serde_json::Map<String, serde_json::Value>,
|
||||
now_unix_secs: u64,
|
||||
) -> Option<f64> {
|
||||
if let Some(reset_at) = admin_pool_json_to_u64(window.get("reset_at")) {
|
||||
return Some(reset_at.saturating_sub(now_unix_secs) as f64);
|
||||
if let Some(remaining) = admin_pool_json_to_f64(window.get("reset_seconds")) {
|
||||
let observed_at_unix_secs = admin_pool_json_to_u64(quota_snapshot.get("observed_at"))
|
||||
.or_else(|| admin_pool_json_to_u64(quota_snapshot.get("updated_at")));
|
||||
let elapsed = observed_at_unix_secs
|
||||
.map(|observed_at| now_unix_secs.saturating_sub(observed_at) as f64)
|
||||
.unwrap_or(0.0);
|
||||
return Some((remaining - elapsed).max(0.0));
|
||||
}
|
||||
|
||||
let remaining = admin_pool_json_to_f64(window.get("reset_seconds"))?;
|
||||
let observed_at_unix_secs = admin_pool_json_to_u64(quota_snapshot.get("observed_at"))
|
||||
.or_else(|| admin_pool_json_to_u64(quota_snapshot.get("updated_at")));
|
||||
let elapsed = observed_at_unix_secs
|
||||
.map(|observed_at| now_unix_secs.saturating_sub(observed_at) as f64)
|
||||
.unwrap_or(0.0);
|
||||
Some((remaining - elapsed).max(0.0))
|
||||
admin_pool_json_to_u64(window.get("reset_at"))
|
||||
.map(|reset_at| reset_at.saturating_sub(now_unix_secs) as f64)
|
||||
}
|
||||
|
||||
fn admin_pool_codex_quota_part_from_window(
|
||||
@@ -323,6 +323,7 @@ fn admin_pool_codex_quota_part_from_window(
|
||||
window_code: &str,
|
||||
label: &str,
|
||||
now_unix_secs: u64,
|
||||
show_reset_without_consumption: bool,
|
||||
) -> Option<String> {
|
||||
let window = admin_pool_quota_window(quota_snapshot, window_code)?;
|
||||
let used_percent = admin_pool_quota_window_used_percent(window)?;
|
||||
@@ -338,7 +339,9 @@ fn admin_pool_codex_quota_part_from_window(
|
||||
"{label}剩余 {}",
|
||||
admin_pool_format_percent(100.0 - effective_used_percent)
|
||||
);
|
||||
if admin_pool_has_quota_consumption(Some(effective_used_percent)) {
|
||||
if show_reset_without_consumption
|
||||
|| admin_pool_has_quota_consumption(Some(effective_used_percent))
|
||||
{
|
||||
if let Some(reset_text) = reset_seconds.and_then(admin_pool_format_reset_after) {
|
||||
part.push_str(&format!(" ({reset_text})"));
|
||||
}
|
||||
@@ -351,15 +354,27 @@ fn admin_pool_build_codex_account_quota_from_snapshot(
|
||||
) -> Option<String> {
|
||||
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
|
||||
let mut parts = Vec::new();
|
||||
let exhausted = quota_snapshot
|
||||
.get("exhausted")
|
||||
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(part) =
|
||||
admin_pool_codex_quota_part_from_window(quota_snapshot, "weekly", "周", now_unix_secs)
|
||||
{
|
||||
if let Some(part) = admin_pool_codex_quota_part_from_window(
|
||||
quota_snapshot,
|
||||
"weekly",
|
||||
"周",
|
||||
now_unix_secs,
|
||||
exhausted,
|
||||
) {
|
||||
parts.push(part);
|
||||
}
|
||||
if let Some(part) =
|
||||
admin_pool_codex_quota_part_from_window(quota_snapshot, "5h", "5H", now_unix_secs)
|
||||
{
|
||||
if let Some(part) = admin_pool_codex_quota_part_from_window(
|
||||
quota_snapshot,
|
||||
"5h",
|
||||
"5H",
|
||||
now_unix_secs,
|
||||
exhausted,
|
||||
) {
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
@@ -798,6 +813,12 @@ pub(super) fn build_admin_pool_key_payload(
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let oauth_temporary = auth_semantics.can_show_oauth_metadata()
|
||||
&& auth_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.get("access_token_import_temporary"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let account_status_code = admin_pool_trimmed_string_from_map(account_snapshot, "code");
|
||||
let account_status_label =
|
||||
admin_pool_trimmed_string(account_snapshot.and_then(|item| item.get("label")));
|
||||
@@ -879,6 +900,7 @@ pub(super) fn build_admin_pool_key_payload(
|
||||
"oauth_organizations".to_string(),
|
||||
serde_json::Value::Array(oauth_organizations),
|
||||
);
|
||||
payload.insert("oauth_temporary".to_string(), json!(oauth_temporary));
|
||||
payload.insert(
|
||||
"account_status_code".to_string(),
|
||||
json!(account_status_code),
|
||||
@@ -1011,6 +1033,10 @@ pub(super) fn build_admin_pool_key_payload(
|
||||
"created_at".to_string(),
|
||||
json!(key.created_at_unix_ms.and_then(unix_secs_to_rfc3339)),
|
||||
);
|
||||
payload.insert(
|
||||
"imported_at".to_string(),
|
||||
json!(key.created_at_unix_ms.and_then(unix_secs_to_rfc3339)),
|
||||
);
|
||||
payload.insert(
|
||||
"last_used_at".to_string(),
|
||||
json!(key.last_used_at_unix_secs.and_then(unix_secs_to_rfc3339)),
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
use super::{
|
||||
admin_pool_provider_id_from_path, admin_provider_pool_config, build_admin_pool_error_response,
|
||||
parse_admin_pool_page, parse_admin_pool_page_size, parse_admin_pool_quick_selectors,
|
||||
parse_admin_pool_search, parse_admin_pool_status_filter, pool_payloads, pool_selection,
|
||||
read_admin_provider_pool_cooldown_key_ids, read_admin_provider_pool_runtime_state,
|
||||
AdminProviderPoolRuntimeState, ProviderCatalogKeyListOrder, ProviderCatalogKeyListQuery,
|
||||
ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
|
||||
parse_admin_pool_key_sort, parse_admin_pool_page, parse_admin_pool_page_size,
|
||||
parse_admin_pool_quick_selectors, parse_admin_pool_search, parse_admin_pool_status_filter,
|
||||
pool_payloads, pool_selection, read_admin_provider_pool_cooldown_key_ids,
|
||||
read_admin_provider_pool_runtime_state, AdminPoolKeySort, AdminPoolKeySortDirection,
|
||||
AdminPoolKeySortField, AdminProviderPoolRuntimeState, ProviderCatalogKeyListOrder,
|
||||
ProviderCatalogKeyListQuery, ADMIN_POOL_PROVIDER_CATALOG_READER_UNAVAILABLE_DETAIL,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::GatewayError;
|
||||
use aether_admin::provider::pool as admin_provider_pool_pure;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http,
|
||||
@@ -16,6 +18,51 @@ use axum::{
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
fn admin_pool_compare_optional_unix_secs(
|
||||
left: Option<u64>,
|
||||
right: Option<u64>,
|
||||
direction: AdminPoolKeySortDirection,
|
||||
) -> Ordering {
|
||||
match (left, right) {
|
||||
(Some(left), Some(right)) => match direction {
|
||||
AdminPoolKeySortDirection::Asc => left.cmp(&right),
|
||||
AdminPoolKeySortDirection::Desc => right.cmp(&left),
|
||||
},
|
||||
(Some(_), None) => Ordering::Less,
|
||||
(None, Some(_)) => Ordering::Greater,
|
||||
(None, None) => Ordering::Equal,
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_sort_keys_for_request(keys: &mut [StoredProviderCatalogKey], sort: AdminPoolKeySort) {
|
||||
match sort.field {
|
||||
AdminPoolKeySortField::Default => pool_selection::admin_pool_sort_keys(keys),
|
||||
AdminPoolKeySortField::ImportedAt => {
|
||||
keys.sort_by(|left, right| {
|
||||
admin_pool_compare_optional_unix_secs(
|
||||
left.created_at_unix_ms,
|
||||
right.created_at_unix_ms,
|
||||
sort.direction,
|
||||
)
|
||||
.then(left.name.cmp(&right.name))
|
||||
.then(left.id.cmp(&right.id))
|
||||
});
|
||||
}
|
||||
AdminPoolKeySortField::LastUsedAt => {
|
||||
keys.sort_by(|left, right| {
|
||||
admin_pool_compare_optional_unix_secs(
|
||||
left.last_used_at_unix_secs,
|
||||
right.last_used_at_unix_secs,
|
||||
sort.direction,
|
||||
)
|
||||
.then(left.name.cmp(&right.name))
|
||||
.then(left.id.cmp(&right.id))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn build_admin_pool_list_keys_response(
|
||||
state: &AdminAppState<'_>,
|
||||
@@ -66,6 +113,15 @@ pub(super) async fn build_admin_pool_list_keys_response(
|
||||
));
|
||||
}
|
||||
};
|
||||
let sort = match parse_admin_pool_key_sort(query) {
|
||||
Ok(value) => value,
|
||||
Err(detail) => {
|
||||
return Ok(build_admin_pool_error_response(
|
||||
http::StatusCode::BAD_REQUEST,
|
||||
detail,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let Some(provider) = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
|
||||
@@ -117,7 +173,7 @@ pub(super) async fn build_admin_pool_list_keys_response(
|
||||
})
|
||||
});
|
||||
}
|
||||
pool_selection::admin_pool_sort_keys(&mut keys);
|
||||
admin_pool_sort_keys_for_request(&mut keys, sort);
|
||||
let total = keys.len();
|
||||
let keys = keys
|
||||
.into_iter()
|
||||
@@ -125,7 +181,7 @@ pub(super) async fn build_admin_pool_list_keys_response(
|
||||
.take(page_size)
|
||||
.collect::<Vec<_>>();
|
||||
(keys, total)
|
||||
} else if !quick_selectors.is_empty() {
|
||||
} else if !quick_selectors.is_empty() || sort.field != AdminPoolKeySortField::Default {
|
||||
let mut keys = state
|
||||
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider.id))
|
||||
.await?
|
||||
@@ -154,7 +210,7 @@ pub(super) async fn build_admin_pool_list_keys_response(
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
pool_selection::admin_pool_sort_keys(&mut keys);
|
||||
admin_pool_sort_keys_for_request(&mut keys, sort);
|
||||
let total = keys.len();
|
||||
let keys = keys
|
||||
.into_iter()
|
||||
|
||||
@@ -15,6 +15,34 @@ pub(crate) const ADMIN_POOL_PROVIDER_CATALOG_WRITER_UNAVAILABLE_DETAIL: &str =
|
||||
"Admin pool cleanup requires provider catalog writer";
|
||||
pub(crate) const ADMIN_POOL_BANNED_KEY_CLEANUP_EMPTY_MESSAGE: &str = "未发现可清理的异常账号";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AdminPoolKeySortField {
|
||||
Default,
|
||||
ImportedAt,
|
||||
LastUsedAt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AdminPoolKeySortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct AdminPoolKeySort {
|
||||
pub field: AdminPoolKeySortField,
|
||||
pub direction: AdminPoolKeySortDirection,
|
||||
}
|
||||
|
||||
impl Default for AdminPoolKeySort {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
field: AdminPoolKeySortField::Default,
|
||||
direction: AdminPoolKeySortDirection::Desc,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_admin_pool_error_response(
|
||||
status: http::StatusCode,
|
||||
detail: impl Into<String>,
|
||||
@@ -83,6 +111,31 @@ pub(crate) fn parse_admin_pool_status_filter(query: Option<&str>) -> Result<Stri
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_admin_pool_key_sort(query: Option<&str>) -> Result<AdminPoolKeySort, String> {
|
||||
let field = match query_param_value(query, "sort_by")
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.filter(|value| !value.is_empty())
|
||||
.as_deref()
|
||||
{
|
||||
None | Some("default") | Some("name") => AdminPoolKeySortField::Default,
|
||||
Some("imported_at") | Some("created_at") => AdminPoolKeySortField::ImportedAt,
|
||||
Some("last_used_at") | Some("last_used") => AdminPoolKeySortField::LastUsedAt,
|
||||
Some(_) => {
|
||||
return Err("sort_by must be one of: name, imported_at, last_used_at".to_string());
|
||||
}
|
||||
};
|
||||
let direction = match query_param_value(query, "sort_order")
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.filter(|value| !value.is_empty())
|
||||
.as_deref()
|
||||
{
|
||||
None | Some("desc") => AdminPoolKeySortDirection::Desc,
|
||||
Some("asc") => AdminPoolKeySortDirection::Asc,
|
||||
Some(_) => return Err("sort_order must be one of: asc, desc".to_string()),
|
||||
};
|
||||
Ok(AdminPoolKeySort { field, direction })
|
||||
}
|
||||
|
||||
pub(crate) fn admin_pool_provider_id_from_path(request_path: &str) -> Option<String> {
|
||||
let raw = request_path.strip_prefix("/api/admin/pool/")?;
|
||||
let mut segments = raw.split('/');
|
||||
|
||||
@@ -79,28 +79,28 @@ fn provider_oauth_export_payload(
|
||||
provider_type: &str,
|
||||
auth_config: &serde_json::Map<String, serde_json::Value>,
|
||||
upstream_metadata: Option<&serde_json::Value>,
|
||||
fallback_access_token: Option<&str>,
|
||||
) -> serde_json::Map<String, serde_json::Value> {
|
||||
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
|
||||
let skip_keys: &[&str] = match normalized_provider_type.as_str() {
|
||||
"kiro" => &["access_token", "expires_at", "updated_at"],
|
||||
_ => &[
|
||||
"access_token",
|
||||
"expires_at",
|
||||
"updated_at",
|
||||
"token_type",
|
||||
"scope",
|
||||
],
|
||||
};
|
||||
let skip_keys = ["updated_at", "updatedAt"];
|
||||
let mut payload = serde_json::Map::new();
|
||||
for (key, value) in auth_config {
|
||||
if skip_keys.contains(&key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if value.is_null() || value.as_str().is_some_and(str::is_empty) {
|
||||
if value.is_null() || value.as_str().is_some_and(|inner| inner.trim().is_empty()) {
|
||||
continue;
|
||||
}
|
||||
payload.insert(key.clone(), value.clone());
|
||||
}
|
||||
if !json_map_has_non_empty_string(&payload, &["access_token", "accessToken"]) {
|
||||
if let Some(access_token) = fallback_access_token
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty() && *value != "__placeholder__")
|
||||
{
|
||||
payload.insert("access_token".to_string(), json!(access_token));
|
||||
}
|
||||
}
|
||||
if normalized_provider_type == "kiro" && !payload.contains_key("email") {
|
||||
if let Some(email) = upstream_metadata
|
||||
.and_then(serde_json::Value::as_object)
|
||||
@@ -116,6 +116,17 @@ fn provider_oauth_export_payload(
|
||||
payload
|
||||
}
|
||||
|
||||
fn json_map_has_non_empty_string(
|
||||
map: &serde_json::Map<String, serde_json::Value>,
|
||||
keys: &[&str],
|
||||
) -> bool {
|
||||
keys.iter().any(|key| {
|
||||
map.get(*key)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_export_key_payload(
|
||||
state: &AdminAppState<'_>,
|
||||
key: &StoredProviderCatalogKey,
|
||||
@@ -133,13 +144,6 @@ pub(crate) async fn build_admin_export_key_payload(
|
||||
.ok()
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.ok_or_else(|| "无法解密认证配置".to_string())?;
|
||||
if !auth_config
|
||||
.get("refresh_token")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
return Err("缺少 refresh_token,无法导出".to_string());
|
||||
}
|
||||
|
||||
let provider_type_from_config = auth_config
|
||||
.get("provider_type")
|
||||
@@ -163,8 +167,18 @@ pub(crate) async fn build_admin_export_key_payload(
|
||||
return Err("仅 OAuth 管理账号支持导出".to_string());
|
||||
}
|
||||
|
||||
let mut payload =
|
||||
provider_oauth_export_payload(&provider_type, &auth_config, key.upstream_metadata.as_ref());
|
||||
let fallback_access_token = key
|
||||
.encrypted_api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|ciphertext| state.decrypt_catalog_secret_with_fallbacks(ciphertext));
|
||||
let mut payload = provider_oauth_export_payload(
|
||||
&provider_type,
|
||||
&auth_config,
|
||||
key.upstream_metadata.as_ref(),
|
||||
fallback_access_token.as_deref(),
|
||||
);
|
||||
payload.insert("name".to_string(), json!(key.name));
|
||||
payload.insert(
|
||||
"exported_at".to_string(),
|
||||
|
||||
@@ -477,6 +477,15 @@ fn quota_windows_min_reset_seconds(windows: &[Value]) -> Option<u64> {
|
||||
.min()
|
||||
}
|
||||
|
||||
fn quota_windows_min_reset_at(windows: &[Value]) -> Option<u64> {
|
||||
windows
|
||||
.iter()
|
||||
.filter_map(Value::as_object)
|
||||
.filter_map(|window| window.get("reset_at"))
|
||||
.filter_map(|value| provider_quota_timestamp_unix_secs(Some(value)))
|
||||
.min()
|
||||
}
|
||||
|
||||
fn quota_windows_all_exhausted(windows: &[Value]) -> bool {
|
||||
let mut total = 0usize;
|
||||
let mut exhausted = 0usize;
|
||||
@@ -515,7 +524,7 @@ fn codex_quota_window_snapshot(
|
||||
let used_percent = metadata
|
||||
.get(&used_percent_key)
|
||||
.and_then(admin_provider_quota_pure::coerce_json_f64);
|
||||
let reset_at = metadata
|
||||
let explicit_reset_at = metadata
|
||||
.get(&reset_at_key)
|
||||
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
||||
let reset_seconds = metadata
|
||||
@@ -528,9 +537,14 @@ fn codex_quota_window_snapshot(
|
||||
})
|
||||
.or_else(|| {
|
||||
observed_at_unix_secs
|
||||
.zip(reset_at)
|
||||
.zip(explicit_reset_at)
|
||||
.map(|(observed_at, reset_at)| reset_at.saturating_sub(observed_at))
|
||||
});
|
||||
let reset_at = explicit_reset_at.or_else(|| {
|
||||
observed_at_unix_secs
|
||||
.zip(reset_seconds)
|
||||
.map(|(observed_at, reset_seconds)| observed_at.saturating_add(reset_seconds))
|
||||
});
|
||||
let window_minutes = metadata
|
||||
.get(&window_minutes_key)
|
||||
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
||||
@@ -611,6 +625,7 @@ fn build_codex_quota_status_snapshot(
|
||||
.filter_map(|window| window.get("reset_seconds"))
|
||||
.filter_map(admin_provider_quota_pure::coerce_json_u64)
|
||||
.min();
|
||||
let reset_at = quota_windows_min_reset_at(&windows);
|
||||
let exhausted_by_credits =
|
||||
credits_unlimited != Some(true) && credits_has_credits == Some(false);
|
||||
let exhausted_by_window = usage_ratio.is_some_and(|value| value >= 1.0 - 1e-6);
|
||||
@@ -647,6 +662,7 @@ fn build_codex_quota_status_snapshot(
|
||||
"exhausted": exhausted,
|
||||
"usage_ratio": usage_ratio,
|
||||
"updated_at": observed_at_unix_secs,
|
||||
"reset_at": reset_at,
|
||||
"reset_seconds": reset_seconds,
|
||||
"plan_type": plan_type,
|
||||
"credits": if credits.is_empty() {
|
||||
@@ -767,6 +783,7 @@ fn build_kiro_quota_status_snapshot(
|
||||
"exhausted": exhausted,
|
||||
"usage_ratio": usage_ratio,
|
||||
"updated_at": observed_at_unix_secs,
|
||||
"reset_at": next_reset_at,
|
||||
"reset_seconds": reset_seconds,
|
||||
"plan_type": plan_type,
|
||||
"windows": windows,
|
||||
@@ -810,6 +827,7 @@ fn build_antigravity_quota_status_snapshot(
|
||||
|
||||
let usage_ratio = quota_windows_usage_ratio(&windows);
|
||||
let reset_seconds = quota_windows_min_reset_seconds(&windows);
|
||||
let reset_at = quota_windows_min_reset_at(&windows);
|
||||
let exhausted = !is_forbidden && quota_windows_all_exhausted(&windows);
|
||||
let reason = if is_forbidden {
|
||||
forbidden_reason
|
||||
@@ -845,6 +863,7 @@ fn build_antigravity_quota_status_snapshot(
|
||||
"exhausted": exhausted,
|
||||
"usage_ratio": usage_ratio,
|
||||
"updated_at": observed_at_unix_secs,
|
||||
"reset_at": reset_at,
|
||||
"reset_seconds": reset_seconds,
|
||||
"plan_type": serde_json::Value::Null,
|
||||
"windows": windows,
|
||||
@@ -906,6 +925,11 @@ fn build_gemini_cli_quota_status_snapshot(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let reset_at = if cooling {
|
||||
quota_windows_min_reset_at(&windows)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Some(json!({
|
||||
"version": 2,
|
||||
@@ -929,6 +953,7 @@ fn build_gemini_cli_quota_status_snapshot(
|
||||
"exhausted": exhausted,
|
||||
"usage_ratio": usage_ratio,
|
||||
"updated_at": observed_at_unix_secs,
|
||||
"reset_at": reset_at,
|
||||
"reset_seconds": reset_seconds,
|
||||
"plan_type": serde_json::Value::Null,
|
||||
"windows": windows,
|
||||
@@ -1235,6 +1260,12 @@ pub(crate) fn build_admin_provider_key_response(
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let oauth_temporary = auth_semantics.can_show_oauth_metadata()
|
||||
&& auth_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.get("access_token_import_temporary"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let oauth_plan_type = derive_catalog_oauth_plan_type(key, provider_type, auth_config.as_ref());
|
||||
let (
|
||||
health_score,
|
||||
@@ -1386,6 +1417,7 @@ pub(crate) fn build_admin_provider_key_response(
|
||||
"oauth_organizations".to_string(),
|
||||
serde_json::Value::Array(oauth_organizations),
|
||||
);
|
||||
payload.insert("oauth_temporary".to_string(), json!(oauth_temporary));
|
||||
payload.insert(
|
||||
"oauth_invalid_at".to_string(),
|
||||
json!(auth_semantics
|
||||
@@ -1609,6 +1641,7 @@ mod tests {
|
||||
assert_eq!(quota.get("provider_type"), Some(&json!("codex")));
|
||||
assert_eq!(quota.get("plan_type"), Some(&json!("plus")));
|
||||
assert_eq!(quota.get("updated_at"), Some(&json!(1_775_553_285u64)));
|
||||
assert_eq!(quota.get("reset_at"), Some(&json!(1_900_000_000u64)));
|
||||
assert_eq!(
|
||||
quota
|
||||
.get("credits")
|
||||
@@ -1622,6 +1655,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_key_status_snapshot_payload_derives_codex_reset_at_from_countdown() {
|
||||
let mut key = sample_catalog_key();
|
||||
key.upstream_metadata = Some(json!({
|
||||
"codex": {
|
||||
"updated_at": 1_775_553_285u64,
|
||||
"primary_used_percent": 55.0,
|
||||
"primary_reset_after_seconds": 3_600u64
|
||||
}
|
||||
}));
|
||||
|
||||
let payload = provider_key_status_snapshot_payload(&key, "codex");
|
||||
let quota = payload
|
||||
.get("quota")
|
||||
.and_then(Value::as_object)
|
||||
.expect("quota snapshot should be object");
|
||||
let window = quota
|
||||
.get("windows")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|windows| windows.first())
|
||||
.and_then(Value::as_object)
|
||||
.expect("quota window should exist");
|
||||
|
||||
assert_eq!(quota.get("reset_at"), Some(&json!(1_775_556_885u64)));
|
||||
assert_eq!(quota.get("reset_seconds"), Some(&json!(3_600u64)));
|
||||
assert_eq!(window.get("reset_at"), Some(&json!(1_775_556_885u64)));
|
||||
assert_eq!(window.get("reset_seconds"), Some(&json!(3_600u64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_key_status_snapshot_payload_preserves_existing_materialized_quota_snapshot() {
|
||||
let mut key = sample_catalog_key();
|
||||
|
||||
@@ -145,9 +145,14 @@ fn secret_fingerprint(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn oauth_invalid_reason_is_account_block(reason: Option<&str>) -> bool {
|
||||
reason
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| value.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX))
|
||||
let Some(reason) = reason.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return false;
|
||||
};
|
||||
if reason.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX) {
|
||||
return true;
|
||||
}
|
||||
aether_admin::provider::status::resolve_account_status_snapshot(None, None, Some(reason))
|
||||
.blocked
|
||||
}
|
||||
|
||||
fn normalize_local_oauth_refresh_error_message(
|
||||
@@ -267,15 +272,8 @@ fn merge_local_oauth_refresh_failure_reason(
|
||||
if current_reason.starts_with(OAUTH_EXPIRED_PREFIX) {
|
||||
return None;
|
||||
}
|
||||
if current_reason.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX) {
|
||||
if let Some((head, _)) = current_reason.split_once("[REFRESH_FAILED]") {
|
||||
return Some(
|
||||
format!("{}\n{}", head.trim_end(), refresh_reason)
|
||||
.trim()
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
return Some(format!("{current_reason}\n{refresh_reason}"));
|
||||
if oauth_invalid_reason_is_account_block(Some(current_reason)) {
|
||||
return None;
|
||||
}
|
||||
Some(refresh_reason.to_string())
|
||||
}
|
||||
|
||||
@@ -655,7 +655,7 @@ async fn gateway_surfaces_local_execution_runtime_miss_reason_when_all_openai_ch
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"找到 1 个支持模型 gpt-5 的候选提供商,但本次同步请求全部不可用:提供商类型不支持本地执行 2 次"
|
||||
"没有可用提供商支持模型 gpt-5 的同步请求"
|
||||
);
|
||||
|
||||
let stored_candidates = request_candidate_repository
|
||||
|
||||
@@ -1004,7 +1004,7 @@ async fn gateway_marks_claude_cli_cross_format_runtime_miss_when_format_conversi
|
||||
assert_eq!(response_json["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
response_json["error"]["message"],
|
||||
"找到 1 个支持模型 gpt-5.4 的候选提供商,但本次同步请求全部不可用:格式转换未启用 2 次"
|
||||
"没有可用提供商支持模型 gpt-5.4 的同步请求"
|
||||
);
|
||||
|
||||
let stored_candidates = request_candidate_repository
|
||||
|
||||
@@ -527,6 +527,80 @@ async fn gateway_exports_admin_provider_key_locally_with_trusted_admin_principal
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_exports_admin_provider_key_access_token_when_refresh_token_is_missing() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/endpoints/keys/key-codex-a/export",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let mut key = sample_key(
|
||||
"key-codex-a",
|
||||
"provider-codex",
|
||||
"openai:responses",
|
||||
"codex-access-token",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"provider_type":"codex","email":"codex@example.com","updated_at":1710000000}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build"),
|
||||
);
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-codex", "codex", 10)],
|
||||
vec![],
|
||||
vec![key],
|
||||
));
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/endpoints/keys/key-codex-a/export"
|
||||
))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["provider_type"], "codex");
|
||||
assert_eq!(payload["email"], "codex@example.com");
|
||||
assert_eq!(payload["access_token"], "codex-access-token");
|
||||
assert!(payload.get("refresh_token").is_none());
|
||||
assert!(payload.get("updated_at").is_none());
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_clears_admin_provider_key_oauth_invalid_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
@@ -78,21 +78,34 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
request_id: plan.request_id,
|
||||
candidate_id: None,
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
headers: BTreeMap::from([
|
||||
(
|
||||
"x-codex-primary-reset-after-seconds".to_string(),
|
||||
"18000".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-primary-reset-at".to_string(),
|
||||
"1900000000".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-secondary-reset-after-seconds".to_string(),
|
||||
"604800".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-secondary-reset-at".to_string(),
|
||||
"1900500000".to_string(),
|
||||
),
|
||||
]),
|
||||
body: Some(aether_contracts::ResponseBody {
|
||||
json_body: Some(json!({
|
||||
"plan_type": "plus",
|
||||
"rate_limit": {
|
||||
"primary_window": {
|
||||
"used_percent": 12.5,
|
||||
"reset_after_seconds": 18000,
|
||||
"reset_at": 1_900_000_000u64,
|
||||
"window_minutes": 300
|
||||
},
|
||||
"secondary_window": {
|
||||
"used_percent": 55.0,
|
||||
"reset_after_seconds": 604800,
|
||||
"reset_at": 1_900_500_000u64,
|
||||
"window_minutes": 10080
|
||||
}
|
||||
},
|
||||
@@ -170,6 +183,10 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
"codex"
|
||||
);
|
||||
assert_eq!(payload["results"][0]["quota_snapshot"]["plan_type"], "plus");
|
||||
assert_eq!(
|
||||
payload["results"][0]["quota_snapshot"]["reset_at"],
|
||||
1_900_000_000u64
|
||||
);
|
||||
assert_eq!(
|
||||
payload["results"][0]["quota_snapshot"]["credits"]["balance"],
|
||||
json!(42.0)
|
||||
@@ -223,6 +240,14 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
.and_then(|value| value.get("primary_used_percent")),
|
||||
Some(&json!(55.0))
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded[0]
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("codex"))
|
||||
.and_then(|value| value.get("primary_reset_at")),
|
||||
Some(&json!(1_900_500_000u64))
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded[0]
|
||||
.upstream_metadata
|
||||
@@ -231,6 +256,14 @@ async fn gateway_refreshes_admin_provider_quota_locally_for_codex_with_trusted_a
|
||||
.and_then(|value| value.get("secondary_used_percent")),
|
||||
Some(&json!(12.5))
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded[0]
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("codex"))
|
||||
.and_then(|value| value.get("secondary_reset_at")),
|
||||
Some(&json!(1_900_000_000u64))
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
|
||||
@@ -118,6 +118,29 @@ fn sample_kiro_device_access_token_without_email() -> String {
|
||||
format!("{header}.{payload}.sig")
|
||||
}
|
||||
|
||||
fn sample_codex_access_token_with_profile_email(email: &str, account_id: &str) -> String {
|
||||
use base64::Engine as _;
|
||||
|
||||
let header =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
|
||||
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
|
||||
json!({
|
||||
"iss": "https://auth.openai.com",
|
||||
"aud": ["https://api.openai.com/v1"],
|
||||
"exp": 2_000_000_000u64,
|
||||
"https://api.openai.com/profile": {
|
||||
"email": email,
|
||||
"email_verified": true,
|
||||
},
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": account_id,
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
format!("{header}.{payload}.sig")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_oauth_supported_types_locally_with_trusted_admin_principal()
|
||||
{
|
||||
@@ -1841,7 +1864,7 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
||||
"access_token": "new-codex-access-token",
|
||||
"refresh_token": "new-codex-refresh-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"expiresAt": 4_102_444_800u64,
|
||||
"email": "alice@example.com",
|
||||
"account_id": "acct-codex-123",
|
||||
"plan_type": "plus",
|
||||
@@ -1911,6 +1934,7 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["provider_type"], "codex");
|
||||
assert_eq!(payload["has_refresh_token"], true);
|
||||
assert_eq!(payload["expires_at"], 4_102_444_800u64);
|
||||
assert_eq!(payload["email"], "alice@example.com");
|
||||
assert_eq!(payload["account_state_recheck_attempted"], false);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -1934,6 +1958,7 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
||||
.await
|
||||
.expect("keys should load");
|
||||
let persisted = reloaded.first().expect("persisted key should exist");
|
||||
assert_eq!(persisted.expires_at_unix_secs, Some(4_102_444_800));
|
||||
let decrypted_api_key = decrypt_python_fernet_ciphertext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
persisted
|
||||
@@ -1955,6 +1980,7 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
|
||||
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
||||
assert_eq!(auth_config["provider_type"], "codex");
|
||||
assert_eq!(auth_config["refresh_token"], "new-codex-refresh-token");
|
||||
assert_eq!(auth_config["expires_at"], 4_102_444_800u64);
|
||||
assert_eq!(auth_config["email"], "alice@example.com");
|
||||
assert_eq!(auth_config["account_id"], "acct-codex-123");
|
||||
assert_eq!(auth_config["plan_type"], "plus");
|
||||
@@ -2009,7 +2035,7 @@ async fn gateway_completes_admin_provider_oauth_provider_locally_with_trusted_ad
|
||||
"access_token": "provider-codex-access-token",
|
||||
"refresh_token": "provider-codex-refresh-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"expires_at": 4_102_444_800u64,
|
||||
"email": "alice@example.com",
|
||||
"account_id": "acct-codex-123",
|
||||
"plan_type": "plus",
|
||||
@@ -2097,6 +2123,7 @@ async fn gateway_completes_admin_provider_oauth_provider_locally_with_trusted_ad
|
||||
assert_eq!(payload["key_id"], "key-codex-inactive-duplicate");
|
||||
assert_eq!(payload["provider_type"], "codex");
|
||||
assert_eq!(payload["has_refresh_token"], true);
|
||||
assert_eq!(payload["expires_at"], 4_102_444_800u64);
|
||||
assert_eq!(payload["email"], "alice@example.com");
|
||||
assert_eq!(payload["replaced"], true);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
@@ -2120,6 +2147,7 @@ async fn gateway_completes_admin_provider_oauth_provider_locally_with_trusted_ad
|
||||
.expect("keys should load");
|
||||
let persisted = reloaded.first().expect("persisted key should exist");
|
||||
assert!(persisted.is_active);
|
||||
assert_eq!(persisted.expires_at_unix_secs, Some(4_102_444_800));
|
||||
assert_eq!(
|
||||
persisted.proxy,
|
||||
Some(json!({"node_id": "proxy-node-codex-oauth", "enabled": true}))
|
||||
@@ -2145,6 +2173,7 @@ async fn gateway_completes_admin_provider_oauth_provider_locally_with_trusted_ad
|
||||
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
||||
assert_eq!(auth_config["provider_type"], "codex");
|
||||
assert_eq!(auth_config["refresh_token"], "provider-codex-refresh-token");
|
||||
assert_eq!(auth_config["expires_at"], 4_102_444_800u64);
|
||||
assert_eq!(auth_config["email"], "alice@example.com");
|
||||
assert_eq!(auth_config["account_id"], "acct-codex-123");
|
||||
assert_eq!(auth_config["plan_type"], "plus");
|
||||
@@ -2330,6 +2359,186 @@ async fn gateway_imports_admin_provider_oauth_refresh_token_locally_with_trusted
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_imports_codex_access_token_without_refresh_token_as_temporary_account() {
|
||||
let token_hits = Arc::new(Mutex::new(0usize));
|
||||
let token_hits_clone = Arc::clone(&token_hits);
|
||||
let token_server = Router::new().route(
|
||||
"/oauth/token",
|
||||
post(move || {
|
||||
let token_hits_inner = Arc::clone(&token_hits_clone);
|
||||
async move {
|
||||
*token_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "unexpected refresh exchange"})),
|
||||
)
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10);
|
||||
provider.provider_type = "codex".to_string();
|
||||
let endpoint = sample_endpoint(
|
||||
"endpoint-codex-chat",
|
||||
"provider-codex",
|
||||
"openai:chat",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
);
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![endpoint],
|
||||
vec![],
|
||||
));
|
||||
|
||||
let (token_url, token_handle) = start_server(token_server).await;
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(
|
||||
provider_catalog_repository.clone(),
|
||||
)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
)
|
||||
.with_provider_oauth_token_url_for_tests("codex", format!("{token_url}/oauth/token")),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let access_token =
|
||||
sample_codex_access_token_with_profile_email("profile@example.com", "acct-profile-123");
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
||||
))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"access_token": access_token,
|
||||
"name": "temporary-codex-access-token",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
let status = response.status();
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
||||
assert_eq!(payload["provider_type"], "codex");
|
||||
assert_eq!(payload["has_refresh_token"], false);
|
||||
assert_eq!(payload["temporary"], true);
|
||||
assert_eq!(payload["email"], "profile@example.com");
|
||||
assert_eq!(*token_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
let reloaded = provider_catalog_repository
|
||||
.list_keys_by_provider_ids(&["provider-codex".to_string()])
|
||||
.await
|
||||
.expect("keys should load");
|
||||
let persisted = reloaded.first().expect("persisted key should exist");
|
||||
assert_eq!(persisted.expires_at_unix_secs, Some(2_000_000_000));
|
||||
let decrypted_api_key = decrypt_python_fernet_ciphertext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
persisted
|
||||
.encrypted_api_key
|
||||
.as_deref()
|
||||
.expect("api key should be present"),
|
||||
)
|
||||
.expect("api key should decrypt");
|
||||
assert_eq!(decrypted_api_key, access_token);
|
||||
let decrypted_auth_config = decrypt_python_fernet_ciphertext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
persisted
|
||||
.encrypted_auth_config
|
||||
.as_deref()
|
||||
.expect("auth config should be stored"),
|
||||
)
|
||||
.expect("auth config should decrypt");
|
||||
let auth_config: serde_json::Value =
|
||||
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
||||
assert_eq!(auth_config["provider_type"], "codex");
|
||||
assert_eq!(auth_config["access_token_import_temporary"], true);
|
||||
assert_eq!(auth_config["email"], "profile@example.com");
|
||||
assert_eq!(auth_config["account_id"], "acct-profile-123");
|
||||
assert!(auth_config.get("refresh_token").is_none());
|
||||
|
||||
gateway_handle.abort();
|
||||
token_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_imports_codex_access_token_with_payload_expires_at_when_token_has_no_exp() {
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10);
|
||||
provider.provider_type = "codex".to_string();
|
||||
let endpoint = sample_endpoint(
|
||||
"endpoint-codex-responses",
|
||||
"provider-codex",
|
||||
"openai:responses",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
);
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![endpoint],
|
||||
vec![],
|
||||
));
|
||||
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_repository_for_tests(
|
||||
provider_catalog_repository.clone(),
|
||||
)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!(
|
||||
"{gateway_url}/api/admin/provider-oauth/providers/provider-codex/import-refresh-token"
|
||||
))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"access_token": "opaque-codex-access-token",
|
||||
"expiresAt": 2_100_000_000u64,
|
||||
"name": "temporary-codex-opaque-access-token",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
let status = response.status();
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(status, StatusCode::OK, "payload={payload}");
|
||||
assert_eq!(payload["expires_at"], 2_100_000_000u64);
|
||||
|
||||
let reloaded = provider_catalog_repository
|
||||
.list_keys_by_provider_ids(&["provider-codex".to_string()])
|
||||
.await
|
||||
.expect("keys should load");
|
||||
let persisted = reloaded.first().expect("persisted key should exist");
|
||||
assert_eq!(persisted.expires_at_unix_secs, Some(2_100_000_000));
|
||||
let decrypted_auth_config = decrypt_python_fernet_ciphertext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
persisted
|
||||
.encrypted_auth_config
|
||||
.as_deref()
|
||||
.expect("auth config should be stored"),
|
||||
)
|
||||
.expect("auth config should decrypt");
|
||||
let auth_config: serde_json::Value =
|
||||
serde_json::from_str(&decrypted_auth_config).expect("auth config json should parse");
|
||||
assert_eq!(auth_config["expires_at"], 2_100_000_000u64);
|
||||
assert_eq!(auth_config["access_token_import_temporary"], true);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_imports_admin_provider_oauth_refresh_token_over_active_expired_duplicate() {
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -495,6 +495,7 @@ async fn gateway_pool_list_ignores_usage_rows_and_uses_persisted_key_stats() {
|
||||
key.request_count = Some(7);
|
||||
key.total_tokens = 1_024;
|
||||
key.total_cost_usd = 3.5;
|
||||
key.created_at_unix_ms = Some(1_711_000_000);
|
||||
key.last_used_at_unix_secs = Some(1_711_000_999);
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
@@ -532,6 +533,10 @@ async fn gateway_pool_list_ignores_usage_rows_and_uses_persisted_key_stats() {
|
||||
keys[0]["last_used_at"],
|
||||
json!(crate::handlers::shared::unix_secs_to_rfc3339(1_711_000_999))
|
||||
);
|
||||
assert_eq!(
|
||||
keys[0]["imported_at"],
|
||||
json!(crate::handlers::shared::unix_secs_to_rfc3339(1_711_000_000))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -692,6 +697,102 @@ async fn gateway_handles_admin_pool_list_keys_locally_with_trusted_admin_princip
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_sorts_admin_pool_keys_by_imported_and_last_used_time() {
|
||||
let provider = sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"pool_advanced": {
|
||||
"enabled": true
|
||||
}
|
||||
})),
|
||||
);
|
||||
let mut old_key = sample_key("key-openai-old", "provider-openai", "openai:chat", "sk-old");
|
||||
old_key.name = "old".to_string();
|
||||
old_key.created_at_unix_ms = Some(1_711_000_000);
|
||||
old_key.last_used_at_unix_secs = Some(1_711_000_500);
|
||||
let mut fresh_key = sample_key(
|
||||
"key-openai-fresh",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-fresh",
|
||||
);
|
||||
fresh_key.name = "fresh".to_string();
|
||||
fresh_key.created_at_unix_ms = Some(1_711_002_000);
|
||||
fresh_key.last_used_at_unix_secs = Some(1_711_000_100);
|
||||
let mut active_key = sample_key(
|
||||
"key-openai-active",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-active",
|
||||
);
|
||||
active_key.name = "active".to_string();
|
||||
active_key.created_at_unix_ms = Some(1_711_001_000);
|
||||
active_key.last_used_at_unix_secs = Some(1_711_003_000);
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
Vec::new(),
|
||||
vec![old_key, fresh_key, active_key],
|
||||
));
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
));
|
||||
|
||||
let imported_response = local_admin_pool_response(
|
||||
&state,
|
||||
http::Method::GET,
|
||||
"/api/admin/pool/provider-openai/keys?page=1&page_size=50&status=all&sort_by=imported_at&sort_order=desc",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(imported_response.status(), StatusCode::OK);
|
||||
let imported_payload: serde_json::Value = serde_json::from_slice(
|
||||
&to_bytes(imported_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read"),
|
||||
)
|
||||
.expect("json body should parse");
|
||||
let imported_names = imported_payload["keys"]
|
||||
.as_array()
|
||||
.expect("keys should be array")
|
||||
.iter()
|
||||
.map(|item| item["key_name"].as_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(imported_names, vec!["fresh", "active", "old"]);
|
||||
|
||||
let last_used_response = local_admin_pool_response(
|
||||
&state,
|
||||
http::Method::GET,
|
||||
"/api/admin/pool/provider-openai/keys?page=1&page_size=50&status=all&sort_by=last_used_at&sort_order=desc",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(last_used_response.status(), StatusCode::OK);
|
||||
let last_used_payload: serde_json::Value = serde_json::from_slice(
|
||||
&to_bytes(last_used_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read"),
|
||||
)
|
||||
.expect("json body should parse");
|
||||
let last_used_names = last_used_payload["keys"]
|
||||
.as_array()
|
||||
.expect("keys should be array")
|
||||
.iter()
|
||||
.map(|item| item["key_name"].as_str().unwrap_or_default())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(last_used_names, vec!["active", "old", "fresh"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_marks_account_blocked_pool_key_in_list_keys_response() {
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
||||
@@ -1677,6 +1778,120 @@ async fn gateway_prefers_status_snapshot_codex_quota_over_stale_metadata() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_shows_codex_quota_reset_for_exhausted_zero_usage_snapshot() {
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"pool_advanced": {
|
||||
"enabled": true,
|
||||
"skip_exhausted_accounts": true
|
||||
}
|
||||
})),
|
||||
);
|
||||
provider.provider_type = "codex".to_string();
|
||||
|
||||
let mut key = sample_key(
|
||||
"key-codex-no-credits",
|
||||
"provider-codex",
|
||||
"openai:responses",
|
||||
"oauth-placeholder",
|
||||
);
|
||||
key.name = "codex no credits".to_string();
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.status_snapshot = Some(json!({
|
||||
"quota": {
|
||||
"version": 2,
|
||||
"provider_type": "codex",
|
||||
"code": "exhausted",
|
||||
"label": "额度耗尽",
|
||||
"reason": "无可用积分",
|
||||
"freshness": "fresh",
|
||||
"source": "refresh_api",
|
||||
"observed_at": 4_102_444_800u64,
|
||||
"exhausted": true,
|
||||
"usage_ratio": 0.0,
|
||||
"updated_at": 4_102_444_800u64,
|
||||
"reset_seconds": 18_000,
|
||||
"plan_type": "plus",
|
||||
"credits": {
|
||||
"has_credits": false,
|
||||
"balance": 0.0,
|
||||
"unlimited": false
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"code": "weekly",
|
||||
"label": "周",
|
||||
"scope": "account",
|
||||
"unit": "percent",
|
||||
"used_ratio": 0.0,
|
||||
"remaining_ratio": 1.0,
|
||||
"reset_at": 4_103_049_600u64,
|
||||
"reset_seconds": 604_800,
|
||||
"window_minutes": 10_080
|
||||
},
|
||||
{
|
||||
"code": "5h",
|
||||
"label": "5H",
|
||||
"scope": "account",
|
||||
"unit": "percent",
|
||||
"used_ratio": 0.0,
|
||||
"remaining_ratio": 1.0,
|
||||
"reset_at": 4_102_462_800u64,
|
||||
"reset_seconds": 18_000,
|
||||
"window_minutes": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
Vec::new(),
|
||||
vec![key],
|
||||
));
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
));
|
||||
|
||||
let response = local_admin_pool_response(
|
||||
&state,
|
||||
http::Method::GET,
|
||||
"/api/admin/pool/provider-codex/keys?page=1&page_size=50&status=all",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = serde_json::from_slice(
|
||||
&to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read"),
|
||||
)
|
||||
.expect("json body should parse");
|
||||
let keys = payload["keys"].as_array().expect("keys should be array");
|
||||
|
||||
assert_eq!(keys[0]["scheduling_status"], json!("blocked"));
|
||||
assert_eq!(
|
||||
keys[0]["scheduling_reason"],
|
||||
json!("account_quota_exhausted")
|
||||
);
|
||||
assert_eq!(
|
||||
keys[0]["account_quota"],
|
||||
json!("周剩余 100.0% (7天0小时后重置) | 5H剩余 100.0% (5小时0分钟后重置)")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_marks_exhausted_kiro_pool_key_as_blocked_when_flag_enabled() {
|
||||
let mut provider = sample_provider("provider-kiro", "kiro", 10).with_transport_fields(
|
||||
|
||||
@@ -1618,7 +1618,7 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s
|
||||
assert_eq!(body_json["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
body_json["error"]["message"],
|
||||
"找到 1 个支持模型 gpt-5.4 的候选提供商,但本次同步请求全部不可用:格式转换未启用 2 次"
|
||||
"没有可用提供商支持模型 gpt-5.4 的同步请求"
|
||||
);
|
||||
|
||||
let stored_usage = wait_for_usage_status(
|
||||
|
||||
Reference in New Issue
Block a user