Skip to content

Commit 72af589

Browse files
authored
storing credits (#6858)
Expand the rate-limit cache/TUI: store credit snapshots alongside primary and secondary windows, render “Credits” when the backend reports they exist (unlimited vs rounded integer balances)
1 parent b3d3204 commit 72af589

File tree

15 files changed

+550
-43
lines changed

15 files changed

+550
-43
lines changed

codex-rs/app-server-protocol/src/protocol/v2.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
1111
use codex_protocol::items::TurnItem as CoreTurnItem;
1212
use codex_protocol::models::ResponseItem;
1313
use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand;
14+
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
1415
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
1516
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
1617
use codex_protocol::user_input::UserInput as CoreUserInput;
@@ -994,13 +995,15 @@ pub struct AccountRateLimitsUpdatedNotification {
994995
pub struct RateLimitSnapshot {
995996
pub primary: Option<RateLimitWindow>,
996997
pub secondary: Option<RateLimitWindow>,
998+
pub credits: Option<CreditsSnapshot>,
997999
}
9981000

9991001
impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
10001002
fn from(value: CoreRateLimitSnapshot) -> Self {
10011003
Self {
10021004
primary: value.primary.map(RateLimitWindow::from),
10031005
secondary: value.secondary.map(RateLimitWindow::from),
1006+
credits: value.credits.map(CreditsSnapshot::from),
10041007
}
10051008
}
10061009
}
@@ -1024,6 +1027,25 @@ impl From<CoreRateLimitWindow> for RateLimitWindow {
10241027
}
10251028
}
10261029

1030+
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
1031+
#[serde(rename_all = "camelCase")]
1032+
#[ts(export_to = "v2/")]
1033+
pub struct CreditsSnapshot {
1034+
pub has_credits: bool,
1035+
pub unlimited: bool,
1036+
pub balance: Option<String>,
1037+
}
1038+
1039+
impl From<CoreCreditsSnapshot> for CreditsSnapshot {
1040+
fn from(value: CoreCreditsSnapshot) -> Self {
1041+
Self {
1042+
has_credits: value.has_credits,
1043+
unlimited: value.unlimited,
1044+
balance: value.balance,
1045+
}
1046+
}
1047+
}
1048+
10271049
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
10281050
#[serde(rename_all = "camelCase")]
10291051
#[ts(export_to = "v2/")]

codex-rs/app-server/src/outgoing_message.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ mod tests {
229229
resets_at: Some(123),
230230
}),
231231
secondary: None,
232+
credits: None,
232233
},
233234
});
234235

@@ -243,7 +244,8 @@ mod tests {
243244
"windowDurationMins": 15,
244245
"resetsAt": 123
245246
},
246-
"secondary": null
247+
"secondary": null,
248+
"credits": null
247249
}
248250
},
249251
}),

codex-rs/app-server/tests/suite/v2/rate_limits.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
152152
window_duration_mins: Some(1440),
153153
resets_at: Some(secondary_reset_timestamp),
154154
}),
155+
credits: None,
155156
},
156157
};
157158
assert_eq!(received, expected);

codex-rs/backend-client/src/client.rs

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
use crate::types::CodeTaskDetailsResponse;
2+
use crate::types::CreditStatusDetails;
23
use crate::types::PaginatedListTaskListItem;
34
use crate::types::RateLimitStatusPayload;
45
use crate::types::RateLimitWindowSnapshot;
56
use crate::types::TurnAttemptsSiblingTurnsResponse;
67
use anyhow::Result;
78
use codex_core::auth::CodexAuth;
89
use codex_core::default_client::get_codex_user_agent;
10+
use codex_protocol::protocol::CreditsSnapshot;
911
use codex_protocol::protocol::RateLimitSnapshot;
1012
use codex_protocol::protocol::RateLimitWindow;
1113
use reqwest::header::AUTHORIZATION;
@@ -272,19 +274,23 @@ impl Client {
272274

273275
// rate limit helpers
274276
fn rate_limit_snapshot_from_payload(payload: RateLimitStatusPayload) -> RateLimitSnapshot {
275-
let Some(details) = payload
277+
let rate_limit_details = payload
276278
.rate_limit
277-
.and_then(|inner| inner.map(|boxed| *boxed))
278-
else {
279-
return RateLimitSnapshot {
280-
primary: None,
281-
secondary: None,
282-
};
279+
.and_then(|inner| inner.map(|boxed| *boxed));
280+
281+
let (primary, secondary) = if let Some(details) = rate_limit_details {
282+
(
283+
Self::map_rate_limit_window(details.primary_window),
284+
Self::map_rate_limit_window(details.secondary_window),
285+
)
286+
} else {
287+
(None, None)
283288
};
284289

285290
RateLimitSnapshot {
286-
primary: Self::map_rate_limit_window(details.primary_window),
287-
secondary: Self::map_rate_limit_window(details.secondary_window),
291+
primary,
292+
secondary,
293+
credits: Self::map_credits(payload.credits),
288294
}
289295
}
290296

@@ -306,6 +312,19 @@ impl Client {
306312
})
307313
}
308314

315+
fn map_credits(credits: Option<Option<Box<CreditStatusDetails>>>) -> Option<CreditsSnapshot> {
316+
let details = match credits {
317+
Some(Some(details)) => *details,
318+
_ => return None,
319+
};
320+
321+
Some(CreditsSnapshot {
322+
has_credits: details.has_credits,
323+
unlimited: details.unlimited,
324+
balance: details.balance.and_then(|inner| inner),
325+
})
326+
}
327+
309328
fn window_minutes_from_seconds(seconds: i32) -> Option<i64> {
310329
if seconds <= 0 {
311330
return None;

codex-rs/backend-client/src/types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub use codex_backend_openapi_models::models::CreditStatusDetails;
12
pub use codex_backend_openapi_models::models::PaginatedListTaskListItem;
23
pub use codex_backend_openapi_models::models::PlanType;
34
pub use codex_backend_openapi_models::models::RateLimitStatusDetails;

codex-rs/core/src/client.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ use crate::model_family::ModelFamily;
5656
use crate::model_provider_info::ModelProviderInfo;
5757
use crate::model_provider_info::WireApi;
5858
use crate::openai_model_info::get_model_info;
59+
use crate::protocol::CreditsSnapshot;
5960
use crate::protocol::RateLimitSnapshot;
6061
use crate::protocol::RateLimitWindow;
6162
use crate::protocol::TokenUsage;
@@ -726,7 +727,13 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option<RateLimitSnapshot> {
726727
"x-codex-secondary-reset-at",
727728
);
728729

729-
Some(RateLimitSnapshot { primary, secondary })
730+
let credits = parse_credits_snapshot(headers);
731+
732+
Some(RateLimitSnapshot {
733+
primary,
734+
secondary,
735+
credits,
736+
})
730737
}
731738

732739
fn parse_rate_limit_window(
@@ -753,6 +760,20 @@ fn parse_rate_limit_window(
753760
})
754761
}
755762

763+
fn parse_credits_snapshot(headers: &HeaderMap) -> Option<CreditsSnapshot> {
764+
let has_credits = parse_header_bool(headers, "x-codex-credits-has-credits")?;
765+
let unlimited = parse_header_bool(headers, "x-codex-credits-unlimited")?;
766+
let balance = parse_header_str(headers, "x-codex-credits-balance")
767+
.map(str::trim)
768+
.filter(|value| !value.is_empty())
769+
.map(std::string::ToString::to_string);
770+
Some(CreditsSnapshot {
771+
has_credits,
772+
unlimited,
773+
balance,
774+
})
775+
}
776+
756777
fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option<f64> {
757778
parse_header_str(headers, name)?
758779
.parse::<f64>()
@@ -764,6 +785,17 @@ fn parse_header_i64(headers: &HeaderMap, name: &str) -> Option<i64> {
764785
parse_header_str(headers, name)?.parse::<i64>().ok()
765786
}
766787

788+
fn parse_header_bool(headers: &HeaderMap, name: &str) -> Option<bool> {
789+
let raw = parse_header_str(headers, name)?;
790+
if raw.eq_ignore_ascii_case("true") || raw == "1" {
791+
Some(true)
792+
} else if raw.eq_ignore_ascii_case("false") || raw == "0" {
793+
Some(false)
794+
} else {
795+
None
796+
}
797+
}
798+
767799
fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
768800
headers.get(name)?.to_str().ok()
769801
}

codex-rs/core/src/error.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,7 @@ mod tests {
499499
window_minutes: Some(120),
500500
resets_at: Some(secondary_reset_at),
501501
}),
502+
credits: None,
502503
}
503504
}
504505

codex-rs/core/tests/suite/client.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,7 +1121,8 @@ async fn token_count_includes_rate_limits_snapshot() {
11211121
"used_percent": 40.0,
11221122
"window_minutes": 60,
11231123
"resets_at": 1704074400
1124-
}
1124+
},
1125+
"credits": null
11251126
}
11261127
})
11271128
);
@@ -1168,7 +1169,8 @@ async fn token_count_includes_rate_limits_snapshot() {
11681169
"used_percent": 40.0,
11691170
"window_minutes": 60,
11701171
"resets_at": 1704074400
1171-
}
1172+
},
1173+
"credits": null
11721174
}
11731175
})
11741176
);
@@ -1238,7 +1240,8 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
12381240
"used_percent": 87.5,
12391241
"window_minutes": 60,
12401242
"resets_at": null
1241-
}
1243+
},
1244+
"credits": null
12421245
});
12431246

12441247
let submission_id = codex

codex-rs/protocol/src/protocol.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,7 @@ pub struct TokenCountEvent {
790790
pub struct RateLimitSnapshot {
791791
pub primary: Option<RateLimitWindow>,
792792
pub secondary: Option<RateLimitWindow>,
793+
pub credits: Option<CreditsSnapshot>,
793794
}
794795

795796
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
@@ -804,6 +805,13 @@ pub struct RateLimitWindow {
804805
pub resets_at: Option<i64>,
805806
}
806807

808+
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
809+
pub struct CreditsSnapshot {
810+
pub has_credits: bool,
811+
pub unlimited: bool,
812+
pub balance: Option<String>,
813+
}
814+
807815
// Includes prompts, tools and space to call compact.
808816
const BASELINE_TOKENS: i64 = 12000;
809817

codex-rs/tui/src/chatwidget/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ fn snapshot(percent: f64) -> RateLimitSnapshot {
8181
resets_at: None,
8282
}),
8383
secondary: None,
84+
credits: None,
8485
}
8586
}
8687

0 commit comments

Comments
 (0)