mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
fix(codex): fence concurrent quota updates
This commit is contained in:
+36
-3
@@ -55,6 +55,33 @@ pub(super) async fn maybe_handle(
|
||||
if idempotency_key.is_empty() {
|
||||
return Ok(Some(bad_request_response("idempotency_key 不能为空")));
|
||||
}
|
||||
if idempotency_key.len() > 256 {
|
||||
return Ok(Some(bad_request_response(
|
||||
"idempotency_key 不能超过 256 个字节",
|
||||
)));
|
||||
}
|
||||
let expected_credential_generation = match payload.expected_credential_generation {
|
||||
serde_json::Value::Null => None,
|
||||
serde_json::Value::String(value) => {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
return Ok(Some(bad_request_response(
|
||||
"expected_credential_generation 不能为空字符串",
|
||||
)));
|
||||
}
|
||||
if value.len() > 256 {
|
||||
return Ok(Some(bad_request_response(
|
||||
"expected_credential_generation 不能超过 256 个字节",
|
||||
)));
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
_ => {
|
||||
return Ok(Some(bad_request_response(
|
||||
"expected_credential_generation 必须是字符串或 null",
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let Some(key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
@@ -93,9 +120,15 @@ pub(super) async fn maybe_handle(
|
||||
)));
|
||||
};
|
||||
|
||||
let (status, payload) =
|
||||
consume_codex_reset_credit_locally(state, &provider, &endpoint, key, &idempotency_key)
|
||||
.await?;
|
||||
let (status, payload) = consume_codex_reset_credit_locally(
|
||||
state,
|
||||
&provider,
|
||||
&endpoint,
|
||||
key,
|
||||
&idempotency_key,
|
||||
expected_credential_generation.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Some((status, Json(payload)).into_response()))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use crate::handlers::admin::admin_provider_pool_config;
|
||||
use crate::handlers::admin::provider::shared::paths::admin_update_key_id;
|
||||
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch;
|
||||
use crate::handlers::admin::provider::write::keys::admin_provider_key_update_requires_immediate_model_fetch;
|
||||
use crate::handlers::admin::provider::write::keys::{
|
||||
admin_provider_key_update_requires_immediate_model_fetch,
|
||||
build_provider_catalog_key_admin_cas_update,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::maintenance::ensure_provider_key_pool_scores_for_keys;
|
||||
use crate::provider_key_auth::provider_key_effective_api_formats;
|
||||
@@ -82,7 +85,25 @@ pub(super) async fn maybe_handle(
|
||||
Ok(record) => record,
|
||||
Err(detail) => return Ok(Some(bad_request_response(detail))),
|
||||
};
|
||||
let Some(mut updated) = state.update_provider_catalog_key(&updated_record).await? else {
|
||||
let admin_update = build_provider_catalog_key_admin_cas_update(
|
||||
&existing_key,
|
||||
updated_record.clone(),
|
||||
&provider.provider_type,
|
||||
);
|
||||
if !state
|
||||
.compare_and_update_provider_catalog_key_admin_state(&admin_update)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(conflict_response(
|
||||
"Key 凭据或配置已被其他请求更新,请刷新后重试",
|
||||
)));
|
||||
}
|
||||
let Some(mut updated) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if updated_record.learned_rpm_limit != existing_key.learned_rpm_limit {
|
||||
@@ -183,3 +204,11 @@ fn not_found_response(detail: impl Into<String>) -> Response<Body> {
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn conflict_response(detail: impl Into<String>) -> Response<Body> {
|
||||
(
|
||||
http::StatusCode::CONFLICT,
|
||||
Json(json!({ "detail": detail.into() })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -22,16 +22,60 @@ use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||
use crate::handlers::shared::sync_provider_key_oauth_status_snapshot;
|
||||
use crate::provider_key_auth::provider_key_is_oauth_managed;
|
||||
use crate::GatewayError;
|
||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyOAuthRuntimeStateCasUpdate;
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
ProviderCatalogKeyOAuthRuntimeStateCasUpdate,
|
||||
ProviderCatalogUpstreamMetadataNamespaceExpectation,
|
||||
};
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const CODEX_OAUTH_COMPLETE_NAMESPACE_CAS_MAX_RETRIES: usize = 3;
|
||||
const CODEX_CREDENTIAL_GENERATION_KEY: &str = "credential_generation";
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum CodexOAuthCompleteCasMissAction {
|
||||
AlreadyCompleted,
|
||||
RetryNamespace(Option<Value>),
|
||||
Conflict,
|
||||
}
|
||||
|
||||
fn codex_oauth_complete_cas_miss_action(
|
||||
latest_encrypted_auth_config: Option<&str>,
|
||||
latest_upstream_metadata: Option<&Value>,
|
||||
latest_status_snapshot: Option<&Value>,
|
||||
expected_encrypted_auth_config: Option<&str>,
|
||||
persisted_encrypted_auth_config: &str,
|
||||
expected_codex_metadata_value: Option<&Value>,
|
||||
replacement_codex_metadata_value: &Value,
|
||||
) -> CodexOAuthCompleteCasMissAction {
|
||||
let latest_codex_metadata_value = latest_upstream_metadata
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get("codex"))
|
||||
.cloned();
|
||||
let quota_is_cleared = latest_status_snapshot
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|snapshot| snapshot.get("quota"))
|
||||
== Some(&Value::Null);
|
||||
if latest_encrypted_auth_config == Some(persisted_encrypted_auth_config)
|
||||
&& latest_codex_metadata_value.as_ref() == Some(replacement_codex_metadata_value)
|
||||
&& quota_is_cleared
|
||||
{
|
||||
return CodexOAuthCompleteCasMissAction::AlreadyCompleted;
|
||||
}
|
||||
if latest_encrypted_auth_config != expected_encrypted_auth_config
|
||||
|| latest_codex_metadata_value.as_ref() == expected_codex_metadata_value
|
||||
{
|
||||
return CodexOAuthCompleteCasMissAction::Conflict;
|
||||
}
|
||||
CodexOAuthCompleteCasMissAction::RetryNamespace(latest_codex_metadata_value)
|
||||
}
|
||||
|
||||
pub(super) async fn handle_admin_provider_oauth_complete_key(
|
||||
state: &AdminAppState<'_>,
|
||||
request_context: &AdminRequestContext<'_>,
|
||||
@@ -277,29 +321,98 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
|
||||
.and_then(|snapshot| snapshot.get("oauth"))
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let mut expected_codex_metadata_value = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|metadata| metadata.get("codex"))
|
||||
.cloned();
|
||||
let mut status_snapshot_patch =
|
||||
serde_json::Map::from_iter([("oauth".to_string(), oauth_status)]);
|
||||
if provider_type == "codex" {
|
||||
status_snapshot_patch.insert("quota".to_string(), serde_json::Value::Null);
|
||||
}
|
||||
let persisted_encrypted_auth_config = recovered_key
|
||||
.encrypted_auth_config
|
||||
.clone()
|
||||
.expect("recovered auth config should be present");
|
||||
let updated_result = state
|
||||
.app()
|
||||
.compare_and_update_provider_catalog_key_oauth_runtime_state(
|
||||
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
||||
key_id: key_id.clone(),
|
||||
expected_encrypted_auth_config: state_data.expected_encrypted_auth_config,
|
||||
expected_credential: None,
|
||||
encrypted_auth_config: persisted_encrypted_auth_config.clone(),
|
||||
encrypted_api_key_update: Some(encrypted_api_key),
|
||||
expires_at_unix_secs_update: Some(expires_at),
|
||||
oauth_invalid_at_unix_secs: None,
|
||||
oauth_invalid_reason: None,
|
||||
reset_error_count: true,
|
||||
upstream_metadata_patch: None,
|
||||
status_snapshot_patch: json!({ "oauth": oauth_status }),
|
||||
updated_at_unix_secs: Some(now_unix_secs),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let replacement_codex_metadata_value = json!({
|
||||
CODEX_CREDENTIAL_GENERATION_KEY: uuid::Uuid::now_v7().to_string()
|
||||
});
|
||||
let expected_encrypted_auth_config = state_data.expected_encrypted_auth_config.clone();
|
||||
let updated_result: Result<bool, GatewayError> = async {
|
||||
let max_namespace_retries = if provider_type == "codex" {
|
||||
CODEX_OAUTH_COMPLETE_NAMESPACE_CAS_MAX_RETRIES
|
||||
} else {
|
||||
0
|
||||
};
|
||||
for retry in 0..=max_namespace_retries {
|
||||
let updated = state
|
||||
.app()
|
||||
.compare_and_update_provider_catalog_key_oauth_runtime_state(
|
||||
&ProviderCatalogKeyOAuthRuntimeStateCasUpdate {
|
||||
key_id: key_id.clone(),
|
||||
expected_encrypted_auth_config: expected_encrypted_auth_config.clone(),
|
||||
expected_credential: None,
|
||||
expected_upstream_metadata_namespace: (provider_type == "codex").then(
|
||||
|| ProviderCatalogUpstreamMetadataNamespaceExpectation {
|
||||
namespace: "codex".to_string(),
|
||||
expected_value: expected_codex_metadata_value.clone(),
|
||||
},
|
||||
),
|
||||
encrypted_auth_config: persisted_encrypted_auth_config.clone(),
|
||||
encrypted_api_key_update: Some(encrypted_api_key.clone()),
|
||||
expires_at_unix_secs_update: Some(expires_at),
|
||||
oauth_invalid_at_unix_secs: None,
|
||||
oauth_invalid_reason: None,
|
||||
reset_error_count: true,
|
||||
upstream_metadata_patch: (provider_type == "codex")
|
||||
.then(|| json!({"codex": replacement_codex_metadata_value.clone()})),
|
||||
upstream_metadata_namespace_to_remove: None,
|
||||
status_snapshot_patch: serde_json::Value::Object(
|
||||
status_snapshot_patch.clone(),
|
||||
),
|
||||
updated_at_unix_secs: Some(now_unix_secs),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if updated {
|
||||
return Ok(true);
|
||||
}
|
||||
if provider_type != "codex" {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let Some(latest_key) = state
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
match codex_oauth_complete_cas_miss_action(
|
||||
latest_key.encrypted_auth_config.as_deref(),
|
||||
latest_key.upstream_metadata.as_ref(),
|
||||
latest_key.status_snapshot.as_ref(),
|
||||
expected_encrypted_auth_config.as_deref(),
|
||||
&persisted_encrypted_auth_config,
|
||||
expected_codex_metadata_value.as_ref(),
|
||||
&replacement_codex_metadata_value,
|
||||
) {
|
||||
CodexOAuthCompleteCasMissAction::AlreadyCompleted => return Ok(true),
|
||||
CodexOAuthCompleteCasMissAction::Conflict => return Ok(false),
|
||||
CodexOAuthCompleteCasMissAction::RetryNamespace(latest_codex_metadata_value) => {
|
||||
if retry == max_namespace_retries {
|
||||
return Ok(false);
|
||||
}
|
||||
expected_codex_metadata_value = latest_codex_metadata_value;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
.await;
|
||||
let _ = state
|
||||
.app()
|
||||
.invalidate_local_oauth_refresh_entry(&key_id)
|
||||
@@ -397,3 +510,78 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{codex_oauth_complete_cas_miss_action, CodexOAuthCompleteCasMissAction};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn codex_oauth_complete_retries_only_when_namespace_changed() {
|
||||
let expected_codex = json!({"request_id": "old"});
|
||||
let replacement_codex = json!({"credential_generation": "generation-new"});
|
||||
let latest_metadata = json!({
|
||||
"codex": {"request_id": "new"},
|
||||
"unrelated": {"preserved": true}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
codex_oauth_complete_cas_miss_action(
|
||||
Some("old-auth"),
|
||||
Some(&latest_metadata),
|
||||
None,
|
||||
Some("old-auth"),
|
||||
"new-auth",
|
||||
Some(&expected_codex),
|
||||
&replacement_codex,
|
||||
),
|
||||
CodexOAuthCompleteCasMissAction::RetryNamespace(Some(json!({
|
||||
"request_id": "new"
|
||||
})))
|
||||
);
|
||||
assert_eq!(
|
||||
codex_oauth_complete_cas_miss_action(
|
||||
Some("old-auth"),
|
||||
Some(&json!({"codex": expected_codex.clone()})),
|
||||
None,
|
||||
Some("old-auth"),
|
||||
"new-auth",
|
||||
Some(&expected_codex),
|
||||
&replacement_codex,
|
||||
),
|
||||
CodexOAuthCompleteCasMissAction::Conflict
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_oauth_complete_accepts_an_ambiguous_success_but_rejects_auth_rotation() {
|
||||
let replacement_codex = json!({"credential_generation": "generation-new"});
|
||||
assert_eq!(
|
||||
codex_oauth_complete_cas_miss_action(
|
||||
Some("new-auth"),
|
||||
Some(&json!({
|
||||
"codex": replacement_codex.clone(),
|
||||
"unrelated": {"preserved": true}
|
||||
})),
|
||||
Some(&json!({"quota": null})),
|
||||
Some("old-auth"),
|
||||
"new-auth",
|
||||
Some(&json!({"request_id": "old"})),
|
||||
&replacement_codex,
|
||||
),
|
||||
CodexOAuthCompleteCasMissAction::AlreadyCompleted
|
||||
);
|
||||
assert_eq!(
|
||||
codex_oauth_complete_cas_miss_action(
|
||||
Some("other-auth"),
|
||||
Some(&json!({"codex": {"request_id": "new"}})),
|
||||
Some(&json!({"quota": null})),
|
||||
Some("old-auth"),
|
||||
"new-auth",
|
||||
Some(&json!({"request_id": "old"})),
|
||||
&replacement_codex,
|
||||
),
|
||||
CodexOAuthCompleteCasMissAction::Conflict
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::ai_serving::{
|
||||
build_provider_key_pool_score_upsert, provider_key_pool_score_id, provider_key_pool_score_scope,
|
||||
};
|
||||
use crate::handlers::admin::admin_provider_pool_config;
|
||||
use crate::handlers::admin::provider::write::keys::build_provider_catalog_key_admin_cas_update;
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::provider_key_auth::provider_active_api_formats;
|
||||
use crate::GatewayError;
|
||||
@@ -286,6 +287,61 @@ fn grok_oauth_catalog_key_fingerprint(
|
||||
grok_browser_transport_fingerprint_from_auth_config(auth_config)
|
||||
}
|
||||
|
||||
pub(crate) fn rotate_codex_credential_generation(
|
||||
key: &mut StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
) {
|
||||
if !provider_type.trim().eq_ignore_ascii_case("codex") {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut upstream_metadata = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
upstream_metadata.insert(
|
||||
"codex".to_string(),
|
||||
json!({
|
||||
aether_admin::provider::quota::CODEX_CREDENTIAL_GENERATION_KEY:
|
||||
Uuid::now_v7().to_string(),
|
||||
}),
|
||||
);
|
||||
key.upstream_metadata = Some(Value::Object(upstream_metadata));
|
||||
|
||||
if let Some(mut status_snapshot) = key
|
||||
.status_snapshot
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
{
|
||||
status_snapshot.insert("quota".to_string(), Value::Null);
|
||||
key.status_snapshot = Some(Value::Object(status_snapshot));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_codex_credential_generation_rotated(
|
||||
key: &mut StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
previous_generation: Option<&str>,
|
||||
) {
|
||||
if !provider_type.trim().eq_ignore_ascii_case("codex") {
|
||||
return;
|
||||
}
|
||||
|
||||
let current_generation = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get("codex"))
|
||||
.and_then(|codex| aether_admin::provider::quota::codex_credential_generation(Some(codex)));
|
||||
let already_rotated = current_generation.is_some() && current_generation != previous_generation;
|
||||
if !already_rotated {
|
||||
rotate_codex_credential_generation(key, provider_type);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn create_provider_oauth_catalog_key(
|
||||
state: &AdminAppState<'_>,
|
||||
provider_id: &str,
|
||||
@@ -344,6 +400,7 @@ pub(crate) async fn create_provider_oauth_catalog_key(
|
||||
record.circuit_breaker_by_format = Some(json!({}));
|
||||
record.created_at_unix_ms = Some(now_unix_secs);
|
||||
record.updated_at_unix_secs = Some(now_unix_secs);
|
||||
rotate_codex_credential_generation(&mut record, provider_type);
|
||||
let created = state.create_provider_catalog_key(&record).await?;
|
||||
if let Some(key) = created.as_ref() {
|
||||
let _ = state
|
||||
@@ -395,17 +452,23 @@ pub(crate) async fn update_existing_provider_oauth_catalog_key(
|
||||
updated.proxy = Some(proxy);
|
||||
}
|
||||
updated.updated_at_unix_secs = Some(now_unix_secs);
|
||||
if state.update_provider_catalog_key(&updated).await?.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
rotate_codex_credential_generation(&mut updated, provider_type);
|
||||
let admin_update =
|
||||
build_provider_catalog_key_admin_cas_update(existing_key, updated.clone(), provider_type);
|
||||
if !state
|
||||
.clear_provider_catalog_key_oauth_invalid_marker(&updated.id)
|
||||
.compare_and_update_provider_catalog_key_admin_state(&admin_update)
|
||||
.await?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let persisted = state
|
||||
.reset_provider_catalog_key_recovery_state(&updated.id)
|
||||
.reset_provider_catalog_key_recovery_state_fenced(
|
||||
&updated.id,
|
||||
updated
|
||||
.encrypted_auth_config
|
||||
.as_deref()
|
||||
.expect("OAuth update always supplies encrypted auth_config"),
|
||||
)
|
||||
.await?;
|
||||
if let Some(key) = persisted.as_ref() {
|
||||
let _ = state
|
||||
@@ -502,10 +565,12 @@ fn provider_oauth_catalog_key_api_formats(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
grok_oauth_catalog_key_fingerprint, provider_oauth_token_payload_expires_at_unix_secs,
|
||||
ensure_codex_credential_generation_rotated, grok_oauth_catalog_key_fingerprint,
|
||||
provider_oauth_token_payload_expires_at_unix_secs, rotate_codex_credential_generation,
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn sample_unsigned_jwt(payload: serde_json::Value) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
|
||||
@@ -609,4 +674,101 @@ mod tests {
|
||||
|
||||
assert!(grok_oauth_catalog_key_fingerprint("openai", auth_config).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_credential_rotation_replaces_quota_namespace_and_preserves_unrelated_state() {
|
||||
let mut key = StoredProviderCatalogKey::new(
|
||||
"key".to_string(),
|
||||
"provider".to_string(),
|
||||
"Codex".to_string(),
|
||||
"oauth".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("key should build");
|
||||
key.upstream_metadata = Some(json!({
|
||||
"codex": {
|
||||
"credential_generation": "old-generation",
|
||||
"primary_used_percent": 75.0,
|
||||
},
|
||||
"unrelated": {"preserved": true},
|
||||
}));
|
||||
key.status_snapshot = Some(json!({
|
||||
"oauth": {"status": "valid"},
|
||||
"quota": {"used_ratio": 0.75},
|
||||
}));
|
||||
|
||||
rotate_codex_credential_generation(&mut key, "codex");
|
||||
|
||||
let codex = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("codex"))
|
||||
.and_then(Value::as_object)
|
||||
.expect("codex namespace should exist");
|
||||
assert_eq!(codex.len(), 1);
|
||||
assert_ne!(
|
||||
codex
|
||||
.get(aether_admin::provider::quota::CODEX_CREDENTIAL_GENERATION_KEY)
|
||||
.and_then(Value::as_str),
|
||||
Some("old-generation")
|
||||
);
|
||||
assert_eq!(
|
||||
key.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.pointer("/unrelated/preserved")),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
key.status_snapshot
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.get("quota")),
|
||||
Some(&Value::Null)
|
||||
);
|
||||
assert_eq!(
|
||||
key.status_snapshot
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.pointer("/oauth/status")),
|
||||
Some(&json!("valid"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_credential_rotation_ensure_does_not_rotate_twice_in_one_write() {
|
||||
let mut key = StoredProviderCatalogKey::new(
|
||||
"key".to_string(),
|
||||
"provider".to_string(),
|
||||
"Codex".to_string(),
|
||||
"oauth".to_string(),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("key should build");
|
||||
key.upstream_metadata = Some(json!({
|
||||
"codex": {"credential_generation": "generation-before-write"}
|
||||
}));
|
||||
|
||||
rotate_codex_credential_generation(&mut key, "codex");
|
||||
let builder_generation = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.pointer("/codex/credential_generation"))
|
||||
.and_then(Value::as_str)
|
||||
.expect("builder should rotate the generation")
|
||||
.to_string();
|
||||
|
||||
ensure_codex_credential_generation_rotated(
|
||||
&mut key,
|
||||
"codex",
|
||||
Some("generation-before-write"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
key.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.pointer("/codex/credential_generation"))
|
||||
.and_then(Value::as_str),
|
||||
Some(builder_generation.as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,6 +470,7 @@ mod tests {
|
||||
candidate_id: None,
|
||||
status_code: 403,
|
||||
headers: BTreeMap::new(),
|
||||
response_observation: None,
|
||||
body: Some(ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(body)),
|
||||
|
||||
@@ -18,24 +18,229 @@ use self::plan::{
|
||||
execute_codex_reset_credit_plan,
|
||||
};
|
||||
use super::shared::{
|
||||
build_quota_snapshot_payload, extract_execution_error_message,
|
||||
oauth_refresh_auto_removed_result, persist_fenced_provider_quota_refresh_state,
|
||||
persist_provider_quota_refresh_state, provider_auto_remove_banned_keys,
|
||||
build_quota_snapshot_payload, complete_codex_account_reset, extract_execution_error_message,
|
||||
oauth_refresh_auto_removed_result, persist_codex_provider_quota_refresh_state,
|
||||
persist_fenced_provider_quota_refresh_state, provider_auto_remove_banned_keys,
|
||||
provider_auto_remove_quota_exhausted_keys, quota_key_auto_removed,
|
||||
quota_refresh_success_invalid_state, should_auto_remove_oauth_invalid_key,
|
||||
ProviderQuotaExecutionOutcome,
|
||||
quota_refresh_success_invalid_state, reserve_codex_account_reset,
|
||||
should_auto_remove_oauth_invalid_key, CodexAccountResetCompleteResult,
|
||||
CodexAccountResetReserveResult, CodexAccountResetTerminal, ProviderQuotaExecutionOutcome,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
|
||||
use crate::provider_key_auth::provider_key_is_oauth_managed;
|
||||
use crate::state::ProviderTransportCredentialFence;
|
||||
use crate::GatewayError;
|
||||
use aether_contracts::ProxySnapshot;
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
ProviderCatalogKeyOAuthCredentialCasDelete, ProviderCatalogKeyOAuthCredentialFence,
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
ProviderCatalogKeyOAuthCredentialCasDelete,
|
||||
ProviderCatalogUpstreamMetadataNamespaceExpectation, StoredProviderCatalogEndpoint,
|
||||
StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const CODEX_OAUTH_CREDENTIAL_STABILIZATION_ATTEMPTS: usize = 3;
|
||||
const CODEX_RESET_QUOTA_RECONCILIATION_DELAYS_MS: [u64; 4] = [1_000, 2_000, 4_000, 8_000];
|
||||
|
||||
enum CodexOAuthRequestPreparation {
|
||||
Ready {
|
||||
transport: AdminGatewayProviderTransportSnapshot,
|
||||
auth: (String, String),
|
||||
credential_fence: ProviderTransportCredentialFence,
|
||||
},
|
||||
MissingAuth,
|
||||
Conflict,
|
||||
}
|
||||
|
||||
async fn prepare_codex_oauth_request(
|
||||
state: &AdminAppState<'_>,
|
||||
initial_transport: &AdminGatewayProviderTransportSnapshot,
|
||||
) -> Result<CodexOAuthRequestPreparation, GatewayError> {
|
||||
for _ in 0..CODEX_OAUTH_CREDENTIAL_STABILIZATION_ATTEMPTS {
|
||||
let Some(transport) = state
|
||||
.read_provider_transport_snapshot_uncached(
|
||||
&initial_transport.provider.id,
|
||||
&initial_transport.endpoint.id,
|
||||
&initial_transport.key.id,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(CodexOAuthRequestPreparation::Conflict);
|
||||
};
|
||||
if !crate::state::provider_transport_context_allows_credential_rotation(
|
||||
initial_transport,
|
||||
&transport,
|
||||
) {
|
||||
return Ok(CodexOAuthRequestPreparation::Conflict);
|
||||
}
|
||||
let Some(before_fence) = state
|
||||
.app()
|
||||
.capture_provider_transport_credential_fence(&transport)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let resolved_auth = state.resolve_local_oauth_header_auth(&transport).await?;
|
||||
let Some(current_transport) = state
|
||||
.read_provider_transport_snapshot_uncached(
|
||||
&initial_transport.provider.id,
|
||||
&initial_transport.endpoint.id,
|
||||
&initial_transport.key.id,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(CodexOAuthRequestPreparation::Conflict);
|
||||
};
|
||||
if !crate::state::provider_transport_context_allows_credential_rotation(
|
||||
initial_transport,
|
||||
¤t_transport,
|
||||
) {
|
||||
return Ok(CodexOAuthRequestPreparation::Conflict);
|
||||
}
|
||||
let Some(after_fence) = state
|
||||
.app()
|
||||
.capture_provider_transport_credential_fence(¤t_transport)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if before_fence != after_fence {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok(match resolved_auth {
|
||||
Some(auth) => CodexOAuthRequestPreparation::Ready {
|
||||
transport: current_transport,
|
||||
auth,
|
||||
credential_fence: after_fence,
|
||||
},
|
||||
None => CodexOAuthRequestPreparation::MissingAuth,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(CodexOAuthRequestPreparation::Conflict)
|
||||
}
|
||||
|
||||
fn codex_reset_refresh_succeeded(payload: Option<&Value>, key_id: &str) -> bool {
|
||||
payload
|
||||
.and_then(|payload| payload.get("results"))
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_object)
|
||||
.find(|item| item.get("key_id").and_then(Value::as_str) == Some(key_id))
|
||||
.and_then(|item| item.get("status"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|status| status.eq_ignore_ascii_case("success"))
|
||||
}
|
||||
|
||||
async fn codex_reset_fence_is_still_pending(
|
||||
state: &AdminAppState<'_>,
|
||||
key_id: &str,
|
||||
expected_credential: &ProviderTransportCredentialFence,
|
||||
reset_fence: &super::shared::CodexAccountResetFence,
|
||||
) -> Result<bool, GatewayError> {
|
||||
let Some(key) = state
|
||||
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
if key.encrypted_auth_config.as_deref()
|
||||
!= Some(expected_credential.encrypted_auth_config.as_str())
|
||||
|| key.encrypted_api_key != expected_credential.credential.encrypted_api_key
|
||||
|| key.auth_type != expected_credential.credential.auth_type
|
||||
|| key.provider_id != expected_credential.credential.provider_id
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let provider_type_matches = state
|
||||
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&key.provider_id))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.is_some_and(|provider| {
|
||||
provider.provider_type == expected_credential.credential.provider_type
|
||||
});
|
||||
if !provider_type_matches {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let codex = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get("codex"))
|
||||
.and_then(Value::as_object);
|
||||
Ok(codex.is_some_and(|codex| {
|
||||
codex
|
||||
.get(aether_admin::provider::quota::CODEX_QUOTA_ACCOUNT_RESET_FENCE_ID_KEY)
|
||||
.and_then(Value::as_str)
|
||||
== Some(reset_fence.id.as_str())
|
||||
&& codex
|
||||
.get(aether_admin::provider::quota::CODEX_QUOTA_ACCOUNT_RESET_GENERATION_KEY)
|
||||
.and_then(aether_admin::provider::quota::coerce_json_u64)
|
||||
== Some(reset_fence.generation)
|
||||
&& codex
|
||||
.get(
|
||||
aether_admin::provider::quota::CODEX_QUOTA_ACCOUNT_RESET_PENDING_GENERATION_KEY,
|
||||
)
|
||||
.and_then(aether_admin::provider::quota::coerce_json_u64)
|
||||
== Some(reset_fence.generation)
|
||||
&& codex
|
||||
.get(aether_admin::provider::quota::CODEX_QUOTA_ACCOUNT_RESET_PENDING_KEY)
|
||||
.and_then(Value::as_bool)
|
||||
== Some(true)
|
||||
}))
|
||||
}
|
||||
|
||||
async fn refresh_codex_quota_after_reset_until_settled(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
endpoint: &StoredProviderCatalogEndpoint,
|
||||
key: &StoredProviderCatalogKey,
|
||||
reset_fence: &super::shared::CodexAccountResetFence,
|
||||
expected_credential: &ProviderTransportCredentialFence,
|
||||
) -> Result<Option<Value>, GatewayError> {
|
||||
let mut latest_payload = None;
|
||||
for attempt in 0..=CODEX_RESET_QUOTA_RECONCILIATION_DELAYS_MS.len() {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
CODEX_RESET_QUOTA_RECONCILIATION_DELAYS_MS[attempt - 1],
|
||||
))
|
||||
.await;
|
||||
}
|
||||
if !codex_reset_fence_is_still_pending(state, &key.id, expected_credential, reset_fence)
|
||||
.await?
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
let payload = refresh_codex_provider_quota_locally_with_reset_fence(
|
||||
state,
|
||||
provider,
|
||||
endpoint,
|
||||
vec![key.clone()],
|
||||
None,
|
||||
Some(reset_fence.id.as_str()),
|
||||
Some(reset_fence.generation),
|
||||
Some(expected_credential),
|
||||
)
|
||||
.await?;
|
||||
let refresh_succeeded = codex_reset_refresh_succeeded(payload.as_ref(), &key.id);
|
||||
latest_payload = payload;
|
||||
if !refresh_succeeded
|
||||
|| !codex_reset_fence_is_still_pending(state, &key.id, expected_credential, reset_fence)
|
||||
.await?
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(latest_payload)
|
||||
}
|
||||
|
||||
fn merge_codex_quota_metadata(
|
||||
header_metadata: Option<&serde_json::Value>,
|
||||
@@ -53,6 +258,26 @@ fn merge_codex_quota_metadata(
|
||||
serde_json::Value::Object(merged)
|
||||
}
|
||||
|
||||
fn codex_quota_window_coverage(
|
||||
body_json: Option<&Value>,
|
||||
) -> aether_admin::provider::quota::CodexQuotaWindowCoverage {
|
||||
let body = body_json.and_then(Value::as_object);
|
||||
let has_account_snapshot = body
|
||||
.and_then(|body| body.get("rate_limit"))
|
||||
.and_then(Value::as_object)
|
||||
.is_some();
|
||||
let has_spark_snapshot = body
|
||||
.and_then(|body| body.get("additional_rate_limits"))
|
||||
.and_then(Value::as_array)
|
||||
.is_some();
|
||||
|
||||
match (has_account_snapshot, has_spark_snapshot) {
|
||||
(true, true) => aether_admin::provider::quota::CodexQuotaWindowCoverage::FullSnapshot,
|
||||
(true, false) => aether_admin::provider::quota::CodexQuotaWindowCoverage::AccountSnapshot,
|
||||
_ => aether_admin::provider::quota::CodexQuotaWindowCoverage::Patch,
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_codex_reset_credit_detail_error(message: impl Into<String>) -> String {
|
||||
let message = message.into();
|
||||
let mut sanitized = message.replace('\n', " ");
|
||||
@@ -198,6 +423,10 @@ fn codex_consume_success_status(outcome: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_reset_credit_outcome_allows_usage_drop(outcome: &str) -> bool {
|
||||
matches!(outcome, "reset" | "already_redeemed")
|
||||
}
|
||||
|
||||
fn codex_extract_refresh_result_fields(
|
||||
refresh_payload: Option<&Value>,
|
||||
key_id: &str,
|
||||
@@ -247,12 +476,74 @@ fn codex_extract_refresh_result_fields(
|
||||
)
|
||||
}
|
||||
|
||||
async fn finish_codex_reset_replay(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
endpoint: &StoredProviderCatalogEndpoint,
|
||||
key: &StoredProviderCatalogKey,
|
||||
credential: &ProviderTransportCredentialFence,
|
||||
terminal: CodexAccountResetTerminal,
|
||||
) -> Result<(StatusCode, Value), GatewayError> {
|
||||
let mut refresh_status = "skipped".to_string();
|
||||
let mut refresh_error = None;
|
||||
let mut metadata = None;
|
||||
let mut quota_snapshot = None;
|
||||
if codex_reset_credit_outcome_allows_usage_drop(&terminal.outcome) {
|
||||
let fence = super::shared::CodexAccountResetFence {
|
||||
unix_ms: crate::clock::current_unix_ms(),
|
||||
id: format!("reset:{}", terminal.idempotency_key),
|
||||
generation: terminal.generation,
|
||||
};
|
||||
if codex_reset_fence_is_still_pending(state, &key.id, credential, &fence).await? {
|
||||
match refresh_codex_quota_after_reset_until_settled(
|
||||
state, provider, endpoint, key, &fence, credential,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(payload) => {
|
||||
(refresh_status, refresh_error, metadata, quota_snapshot) =
|
||||
codex_extract_refresh_result_fields(payload.as_ref(), &key.id);
|
||||
}
|
||||
Err(err) => {
|
||||
refresh_status = "failed".to_string();
|
||||
refresh_error =
|
||||
Some(truncate_codex_reset_credit_detail_error(err.into_message()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut payload = Map::new();
|
||||
payload.insert("key_id".to_string(), json!(key.id));
|
||||
payload.insert(
|
||||
"status".to_string(),
|
||||
json!(codex_consume_success_status(&terminal.outcome)),
|
||||
);
|
||||
payload.insert("outcome".to_string(), json!(terminal.outcome));
|
||||
payload.insert(
|
||||
"idempotency_key".to_string(),
|
||||
json!(terminal.idempotency_key),
|
||||
);
|
||||
payload.insert("replay".to_string(), json!(true));
|
||||
payload.insert("refresh_status".to_string(), json!(refresh_status));
|
||||
if let Some(refresh_error) = refresh_error {
|
||||
payload.insert("refresh_error".to_string(), json!(refresh_error));
|
||||
}
|
||||
if let Some(metadata) = metadata {
|
||||
payload.insert("metadata".to_string(), metadata);
|
||||
}
|
||||
if let Some(quota_snapshot) = quota_snapshot {
|
||||
payload.insert("quota_snapshot".to_string(), quota_snapshot);
|
||||
}
|
||||
Ok((StatusCode::OK, Value::Object(payload)))
|
||||
}
|
||||
|
||||
pub(crate) async fn consume_codex_reset_credit_locally(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
endpoint: &StoredProviderCatalogEndpoint,
|
||||
key: StoredProviderCatalogKey,
|
||||
idempotency_key: &str,
|
||||
expected_credential_generation: Option<&str>,
|
||||
) -> Result<(StatusCode, Value), GatewayError> {
|
||||
let transport = match state
|
||||
.read_provider_transport_snapshot(&provider.id, &endpoint.id, &key.id)
|
||||
@@ -273,22 +564,48 @@ pub(crate) async fn consume_codex_reset_credit_locally(
|
||||
};
|
||||
|
||||
let is_oauth_managed = provider_key_is_oauth_managed(&key, provider.provider_type.as_str());
|
||||
let resolved_oauth_auth = if is_oauth_managed {
|
||||
state.resolve_local_oauth_header_auth(&transport).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if is_oauth_managed && resolved_oauth_auth.is_none() {
|
||||
if !is_oauth_managed {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
json!({
|
||||
"key_id": key.id,
|
||||
"status": "error",
|
||||
"outcome": "error",
|
||||
"message": "缺少 Codex OAuth 认证信息,请先重新授权/刷新 Token",
|
||||
"message": "Codex reset credit 仅支持 OAuth 托管账号",
|
||||
}),
|
||||
));
|
||||
}
|
||||
let (transport, resolved_oauth_auth, reset_credential_fence) =
|
||||
match prepare_codex_oauth_request(state, &transport).await? {
|
||||
CodexOAuthRequestPreparation::Ready {
|
||||
transport,
|
||||
auth,
|
||||
credential_fence,
|
||||
} => (transport, Some(auth), credential_fence),
|
||||
CodexOAuthRequestPreparation::MissingAuth => {
|
||||
return Ok((
|
||||
StatusCode::BAD_REQUEST,
|
||||
json!({
|
||||
"key_id": key.id,
|
||||
"status": "error",
|
||||
"outcome": "error",
|
||||
"message": "缺少 Codex OAuth 认证信息,请先重新授权/刷新 Token",
|
||||
}),
|
||||
));
|
||||
}
|
||||
CodexOAuthRequestPreparation::Conflict => {
|
||||
return Ok((
|
||||
StatusCode::CONFLICT,
|
||||
json!({
|
||||
"key_id": key.id,
|
||||
"status": "error",
|
||||
"outcome": "error",
|
||||
"idempotency_key": idempotency_key,
|
||||
"message": "Codex credential changed before reset credit could be consumed",
|
||||
}),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let request_spec = match build_codex_reset_credit_consume_request_spec(
|
||||
&transport,
|
||||
@@ -309,6 +626,79 @@ pub(crate) async fn consume_codex_reset_credit_locally(
|
||||
}
|
||||
};
|
||||
|
||||
let reservation = match reserve_codex_account_reset(
|
||||
state,
|
||||
&key.id,
|
||||
reset_credential_fence.encrypted_auth_config.as_str(),
|
||||
&reset_credential_fence.credential,
|
||||
expected_credential_generation,
|
||||
idempotency_key,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(CodexAccountResetReserveResult::Reserved(reservation)) => reservation,
|
||||
Some(CodexAccountResetReserveResult::Replay(terminal)) => {
|
||||
return finish_codex_reset_replay(
|
||||
state,
|
||||
provider,
|
||||
endpoint,
|
||||
&key,
|
||||
&reset_credential_fence,
|
||||
terminal,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Some(CodexAccountResetReserveResult::LegacyReplay) => {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
json!({
|
||||
"key_id": key.id,
|
||||
"status": "success",
|
||||
"outcome": "historical_replay",
|
||||
"idempotency_key": idempotency_key,
|
||||
"refresh_status": "skipped",
|
||||
}),
|
||||
));
|
||||
}
|
||||
Some(CodexAccountResetReserveResult::Busy(active)) => {
|
||||
return Ok((
|
||||
StatusCode::CONFLICT,
|
||||
json!({
|
||||
"key_id": key.id,
|
||||
"status": "error",
|
||||
"outcome": "busy",
|
||||
"idempotency_key": idempotency_key,
|
||||
"active_idempotency_key": active.idempotency_key,
|
||||
"message": "Another Codex reset credit operation is unresolved",
|
||||
}),
|
||||
));
|
||||
}
|
||||
Some(CodexAccountResetReserveResult::CredentialGenerationMismatch) => {
|
||||
return Ok((
|
||||
StatusCode::CONFLICT,
|
||||
json!({
|
||||
"key_id": key.id,
|
||||
"status": "error",
|
||||
"outcome": "credential_changed",
|
||||
"idempotency_key": idempotency_key,
|
||||
"message": "Codex credential changed since this reset request was prepared",
|
||||
}),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
return Ok((
|
||||
StatusCode::CONFLICT,
|
||||
json!({
|
||||
"key_id": key.id,
|
||||
"status": "error",
|
||||
"outcome": "error",
|
||||
"idempotency_key": idempotency_key,
|
||||
"message": "Codex reset reservation could not be persisted",
|
||||
}),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let result =
|
||||
match execute_codex_reset_credit_plan(state, &transport, request_spec, None).await? {
|
||||
ProviderQuotaExecutionOutcome::Response(result) => result,
|
||||
@@ -331,11 +721,11 @@ pub(crate) async fn consume_codex_reset_credit_locally(
|
||||
.and_then(|body| body.json_body.as_ref());
|
||||
let outcome = normalize_codex_reset_credit_consume_outcome(body_json)
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let known_non_error_outcome = matches!(
|
||||
let known_terminal_outcome = matches!(
|
||||
outcome.as_str(),
|
||||
"reset" | "already_redeemed" | "nothing_to_reset" | "no_credit"
|
||||
);
|
||||
if result.status_code >= 400 && !known_non_error_outcome {
|
||||
if !known_terminal_outcome {
|
||||
let detail = extract_execution_error_message(&result)
|
||||
.unwrap_or_else(|| format!("HTTP {}", result.status_code));
|
||||
return Ok((
|
||||
@@ -345,19 +735,88 @@ pub(crate) async fn consume_codex_reset_credit_locally(
|
||||
"status": "error",
|
||||
"outcome": "error",
|
||||
"idempotency_key": idempotency_key,
|
||||
"message": format!("reset credit consume 返回状态码 {}: {detail}", result.status_code),
|
||||
"message": format!("reset credit consume outcome is ambiguous: {detail}"),
|
||||
"status_code": result.status_code,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let (refresh_status, refresh_error, metadata, quota_snapshot) =
|
||||
match refresh_codex_provider_quota_locally(
|
||||
let fence_unix_ms = result
|
||||
.response_observation
|
||||
.as_ref()
|
||||
.map(|observation| observation.response_headers_observed_at_unix_ms)
|
||||
.unwrap_or_else(crate::clock::current_unix_ms);
|
||||
let Some(completed) = complete_codex_account_reset(
|
||||
state,
|
||||
&key.id,
|
||||
reset_credential_fence.encrypted_auth_config.as_str(),
|
||||
&reset_credential_fence.credential,
|
||||
&reservation,
|
||||
&outcome,
|
||||
fence_unix_ms,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok((
|
||||
StatusCode::CONFLICT,
|
||||
json!({
|
||||
"key_id": key.id,
|
||||
"status": "error",
|
||||
"outcome": "error",
|
||||
"idempotency_key": idempotency_key,
|
||||
"message": "Codex reset completion could not be persisted",
|
||||
}),
|
||||
));
|
||||
};
|
||||
|
||||
let (effective_outcome, reset_fence) = match completed {
|
||||
CodexAccountResetCompleteResult::Activated(fence) => (outcome.clone(), Some(fence)),
|
||||
CodexAccountResetCompleteResult::Noop(terminal)
|
||||
| CodexAccountResetCompleteResult::Replay(terminal) => {
|
||||
let fence =
|
||||
codex_reset_credit_outcome_allows_usage_drop(&terminal.outcome).then(|| {
|
||||
super::shared::CodexAccountResetFence {
|
||||
unix_ms: fence_unix_ms,
|
||||
id: format!("reset:{}", terminal.idempotency_key),
|
||||
generation: terminal.generation,
|
||||
}
|
||||
});
|
||||
(terminal.outcome, fence)
|
||||
}
|
||||
};
|
||||
|
||||
let (refresh_status, refresh_error, metadata, quota_snapshot) = match reset_fence.as_ref() {
|
||||
Some(reset_fence) => {
|
||||
match refresh_codex_quota_after_reset_until_settled(
|
||||
state,
|
||||
provider,
|
||||
endpoint,
|
||||
&key,
|
||||
reset_fence,
|
||||
&reset_credential_fence,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(refresh_payload) => {
|
||||
codex_extract_refresh_result_fields(refresh_payload.as_ref(), &key.id)
|
||||
}
|
||||
Err(err) => (
|
||||
"failed".to_string(),
|
||||
Some(truncate_codex_reset_credit_detail_error(err.into_message())),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
}
|
||||
}
|
||||
None => match refresh_codex_provider_quota_locally_with_reset_fence(
|
||||
state,
|
||||
provider,
|
||||
endpoint,
|
||||
vec![key.clone()],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&reset_credential_fence),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -370,15 +829,16 @@ pub(crate) async fn consume_codex_reset_credit_locally(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
let mut payload = Map::new();
|
||||
payload.insert("key_id".to_string(), json!(key.id));
|
||||
payload.insert(
|
||||
"status".to_string(),
|
||||
json!(codex_consume_success_status(&outcome)),
|
||||
json!(codex_consume_success_status(&effective_outcome)),
|
||||
);
|
||||
payload.insert("outcome".to_string(), json!(outcome));
|
||||
payload.insert("outcome".to_string(), json!(effective_outcome));
|
||||
payload.insert("idempotency_key".to_string(), json!(idempotency_key));
|
||||
payload.insert("refresh_status".to_string(), json!(refresh_status));
|
||||
if let Some(refresh_error) = refresh_error {
|
||||
@@ -400,6 +860,29 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
endpoint: &StoredProviderCatalogEndpoint,
|
||||
keys: Vec<StoredProviderCatalogKey>,
|
||||
proxy_override: Option<ProxySnapshot>,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
refresh_codex_provider_quota_locally_with_reset_fence(
|
||||
state,
|
||||
provider,
|
||||
endpoint,
|
||||
keys,
|
||||
proxy_override,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn refresh_codex_provider_quota_locally_with_reset_fence(
|
||||
state: &AdminAppState<'_>,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
endpoint: &StoredProviderCatalogEndpoint,
|
||||
keys: Vec<StoredProviderCatalogKey>,
|
||||
proxy_override: Option<ProxySnapshot>,
|
||||
account_reset_fence_id: Option<&str>,
|
||||
authoritative_reset_generation: Option<u64>,
|
||||
expected_reset_credential: Option<&crate::state::ProviderTransportCredentialFence>,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
let mut results = Vec::new();
|
||||
let mut success_count = 0usize;
|
||||
@@ -412,7 +895,7 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
for key in keys {
|
||||
let had_oauth_refresh_issue =
|
||||
codex_oauth_refresh_issue_reason(key.oauth_invalid_reason.as_deref());
|
||||
let transport = match state
|
||||
let initial_transport = match state
|
||||
.read_provider_transport_snapshot(&provider.id, &endpoint.id, &key.id)
|
||||
.await?
|
||||
{
|
||||
@@ -429,48 +912,70 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
}
|
||||
};
|
||||
let is_oauth_managed = provider_key_is_oauth_managed(&key, provider.provider_type.as_str());
|
||||
let quota_auth_config_fence = if is_oauth_managed {
|
||||
match state
|
||||
.app()
|
||||
.capture_provider_transport_auth_config_fence(&transport)
|
||||
.await?
|
||||
{
|
||||
Some(ciphertext) => Some(ciphertext),
|
||||
None => {
|
||||
let (transport, resolved_oauth_auth, quota_credential_fence) = if is_oauth_managed {
|
||||
match prepare_codex_oauth_request(state, &initial_transport).await? {
|
||||
CodexOAuthRequestPreparation::Ready {
|
||||
transport,
|
||||
auth,
|
||||
credential_fence,
|
||||
} => (transport, Some(auth), Some(credential_fence)),
|
||||
CodexOAuthRequestPreparation::MissingAuth => {
|
||||
failed_count += 1;
|
||||
results.push(json!({
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
"status": "error",
|
||||
"message": "OAuth credential changed before quota refresh",
|
||||
"message": "缺少 Codex OAuth 认证信息,请先重新授权/刷新 Token",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
CodexOAuthRequestPreparation::Conflict => {
|
||||
if quota_key_auto_removed(state, &key.id).await? {
|
||||
auto_removed_count += 1;
|
||||
results.push(oauth_refresh_auto_removed_result(&key));
|
||||
} else {
|
||||
failed_count += 1;
|
||||
results.push(json!({
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
"status": "error",
|
||||
"message": "OAuth credential changed before quota refresh",
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
(initial_transport, None, None)
|
||||
};
|
||||
|
||||
let resolved_oauth_auth = if is_oauth_managed {
|
||||
state.resolve_local_oauth_header_auth(&transport).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if is_oauth_managed && quota_key_auto_removed(state, &key.id).await? {
|
||||
auto_removed_count += 1;
|
||||
results.push(oauth_refresh_auto_removed_result(&key));
|
||||
continue;
|
||||
}
|
||||
if is_oauth_managed && resolved_oauth_auth.is_none() {
|
||||
failed_count += 1;
|
||||
results.push(json!({
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
"status": "error",
|
||||
"message": "缺少 Codex OAuth 认证信息,请先重新授权/刷新 Token",
|
||||
}));
|
||||
continue;
|
||||
if let Some(expected_reset_credential) = expected_reset_credential {
|
||||
if quota_credential_fence.as_ref() != Some(expected_reset_credential) {
|
||||
failed_count += 1;
|
||||
results.push(json!({
|
||||
"key_id": key.id,
|
||||
"key_name": key.name,
|
||||
"status": "error",
|
||||
"message": "Codex credential changed after reset credit was consumed",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let transport_codex_metadata = transport
|
||||
.key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get("codex"));
|
||||
let observed_reset_generation = authoritative_reset_generation.or_else(|| {
|
||||
Some(
|
||||
aether_admin::provider::quota::codex_quota_account_reset_generation(
|
||||
transport_codex_metadata,
|
||||
),
|
||||
)
|
||||
});
|
||||
let observed_credential_generation =
|
||||
aether_admin::provider::quota::codex_credential_generation(transport_codex_metadata)
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
let request_spec =
|
||||
match build_codex_quota_request_spec(&transport, resolved_oauth_auth.clone()) {
|
||||
@@ -487,6 +992,8 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
}
|
||||
};
|
||||
|
||||
let quota_request_fallback_started_at_unix_ms = crate::clock::current_unix_ms();
|
||||
let quota_request_fallback_order_id = uuid::Uuid::now_v7().to_string();
|
||||
let result = match execute_codex_quota_plan(
|
||||
state,
|
||||
&transport,
|
||||
@@ -508,16 +1015,25 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let quota_response_fallback_observed_at_unix_ms = crate::clock::current_unix_ms();
|
||||
let quota_response_observation = result.response_observation.as_ref();
|
||||
let quota_request_started_at_unix_ms = quota_response_observation
|
||||
.map(|observation| observation.request_started_at_unix_ms)
|
||||
.unwrap_or(quota_request_fallback_started_at_unix_ms);
|
||||
let quota_response_observed_at_unix_ms = quota_response_observation
|
||||
.map(|observation| observation.response_headers_observed_at_unix_ms)
|
||||
.unwrap_or(quota_response_fallback_observed_at_unix_ms);
|
||||
let quota_request_order_id = quota_response_observation
|
||||
.map(|observation| observation.request_order_id.as_str())
|
||||
.unwrap_or(quota_request_fallback_order_id.as_str());
|
||||
let now_unix_secs = quota_response_observed_at_unix_ms / 1_000;
|
||||
|
||||
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 quota_window_coverage =
|
||||
aether_admin::provider::quota::CodexQuotaWindowCoverage::Patch;
|
||||
let (mut oauth_invalid_at_unix_secs, mut oauth_invalid_reason) = (None, None);
|
||||
let mut status = "error".to_string();
|
||||
let mut message = None::<String>;
|
||||
@@ -544,6 +1060,7 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
now_unix_secs,
|
||||
)
|
||||
.await?;
|
||||
quota_window_coverage = codex_quota_window_coverage(Some(body_json));
|
||||
metadata_update = Some(json!({
|
||||
"codex": codex_metadata
|
||||
}));
|
||||
@@ -586,6 +1103,8 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
}
|
||||
402 => {
|
||||
if codex_looks_like_workspace_deactivated(err_msg.as_deref()) {
|
||||
quota_window_coverage =
|
||||
aether_admin::provider::quota::CodexQuotaWindowCoverage::Patch;
|
||||
let mut codex_meta = metadata_update
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("codex"))
|
||||
@@ -627,6 +1146,8 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
oauth_invalid_reason = reason;
|
||||
status = "workspace_deactivated".to_string();
|
||||
} else {
|
||||
quota_window_coverage =
|
||||
aether_admin::provider::quota::CodexQuotaWindowCoverage::Patch;
|
||||
let plan_type = transport
|
||||
.key
|
||||
.decrypted_auth_config
|
||||
@@ -667,24 +1188,45 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
}
|
||||
}
|
||||
|
||||
let persisted = if let Some(expected_auth_config) = quota_auth_config_fence.as_deref() {
|
||||
let persisted = if let Some(expected_credential) = quota_credential_fence.as_ref() {
|
||||
persist_fenced_provider_quota_refresh_state(
|
||||
state,
|
||||
&key.id,
|
||||
expected_auth_config,
|
||||
expected_credential.encrypted_auth_config.as_str(),
|
||||
metadata_update.as_ref(),
|
||||
oauth_invalid_at_unix_secs,
|
||||
oauth_invalid_reason.clone(),
|
||||
aether_admin::provider::quota::CodexQuotaMergeContext {
|
||||
observed_at_unix_secs: now_unix_secs,
|
||||
request_started_at_unix_ms: Some(quota_request_started_at_unix_ms),
|
||||
request_order_id: Some(quota_request_order_id),
|
||||
observed_reset_generation,
|
||||
authoritative_reset_generation,
|
||||
observed_credential_generation: observed_credential_generation.as_deref(),
|
||||
account_reset_fence_id,
|
||||
coverage: quota_window_coverage,
|
||||
},
|
||||
Some(&expected_credential.credential),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
persist_provider_quota_refresh_state(
|
||||
persist_codex_provider_quota_refresh_state(
|
||||
state,
|
||||
&key.id,
|
||||
metadata_update.as_ref(),
|
||||
oauth_invalid_at_unix_secs,
|
||||
oauth_invalid_reason.clone(),
|
||||
None,
|
||||
aether_admin::provider::quota::CodexQuotaMergeContext {
|
||||
observed_at_unix_secs: now_unix_secs,
|
||||
request_started_at_unix_ms: Some(quota_request_started_at_unix_ms),
|
||||
request_order_id: Some(quota_request_order_id),
|
||||
observed_reset_generation,
|
||||
authoritative_reset_generation,
|
||||
observed_credential_generation: observed_credential_generation.as_deref(),
|
||||
account_reset_fence_id,
|
||||
coverage: quota_window_coverage,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
};
|
||||
@@ -698,26 +1240,57 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
let credential_cas_delete = quota_auth_config_fence.as_ref().map(|auth_config| {
|
||||
let persisted_key = state
|
||||
.read_provider_catalog_keys_by_ids(&[key.id.clone()])
|
||||
.await?
|
||||
.into_iter()
|
||||
.next();
|
||||
let persisted_codex_metadata = persisted_key
|
||||
.as_ref()
|
||||
.and_then(|key| key.upstream_metadata.as_ref())
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|metadata| metadata.get("codex"))
|
||||
.cloned();
|
||||
if let Some(codex_metadata) = persisted_codex_metadata.as_ref() {
|
||||
metadata_update = Some(json!({"codex": codex_metadata}));
|
||||
}
|
||||
let persisted_codex_object = persisted_codex_metadata
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let request_owns_persisted_oauth_state = quota_credential_fence.is_none()
|
||||
|| (persisted_codex_object
|
||||
.and_then(|codex| codex.get("oauth_state_request_started_at_unix_ms"))
|
||||
.and_then(aether_admin::provider::quota::coerce_json_u64)
|
||||
== Some(quota_request_started_at_unix_ms)
|
||||
&& persisted_codex_object
|
||||
.and_then(|codex| codex.get("oauth_state_request_id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some(quota_request_order_id));
|
||||
let credential_cas_delete = quota_credential_fence.as_ref().map(|credential_fence| {
|
||||
ProviderCatalogKeyOAuthCredentialCasDelete {
|
||||
key_id: key.id.clone(),
|
||||
expected_encrypted_auth_config: Some(auth_config.clone()),
|
||||
expected_credential: ProviderCatalogKeyOAuthCredentialFence {
|
||||
encrypted_api_key: key.encrypted_api_key.clone(),
|
||||
auth_type: key.auth_type.clone(),
|
||||
provider_id: key.provider_id.clone(),
|
||||
provider_type: provider.provider_type.clone(),
|
||||
},
|
||||
expected_encrypted_auth_config: Some(
|
||||
credential_fence.encrypted_auth_config.clone(),
|
||||
),
|
||||
expected_credential: credential_fence.credential.clone(),
|
||||
expected_upstream_metadata_namespace: Some(
|
||||
ProviderCatalogUpstreamMetadataNamespaceExpectation {
|
||||
namespace: "codex".to_string(),
|
||||
expected_value: persisted_codex_metadata.clone(),
|
||||
},
|
||||
),
|
||||
}
|
||||
});
|
||||
let should_auto_remove_hard_banned =
|
||||
provider_auto_remove_banned_keys(provider.config.as_ref())
|
||||
&& should_auto_remove_oauth_invalid_key(
|
||||
&key,
|
||||
oauth_invalid_reason.as_deref(),
|
||||
matches!(status_code, Some(401 | 403)),
|
||||
now_unix_secs,
|
||||
);
|
||||
let should_auto_remove_hard_banned = request_owns_persisted_oauth_state
|
||||
&& provider_auto_remove_banned_keys(provider.config.as_ref())
|
||||
&& should_auto_remove_oauth_invalid_key(
|
||||
persisted_key.as_ref().unwrap_or(&key),
|
||||
persisted_key
|
||||
.as_ref()
|
||||
.and_then(|key| key.oauth_invalid_reason.as_deref()),
|
||||
matches!(status_code, Some(401 | 403)),
|
||||
now_unix_secs,
|
||||
);
|
||||
let auto_removed_hard_banned = if should_auto_remove_hard_banned {
|
||||
match credential_cas_delete.as_ref() {
|
||||
Some(delete) => {
|
||||
@@ -735,6 +1308,7 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
auto_removed_hard_banned_count += 1;
|
||||
}
|
||||
let auto_removed_quota_exhausted = if !auto_removed_hard_banned
|
||||
&& request_owns_persisted_oauth_state
|
||||
&& status == "quota_exhausted"
|
||||
&& provider_auto_remove_quota_exhausted_keys(provider.config.as_ref())
|
||||
{
|
||||
@@ -798,7 +1372,9 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
|
||||
}
|
||||
if let Some(quota_snapshot) = build_quota_snapshot_payload(
|
||||
"codex",
|
||||
key.status_snapshot.as_ref(),
|
||||
persisted_key
|
||||
.as_ref()
|
||||
.and_then(|key| key.status_snapshot.as_ref()),
|
||||
metadata_update.as_ref(),
|
||||
) {
|
||||
payload.insert("quota_snapshot".to_string(), quota_snapshot);
|
||||
@@ -866,6 +1442,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_reset_credit_only_allows_usage_drop_after_confirmed_redemption() {
|
||||
assert!(codex_reset_credit_outcome_allows_usage_drop("reset"));
|
||||
assert!(codex_reset_credit_outcome_allows_usage_drop(
|
||||
"already_redeemed"
|
||||
));
|
||||
assert!(!codex_reset_credit_outcome_allows_usage_drop(
|
||||
"nothing_to_reset"
|
||||
));
|
||||
assert!(!codex_reset_credit_outcome_allows_usage_drop("no_credit"));
|
||||
assert!(!codex_reset_credit_outcome_allows_usage_drop("unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_reset_credit_detail_failure_records_attempt_time() {
|
||||
let mut metadata = Map::new();
|
||||
@@ -880,4 +1469,29 @@ mod tests {
|
||||
Some(&json!(1_777_000_000u64))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_quota_coverage_only_replaces_observed_window_families() {
|
||||
assert_eq!(
|
||||
codex_quota_window_coverage(Some(&json!({"credits":{"balance":5}}))),
|
||||
aether_admin::provider::quota::CodexQuotaWindowCoverage::Patch
|
||||
);
|
||||
assert_eq!(
|
||||
codex_quota_window_coverage(None),
|
||||
aether_admin::provider::quota::CodexQuotaWindowCoverage::Patch
|
||||
);
|
||||
assert_eq!(
|
||||
codex_quota_window_coverage(Some(&json!({
|
||||
"rate_limit":{"primary_window":{}}
|
||||
}))),
|
||||
aether_admin::provider::quota::CodexQuotaWindowCoverage::AccountSnapshot
|
||||
);
|
||||
assert_eq!(
|
||||
codex_quota_window_coverage(Some(&json!({
|
||||
"rate_limit":{"primary_window":{}},
|
||||
"additional_rate_limits":[]
|
||||
}))),
|
||||
aether_admin::provider::quota::CodexQuotaWindowCoverage::FullSnapshot
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,6 +796,7 @@ mod tests {
|
||||
candidate_id: None,
|
||||
status_code: 403,
|
||||
headers: BTreeMap::new(),
|
||||
response_observation: None,
|
||||
body: Some(ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(base64::engine::general_purpose::STANDARD.encode(body)),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -178,6 +178,7 @@ fn provider_query_execution_json_body_decodes_stream_encoded_json_response() {
|
||||
"content-type".to_string(),
|
||||
"application/json".to_string(),
|
||||
)]),
|
||||
response_observation: None,
|
||||
body: Some(aether_contracts::ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(encoded_body),
|
||||
@@ -436,6 +437,7 @@ fn provider_query_standard_test_aggregates_responses_stream_body() {
|
||||
candidate_id: Some("candidate-0".to_string()),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
response_observation: None,
|
||||
body: Some(aether_contracts::ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(
|
||||
@@ -468,6 +470,7 @@ fn provider_query_standard_test_aggregates_responses_image_generation_call() {
|
||||
candidate_id: Some("candidate-0".to_string()),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
response_observation: None,
|
||||
body: Some(aether_contracts::ResponseBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: Some(
|
||||
@@ -601,6 +604,7 @@ fn provider_query_search_success_requires_non_empty_output() {
|
||||
candidate_id: Some("candidate-0".to_string()),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
response_observation: None,
|
||||
body: Some(aether_contracts::ResponseBody {
|
||||
json_body: Some(body),
|
||||
body_bytes_b64: None,
|
||||
@@ -751,6 +755,7 @@ fn provider_query_standard_test_rejects_gemini_success_without_visible_output()
|
||||
candidate_id: Some("candidate-0".to_string()),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
response_observation: None,
|
||||
body: Some(aether_contracts::ResponseBody {
|
||||
json_body: Some(json!({
|
||||
"candidates": [{
|
||||
|
||||
@@ -122,6 +122,7 @@ pub(crate) struct AdminProviderQuotaRefreshRequest {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct AdminCodexResetCreditConsumeRequest {
|
||||
pub(crate) idempotency_key: String,
|
||||
pub(crate) expected_credential_generation: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -339,3 +340,37 @@ pub(crate) struct AdminImportProviderModelsRequest {
|
||||
)]
|
||||
pub(crate) price_per_request: Option<f64>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AdminCodexResetCreditConsumeRequest;
|
||||
|
||||
#[test]
|
||||
fn codex_reset_credit_consume_requires_an_explicit_credential_generation() {
|
||||
assert!(
|
||||
serde_json::from_value::<AdminCodexResetCreditConsumeRequest>(
|
||||
serde_json::json!({"idempotency_key":"reset-old-client"}),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let legacy_account =
|
||||
serde_json::from_value::<AdminCodexResetCreditConsumeRequest>(serde_json::json!({
|
||||
"idempotency_key":"reset-legacy-account",
|
||||
"expected_credential_generation":null,
|
||||
}))
|
||||
.expect("explicit null should fence an account without a generation");
|
||||
assert!(legacy_account.expected_credential_generation.is_null());
|
||||
|
||||
let generated_account =
|
||||
serde_json::from_value::<AdminCodexResetCreditConsumeRequest>(serde_json::json!({
|
||||
"idempotency_key":"reset-generated-account",
|
||||
"expected_credential_generation":"credential-v2",
|
||||
}))
|
||||
.expect("string generation should deserialize");
|
||||
assert_eq!(
|
||||
generated_account.expected_credential_generation,
|
||||
serde_json::json!("credential-v2")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::handlers::admin::provider::oauth::provisioning::rotate_codex_credential_generation;
|
||||
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyCreateRequest;
|
||||
use crate::handlers::admin::provider::write::normalize::{
|
||||
normalize_allow_auth_channel_mismatch_formats, normalize_api_format_json_object_keys,
|
||||
@@ -216,6 +217,7 @@ pub(crate) async fn build_admin_create_provider_key_record(
|
||||
)?;
|
||||
key.created_at_unix_ms = Some(now_unix_secs);
|
||||
key.updated_at_unix_secs = Some(now_unix_secs);
|
||||
rotate_codex_credential_generation(&mut key, &provider.provider_type);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ pub(crate) use self::update::build_admin_update_provider_key_record;
|
||||
pub(crate) use self::update::{
|
||||
admin_provider_key_update_requires_immediate_model_fetch,
|
||||
build_admin_update_provider_key_record_with_existing_keys,
|
||||
build_provider_catalog_key_admin_cas_update,
|
||||
};
|
||||
|
||||
mod batch;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::handlers::admin::provider::oauth::provisioning::rotate_codex_credential_generation;
|
||||
use crate::handlers::admin::provider::shared::payloads::AdminProviderKeyUpdatePatch;
|
||||
use crate::handlers::admin::provider::write::normalize::{
|
||||
normalize_allow_auth_channel_mismatch_formats, normalize_api_format_json_object_keys,
|
||||
@@ -13,6 +14,7 @@ use crate::handlers::admin::shared::{
|
||||
use crate::handlers::shared::normalize_optional_api_key_concurrent_limit;
|
||||
use crate::provider_key_auth::provider_key_is_oauth_managed;
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
ProviderCatalogKeyAdminCasUpdate, ProviderCatalogKeyOAuthCredentialFence,
|
||||
StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
use aether_provider_transport::provider_types::provider_type_is_fixed;
|
||||
@@ -368,6 +370,12 @@ pub(crate) fn build_admin_update_provider_key_record_with_existing_keys(
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs());
|
||||
let credential_identity_changed = !updated.auth_type.eq_ignore_ascii_case(&existing.auth_type)
|
||||
|| updated.encrypted_api_key != existing.encrypted_api_key
|
||||
|| updated.encrypted_auth_config != existing.encrypted_auth_config;
|
||||
if credential_identity_changed {
|
||||
rotate_codex_credential_generation(&mut updated, &provider.provider_type);
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
@@ -382,6 +390,49 @@ pub(crate) fn admin_provider_key_update_requires_immediate_model_fetch(
|
||||
&& (!existing.auto_fetch_models || filters_changed || locked_models_changed)
|
||||
}
|
||||
|
||||
pub(crate) fn build_provider_catalog_key_admin_cas_update(
|
||||
existing: &StoredProviderCatalogKey,
|
||||
updated: StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
) -> ProviderCatalogKeyAdminCasUpdate {
|
||||
let previous_generation = existing
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.pointer("/codex/credential_generation"))
|
||||
.and_then(serde_json::Value::as_str);
|
||||
let next_generation = updated
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.pointer("/codex/credential_generation"))
|
||||
.and_then(serde_json::Value::as_str);
|
||||
let credential_changed = existing.auth_type != updated.auth_type
|
||||
|| existing.encrypted_api_key != updated.encrypted_api_key
|
||||
|| existing.encrypted_auth_config != updated.encrypted_auth_config;
|
||||
let codex_rotation = provider_type
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("codex")
|
||||
.then(|| next_generation.filter(|next| Some(*next) != previous_generation))
|
||||
.flatten()
|
||||
.map(|generation| {
|
||||
json!({
|
||||
aether_admin::provider::quota::CODEX_CREDENTIAL_GENERATION_KEY: generation,
|
||||
})
|
||||
});
|
||||
|
||||
ProviderCatalogKeyAdminCasUpdate {
|
||||
expected_encrypted_auth_config: existing.encrypted_auth_config.clone(),
|
||||
expected_credential: ProviderCatalogKeyOAuthCredentialFence {
|
||||
encrypted_api_key: existing.encrypted_api_key.clone(),
|
||||
auth_type: existing.auth_type.clone(),
|
||||
provider_id: existing.provider_id.clone(),
|
||||
provider_type: provider_type.to_string(),
|
||||
},
|
||||
key: updated,
|
||||
codex_rotation,
|
||||
reset_oauth_runtime: credential_changed,
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_secret_auth_type(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
|
||||
@@ -186,6 +186,15 @@ impl<'a> AdminAppState<'a> {
|
||||
self.app.update_provider_catalog_key(key).await
|
||||
}
|
||||
|
||||
pub(crate) async fn compare_and_update_provider_catalog_key_admin_state(
|
||||
&self,
|
||||
update: &aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyAdminCasUpdate,
|
||||
) -> Result<bool, GatewayError> {
|
||||
self.app
|
||||
.compare_and_update_provider_catalog_key_admin_state(update)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn compare_and_update_provider_catalog_key_adaptive_state(
|
||||
&self,
|
||||
update: &aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyAdaptiveStateUpdate,
|
||||
|
||||
@@ -513,6 +513,7 @@ impl<'a> AdminAppState<'a> {
|
||||
provider_id: key.provider_id.clone(),
|
||||
provider_type: provider.provider_type.clone(),
|
||||
},
|
||||
expected_upstream_metadata_namespace: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -4,10 +4,12 @@ use crate::api::ai::admin_endpoint_signature_parts;
|
||||
use crate::handlers::admin::admin_provider_pool_config;
|
||||
use crate::handlers::admin::model::ADMIN_EXTERNAL_MODELS_PROXY_NODE_CONFIG_KEY;
|
||||
use crate::handlers::admin::provider::endpoints_admin::payloads::AdminProviderEndpointUpdatePatch;
|
||||
use crate::handlers::admin::provider::oauth::provisioning::ensure_codex_credential_generation_rotated;
|
||||
use crate::handlers::admin::provider::shared::payloads::{
|
||||
AdminProviderCreateRequest, AdminProviderKeyCreateRequest, AdminProviderKeyUpdatePatch,
|
||||
AdminProviderUpdatePatch,
|
||||
};
|
||||
use crate::handlers::admin::provider::write::keys::build_provider_catalog_key_admin_cas_update;
|
||||
use crate::handlers::admin::shared::{
|
||||
normalize_json_array, normalize_json_object, normalize_string_list,
|
||||
};
|
||||
@@ -377,10 +379,14 @@ fn normalize_import_key_raw_payload(
|
||||
|
||||
fn apply_imported_oauth_key_credentials(
|
||||
state: &AdminAppState<'_>,
|
||||
provider_type: &str,
|
||||
previous_codex_credential_generation: Option<&str>,
|
||||
raw_key: &Map<String, Value>,
|
||||
normalized_auth_config: Option<&Value>,
|
||||
record: &mut aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
|
||||
) -> Result<bool, String> {
|
||||
let previous_encrypted_api_key = record.encrypted_api_key.clone();
|
||||
let previous_encrypted_auth_config = record.encrypted_auth_config.clone();
|
||||
let mut credentials_supplied = false;
|
||||
let mut api_key_supplied = false;
|
||||
if let Some(api_key_value) = raw_key.get("api_key") {
|
||||
@@ -424,10 +430,19 @@ fn apply_imported_oauth_key_credentials(
|
||||
api_key_supplied,
|
||||
);
|
||||
|
||||
let credential_material_changed = record.encrypted_api_key != previous_encrypted_api_key
|
||||
|| record.encrypted_auth_config != previous_encrypted_auth_config;
|
||||
if credentials_supplied {
|
||||
record.oauth_invalid_at_unix_secs = None;
|
||||
record.oauth_invalid_reason = None;
|
||||
}
|
||||
if credential_material_changed {
|
||||
ensure_codex_credential_generation_rotated(
|
||||
record,
|
||||
provider_type,
|
||||
previous_codex_credential_generation,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(credentials_supplied)
|
||||
}
|
||||
@@ -1861,6 +1876,15 @@ impl<'a> AdminAppState<'a> {
|
||||
|
||||
if let Some(existing_index) = existing_key_index {
|
||||
let existing_key = existing_keys[existing_index].clone();
|
||||
let previous_codex_credential_generation = existing_key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get("codex"))
|
||||
.and_then(|codex| {
|
||||
aether_admin::provider::quota::codex_credential_generation(Some(codex))
|
||||
})
|
||||
.map(ToOwned::to_owned);
|
||||
match merge_mode {
|
||||
AdminImportMergeMode::Skip => {
|
||||
stats.keys.skipped += 1;
|
||||
@@ -1890,6 +1914,8 @@ impl<'a> AdminAppState<'a> {
|
||||
let oauth_credentials_supplied = if auth_type == "oauth" {
|
||||
invalid!(apply_imported_oauth_key_credentials(
|
||||
self,
|
||||
&provider.provider_type,
|
||||
previous_codex_credential_generation.as_deref(),
|
||||
&raw_key,
|
||||
normalized_auth_config.as_ref(),
|
||||
&mut updated,
|
||||
@@ -1903,8 +1929,31 @@ impl<'a> AdminAppState<'a> {
|
||||
imported_key.fingerprint.clone(),
|
||||
"fingerprint",
|
||||
));
|
||||
let Some(mut persisted) =
|
||||
self.update_provider_catalog_key(&updated).await?
|
||||
let admin_update = build_provider_catalog_key_admin_cas_update(
|
||||
&existing_key,
|
||||
updated.clone(),
|
||||
&provider.provider_type,
|
||||
);
|
||||
if !self
|
||||
.compare_and_update_provider_catalog_key_admin_state(&admin_update)
|
||||
.await?
|
||||
{
|
||||
return Ok(Err((
|
||||
http::StatusCode::CONFLICT,
|
||||
json!({
|
||||
"detail": format!(
|
||||
"Provider '{provider_name}' 的 Key 已被其他请求更新,请重试"
|
||||
)
|
||||
}),
|
||||
)));
|
||||
}
|
||||
let Some(mut persisted) = self
|
||||
.read_provider_catalog_keys_by_ids(std::slice::from_ref(
|
||||
&updated.id,
|
||||
))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
else {
|
||||
return Ok(Err(invalid_request(format!(
|
||||
"更新 Provider '{provider_name}' 的 Key 失败"
|
||||
@@ -1926,16 +1975,15 @@ impl<'a> AdminAppState<'a> {
|
||||
persisted = reloaded;
|
||||
}
|
||||
if oauth_credentials_supplied {
|
||||
if !self
|
||||
.clear_provider_catalog_key_oauth_invalid_marker(&updated.id)
|
||||
.await?
|
||||
{
|
||||
return Ok(Err(invalid_request(format!(
|
||||
"更新 Provider '{provider_name}' 的 Key 失败"
|
||||
))));
|
||||
}
|
||||
let Some(reloaded) = self
|
||||
.reset_provider_catalog_key_recovery_state(&updated.id)
|
||||
.reset_provider_catalog_key_recovery_state_fenced(
|
||||
&updated.id,
|
||||
updated.encrypted_auth_config.as_deref().ok_or_else(|| {
|
||||
GatewayError::Internal(format!(
|
||||
"OAuth Provider '{provider_name}' imported without auth_config"
|
||||
))
|
||||
})?,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Err(invalid_request(format!(
|
||||
@@ -1975,6 +2023,8 @@ impl<'a> AdminAppState<'a> {
|
||||
let oauth_credentials_supplied = if auth_type == "oauth" {
|
||||
invalid!(apply_imported_oauth_key_credentials(
|
||||
self,
|
||||
&provider.provider_type,
|
||||
None,
|
||||
&raw_key,
|
||||
normalized_auth_config.as_ref(),
|
||||
&mut record,
|
||||
|
||||
Reference in New Issue
Block a user