Merge pull request #662 from MMEXA/codex/reset-credit-20260704

增加 Codex 重置次数功能
This commit is contained in:
fawney19
2026-07-07 01:58:44 +08:00
committed by GitHub
22 changed files with 1670 additions and 35 deletions
@@ -195,6 +195,17 @@ pub(super) fn classify_admin_endpoints_family_route(
"admin:endpoints_manage",
false,
))
} else if method == http::Method::POST
&& normalized_path.starts_with("/api/admin/endpoints/keys/")
&& normalized_path.ends_with("/codex-reset-credit/consume")
{
Some(classified(
"admin_proxy",
"endpoints_manage",
"codex_reset_credit_consume",
"admin:endpoints_manage",
false,
))
} else if method == http::Method::POST
&& normalized_path.starts_with("/api/admin/endpoints/providers/")
&& normalized_path.ends_with("/refresh-quota")
@@ -437,6 +437,23 @@ fn classifies_admin_refresh_provider_quota_as_admin_proxy_route() {
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn classifies_admin_codex_reset_credit_consume_as_admin_proxy_route() {
let headers = http::HeaderMap::new();
let uri: Uri = "/api/admin/endpoints/keys/key-codex/codex-reset-credit/consume"
.parse()
.expect("uri should parse");
let decision = classify_control_route(&http::Method::POST, &uri, &headers)
.expect("decision should resolve");
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
assert_eq!(decision.route_family.as_deref(), Some("endpoints_manage"));
assert_eq!(
decision.route_kind.as_deref(),
Some("codex_reset_credit_consume")
);
assert!(!decision.is_execution_runtime_candidate());
}
#[test]
fn admin_refresh_provider_quota_buffers_request_body_for_key_selection() {
let headers = headers(&[]);
@@ -0,0 +1,116 @@
use crate::handlers::admin::provider::oauth::quota::codex::consume_codex_reset_credit_locally;
use crate::handlers::admin::provider::oauth::quota::shared::{
provider_quota_refresh_endpoint_for_provider, provider_quota_refresh_missing_endpoint_message,
};
use crate::handlers::admin::provider::shared::paths::admin_codex_reset_credit_consume_key_id;
use crate::handlers::admin::provider::shared::payloads::AdminCodexResetCreditConsumeRequest;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::GatewayError;
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
pub(super) async fn maybe_handle(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.decision() else {
return Ok(None);
};
if decision.route_family.as_deref() != Some("endpoints_manage")
|| decision.route_kind.as_deref() != Some("codex_reset_credit_consume")
|| request_context.method() != http::Method::POST
|| !request_context
.path()
.starts_with("/api/admin/endpoints/keys/")
|| !request_context
.path()
.ends_with("/codex-reset-credit/consume")
{
return Ok(None);
}
let Some(key_id) = admin_codex_reset_credit_consume_key_id(request_context.path()) else {
return Ok(Some(not_found_response("Key 不存在")));
};
let payload = match request_body.filter(|body| !body.is_empty()) {
Some(request_body) => {
match serde_json::from_slice::<AdminCodexResetCreditConsumeRequest>(request_body) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(bad_request_response("请求体必须是合法的 JSON 对象")));
}
}
}
None => {
return Ok(Some(bad_request_response("请求体必须包含 idempotency_key")));
}
};
let idempotency_key = payload.idempotency_key.trim().to_string();
if idempotency_key.is_empty() {
return Ok(Some(bad_request_response("idempotency_key 不能为空")));
}
let Some(key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(not_found_response(format!("Key {key_id} 不存在"))));
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&key.provider_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(not_found_response(format!(
"Provider {} 不存在",
key.provider_id
))));
};
let normalized_provider_type = provider.provider_type.trim().to_ascii_lowercase();
if normalized_provider_type != "codex" {
return Ok(Some(bad_request_response(
"仅 Codex Provider 支持使用重置机会",
)));
}
let endpoints = state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider.id))
.await?;
let Some(endpoint) =
provider_quota_refresh_endpoint_for_provider(&normalized_provider_type, &endpoints, true)
else {
return Ok(Some(bad_request_response(
provider_quota_refresh_missing_endpoint_message(&normalized_provider_type),
)));
};
let (status, payload) =
consume_codex_reset_credit_locally(state, &provider, &endpoint, key, &idempotency_key)
.await?;
Ok(Some((status, Json(payload)).into_response()))
}
fn bad_request_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
fn not_found_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
@@ -1,4 +1,5 @@
mod batch;
mod codex_reset_credit;
mod create;
mod delete;
mod oauth_invalid;
@@ -36,6 +37,11 @@ pub(super) async fn maybe_handle(
{
return Ok(Some(response));
}
if let Some(response) =
codex_reset_credit::maybe_handle(state, request_context, request_body).await?
{
return Ok(Some(response));
}
if let Some(response) = create::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response));
}
@@ -8,10 +8,15 @@ use self::invalid::{
codex_structured_invalid_reason,
};
use self::parse::{
build_codex_quota_exhausted_fallback_metadata, parse_codex_usage_headers,
build_codex_quota_exhausted_fallback_metadata, normalize_codex_reset_credit_consume_outcome,
parse_codex_usage_headers, parse_codex_wham_reset_credits_detail_response,
parse_codex_wham_usage_response,
};
use self::plan::{build_codex_quota_request_spec, execute_codex_quota_plan};
use self::plan::{
build_codex_quota_request_spec, build_codex_reset_credit_consume_request_spec,
build_codex_reset_credits_request_spec, execute_codex_quota_plan,
execute_codex_reset_credit_plan,
};
use super::shared::{
build_quota_snapshot_payload, extract_execution_error_message,
oauth_refresh_auto_removed_result, persist_provider_quota_refresh_state,
@@ -26,7 +31,8 @@ use aether_contracts::ProxySnapshot;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use serde_json::json;
use axum::http::StatusCode;
use serde_json::{json, Map, Value};
use std::time::{SystemTime, UNIX_EPOCH};
fn merge_codex_quota_metadata(
@@ -45,6 +51,139 @@ fn merge_codex_quota_metadata(
serde_json::Value::Object(merged)
}
fn codex_reset_credits_available_count(metadata: &Map<String, Value>) -> Option<u64> {
metadata
.get("reset_credits")
.and_then(Value::as_object)
.and_then(|reset_credits| reset_credits.get("available_count"))
.and_then(aether_admin::provider::quota::coerce_json_u64)
}
fn truncate_codex_reset_credit_detail_error(message: impl Into<String>) -> String {
let message = message.into();
let mut sanitized = message.replace('\n', " ");
if sanitized.len() > 240 {
sanitized.truncate(240);
sanitized.push('…');
}
sanitized
}
fn merge_codex_reset_credit_detail_metadata(
codex_metadata: &mut Map<String, Value>,
detail_metadata: &Value,
) {
let Some(detail_reset_credits) = detail_metadata
.get("reset_credits")
.and_then(Value::as_object)
else {
return;
};
let mut reset_credits = codex_metadata
.get("reset_credits")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let has_usage_available_count = reset_credits.contains_key("available_count");
for (key, value) in detail_reset_credits {
if key == "available_count" && has_usage_available_count {
continue;
}
reset_credits.insert(key.clone(), value.clone());
}
codex_metadata.insert("reset_credits".to_string(), Value::Object(reset_credits));
}
fn mark_codex_reset_credit_detail_failed(
codex_metadata: &mut Map<String, Value>,
detail_error: impl Into<String>,
) {
let mut reset_credits = codex_metadata
.get("reset_credits")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
reset_credits.insert("detail_source".to_string(), json!("wham_readonly"));
reset_credits.insert("detail_status".to_string(), json!("failed"));
reset_credits.insert(
"detail_error".to_string(),
json!(truncate_codex_reset_credit_detail_error(detail_error)),
);
reset_credits
.entry("credits".to_string())
.or_insert_with(|| json!([]));
codex_metadata.insert("reset_credits".to_string(), Value::Object(reset_credits));
}
async fn enrich_codex_reset_credit_details(
state: &AdminAppState<'_>,
transport: &crate::handlers::admin::request::AdminGatewayProviderTransportSnapshot,
resolved_oauth_auth: Option<(String, String)>,
proxy_override: Option<&ProxySnapshot>,
codex_metadata: &mut Map<String, Value>,
now_unix_secs: u64,
) -> Result<(), GatewayError> {
let available_count = codex_reset_credits_available_count(codex_metadata).unwrap_or(0);
if available_count == 0 {
return Ok(());
}
let request_spec = match build_codex_reset_credits_request_spec(transport, resolved_oauth_auth)
{
Ok(request_spec) => request_spec,
Err(message) => {
mark_codex_reset_credit_detail_failed(codex_metadata, message);
return Ok(());
}
};
let result =
match execute_codex_reset_credit_plan(state, transport, request_spec, proxy_override)
.await?
{
ProviderQuotaExecutionOutcome::Response(result) => result,
ProviderQuotaExecutionOutcome::Failure(detail) => {
mark_codex_reset_credit_detail_failed(
codex_metadata,
format!("reset credit detail 请求执行失败: {detail}"),
);
return Ok(());
}
};
if result.status_code != 200 {
let detail = extract_execution_error_message(&result)
.unwrap_or_else(|| format!("HTTP {}", result.status_code));
mark_codex_reset_credit_detail_failed(
codex_metadata,
format!(
"reset credit detail 返回状态码 {}: {detail}",
result.status_code
),
);
return Ok(());
}
let Some(body_json) = result
.body
.as_ref()
.and_then(|body| body.json_body.as_ref())
else {
mark_codex_reset_credit_detail_failed(codex_metadata, "无法解析 reset credit detail 响应");
return Ok(());
};
if let Some(detail_metadata) =
parse_codex_wham_reset_credits_detail_response(body_json, now_unix_secs)
{
merge_codex_reset_credit_detail_metadata(codex_metadata, &detail_metadata);
} else {
mark_codex_reset_credit_detail_failed(codex_metadata, "reset credit detail 响应为空");
}
Ok(())
}
fn codex_oauth_refresh_issue_reason(reason: Option<&str>) -> bool {
reason.is_some_and(|reason| {
reason
@@ -54,6 +193,210 @@ fn codex_oauth_refresh_issue_reason(reason: Option<&str>) -> bool {
})
}
fn codex_consume_success_status(outcome: &str) -> &'static str {
match outcome {
"reset" | "already_redeemed" => "success",
"nothing_to_reset" | "no_credit" => "noop",
_ => "unknown",
}
}
fn codex_extract_refresh_result_fields(
refresh_payload: Option<&Value>,
key_id: &str,
) -> (String, Option<String>, Option<Value>, Option<Value>) {
let Some(result) = refresh_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))
else {
return (
"failed".to_string(),
Some("刷新结果中缺少当前 key".to_string()),
None,
None,
);
};
let status = result
.get("status")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
let refresh_status = if status.eq_ignore_ascii_case("success") {
"success"
} else {
"failed"
}
.to_string();
let refresh_error = if refresh_status == "success" {
None
} else {
result
.get("message")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
};
(
refresh_status,
refresh_error,
result.get("metadata").cloned(),
result.get("quota_snapshot").cloned(),
)
}
pub(crate) async fn consume_codex_reset_credit_locally(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
endpoint: &StoredProviderCatalogEndpoint,
key: StoredProviderCatalogKey,
idempotency_key: &str,
) -> Result<(StatusCode, Value), GatewayError> {
let transport = match state
.read_provider_transport_snapshot(&provider.id, &endpoint.id, &key.id)
.await?
{
Some(transport) => transport,
None => {
return Ok((
StatusCode::BAD_GATEWAY,
json!({
"key_id": key.id,
"status": "error",
"outcome": "error",
"message": "Provider transport snapshot unavailable",
}),
));
}
};
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() {
return Ok((
StatusCode::BAD_REQUEST,
json!({
"key_id": key.id,
"status": "error",
"outcome": "error",
"message": "缺少 Codex OAuth 认证信息,请先重新授权/刷新 Token",
}),
));
}
let request_spec = match build_codex_reset_credit_consume_request_spec(
&transport,
resolved_oauth_auth,
idempotency_key,
) {
Ok(request_spec) => request_spec,
Err(message) => {
return Ok((
StatusCode::BAD_REQUEST,
json!({
"key_id": key.id,
"status": "error",
"outcome": "error",
"message": message,
}),
));
}
};
let result =
match execute_codex_reset_credit_plan(state, &transport, request_spec, None).await? {
ProviderQuotaExecutionOutcome::Response(result) => result,
ProviderQuotaExecutionOutcome::Failure(detail) => {
return Ok((
StatusCode::BAD_GATEWAY,
json!({
"key_id": key.id,
"status": "error",
"outcome": "error",
"message": format!("reset credit consume 请求执行失败: {detail}"),
}),
));
}
};
let body_json = result
.body
.as_ref()
.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!(
outcome.as_str(),
"reset" | "already_redeemed" | "nothing_to_reset" | "no_credit"
);
if result.status_code >= 400 && !known_non_error_outcome {
let detail = extract_execution_error_message(&result)
.unwrap_or_else(|| format!("HTTP {}", result.status_code));
return Ok((
StatusCode::BAD_GATEWAY,
json!({
"key_id": key.id,
"status": "error",
"outcome": "error",
"idempotency_key": idempotency_key,
"message": format!("reset credit consume 返回状态码 {}: {detail}", result.status_code),
"status_code": result.status_code,
}),
));
}
let (refresh_status, refresh_error, metadata, quota_snapshot) =
match refresh_codex_provider_quota_locally(
state,
provider,
endpoint,
vec![key.clone()],
None,
)
.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,
),
};
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)),
);
payload.insert("outcome".to_string(), json!(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 {
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 refresh_codex_provider_quota_locally(
state: &AdminAppState<'_>,
provider: &StoredProviderCatalogProvider,
@@ -114,19 +457,20 @@ pub(crate) async fn refresh_codex_provider_quota_locally(
continue;
}
let request_spec = match build_codex_quota_request_spec(&transport, resolved_oauth_auth) {
Ok(request_spec) => request_spec,
Err(message) => {
failed_count += 1;
results.push(json!({
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": message,
}));
continue;
}
};
let request_spec =
match build_codex_quota_request_spec(&transport, resolved_oauth_auth.clone()) {
Ok(request_spec) => request_spec,
Err(message) => {
failed_count += 1;
results.push(json!({
"key_id": key.id,
"key_name": key.name,
"status": "error",
"message": message,
}));
continue;
}
};
let result = match execute_codex_quota_plan(
state,
@@ -171,8 +515,22 @@ 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) {
let mut codex_metadata =
match merge_codex_quota_metadata(header_metadata.as_ref(), &parsed) {
Value::Object(object) => object,
_ => Map::new(),
};
enrich_codex_reset_credit_details(
state,
&transport,
resolved_oauth_auth.clone(),
proxy_override.as_ref(),
&mut codex_metadata,
now_unix_secs,
)
.await?;
metadata_update = Some(json!({
"codex": merge_codex_quota_metadata(header_metadata.as_ref(), &parsed)
"codex": codex_metadata
}));
(oauth_invalid_at_unix_secs, oauth_invalid_reason) =
quota_refresh_success_invalid_state(&key);
@@ -22,6 +22,22 @@ pub(super) fn parse_codex_wham_usage_response(
admin_provider_quota_pure::parse_codex_wham_usage_response(value, updated_at_unix_secs)
}
pub(super) fn parse_codex_wham_reset_credits_detail_response(
value: &serde_json::Value,
updated_at_unix_secs: u64,
) -> Option<serde_json::Value> {
admin_provider_quota_pure::parse_codex_wham_reset_credits_detail_response(
value,
updated_at_unix_secs,
)
}
pub(super) fn normalize_codex_reset_credit_consume_outcome(
value: Option<&serde_json::Value>,
) -> Option<String> {
admin_provider_quota_pure::normalize_codex_reset_credit_consume_outcome(value)
}
pub(super) fn parse_codex_usage_headers(
headers: &BTreeMap<String, String>,
updated_at_unix_secs: u64,
@@ -5,17 +5,26 @@ use super::super::shared::{
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
use crate::GatewayError;
use aether_contracts::ProxySnapshot;
use aether_provider_pool::{build_codex_pool_quota_request, ProviderPoolQuotaRequestSpec};
use aether_provider_pool::{
build_codex_pool_quota_request, build_codex_pool_reset_credit_consume_request,
build_codex_pool_reset_credits_request, ProviderPoolQuotaRequestSpec,
};
fn codex_auth_config(
transport: &AdminGatewayProviderTransportSnapshot,
) -> Option<serde_json::Value> {
transport
.key
.decrypted_auth_config
.as_deref()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
}
pub(super) fn build_codex_quota_request_spec(
transport: &AdminGatewayProviderTransportSnapshot,
resolved_oauth_auth: Option<(String, String)>,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
let auth_config = transport
.key
.decrypted_auth_config
.as_deref()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok());
let auth_config = codex_auth_config(transport);
let mut request = build_codex_pool_quota_request(
&transport.key.id,
resolved_oauth_auth,
@@ -29,6 +38,44 @@ pub(super) fn build_codex_quota_request_spec(
Ok(request)
}
pub(super) fn build_codex_reset_credits_request_spec(
transport: &AdminGatewayProviderTransportSnapshot,
resolved_oauth_auth: Option<(String, String)>,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
let auth_config = codex_auth_config(transport);
let mut request = build_codex_pool_reset_credits_request(
&transport.key.id,
resolved_oauth_auth,
Some(transport.key.decrypted_api_key.as_str()),
auth_config.as_ref(),
)?;
crate::provider_transport::apply_local_auth_config_header_overrides(
&mut request.headers,
transport.key.decrypted_auth_config.as_deref(),
);
Ok(request)
}
pub(super) fn build_codex_reset_credit_consume_request_spec(
transport: &AdminGatewayProviderTransportSnapshot,
resolved_oauth_auth: Option<(String, String)>,
idempotency_key: &str,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
let auth_config = codex_auth_config(transport);
let mut request = build_codex_pool_reset_credit_consume_request(
&transport.key.id,
resolved_oauth_auth,
Some(transport.key.decrypted_api_key.as_str()),
auth_config.as_ref(),
idempotency_key,
)?;
crate::provider_transport::apply_local_auth_config_header_overrides(
&mut request.headers,
transport.key.decrypted_auth_config.as_deref(),
);
Ok(request)
}
pub(super) async fn execute_codex_quota_plan(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
@@ -56,3 +103,31 @@ pub(super) async fn execute_codex_quota_plan(
);
execute_provider_quota_plan(state, transport, plan, "codex").await
}
pub(super) async fn execute_codex_reset_credit_plan(
state: &AdminAppState<'_>,
transport: &AdminGatewayProviderTransportSnapshot,
spec: ProviderPoolQuotaRequestSpec,
proxy_override: Option<&ProxySnapshot>,
) -> Result<ProviderQuotaExecutionOutcome, GatewayError> {
let proxy = match proxy_override {
Some(proxy) => Some(proxy.clone()),
None => {
state
.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
.await
}
};
let timeouts = Some(resolve_provider_quota_execution_timeouts(
state.resolve_transport_execution_timeouts(transport),
proxy.as_ref(),
));
let plan = build_provider_quota_execution_plan(
transport,
spec,
proxy,
state.resolve_transport_profile(transport),
timeouts,
);
execute_provider_quota_plan(state, transport, plan, "codex_reset_credit").await
}
@@ -40,6 +40,13 @@ pub(crate) fn admin_reset_cycle_stats_key_id(request_path: &str) -> Option<Strin
.map(ToOwned::to_owned)
}
pub(crate) fn admin_codex_reset_credit_consume_key_id(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/admin/endpoints/keys/")?
.strip_suffix("/codex-reset-credit/consume")
.map(ToOwned::to_owned)
}
pub(crate) fn admin_update_key_id(request_path: &str) -> Option<String> {
let key_id = request_path.strip_prefix("/api/admin/endpoints/keys/")?;
(!key_id.is_empty() && !key_id.contains('/')).then_some(key_id.to_string())
@@ -15,9 +15,9 @@ pub(crate) use self::crud::{
is_admin_providers_root,
};
pub(crate) use self::endpoint_keys::{
admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_keys,
admin_provider_id_for_refresh_quota, admin_reset_cycle_stats_key_id, admin_reveal_key_id,
admin_update_key_id,
admin_clear_oauth_invalid_key_id, admin_codex_reset_credit_consume_key_id, admin_export_key_id,
admin_provider_id_for_keys, admin_provider_id_for_refresh_quota,
admin_reset_cycle_stats_key_id, admin_reveal_key_id, admin_update_key_id,
};
pub(crate) use self::oauth::{
admin_provider_oauth_batch_import_provider_id, admin_provider_oauth_batch_import_task_path,
@@ -113,6 +113,11 @@ pub(crate) struct AdminProviderQuotaRefreshRequest {
pub(crate) key_ids: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AdminCodexResetCreditConsumeRequest {
pub(crate) idempotency_key: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AdminProviderCreateRequest {
pub(crate) name: String,
@@ -855,6 +855,7 @@ fn build_codex_quota_status_snapshot(
let credits_unlimited = metadata
.get("credits_unlimited")
.and_then(admin_provider_quota_pure::coerce_json_bool);
let reset_credits = build_codex_reset_credits_status_snapshot(metadata, observed_at_unix_secs);
let windows = [
codex_quota_window_snapshot(metadata, "primary", "weekly", "", observed_at_unix_secs),
@@ -883,6 +884,7 @@ fn build_codex_quota_status_snapshot(
&& credits_has_credits.is_none()
&& credits_balance.is_none()
&& credits_unlimited.is_none()
&& reset_credits.is_none()
&& observed_at_unix_secs.is_none()
{
return None;
@@ -958,6 +960,7 @@ fn build_codex_quota_status_snapshot(
} else {
Value::Object(credits)
},
"reset_credits": reset_credits,
"windows": windows,
}))
}
@@ -1815,6 +1818,119 @@ fn build_gemini_cli_quota_status_snapshot(
}))
}
fn build_codex_reset_credits_status_snapshot(
metadata: &Map<String, Value>,
observed_at_unix_secs: Option<u64>,
) -> Option<Value> {
let reset_credits = metadata.get("reset_credits").and_then(Value::as_object)?;
let available_count = reset_credits
.get("available_count")
.and_then(admin_provider_quota_pure::coerce_json_u64);
let updated_at = reset_credits
.get("updated_at")
.and_then(admin_provider_quota_pure::coerce_json_u64)
.or(observed_at_unix_secs);
let detail_source = reset_credits
.get("detail_source")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let detail_status = reset_credits
.get("detail_status")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let detail_error = reset_credits
.get("detail_error")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let mut credits = reset_credits
.get("credits")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|item| {
let object = item.as_object()?;
let expires_at = object
.get("expires_at")
.and_then(admin_provider_quota_pure::coerce_json_u64)?;
let display_key = object
.get("display_key")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let mut out = Map::new();
if let Some(id) = object
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
out.insert("id".to_string(), json!(id));
}
out.insert("display_key".to_string(), json!(display_key));
if let Some(status) = object
.get("status")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
out.insert("status".to_string(), json!(status));
}
if let Some(granted_at) = object
.get("granted_at")
.and_then(admin_provider_quota_pure::coerce_json_u64)
{
out.insert("granted_at".to_string(), json!(granted_at));
}
out.insert("expires_at".to_string(), json!(expires_at));
if let Some(observed_at) = observed_at_unix_secs {
out.insert(
"remaining_seconds".to_string(),
json!(expires_at.saturating_sub(observed_at)),
);
}
Some(Value::Object(out))
})
.collect::<Vec<_>>();
credits.sort_by_key(|item| {
item.get("expires_at")
.and_then(admin_provider_quota_pure::coerce_json_u64)
.unwrap_or(u64::MAX)
});
if available_count.is_none()
&& updated_at.is_none()
&& detail_source.is_none()
&& detail_status.is_none()
&& detail_error.is_none()
&& credits.is_empty()
{
return None;
}
let mut out = Map::new();
if let Some(value) = available_count {
out.insert("available_count".to_string(), json!(value));
}
if let Some(value) = updated_at {
out.insert("updated_at".to_string(), json!(value));
}
if let Some(value) = detail_source {
out.insert("detail_source".to_string(), json!(value));
}
if let Some(value) = detail_status {
out.insert("detail_status".to_string(), json!(value));
}
if let Some(value) = detail_error {
out.insert("detail_error".to_string(), json!(value));
}
out.insert("credits".to_string(), Value::Array(credits));
Some(Value::Object(out))
}
pub(crate) fn sync_provider_key_quota_status_snapshot(
status_snapshot: Option<&Value>,
provider_type: &str,
@@ -1882,6 +1998,12 @@ fn quota_snapshot_has_materialized_data(
{
return true;
}
if quota_snapshot
.get("reset_credits")
.is_some_and(|reset_credits| !reset_credits.is_null())
{
return true;
}
quota_snapshot
.get("code")
@@ -2644,6 +2766,68 @@ mod tests {
);
}
#[test]
fn provider_key_status_snapshot_payload_backfills_codex_reset_credits() {
let mut key = sample_catalog_key();
key.upstream_metadata = Some(json!({
"codex": {
"updated_at": 1_775_553_285u64,
"plan_type": "plus",
"primary_used_percent": 55.0,
"primary_reset_at": 1_900_000_000u64,
"has_credits": true,
"credits_balance": 42.0,
"reset_credits": {
"available_count": 2,
"updated_at": 1_775_553_285u64,
"detail_source": "wham_readonly",
"detail_status": "available",
"credits": [
{
"id": "bbbbbbbb-1111-2222-3333-444444444444",
"display_key": "bbbbbbbb",
"status": "available",
"expires_at": 1_775_900_000u64
},
{
"id": "aaaaaaaa-1111-2222-3333-444444444444",
"display_key": "aaaaaaaa",
"status": "available",
"expires_at": 1_775_700_000u64
}
]
}
}
}));
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");
assert_eq!(quota.get("exhausted"), Some(&json!(false)));
assert_eq!(
payload.pointer("/quota/reset_credits/available_count"),
Some(&json!(2u64))
);
assert_eq!(
payload.pointer("/quota/reset_credits/credits/0/display_key"),
Some(&json!("aaaaaaaa"))
);
assert_eq!(
payload.pointer("/quota/reset_credits/credits/0/remaining_seconds"),
Some(&json!(146_715u64))
);
assert_eq!(
quota
.get("credits")
.and_then(Value::as_object)
.and_then(|credits| credits.get("balance")),
Some(&json!(42.0))
);
}
#[test]
fn provider_key_status_snapshot_payload_backfills_codex_spark_windows() {
let mut key = sample_catalog_key();
@@ -1882,9 +1882,16 @@ fn admin_provider_oauth_quota_mod_stays_thin() {
"apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/plan.rs",
);
for pattern in [
"use aether_provider_pool::{build_codex_pool_quota_request, ProviderPoolQuotaRequestSpec};",
"use aether_provider_pool::{",
"build_codex_pool_quota_request",
"build_codex_pool_reset_credits_request",
"build_codex_pool_reset_credit_consume_request",
"ProviderPoolQuotaRequestSpec",
"pub(super) fn build_codex_quota_request_spec(",
"pub(super) fn build_codex_reset_credits_request_spec(",
"pub(super) fn build_codex_reset_credit_consume_request_spec(",
"pub(super) async fn execute_codex_quota_plan(",
"pub(super) async fn execute_codex_reset_credit_plan(",
] {
assert!(
quota_codex_plan.contains(pattern),
+318 -1
View File
@@ -601,6 +601,47 @@ fn codex_find_spark_rate_limit(
.and_then(serde_json::Value::as_object)
}
fn codex_reset_credits_container(
root: &serde_json::Map<String, serde_json::Value>,
) -> Option<&serde_json::Map<String, serde_json::Value>> {
[
"rate_limit_reset_credits",
"rateLimitResetCredits",
"reset_credits",
"resetCredits",
]
.iter()
.find_map(|key| root.get(*key).and_then(serde_json::Value::as_object))
}
fn codex_reset_credits_available_count(
root: &serde_json::Map<String, serde_json::Value>,
) -> Option<u64> {
let container = codex_reset_credits_container(root)?;
[
"available_count",
"availableCount",
"available",
"remaining",
"count",
]
.iter()
.find_map(|key| container.get(*key).and_then(coerce_json_u64))
}
fn codex_reset_credits_count_snapshot(
available_count: u64,
updated_at_unix_secs: u64,
) -> serde_json::Value {
json!({
"available_count": available_count,
"updated_at": updated_at_unix_secs,
"detail_source": "wham_usage",
"detail_status": "not_requested",
"credits": [],
})
}
pub fn parse_codex_wham_usage_response(
value: &serde_json::Value,
updated_at_unix_secs: u64,
@@ -668,6 +709,13 @@ pub fn parse_codex_wham_usage_response(
}
}
if let Some(available_count) = codex_reset_credits_available_count(root) {
result.insert(
"reset_credits".to_string(),
codex_reset_credits_count_snapshot(available_count, updated_at_unix_secs),
);
}
if result.is_empty() {
return None;
}
@@ -675,6 +723,185 @@ pub fn parse_codex_wham_usage_response(
Some(serde_json::Value::Object(result))
}
fn parse_codex_reset_credit_timestamp(value: Option<&serde_json::Value>) -> Option<u64> {
let value = value?;
if let Some(timestamp) = coerce_json_u64(value) {
return Some(if timestamp > 1_000_000_000_000 {
timestamp / 1000
} else {
timestamp
});
}
let raw = value.as_str()?.trim();
if raw.is_empty() {
return None;
}
chrono::DateTime::parse_from_rfc3339(raw)
.ok()
.and_then(|timestamp| u64::try_from(timestamp.timestamp()).ok())
}
fn codex_reset_credit_detail_items(value: &serde_json::Value) -> Option<&Vec<serde_json::Value>> {
first_json_value_by_paths(
value,
&[
&["credits"],
&["data"],
&["items"],
&["rate_limit_reset_credits", "credits"],
&["rate_limit_reset_credits", "data"],
&["rateLimitResetCredits", "credits"],
&["rateLimitResetCredits", "data"],
&["reset_credits", "credits"],
&["resetCredits", "credits"],
],
)
.and_then(serde_json::Value::as_array)
}
fn codex_reset_credit_id(object: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
[
"id",
"credit_id",
"creditId",
"key",
"idempotency_key",
"idempotencyKey",
]
.iter()
.find_map(|key| coerce_json_string(object.get(*key)))
}
fn codex_reset_credit_display_key(id: &str) -> Option<String> {
id.split('-')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn codex_reset_credit_status(
object: &serde_json::Map<String, serde_json::Value>,
) -> Option<String> {
["status", "state"]
.iter()
.find_map(|key| coerce_json_string(object.get(*key)))
}
fn parse_codex_reset_credit_detail_item(item: &serde_json::Value) -> Option<serde_json::Value> {
let object = item.as_object()?;
let id = codex_reset_credit_id(object)?;
let display_key = codex_reset_credit_display_key(&id)?;
let expires_at = parse_codex_reset_credit_timestamp(
object
.get("expires_at")
.or_else(|| object.get("expiresAt"))
.or_else(|| object.get("expiration_time"))
.or_else(|| object.get("expirationTime")),
)?;
let granted_at = parse_codex_reset_credit_timestamp(
object
.get("granted_at")
.or_else(|| object.get("grantedAt"))
.or_else(|| object.get("created_at"))
.or_else(|| object.get("createdAt")),
);
let mut out = serde_json::Map::new();
out.insert("id".to_string(), json!(id));
out.insert("display_key".to_string(), json!(display_key));
if let Some(status) = codex_reset_credit_status(object) {
out.insert("status".to_string(), json!(status));
}
if let Some(granted_at) = granted_at {
out.insert("granted_at".to_string(), json!(granted_at));
}
out.insert("expires_at".to_string(), json!(expires_at));
Some(serde_json::Value::Object(out))
}
pub fn parse_codex_wham_reset_credits_detail_response(
value: &serde_json::Value,
updated_at_unix_secs: u64,
) -> Option<serde_json::Value> {
value.as_object()?;
let mut credits = codex_reset_credit_detail_items(value)
.into_iter()
.flatten()
.filter_map(parse_codex_reset_credit_detail_item)
.collect::<Vec<_>>();
credits.sort_by_key(|item| {
item.get("expires_at")
.and_then(coerce_json_u64)
.unwrap_or(u64::MAX)
});
let detail_status = if credits.is_empty() {
"empty"
} else {
"available"
};
let mut reset_credits = serde_json::Map::new();
reset_credits.insert("updated_at".to_string(), json!(updated_at_unix_secs));
reset_credits.insert("detail_source".to_string(), json!("wham_readonly"));
reset_credits.insert("detail_status".to_string(), json!(detail_status));
reset_credits.insert("credits".to_string(), serde_json::Value::Array(credits));
if let Some(root) = value.as_object() {
if let Some(available_count) = codex_reset_credits_available_count(root).or_else(|| {
[
"available_count",
"availableCount",
"available",
"remaining",
"count",
]
.iter()
.find_map(|key| root.get(*key).and_then(coerce_json_u64))
}) {
reset_credits.insert("available_count".to_string(), json!(available_count));
}
}
Some(json!({ "reset_credits": reset_credits }))
}
pub fn normalize_codex_reset_credit_consume_outcome(
value: Option<&serde_json::Value>,
) -> Option<String> {
let object = value.and_then(serde_json::Value::as_object)?;
let raw = ["outcome", "status", "result", "code"]
.iter()
.find_map(|key| coerce_json_string(object.get(*key)));
if let Some(raw) = raw {
let normalized = raw.trim().replace(['-', ' '], "_").to_ascii_lowercase();
return match normalized.as_str() {
"reset" | "success" | "redeemed" => Some("reset".to_string()),
"alreadyredeemed" | "already_redeemed" => Some("already_redeemed".to_string()),
"nothingtoreset" | "nothing_to_reset" => Some("nothing_to_reset".to_string()),
"nocredit" | "no_credit" => Some("no_credit".to_string()),
"error" | "failed" => Some("error".to_string()),
_ => None,
};
}
for (field, outcome) in [
("reset", "reset"),
("alreadyRedeemed", "already_redeemed"),
("already_redeemed", "already_redeemed"),
("nothingToReset", "nothing_to_reset"),
("nothing_to_reset", "nothing_to_reset"),
("noCredit", "no_credit"),
("no_credit", "no_credit"),
] {
if object.get(field).and_then(coerce_json_bool) == Some(true) {
return Some(outcome.to_string());
}
}
None
}
fn codex_json_object<'a>(
root: &'a serde_json::Map<String, serde_json::Value>,
keys: &[&str],
@@ -1772,7 +1999,8 @@ pub fn parse_chatgpt_web_conversation_init_response(
mod tests {
use super::{
codex_build_invalid_state, codex_runtime_invalid_reason,
parse_chatgpt_web_conversation_init_response, parse_codex_backend_me_response,
normalize_codex_reset_credit_consume_outcome, parse_chatgpt_web_conversation_init_response,
parse_codex_backend_me_response, parse_codex_wham_reset_credits_detail_response,
parse_codex_wham_usage_response, parse_gemini_cli_retrieve_user_quota_response,
parse_gemini_cli_v1internal_credits_response, parse_windsurf_model_configs_response,
parse_windsurf_rate_limit_response, parse_windsurf_user_status_response,
@@ -2187,6 +2415,95 @@ mod tests {
);
}
#[test]
fn parses_codex_reset_credit_count_from_wham_usage() {
let parsed = parse_codex_wham_usage_response(
&json!({
"plan_type": "plus",
"rate_limit": {
"primary_window": {
"used_percent": 25.0,
"reset_after_seconds": 604800
}
},
"rate_limit_reset_credits": {
"available_count": 2
}
}),
1_777_000_000,
)
.expect("codex wham usage should parse");
assert_eq!(
parsed.pointer("/reset_credits/available_count"),
Some(&json!(2u64))
);
assert_eq!(
parsed.pointer("/reset_credits/detail_status"),
Some(&json!("not_requested"))
);
assert_eq!(
parsed.pointer("/reset_credits/updated_at"),
Some(&json!(1_777_000_000u64))
);
}
#[test]
fn parses_codex_reset_credit_detail_sorted_by_expiry() {
let parsed = parse_codex_wham_reset_credits_detail_response(
&json!({
"credits": [
{
"idempotencyKey": "bbbbbbbb-1111-2222-3333-444444444444",
"status": "available",
"expiresAt": "2030-01-04T00:00:00Z"
},
{
"idempotencyKey": "aaaaaaaa-1111-2222-3333-444444444444",
"status": "available",
"grantedAt": 1_893_456_000_000u64,
"expiresAt": "2030-01-02T00:00:00Z"
}
]
}),
1_777_000_000,
)
.expect("detail should parse");
assert_eq!(
parsed.pointer("/reset_credits/detail_status"),
Some(&json!("available"))
);
assert_eq!(
parsed.pointer("/reset_credits/credits/0/display_key"),
Some(&json!("aaaaaaaa"))
);
assert_eq!(
parsed.pointer("/reset_credits/credits/0/granted_at"),
Some(&json!(1_893_456_000u64))
);
assert_eq!(
parsed.pointer("/reset_credits/credits/1/display_key"),
Some(&json!("bbbbbbbb"))
);
}
#[test]
fn normalizes_codex_reset_credit_consume_outcome() {
assert_eq!(
normalize_codex_reset_credit_consume_outcome(Some(&json!({
"outcome": "alreadyRedeemed"
}))),
Some("already_redeemed".to_string())
);
assert_eq!(
normalize_codex_reset_credit_consume_outcome(Some(&json!({
"noCredit": true
}))),
Some("no_credit".to_string())
);
}
#[test]
fn parses_codex_backend_me_identity_metadata_without_quota_windows() {
let parsed = parse_codex_backend_me_response(
+63 -2
View File
@@ -16,7 +16,8 @@ pub use presets::{
pub use provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
pub use providers::{
build_antigravity_pool_quota_request, build_chatgpt_web_pool_quota_request,
build_codex_pool_quota_request, build_gemini_cli_pool_quota_request,
build_codex_pool_quota_request, build_codex_pool_reset_credit_consume_request,
build_codex_pool_reset_credits_request, build_gemini_cli_pool_quota_request,
build_kiro_pool_quota_request, build_windsurf_pool_model_configs_request,
build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request,
build_windsurf_pool_quota_request_with_base_url, build_windsurf_pool_rate_limit_request,
@@ -27,7 +28,8 @@ pub use providers::{
DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter,
KiroPoolQuotaAuthInput, KiroProviderPoolAdapter, UnsupportedQuotaProviderPoolAdapter,
ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH, CHATGPT_WEB_CONVERSATION_INIT_PATH,
CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_USAGE_URL, GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH,
CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_RESET_CREDITS_CONSUME_URL,
CODEX_WHAM_RESET_CREDITS_URL, CODEX_WHAM_USAGE_URL, GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH,
GEMINI_CLI_USER_AGENT, KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH, WINDSURF_USER_STATUS_PATH,
};
@@ -215,6 +217,65 @@ mod tests {
assert_eq!(spec.model_name.as_deref(), Some("codex-wham-usage"));
}
#[test]
fn codex_reset_credits_request_uses_wham_detail_endpoint() {
let spec = build_codex_pool_reset_credits_request(
"key-1",
Some(("authorization".to_string(), "Bearer access".to_string())),
None,
Some(&json!({
"plan_type": "plus",
"account_id": "acct-1"
})),
)
.expect("spec should build");
assert_eq!(spec.method, "GET");
assert_eq!(spec.url, CODEX_WHAM_RESET_CREDITS_URL);
assert_eq!(
spec.headers.get("authorization").map(String::as_str),
Some("Bearer access")
);
assert_eq!(
spec.headers.get("chatgpt-account-id").map(String::as_str),
Some("acct-1")
);
assert_eq!(spec.model_name.as_deref(), Some("codex-wham-reset-credits"));
}
#[test]
fn codex_reset_credit_consume_request_posts_redeem_request_id() {
let spec = build_codex_pool_reset_credit_consume_request(
"key-1",
Some(("authorization".to_string(), "Bearer access".to_string())),
None,
None,
"8ae6f1c7-7e9e-4f5d-9b8a-000000000000",
)
.expect("spec should build");
assert_eq!(spec.method, "POST");
assert_eq!(spec.url, CODEX_WHAM_RESET_CREDITS_CONSUME_URL);
assert_eq!(spec.content_type.as_deref(), Some("application/json"));
assert_eq!(
spec.json_body
.as_ref()
.and_then(|body| body.get("redeem_request_id")),
Some(&json!("8ae6f1c7-7e9e-4f5d-9b8a-000000000000"))
);
assert_eq!(
spec.json_body
.as_ref()
.and_then(|body| body.as_object())
.map(|body| body.len()),
Some(1)
);
assert_eq!(
spec.model_name.as_deref(),
Some("codex-wham-reset-credit-consume")
);
}
#[test]
fn codex_quota_request_skips_account_header_for_free_accounts() {
let spec = build_codex_pool_quota_request(
@@ -17,6 +17,10 @@ use crate::quota::{
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
pub const CODEX_WHAM_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
pub const CODEX_WHAM_RESET_CREDITS_URL: &str =
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
pub const CODEX_WHAM_RESET_CREDITS_CONSUME_URL: &str =
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
#[derive(Debug, Clone, Default)]
@@ -68,12 +72,11 @@ impl ProviderPoolAdapter for CodexProviderPoolAdapter {
}
}
pub fn build_codex_pool_quota_request(
key_id: &str,
fn build_codex_wham_headers(
resolved_oauth_auth: Option<(String, String)>,
decrypted_api_key: Option<&str>,
auth_config: Option<&Value>,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
) -> Result<BTreeMap<String, String>, String> {
let mut headers = BTreeMap::new();
headers.insert("accept".to_string(), "application/json".to_string());
let auth_config_headers = auth_config
@@ -120,6 +123,17 @@ pub fn build_codex_pool_quota_request(
);
}
Ok(headers)
}
pub fn build_codex_pool_quota_request(
key_id: &str,
resolved_oauth_auth: Option<(String, String)>,
decrypted_api_key: Option<&str>,
auth_config: Option<&Value>,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
let headers = build_codex_wham_headers(resolved_oauth_auth, decrypted_api_key, auth_config)?;
Ok(ProviderPoolQuotaRequestSpec {
request_id: format!("codex-quota:{key_id}"),
provider_name: "codex".to_string(),
@@ -136,6 +150,64 @@ pub fn build_codex_pool_quota_request(
})
}
pub fn build_codex_pool_reset_credits_request(
key_id: &str,
resolved_oauth_auth: Option<(String, String)>,
decrypted_api_key: Option<&str>,
auth_config: Option<&Value>,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
let headers = build_codex_wham_headers(resolved_oauth_auth, decrypted_api_key, auth_config)?;
Ok(ProviderPoolQuotaRequestSpec {
request_id: format!("codex-reset-credits:{key_id}"),
provider_name: "codex".to_string(),
quota_kind: "codex_reset_credits".to_string(),
method: "GET".to_string(),
url: CODEX_WHAM_RESET_CREDITS_URL.to_string(),
headers,
content_type: None,
json_body: None,
client_api_format: "openai:responses".to_string(),
provider_api_format: "openai:responses".to_string(),
model_name: Some("codex-wham-reset-credits".to_string()),
accept_invalid_certs: false,
})
}
pub fn build_codex_pool_reset_credit_consume_request(
key_id: &str,
resolved_oauth_auth: Option<(String, String)>,
decrypted_api_key: Option<&str>,
auth_config: Option<&Value>,
redeem_request_id: &str,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
let redeem_request_id = redeem_request_id.trim();
if redeem_request_id.is_empty() {
return Err("缺少 Codex reset credit 幂等请求 ID".to_string());
}
let mut headers =
build_codex_wham_headers(resolved_oauth_auth, decrypted_api_key, auth_config)?;
headers.insert("content-type".to_string(), "application/json".to_string());
Ok(ProviderPoolQuotaRequestSpec {
request_id: format!("codex-reset-credit-consume:{key_id}:{redeem_request_id}"),
provider_name: "codex".to_string(),
quota_kind: "codex_reset_credit_consume".to_string(),
method: "POST".to_string(),
url: CODEX_WHAM_RESET_CREDITS_CONSUME_URL.to_string(),
headers,
content_type: Some("application/json".to_string()),
json_body: Some(serde_json::json!({
"redeem_request_id": redeem_request_id,
})),
client_api_format: "openai:responses".to_string(),
provider_api_format: "openai:responses".to_string(),
model_name: Some("codex-wham-reset-credit-consume".to_string()),
accept_invalid_certs: false,
})
}
fn codex_window_reset_elapsed(bucket: &Map<String, Value>, prefix: &str) -> bool {
let Some(now_unix_secs) = provider_pool_current_unix_secs() else {
return false;
@@ -19,7 +19,11 @@ pub use chatgpt_web::{
CHATGPT_WEB_DEFAULT_BASE_URL,
};
pub use codex::CodexProviderPoolAdapter;
pub use codex::{build_codex_pool_quota_request, CODEX_WHAM_USAGE_URL};
pub use codex::{
build_codex_pool_quota_request, build_codex_pool_reset_credit_consume_request,
build_codex_pool_reset_credits_request, CODEX_WHAM_RESET_CREDITS_CONSUME_URL,
CODEX_WHAM_RESET_CREDITS_URL, CODEX_WHAM_USAGE_URL,
};
pub use default::DefaultProviderPoolAdapter;
pub use gemini_cli::GeminiCliProviderPoolAdapter;
pub use gemini_cli::{
+36
View File
@@ -286,6 +286,42 @@ export async function refreshProviderQuota(
return response.data
}
export interface ConsumeCodexResetCreditPayload {
idempotency_key: string
}
export interface ConsumeCodexResetCreditResult {
key_id: string
status: 'success' | 'noop' | 'unknown' | 'error' | string
outcome:
| 'reset'
| 'already_redeemed'
| 'nothing_to_reset'
| 'no_credit'
| 'unknown'
| 'error'
| string
idempotency_key: string
refresh_status?: 'success' | 'failed' | string
refresh_error?: string | null
metadata?: Record<string, unknown>
quota_snapshot?: QuotaStatusSnapshot
message?: string
status_code?: number
}
export async function consumeCodexResetCredit(
keyId: string,
payload: ConsumeCodexResetCreditPayload,
): Promise<ConsumeCodexResetCreditResult> {
const response = await client.post(
`/api/admin/endpoints/keys/${keyId}/codex-reset-credit/consume`,
payload,
{ timeout: 5 * 60 * 1000 },
)
return response.data
}
/**
* 批量导入 OAuth 凭据(通用)
* 支持的 Provider 类型:Codex、Antigravity、GeminiCli、ClaudeCode、Kiro
+2 -1
View File
@@ -1,4 +1,4 @@
import type { ProviderKeyStatusSnapshot } from './statusSnapshot'
import type { ProviderKeyStatusSnapshot, QuotaResetCreditsSnapshot } from './statusSnapshot'
/**
* 代理配置类型
@@ -340,6 +340,7 @@ export interface CodexUpstreamMetadata {
spark_secondary_window_minutes?: number // Spark 周限额窗口大小(分钟)
has_credits?: boolean // 是否有积分
credits_balance?: number // 积分余额
reset_credits?: QuotaResetCreditsSnapshot | null // Codex earned rate-limit reset credits
}
export interface AntigravityModelQuota {
@@ -54,6 +54,24 @@ export interface QuotaCreditsSnapshot {
updated_at?: number | null
}
export interface QuotaResetCreditSnapshot {
id?: string | null
display_key?: string | null
status?: string | null
granted_at?: number | null
expires_at?: number | null
remaining_seconds?: number | null
}
export interface QuotaResetCreditsSnapshot {
available_count?: number | null
updated_at?: number | null
detail_source?: string | null
detail_status?: string | null
detail_error?: string | null
credits?: QuotaResetCreditSnapshot[] | null
}
export interface QuotaStatusSnapshot {
version?: number | null
provider_type?: string | null
@@ -71,6 +89,7 @@ export interface QuotaStatusSnapshot {
plan_type?: string | null
pool_tier?: string | null
credits?: QuotaCreditsSnapshot | null
reset_credits?: QuotaResetCreditsSnapshot | null
allowed_models_count?: number | null
rate_limit?: Record<string, unknown> | null
windows?: QuotaWindowSnapshot[] | null
@@ -278,6 +278,48 @@
/>
</div>
</div>
<div
v-if="getCodexResetCreditsDisplay(key)"
class="mt-3 border-t border-border/60 pt-2"
>
<div class="flex flex-wrap items-center gap-x-1 gap-y-1 text-[10px] leading-4 text-muted-foreground">
<button
v-if="canConsumeCodexResetCredit(key)"
type="button"
class="font-medium text-primary underline-offset-2 transition-colors hover:text-primary/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-60"
:disabled="consumingCodexResetCreditKeyId === key.id"
@click="handleConsumeCodexResetCredit(key)"
>
{{ consumingCodexResetCreditKeyId === key.id ? legacyT('重置中...') : legacyT('点击以进行重置') }}
</button>
<span
v-else
class="font-medium"
>
{{ legacyT('点击以进行重置') }}
</span>
<span>{{ formatCodexResetCreditCount(key) }}</span>
<template v-if="getVisibleCodexResetCreditItems(key).length > 0">
<span aria-hidden="true">|</span>
<span>{{ legacyT('临近过期') }}</span>
<template
v-for="(item, itemIndex) in getVisibleCodexResetCreditItems(key)"
:key="item.id || `${item.displayKey}-${item.expiresAt}`"
>
<span
:title="item.title"
class="tabular-nums"
>
{{ item.displayKey }} {{ formatCodexResetCreditDays(item.remainingSeconds) }}
</span>
<span
v-if="itemIndex < getVisibleCodexResetCreditItems(key).length - 1"
aria-hidden="true"
>·</span>
</template>
</template>
</div>
</div>
</div>
<!-- Antigravity 上游额度摘要(按家族分组展示关键配额) -->
<div
@@ -916,6 +958,7 @@ import {
exportKey,
refreshProviderOAuth,
refreshProviderQuota,
consumeCodexResetCredit,
clearOAuthInvalid,
type ProviderEndpoint,
type EndpointAPIKey,
@@ -931,6 +974,7 @@ import type {
GrokUpstreamMetadata,
KiroUpstreamMetadata,
WindsurfUpstreamMetadata,
QuotaResetCreditsSnapshot,
QuotaStatusSnapshot,
QuotaWindowSnapshot,
} from '@/api/endpoints/types'
@@ -960,6 +1004,12 @@ import {
getOAuthStatusTitle as resolveOAuthStatusTitle,
} from '@/utils/providerKeyStatus'
import { getGeminiCliAccountCreditsText } from '@/utils/providerKeyQuota'
import {
formatCodexResetCreditCount as formatCodexResetCreditCountLabel,
formatCodexResetCreditDays,
getCodexResetCreditAvailableCount as getCodexResetCreditAvailableCountFromSnapshot,
getVisibleCodexResetCreditItems as getVisibleCodexResetCreditItemsFromSnapshot,
} from './codex-reset-credit-display'
// 扩展端点类型,包含密钥列表
interface ProviderEndpointWithKeys extends ProviderEndpoint {
@@ -1059,6 +1109,9 @@ const refreshingOAuthKeyId = ref<string | null>(null)
// OAuth 失效清除状态
const clearingOAuthInvalidKeyId = ref<string | null>(null)
// Codex reset credit 消费状态
const consumingCodexResetCreditKeyId = ref<string | null>(null)
// 限额刷新状态(Codex / Antigravity
const refreshingQuota = ref(false)
@@ -1607,6 +1660,75 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
}
}
function createCodexResetCreditIdempotencyKey(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
throw new Error('浏览器不支持 crypto.randomUUID,无法生成安全幂等 ID')
}
function codexResetCreditOutcomeFeedback(
result: Awaited<ReturnType<typeof consumeCodexResetCredit>>,
): { tone: 'success' | 'warning'; message: string } {
switch (result.outcome) {
case 'reset':
return { tone: 'success', message: '已使用 Codex 重置机会,并刷新账号配额' }
case 'already_redeemed':
return { tone: 'success', message: '本次重置请求已处理,账号配额已刷新' }
case 'nothing_to_reset':
return { tone: 'warning', message: '当前没有需要重置的 Codex 额度窗口' }
case 'no_credit':
return { tone: 'warning', message: '当前没有可用的 Codex 重置机会' }
default:
return { tone: 'warning', message: '重置请求已返回,但结果类型未知,请查看最新账号配额' }
}
}
async function handleConsumeCodexResetCredit(key: EndpointAPIKey) {
if (!canConsumeCodexResetCredit(key)) return
const earliest = getVisibleCodexResetCreditItems(key)[0]
const detailMessage = earliest
? `\n当前最早过期项:${earliest.displayKey},约 ${formatCodexResetCreditDays(earliest.remainingSeconds)} 后过期。`
: ''
const confirmed = await confirm({
title: legacyT('确认使用 Codex 重置机会'),
message: `${legacyT('将消耗 1 次 Codex 重置机会。操作完成后会重新刷新账号配额状态。')}${detailMessage}`,
confirmText: legacyT('确认重置'),
cancelText: legacyT('取消'),
variant: 'warning',
})
if (!confirmed) return
consumingCodexResetCreditKeyId.value = key.id
try {
const idempotencyKey = createCodexResetCreditIdempotencyKey()
const result = await consumeCodexResetCredit(key.id, {
idempotency_key: idempotencyKey,
})
applyQuotaResults([{
key_id: result.key_id,
status: result.refresh_status === 'success' ? 'success' : result.status,
metadata: result.metadata,
quota_snapshot: result.quota_snapshot,
}])
const feedback = codexResetCreditOutcomeFeedback(result)
if (feedback.tone === 'success') {
showSuccess(legacyT(feedback.message))
} else {
showWarning(legacyT(feedback.message))
}
if (result.refresh_status === 'failed') {
showWarning(legacyT(result.refresh_error || '重置请求已处理,但最新配额刷新失败'))
}
} catch (err: unknown) {
showError(localizedApiError(err, 'Codex 重置机会使用失败'), legacyT('错误'))
} finally {
consumingCodexResetCreditKeyId.value = null
}
}
// Codex / Gemini CLI / Antigravity / Kiro / Windsurf / ChatGPT Web:打开抽屉后自动后台刷新(配额缓存缺失/过期,或 Token 即将过期时触发)
const AUTO_QUOTA_REFRESH_STALE_SECONDS = 5 * 60
// 与后端 OAuth 懒刷新阈值对齐:到期前 2 分钟内视为需要刷新
@@ -1755,6 +1877,7 @@ function getCodexQuotaDisplayFromMetadata(metadata: CodexUpstreamMetadata | null
]
numberFields.forEach(field => copyCodexNumberField(display, metadata, field))
if (metadata.has_credits !== undefined) display.has_credits = metadata.has_credits
if (metadata.reset_credits) display.reset_credits = metadata.reset_credits
return Object.keys(display).length > 0 ? display : null
}
@@ -1810,6 +1933,7 @@ function getCodexQuotaDisplayFromSnapshot(quota: QuotaStatusSnapshot | null | un
if (typeof sparkSecondaryWindow?.window_minutes === 'number') {
display.spark_secondary_window_minutes = sparkSecondaryWindow.window_minutes
}
if (quota.reset_credits) display.reset_credits = quota.reset_credits
return Object.keys(display).length > 0 ? display : null
}
@@ -1828,6 +1952,11 @@ function codexDisplayHasUsage(display: CodexUpstreamMetadata | null | undefined)
)
}
function codexDisplayHasResetCredits(display: CodexUpstreamMetadata | null | undefined): boolean {
const count = display?.reset_credits?.available_count
return typeof count === 'number' && Number.isFinite(count)
}
function getCodexQuotaDisplay(key: EndpointAPIKey): CodexUpstreamMetadata | null {
const snapshotDisplay = getCodexQuotaDisplayFromSnapshot(getQuotaSnapshotForProvider(key, 'codex'))
const metadataDisplay = getCodexQuotaDisplayFromMetadata(key.upstream_metadata?.codex)
@@ -1854,6 +1983,7 @@ function hasCodexQuotaDisplayData(key: EndpointAPIKey): boolean {
|| codex.secondary_used_percent !== undefined
|| codex.spark_primary_used_percent !== undefined
|| codex.spark_secondary_used_percent !== undefined
|| codexDisplayHasResetCredits(codex)
)
}
@@ -1865,6 +1995,29 @@ function hasCodexSparkQuotaDisplayData(key: EndpointAPIKey): boolean {
)
}
function getCodexResetCreditsDisplay(key: EndpointAPIKey): QuotaResetCreditsSnapshot | null {
return getCodexQuotaDisplay(key)?.reset_credits ?? null
}
function getCodexResetCreditAvailableCount(key: EndpointAPIKey): number | null {
return getCodexResetCreditAvailableCountFromSnapshot(getCodexResetCreditsDisplay(key))
}
function formatCodexResetCreditCount(key: EndpointAPIKey): string {
return formatCodexResetCreditCountLabel(getCodexResetCreditAvailableCount(key))
}
function getVisibleCodexResetCreditItems(key: EndpointAPIKey) {
return getVisibleCodexResetCreditItemsFromSnapshot(getCodexResetCreditsDisplay(key))
}
function canConsumeCodexResetCredit(key: EndpointAPIKey): boolean {
return provider.value?.provider_type === 'codex'
&& getCodexResetCreditAvailableCount(key) !== null
&& (getCodexResetCreditAvailableCount(key) ?? 0) > 0
&& !consumingCodexResetCreditKeyId.value
}
function getKiroQuotaDisplay(key: EndpointAPIKey): KiroUpstreamMetadata | null {
const quota = getQuotaSnapshotForProvider(key, 'kiro')
if (!quota) return null
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import {
formatCodexResetCreditCount,
formatCodexResetCreditDays,
getCodexResetCreditAvailableCount,
getVisibleCodexResetCreditItems,
} from '@/features/providers/components/codex-reset-credit-display'
import type { QuotaResetCreditsSnapshot } from '@/api/endpoints/types'
describe('codex reset credit display helpers', () => {
it('keeps zero available credits displayable but non-positive detail items hidden', () => {
const snapshot: QuotaResetCreditsSnapshot = {
available_count: 0,
updated_at: 1_700_000_000,
credits: [
{
id: 'expired-1111',
display_key: 'expired',
status: 'available',
expires_at: 1_699_999_999,
},
],
}
expect(getCodexResetCreditAvailableCount(snapshot)).toBe(0)
expect(formatCodexResetCreditCount(0)).toBe('共 0 次机会')
expect(getVisibleCodexResetCreditItems(snapshot, 1_700_000_000)).toEqual([])
})
it('sorts available detail items by remaining time and labels visible items with short ordinal keys', () => {
const snapshot: QuotaResetCreditsSnapshot = {
available_count: 7,
updated_at: 1_700_000_000,
credits: [
{ id: 'sixth-0000', status: 'available', expires_at: 1_700_060_000 },
{ id: 'spent-0000', status: 'redeemed', expires_at: 1_700_001_000 },
{ id: 'fifth-0000', status: 'active', expires_at: 1_700_050_000 },
{ id: 'third-0000', status: 'available', remaining_seconds: 30_000 },
{ id: 'missing-expiry-0000', status: 'available' },
{ id: 'first-0000', status: 'available', expires_at: 1_700_010_000 },
{ id: 'second-0000', status: 'available', expires_at: 1_700_020_000 },
{
id: 'fourth-0000',
display_key: 'RateLimitResetCredit_05cbb6eeeb9c81918e011d8300f9ebfb',
status: 'available',
expires_at: 1_700_040_000,
},
],
}
const items = getVisibleCodexResetCreditItems(snapshot, 1_700_000_000)
expect(items.map(item => item.displayKey)).toEqual([
'Key-1',
'Key-2',
'Key-3',
'Key-4',
'Key-5',
])
expect(items.map(item => item.title)).toEqual([
'Codex 重置机会 Key-1',
'Codex 重置机会 Key-2',
'Codex 重置机会 Key-3',
'Codex 重置机会 Key-4',
'Codex 重置机会 Key-5',
])
expect(items.map(item => item.remainingSeconds)).toEqual([
10_000,
20_000,
30_000,
40_000,
50_000,
])
})
it('formats reset credit remaining days with a one-day minimum', () => {
expect(formatCodexResetCreditDays(1)).toBe('1天')
expect(formatCodexResetCreditDays(86_401)).toBe('2天')
})
})
@@ -0,0 +1,89 @@
import type {
QuotaResetCreditSnapshot,
QuotaResetCreditsSnapshot,
} from '@/api/endpoints/types'
export interface CodexResetCreditDisplayItem {
id?: string | null
displayKey: string
expiresAt?: number | null
remainingSeconds: number
title: string
}
interface CodexResetCreditDisplayCandidate {
id?: string | null
expiresAt?: number | null
remainingSeconds: number
}
export function getCodexResetCreditAvailableCount(
snapshot: QuotaResetCreditsSnapshot | null | undefined,
): number | null {
const count = snapshot?.available_count
return typeof count === 'number' && Number.isFinite(count) && count >= 0 ? count : null
}
export function formatCodexResetCreditCount(count: number | null | undefined): string {
return `${count ?? 0} 次机会`
}
function codexResetCreditRemainingSeconds(
item: QuotaResetCreditSnapshot,
snapshot: QuotaResetCreditsSnapshot,
nowUnixSecs: number,
): number | null {
if (typeof item.expires_at === 'number' && Number.isFinite(item.expires_at)) {
return Math.max(item.expires_at - nowUnixSecs, 0)
}
if (typeof item.remaining_seconds === 'number' && Number.isFinite(item.remaining_seconds)) {
const updatedAt = snapshot.updated_at
const elapsed = typeof updatedAt === 'number' && Number.isFinite(updatedAt)
? Math.max(nowUnixSecs - updatedAt, 0)
: 0
return Math.max(item.remaining_seconds - elapsed, 0)
}
return null
}
function codexResetCreditStatusIsDisplayable(item: QuotaResetCreditSnapshot): boolean {
const status = item.status?.trim().toLowerCase()
return !status || status === 'available' || status === 'active'
}
export function getVisibleCodexResetCreditItems(
snapshot: QuotaResetCreditsSnapshot | null | undefined,
nowUnixSecs = Math.floor(Date.now() / 1000),
limit = 5,
): CodexResetCreditDisplayItem[] {
const credits = snapshot?.credits
if (!snapshot || !Array.isArray(credits)) return []
return credits
.map((item) => {
if (!codexResetCreditStatusIsDisplayable(item)) return null
const remainingSeconds = codexResetCreditRemainingSeconds(item, snapshot, nowUnixSecs)
if (remainingSeconds === null || remainingSeconds <= 0) return null
return {
id: item.id,
expiresAt: item.expires_at,
remainingSeconds,
} satisfies CodexResetCreditDisplayCandidate
})
.filter((item): item is CodexResetCreditDisplayCandidate => item !== null)
.sort((a, b) => a.remainingSeconds - b.remainingSeconds)
.slice(0, limit)
.map((item, index) => {
const displayKey = `Key-${index + 1}`
return {
...item,
displayKey,
title: `Codex 重置机会 ${displayKey}`,
} satisfies CodexResetCreditDisplayItem
})
}
export function formatCodexResetCreditDays(remainingSeconds: number): string {
const days = Math.max(1, Math.ceil(remainingSeconds / 86_400))
return `${days}`
}