mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 13:10:21 +08:00
Merge pull request #662 from MMEXA/codex/reset-credit-20260704
增加 Codex 重置次数功能
This commit is contained in:
@@ -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(&[]);
|
||||
|
||||
+116
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+81
@@ -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}天`
|
||||
}
|
||||
Reference in New Issue
Block a user