Compare commits

...

2 Commits

Author SHA1 Message Date
Edward Frazer
ad1facce2b feat: show spend controls in tui status 2026-05-15 23:45:26 -07:00
Edward Frazer
d322741267 feat: expose spend controls in codex usage snapshots 2026-05-15 23:45:09 -07:00
28 changed files with 1644 additions and 1 deletions

View File

@@ -1421,6 +1421,25 @@
},
"type": "object"
},
"GroupSpendControlLimitSnapshot": {
"properties": {
"details": {
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
"groupId": {
"type": "string"
},
"groupName": {
"type": "string"
}
},
"required": [
"details",
"groupId",
"groupName"
],
"type": "object"
},
"GuardianApprovalReview": {
"description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.",
"properties": {
@@ -2598,6 +2617,16 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -2854,6 +2883,137 @@
"description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
"type": "object"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remaining": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetAfterSeconds": {
"format": "int32",
"type": "integer"
},
"resetAt": {
"format": "int32",
"type": "integer"
},
"used": {
"type": "string"
},
"usedPercent": {
"format": "int32",
"type": "integer"
}
},
"required": [
"limit",
"remaining",
"remainingPercent",
"resetAfterSeconds",
"resetAt",
"used",
"usedPercent"
],
"type": "object"
},
"SpendControlLimitType": {
"enum": [
"individual",
"group_default",
"workspace_default",
"role_budget",
"group_shared",
"workspace_shared"
],
"type": "string"
},
"SpendControlSnapshot": {
"properties": {
"groupDefaultLimit": {
"anyOf": [
{
"$ref": "#/definitions/GroupSpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"groupSharedLimit": {
"anyOf": [
{
"$ref": "#/definitions/GroupSpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"reached": {
"type": "boolean"
},
"reachedLimitType": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitType"
},
{
"type": "null"
}
]
},
"roleBudgetLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"workspaceDefaultLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"workspaceSharedLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
}
},
"required": [
"reached"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{

View File

@@ -9226,6 +9226,25 @@
},
"type": "object"
},
"GroupSpendControlLimitSnapshot": {
"properties": {
"details": {
"$ref": "#/definitions/v2/SpendControlLimitSnapshot"
},
"groupId": {
"type": "string"
},
"groupName": {
"type": "string"
}
},
"required": [
"details",
"groupId",
"groupName"
],
"type": "object"
},
"GuardianApprovalReview": {
"description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.",
"properties": {
@@ -13028,6 +13047,16 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/v2/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -14971,6 +15000,137 @@
],
"type": "string"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remaining": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetAfterSeconds": {
"format": "int32",
"type": "integer"
},
"resetAt": {
"format": "int32",
"type": "integer"
},
"used": {
"type": "string"
},
"usedPercent": {
"format": "int32",
"type": "integer"
}
},
"required": [
"limit",
"remaining",
"remainingPercent",
"resetAfterSeconds",
"resetAt",
"used",
"usedPercent"
],
"type": "object"
},
"SpendControlLimitType": {
"enum": [
"individual",
"group_default",
"workspace_default",
"role_budget",
"group_shared",
"workspace_shared"
],
"type": "string"
},
"SpendControlSnapshot": {
"properties": {
"groupDefaultLimit": {
"anyOf": [
{
"$ref": "#/definitions/v2/GroupSpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"groupSharedLimit": {
"anyOf": [
{
"$ref": "#/definitions/v2/GroupSpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/v2/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"reached": {
"type": "boolean"
},
"reachedLimitType": {
"anyOf": [
{
"$ref": "#/definitions/v2/SpendControlLimitType"
},
{
"type": "null"
}
]
},
"roleBudgetLimit": {
"anyOf": [
{
"$ref": "#/definitions/v2/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"workspaceDefaultLimit": {
"anyOf": [
{
"$ref": "#/definitions/v2/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"workspaceSharedLimit": {
"anyOf": [
{
"$ref": "#/definitions/v2/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
}
},
"required": [
"reached"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{

View File

@@ -5726,6 +5726,25 @@
},
"type": "object"
},
"GroupSpendControlLimitSnapshot": {
"properties": {
"details": {
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
"groupId": {
"type": "string"
},
"groupName": {
"type": "string"
}
},
"required": [
"details",
"groupId",
"groupName"
],
"type": "object"
},
"GuardianApprovalReview": {
"description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.",
"properties": {
@@ -9577,6 +9596,16 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -12795,6 +12824,137 @@
],
"type": "string"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remaining": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetAfterSeconds": {
"format": "int32",
"type": "integer"
},
"resetAt": {
"format": "int32",
"type": "integer"
},
"used": {
"type": "string"
},
"usedPercent": {
"format": "int32",
"type": "integer"
}
},
"required": [
"limit",
"remaining",
"remainingPercent",
"resetAfterSeconds",
"resetAt",
"used",
"usedPercent"
],
"type": "object"
},
"SpendControlLimitType": {
"enum": [
"individual",
"group_default",
"workspace_default",
"role_budget",
"group_shared",
"workspace_shared"
],
"type": "string"
},
"SpendControlSnapshot": {
"properties": {
"groupDefaultLimit": {
"anyOf": [
{
"$ref": "#/definitions/GroupSpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"groupSharedLimit": {
"anyOf": [
{
"$ref": "#/definitions/GroupSpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"reached": {
"type": "boolean"
},
"reachedLimitType": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitType"
},
{
"type": "null"
}
]
},
"roleBudgetLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"workspaceDefaultLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"workspaceSharedLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
}
},
"required": [
"reached"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{

View File

@@ -22,6 +22,25 @@
],
"type": "object"
},
"GroupSpendControlLimitSnapshot": {
"properties": {
"details": {
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
"groupId": {
"type": "string"
},
"groupName": {
"type": "string"
}
},
"required": [
"details",
"groupId",
"groupName"
],
"type": "object"
},
"PlanType": {
"enum": [
"free",
@@ -112,6 +131,16 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -141,6 +170,137 @@
"usedPercent"
],
"type": "object"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remaining": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetAfterSeconds": {
"format": "int32",
"type": "integer"
},
"resetAt": {
"format": "int32",
"type": "integer"
},
"used": {
"type": "string"
},
"usedPercent": {
"format": "int32",
"type": "integer"
}
},
"required": [
"limit",
"remaining",
"remainingPercent",
"resetAfterSeconds",
"resetAt",
"used",
"usedPercent"
],
"type": "object"
},
"SpendControlLimitType": {
"enum": [
"individual",
"group_default",
"workspace_default",
"role_budget",
"group_shared",
"workspace_shared"
],
"type": "string"
},
"SpendControlSnapshot": {
"properties": {
"groupDefaultLimit": {
"anyOf": [
{
"$ref": "#/definitions/GroupSpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"groupSharedLimit": {
"anyOf": [
{
"$ref": "#/definitions/GroupSpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"reached": {
"type": "boolean"
},
"reachedLimitType": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitType"
},
{
"type": "null"
}
]
},
"roleBudgetLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"workspaceDefaultLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"workspaceSharedLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
}
},
"required": [
"reached"
],
"type": "object"
}
},
"properties": {

View File

@@ -22,6 +22,25 @@
],
"type": "object"
},
"GroupSpendControlLimitSnapshot": {
"properties": {
"details": {
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
"groupId": {
"type": "string"
},
"groupName": {
"type": "string"
}
},
"required": [
"details",
"groupId",
"groupName"
],
"type": "object"
},
"PlanType": {
"enum": [
"free",
@@ -112,6 +131,16 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -141,6 +170,137 @@
"usedPercent"
],
"type": "object"
},
"SpendControlLimitSnapshot": {
"properties": {
"limit": {
"type": "string"
},
"remaining": {
"type": "string"
},
"remainingPercent": {
"format": "int32",
"type": "integer"
},
"resetAfterSeconds": {
"format": "int32",
"type": "integer"
},
"resetAt": {
"format": "int32",
"type": "integer"
},
"used": {
"type": "string"
},
"usedPercent": {
"format": "int32",
"type": "integer"
}
},
"required": [
"limit",
"remaining",
"remainingPercent",
"resetAfterSeconds",
"resetAt",
"used",
"usedPercent"
],
"type": "object"
},
"SpendControlLimitType": {
"enum": [
"individual",
"group_default",
"workspace_default",
"role_budget",
"group_shared",
"workspace_shared"
],
"type": "string"
},
"SpendControlSnapshot": {
"properties": {
"groupDefaultLimit": {
"anyOf": [
{
"$ref": "#/definitions/GroupSpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"groupSharedLimit": {
"anyOf": [
{
"$ref": "#/definitions/GroupSpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"individualLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"reached": {
"type": "boolean"
},
"reachedLimitType": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitType"
},
{
"type": "null"
}
]
},
"roleBudgetLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"workspaceDefaultLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
},
"workspaceSharedLimit": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlLimitSnapshot"
},
{
"type": "null"
}
]
}
},
"required": [
"reached"
],
"type": "object"
}
},
"properties": {

View File

@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot";
export type GroupSpendControlLimitSnapshot = { groupId: string, groupName: string, details: SpendControlLimitSnapshot, };

View File

@@ -5,5 +5,6 @@ import type { PlanType } from "../PlanType";
import type { CreditsSnapshot } from "./CreditsSnapshot";
import type { RateLimitReachedType } from "./RateLimitReachedType";
import type { RateLimitWindow } from "./RateLimitWindow";
import type { SpendControlSnapshot } from "./SpendControlSnapshot";
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, };
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, spendControl: SpendControlSnapshot | null, planType: PlanType | null, rateLimitReachedType: RateLimitReachedType | null, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SpendControlLimitSnapshot = { limit: string, used: string, remaining: string, usedPercent: number, remainingPercent: number, resetAfterSeconds: number, resetAt: number, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SpendControlLimitType = "individual" | "group_default" | "workspace_default" | "role_budget" | "group_shared" | "workspace_shared";

View File

@@ -0,0 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { GroupSpendControlLimitSnapshot } from "./GroupSpendControlLimitSnapshot";
import type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot";
import type { SpendControlLimitType } from "./SpendControlLimitType";
export type SpendControlSnapshot = { reached: boolean, reachedLimitType: SpendControlLimitType | null, individualLimit: SpendControlLimitSnapshot | null, groupDefaultLimit: GroupSpendControlLimitSnapshot | null, workspaceDefaultLimit: SpendControlLimitSnapshot | null, roleBudgetLimit: SpendControlLimitSnapshot | null, groupSharedLimit: GroupSpendControlLimitSnapshot | null, workspaceSharedLimit: SpendControlLimitSnapshot | null, };

View File

@@ -138,6 +138,7 @@ export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsRespons
export type { GetAccountResponse } from "./GetAccountResponse";
export type { GitInfo } from "./GitInfo";
export type { GrantedPermissionProfile } from "./GrantedPermissionProfile";
export type { GroupSpendControlLimitSnapshot } from "./GroupSpendControlLimitSnapshot";
export type { GuardianApprovalReview } from "./GuardianApprovalReview";
export type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewAction";
export type { GuardianApprovalReviewStatus } from "./GuardianApprovalReviewStatus";
@@ -344,6 +345,9 @@ export type { SkillsListEntry } from "./SkillsListEntry";
export type { SkillsListParams } from "./SkillsListParams";
export type { SkillsListResponse } from "./SkillsListResponse";
export type { SortDirection } from "./SortDirection";
export type { SpendControlLimitSnapshot } from "./SpendControlLimitSnapshot";
export type { SpendControlLimitType } from "./SpendControlLimitType";
export type { SpendControlSnapshot } from "./SpendControlSnapshot";
export type { SubagentMigration } from "./SubagentMigration";
export type { TerminalInteractionNotification } from "./TerminalInteractionNotification";
export type { TextElement } from "./TextElement";

View File

@@ -3,9 +3,13 @@ use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::account::PlanType;
use codex_protocol::account::ProviderAccount;
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::GroupSpendControlLimitSnapshot as CoreGroupSpendControlLimitSnapshot;
use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
use codex_protocol::protocol::SpendControlLimitSnapshot as CoreSpendControlLimitSnapshot;
use codex_protocol::protocol::SpendControlLimitType as CoreSpendControlLimitType;
use codex_protocol::protocol::SpendControlSnapshot as CoreSpendControlSnapshot;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -260,6 +264,7 @@ pub struct RateLimitSnapshot {
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub spend_control: Option<SpendControlSnapshot>,
pub plan_type: Option<PlanType>,
pub rate_limit_reached_type: Option<RateLimitReachedType>,
}
@@ -272,6 +277,7 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
primary: value.primary.map(RateLimitWindow::from),
secondary: value.secondary.map(RateLimitWindow::from),
credits: value.credits.map(CreditsSnapshot::from),
spend_control: value.spend_control.map(SpendControlSnapshot::from),
plan_type: value.plan_type,
rate_limit_reached_type: value
.rate_limit_reached_type
@@ -371,6 +377,114 @@ impl From<CoreCreditsSnapshot> for CreditsSnapshot {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SpendControlSnapshot {
pub reached: bool,
pub reached_limit_type: Option<SpendControlLimitType>,
pub individual_limit: Option<SpendControlLimitSnapshot>,
pub group_default_limit: Option<GroupSpendControlLimitSnapshot>,
pub workspace_default_limit: Option<SpendControlLimitSnapshot>,
pub role_budget_limit: Option<SpendControlLimitSnapshot>,
pub group_shared_limit: Option<GroupSpendControlLimitSnapshot>,
pub workspace_shared_limit: Option<SpendControlLimitSnapshot>,
}
impl From<CoreSpendControlSnapshot> for SpendControlSnapshot {
fn from(value: CoreSpendControlSnapshot) -> Self {
Self {
reached: value.reached,
reached_limit_type: value.reached_limit_type.map(SpendControlLimitType::from),
individual_limit: value.individual_limit.map(SpendControlLimitSnapshot::from),
group_default_limit: value
.group_default_limit
.map(GroupSpendControlLimitSnapshot::from),
workspace_default_limit: value
.workspace_default_limit
.map(SpendControlLimitSnapshot::from),
role_budget_limit: value.role_budget_limit.map(SpendControlLimitSnapshot::from),
group_shared_limit: value
.group_shared_limit
.map(GroupSpendControlLimitSnapshot::from),
workspace_shared_limit: value
.workspace_shared_limit
.map(SpendControlLimitSnapshot::from),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/", rename_all = "snake_case")]
pub enum SpendControlLimitType {
Individual,
GroupDefault,
WorkspaceDefault,
RoleBudget,
GroupShared,
WorkspaceShared,
}
impl From<CoreSpendControlLimitType> for SpendControlLimitType {
fn from(value: CoreSpendControlLimitType) -> Self {
match value {
CoreSpendControlLimitType::Individual => Self::Individual,
CoreSpendControlLimitType::GroupDefault => Self::GroupDefault,
CoreSpendControlLimitType::WorkspaceDefault => Self::WorkspaceDefault,
CoreSpendControlLimitType::RoleBudget => Self::RoleBudget,
CoreSpendControlLimitType::GroupShared => Self::GroupShared,
CoreSpendControlLimitType::WorkspaceShared => Self::WorkspaceShared,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SpendControlLimitSnapshot {
pub limit: String,
pub used: String,
pub remaining: String,
pub used_percent: i32,
pub remaining_percent: i32,
pub reset_after_seconds: i32,
pub reset_at: i32,
}
impl From<CoreSpendControlLimitSnapshot> for SpendControlLimitSnapshot {
fn from(value: CoreSpendControlLimitSnapshot) -> Self {
Self {
limit: value.limit,
used: value.used,
remaining: value.remaining,
used_percent: value.used_percent,
remaining_percent: value.remaining_percent,
reset_after_seconds: value.reset_after_seconds,
reset_at: value.reset_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GroupSpendControlLimitSnapshot {
pub group_id: String,
pub group_name: String,
pub details: SpendControlLimitSnapshot,
}
impl From<CoreGroupSpendControlLimitSnapshot> for GroupSpendControlLimitSnapshot {
fn from(value: CoreGroupSpendControlLimitSnapshot) -> Self {
Self {
group_id: value.group_id,
group_name: value.group_name,
details: SpendControlLimitSnapshot::from(value.details),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]

View File

@@ -788,6 +788,7 @@ mod tests {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(PlanType::Plus),
rate_limit_reached_type: None,
},

View File

@@ -181,6 +181,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: Some(secondary_reset_timestamp),
}),
credits: None,
spend_control: None,
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached),
},
@@ -202,6 +203,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: Some(secondary_reset_timestamp),
}),
credits: None,
spend_control: None,
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: Some(
RateLimitReachedType::WorkspaceMemberUsageLimitReached,
@@ -220,6 +222,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: None,
},

View File

@@ -12,9 +12,13 @@ use codex_login::CodexAuth;
use codex_login::default_client::get_codex_user_agent;
use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::GroupSpendControlLimitSnapshot;
use codex_protocol::protocol::RateLimitReachedType;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::SpendControlLimitSnapshot;
use codex_protocol::protocol::SpendControlLimitType;
use codex_protocol::protocol::SpendControlSnapshot;
use reqwest::StatusCode;
use reqwest::header::CONTENT_TYPE;
use reqwest::header::HeaderMap;
@@ -457,6 +461,7 @@ impl Client {
/*limit_name*/ None,
payload.rate_limit.flatten().map(|details| *details),
payload.credits.flatten().map(|details| *details),
payload.spend_control.flatten().map(|details| *details),
plan_type,
rate_limit_reached_type,
)];
@@ -467,6 +472,7 @@ impl Client {
Some(details.limit_name),
details.rate_limit.flatten().map(|rate_limit| *rate_limit),
/*credits*/ None,
/*spend_control*/ None,
plan_type,
/*rate_limit_reached_type*/ None,
)
@@ -480,6 +486,7 @@ impl Client {
limit_name: Option<String>,
rate_limit: Option<crate::types::RateLimitStatusDetails>,
credits: Option<crate::types::CreditStatusDetails>,
spend_control: Option<crate::types::SpendControlStatusDetails>,
plan_type: Option<AccountPlanType>,
rate_limit_reached_type: Option<RateLimitReachedType>,
) -> RateLimitSnapshot {
@@ -496,6 +503,7 @@ impl Client {
primary,
secondary,
credits: Self::map_credits(credits),
spend_control: Self::map_spend_control(spend_control),
plan_type,
rate_limit_reached_type,
}
@@ -564,6 +572,87 @@ impl Client {
})
}
fn map_spend_control(
spend_control: Option<crate::types::SpendControlStatusDetails>,
) -> Option<SpendControlSnapshot> {
let details = spend_control?;
Some(SpendControlSnapshot {
reached: details.reached,
reached_limit_type: details
.reached_limit_type
.flatten()
.map(Self::map_spend_control_limit_type),
individual_limit: details
.individual_limit
.flatten()
.map(|limit| Self::map_spend_control_limit(*limit)),
group_default_limit: details
.group_default_limit
.flatten()
.map(|limit| Self::map_group_spend_control_limit(*limit)),
workspace_default_limit: details
.workspace_default_limit
.flatten()
.map(|limit| Self::map_spend_control_limit(*limit)),
role_budget_limit: details
.role_budget_limit
.flatten()
.map(|limit| Self::map_spend_control_limit(*limit)),
group_shared_limit: details
.group_shared_limit
.flatten()
.map(|limit| Self::map_group_spend_control_limit(*limit)),
workspace_shared_limit: details
.workspace_shared_limit
.flatten()
.map(|limit| Self::map_spend_control_limit(*limit)),
})
}
fn map_group_spend_control_limit(
limit: crate::types::GroupSpendControlLimitDetails,
) -> GroupSpendControlLimitSnapshot {
GroupSpendControlLimitSnapshot {
group_id: limit.group_id,
group_name: limit.group_name,
details: Self::map_spend_control_limit(*limit.details),
}
}
fn map_spend_control_limit(
limit: crate::types::SpendControlLimitDetails,
) -> SpendControlLimitSnapshot {
SpendControlLimitSnapshot {
limit: limit.limit,
used: limit.used,
remaining: limit.remaining,
used_percent: limit.used_percent,
remaining_percent: limit.remaining_percent,
reset_after_seconds: limit.reset_after_seconds,
reset_at: limit.reset_at,
}
}
fn map_spend_control_limit_type(
limit_type: crate::types::SpendControlLimitType,
) -> SpendControlLimitType {
match limit_type {
crate::types::SpendControlLimitType::Individual => SpendControlLimitType::Individual,
crate::types::SpendControlLimitType::GroupDefault => {
SpendControlLimitType::GroupDefault
}
crate::types::SpendControlLimitType::WorkspaceDefault => {
SpendControlLimitType::WorkspaceDefault
}
crate::types::SpendControlLimitType::RoleBudget => SpendControlLimitType::RoleBudget,
crate::types::SpendControlLimitType::GroupShared => SpendControlLimitType::GroupShared,
crate::types::SpendControlLimitType::WorkspaceShared => {
SpendControlLimitType::WorkspaceShared
}
}
}
fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType {
match plan_type {
crate::types::PlanType::Free => AccountPlanType::Free,
@@ -658,6 +747,7 @@ mod tests {
balance: Some(Some("9.99".to_string())),
..Default::default()
}))),
spend_control: None,
rate_limit_reached_type: Some(Some(BackendRateLimitReachedType {
kind: RateLimitReachedKind::WorkspaceMemberCreditsDepleted,
})),
@@ -712,6 +802,7 @@ mod tests {
rate_limit: None,
}])),
credits: None,
spend_control: None,
rate_limit_reached_type: None,
};
@@ -724,6 +815,59 @@ mod tests {
assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other"));
}
#[test]
fn usage_payload_maps_spend_controls_v2_details() {
let payload = RateLimitStatusPayload {
plan_type: crate::types::PlanType::Enterprise,
rate_limit: None,
additional_rate_limits: None,
credits: None,
spend_control: Some(Some(Box::new(crate::types::SpendControlStatusDetails {
reached: true,
reached_limit_type: Some(Some(crate::types::SpendControlLimitType::GroupShared)),
individual_limit: None,
group_default_limit: None,
workspace_default_limit: None,
role_budget_limit: None,
group_shared_limit: Some(Some(Box::new(
crate::types::GroupSpendControlLimitDetails {
group_id: "group-1".to_string(),
group_name: "Engineering".to_string(),
details: Box::new(crate::types::SpendControlLimitDetails {
limit: "100".to_string(),
used: "82".to_string(),
remaining: "18".to_string(),
used_percent: 82,
remaining_percent: 18,
reset_after_seconds: 60,
reset_at: 120,
}),
},
))),
workspace_shared_limit: None,
}))),
rate_limit_reached_type: None,
};
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
let spend_control = snapshots[0]
.spend_control
.as_ref()
.expect("spend control should be mapped");
assert!(spend_control.reached);
assert_eq!(
spend_control.reached_limit_type,
Some(SpendControlLimitType::GroupShared)
);
let group_shared_limit = spend_control
.group_shared_limit
.as_ref()
.expect("group shared limit should be mapped");
assert_eq!(group_shared_limit.group_id, "group-1");
assert_eq!(group_shared_limit.group_name, "Engineering");
assert_eq!(group_shared_limit.details.remaining_percent, 18);
}
#[test]
fn preferred_snapshot_selection_matches_get_rate_limits_behavior() {
let snapshots = [
@@ -737,6 +881,7 @@ mod tests {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: None,
},
@@ -750,6 +895,7 @@ mod tests {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(AccountPlanType::Pro),
rate_limit_reached_type: None,
},
@@ -795,6 +941,7 @@ mod tests {
rate_limit: None,
credits: None,
additional_rate_limits: None,
spend_control: None,
rate_limit_reached_type: Some(Some(BackendRateLimitReachedType { kind })),
};
@@ -810,6 +957,7 @@ mod tests {
rate_limit: None,
credits: None,
additional_rate_limits: None,
spend_control: None,
rate_limit_reached_type: None,
};

View File

@@ -1,11 +1,15 @@
pub use codex_backend_openapi_models::models::ConfigFileResponse;
pub use codex_backend_openapi_models::models::CreditStatusDetails;
pub use codex_backend_openapi_models::models::GroupSpendControlLimitDetails;
pub use codex_backend_openapi_models::models::PaginatedListTaskListItem;
pub use codex_backend_openapi_models::models::PlanType;
pub use codex_backend_openapi_models::models::RateLimitReachedKind;
pub use codex_backend_openapi_models::models::RateLimitStatusDetails;
pub use codex_backend_openapi_models::models::RateLimitStatusPayload;
pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot;
pub use codex_backend_openapi_models::models::SpendControlLimitDetails;
pub use codex_backend_openapi_models::models::SpendControlLimitType;
pub use codex_backend_openapi_models::models::SpendControlStatusDetails;
pub use codex_backend_openapi_models::models::TaskListItem;
use serde::Deserialize;

View File

@@ -92,6 +92,7 @@ pub fn parse_rate_limit_for_limit(
primary,
secondary,
credits,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
})
@@ -156,6 +157,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
primary,
secondary,
credits,
spend_control: None,
plan_type: event.plan_type,
rate_limit_reached_type: None,
})

View File

@@ -0,0 +1,37 @@
/*
* codex-backend
*
* codex-backend
*
* The version of the OpenAPI document: 0.0.1
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GroupSpendControlLimitDetails {
#[serde(rename = "group_id")]
pub group_id: String,
#[serde(rename = "group_name")]
pub group_name: String,
#[serde(rename = "details")]
pub details: Box<models::SpendControlLimitDetails>,
}
impl GroupSpendControlLimitDetails {
pub fn new(
group_id: String,
group_name: String,
details: models::SpendControlLimitDetails,
) -> Self {
Self {
group_id,
group_name,
details: Box::new(details),
}
}
}

View File

@@ -44,3 +44,13 @@ pub use self::rate_limit_window_snapshot::RateLimitWindowSnapshot;
pub(crate) mod credit_status_details;
pub use self::credit_status_details::CreditStatusDetails;
pub(crate) mod spend_control_limit_details;
pub use self::spend_control_limit_details::SpendControlLimitDetails;
pub(crate) mod group_spend_control_limit_details;
pub use self::group_spend_control_limit_details::GroupSpendControlLimitDetails;
pub(crate) mod spend_control_status_details;
pub use self::spend_control_status_details::SpendControlLimitType;
pub use self::spend_control_status_details::SpendControlStatusDetails;

View File

@@ -37,6 +37,13 @@ pub struct RateLimitStatusPayload {
skip_serializing_if = "Option::is_none"
)]
pub additional_rate_limits: Option<Option<Vec<models::AdditionalRateLimitDetails>>>,
#[serde(
rename = "spend_control",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub spend_control: Option<Option<Box<models::SpendControlStatusDetails>>>,
#[serde(
rename = "rate_limit_reached_type",
default,
@@ -53,6 +60,7 @@ impl RateLimitStatusPayload {
rate_limit: None,
credits: None,
additional_rate_limits: None,
spend_control: None,
rate_limit_reached_type: None,
}
}

View File

@@ -0,0 +1,52 @@
/*
* codex-backend
*
* codex-backend
*
* The version of the OpenAPI document: 0.0.1
*
* Generated by: https://openapi-generator.tech
*/
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct SpendControlLimitDetails {
#[serde(rename = "limit")]
pub limit: String,
#[serde(rename = "used")]
pub used: String,
#[serde(rename = "remaining")]
pub remaining: String,
#[serde(rename = "used_percent")]
pub used_percent: i32,
#[serde(rename = "remaining_percent")]
pub remaining_percent: i32,
#[serde(rename = "reset_after_seconds")]
pub reset_after_seconds: i32,
#[serde(rename = "reset_at")]
pub reset_at: i32,
}
impl SpendControlLimitDetails {
pub fn new(
limit: String,
used: String,
remaining: String,
used_percent: i32,
remaining_percent: i32,
reset_after_seconds: i32,
reset_at: i32,
) -> Self {
Self {
limit,
used,
remaining,
used_percent,
remaining_percent,
reset_after_seconds,
reset_at,
}
}
}

View File

@@ -0,0 +1,99 @@
/*
* codex-backend
*
* codex-backend
*
* The version of the OpenAPI document: 0.0.1
*
* Generated by: https://openapi-generator.tech
*/
use crate::models;
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
pub enum SpendControlLimitType {
#[serde(rename = "individual")]
Individual,
#[serde(rename = "group_default")]
GroupDefault,
#[serde(rename = "workspace_default")]
WorkspaceDefault,
#[serde(rename = "role_budget")]
RoleBudget,
#[serde(rename = "group_shared")]
GroupShared,
#[serde(rename = "workspace_shared")]
WorkspaceShared,
}
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct SpendControlStatusDetails {
#[serde(rename = "reached")]
pub reached: bool,
#[serde(
rename = "reached_limit_type",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub reached_limit_type: Option<Option<SpendControlLimitType>>,
#[serde(
rename = "individual_limit",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub individual_limit: Option<Option<Box<models::SpendControlLimitDetails>>>,
#[serde(
rename = "group_default_limit",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub group_default_limit: Option<Option<Box<models::GroupSpendControlLimitDetails>>>,
#[serde(
rename = "workspace_default_limit",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub workspace_default_limit: Option<Option<Box<models::SpendControlLimitDetails>>>,
#[serde(
rename = "role_budget_limit",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub role_budget_limit: Option<Option<Box<models::SpendControlLimitDetails>>>,
#[serde(
rename = "group_shared_limit",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub group_shared_limit: Option<Option<Box<models::GroupSpendControlLimitDetails>>>,
#[serde(
rename = "workspace_shared_limit",
default,
with = "::serde_with::rust::double_option",
skip_serializing_if = "Option::is_none"
)]
pub workspace_shared_limit: Option<Option<Box<models::SpendControlLimitDetails>>>,
}
impl SpendControlStatusDetails {
pub fn new(reached: bool) -> Self {
Self {
reached,
reached_limit_type: None,
individual_limit: None,
group_default_limit: None,
workspace_default_limit: None,
role_budget_limit: None,
group_shared_limit: None,
workspace_shared_limit: None,
}
}
}

View File

@@ -35,6 +35,7 @@ fn rate_limit_snapshot() -> RateLimitSnapshot {
resets_at: Some(secondary_reset_at),
}),
credits: None,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
}

View File

@@ -2083,6 +2083,7 @@ pub struct RateLimitSnapshot {
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub spend_control: Option<SpendControlSnapshot>,
pub plan_type: Option<crate::account::PlanType>,
pub rate_limit_reached_type: Option<RateLimitReachedType>,
}
@@ -2117,6 +2118,48 @@ pub struct CreditsSnapshot {
pub balance: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
pub struct SpendControlSnapshot {
pub reached: bool,
pub reached_limit_type: Option<SpendControlLimitType>,
pub individual_limit: Option<SpendControlLimitSnapshot>,
pub group_default_limit: Option<GroupSpendControlLimitSnapshot>,
pub workspace_default_limit: Option<SpendControlLimitSnapshot>,
pub role_budget_limit: Option<SpendControlLimitSnapshot>,
pub group_shared_limit: Option<GroupSpendControlLimitSnapshot>,
pub workspace_shared_limit: Option<SpendControlLimitSnapshot>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
pub enum SpendControlLimitType {
Individual,
GroupDefault,
WorkspaceDefault,
RoleBudget,
GroupShared,
WorkspaceShared,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
pub struct SpendControlLimitSnapshot {
pub limit: String,
pub used: String,
pub remaining: String,
pub used_percent: i32,
pub remaining_percent: i32,
pub reset_after_seconds: i32,
pub reset_at: i32,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
pub struct GroupSpendControlLimitSnapshot {
pub group_id: String,
pub group_name: String,
pub details: SpendControlLimitSnapshot,
}
// Includes prompts, tools and space to call compact.
const BASELINE_TOKENS: i64 = 12000;

View File

@@ -112,6 +112,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
}

View File

@@ -531,6 +531,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
unlimited: false,
balance: Some("17.5".to_string()),
}),
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -551,6 +552,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -590,6 +592,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
resets_at: None,
}),
credits: None,
spend_control: None,
plan_type: Some(PlanType::Plus),
rate_limit_reached_type: None,
}));
@@ -609,6 +612,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
resets_at: Some(234),
}),
credits: None,
spend_control: None,
plan_type: Some(PlanType::Pro),
rate_limit_reached_type: None,
}));
@@ -628,6 +632,7 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
resets_at: Some(567),
}),
credits: None,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
}));
@@ -652,6 +657,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
unlimited: false,
balance: Some("5.00".to_string()),
}),
spend_control: None,
plan_type: Some(PlanType::Pro),
rate_limit_reached_type: None,
}));
@@ -666,6 +672,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(PlanType::Pro),
rate_limit_reached_type: None,
}));
@@ -719,6 +726,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
}));

View File

@@ -14,8 +14,12 @@ use chrono::Duration as ChronoDuration;
use chrono::Local;
use chrono::Utc;
use codex_app_server_protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_app_server_protocol::GroupSpendControlLimitSnapshot;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::SpendControlLimitSnapshot;
use codex_app_server_protocol::SpendControlLimitType;
use codex_app_server_protocol::SpendControlSnapshot;
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
const STATUS_LIMIT_BAR_FILLED: &str = "";
@@ -98,6 +102,8 @@ pub(crate) struct RateLimitSnapshotDisplay {
pub secondary: Option<RateLimitWindowDisplay>,
/// Optional credits metadata when available.
pub credits: Option<CreditsSnapshotDisplay>,
/// Optional spend-control metadata when available.
pub spend_control: Option<SpendControlSnapshotDisplay>,
}
/// Display-ready credits state extracted from protocol snapshots.
@@ -111,6 +117,28 @@ pub(crate) struct CreditsSnapshotDisplay {
pub balance: Option<String>,
}
/// Display-ready spend-control state extracted from protocol snapshots.
#[derive(Debug, Clone)]
pub(crate) struct SpendControlSnapshotDisplay {
/// Whether the backend says a spend control blocked the current request path.
pub reached: bool,
/// The concrete control type that was reached, when one is known.
pub reached_limit_type: Option<SpendControlLimitType>,
/// The most relevant active limit to summarize for `/status`.
pub active_limit: Option<SpendControlLimitDisplay>,
}
/// Display-ready view of one configured spend-control limit.
#[derive(Debug, Clone)]
pub(crate) struct SpendControlLimitDisplay {
/// Friendly control label for status output.
pub label: String,
/// Percent remaining according to the backend snapshot.
pub remaining_percent: f64,
/// Human-readable local reset time.
pub resets_at: Option<String>,
}
/// Converts a protocol snapshot into UI-friendly display data.
///
/// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly
@@ -140,6 +168,9 @@ pub(crate) fn rate_limit_snapshot_display_for_limit(
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from),
spend_control: snapshot.spend_control.as_ref().map(|spend_control| {
SpendControlSnapshotDisplay::from_snapshot(spend_control, captured_at)
}),
}
}
@@ -153,6 +184,16 @@ impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay {
}
}
impl SpendControlSnapshotDisplay {
fn from_snapshot(snapshot: &SpendControlSnapshot, captured_at: DateTime<Local>) -> Self {
Self {
reached: snapshot.reached,
reached_limit_type: snapshot.reached_limit_type,
active_limit: active_spend_control_limit(snapshot, captured_at),
}
}
}
/// Builds display rows from a snapshot and marks stale data by capture age.
///
/// Callers should pass `Local::now()` for `now` at render time; using a cached timestamp can make
@@ -268,6 +309,9 @@ pub(crate) fn compose_rate_limit_data_many(
{
rows.push(row);
}
if let Some(spend_control) = snapshot.spend_control.as_ref() {
rows.extend(spend_control_status_rows(spend_control));
}
}
if rows.is_empty() {
@@ -279,6 +323,107 @@ pub(crate) fn compose_rate_limit_data_many(
}
}
fn spend_control_status_rows(
spend_control: &SpendControlSnapshotDisplay,
) -> Vec<StatusRateLimitRow> {
let mut rows = Vec::new();
if let Some(active_limit) = spend_control.active_limit.as_ref() {
rows.push(StatusRateLimitRow {
label: active_limit.label.clone(),
value: StatusRateLimitValue::Window {
percent_used: (100.0 - active_limit.remaining_percent).clamp(0.0, 100.0),
resets_at: active_limit.resets_at.clone(),
},
});
}
if spend_control.reached
&& let Some(reached_limit_type) = spend_control.reached_limit_type
{
rows.push(StatusRateLimitRow {
label: "Spend control reached".to_string(),
value: StatusRateLimitValue::Text(spend_control_limit_type_label(reached_limit_type)),
});
}
rows
}
fn active_spend_control_limit(
snapshot: &SpendControlSnapshot,
captured_at: DateTime<Local>,
) -> Option<SpendControlLimitDisplay> {
snapshot
.individual_limit
.as_ref()
.map(|limit| spend_control_limit_display("Individual spend control", limit, captured_at))
.or_else(|| {
snapshot.group_default_limit.as_ref().map(|limit| {
group_spend_control_limit_display("Group default spend control", limit, captured_at)
})
})
.or_else(|| {
snapshot.workspace_default_limit.as_ref().map(|limit| {
spend_control_limit_display("Workspace default spend control", limit, captured_at)
})
})
.or_else(|| {
snapshot
.role_budget_limit
.as_ref()
.map(|limit| spend_control_limit_display("Role budget", limit, captured_at))
})
.or_else(|| {
snapshot.group_shared_limit.as_ref().map(|limit| {
group_spend_control_limit_display("Group shared spend pool", limit, captured_at)
})
})
.or_else(|| {
snapshot.workspace_shared_limit.as_ref().map(|limit| {
spend_control_limit_display("Workspace shared spend pool", limit, captured_at)
})
})
}
fn spend_control_limit_display(
label: &str,
limit: &SpendControlLimitSnapshot,
captured_at: DateTime<Local>,
) -> SpendControlLimitDisplay {
SpendControlLimitDisplay {
label: label.to_string(),
remaining_percent: f64::from(limit.remaining_percent),
resets_at: format_spend_control_reset(limit.reset_at, captured_at),
}
}
fn group_spend_control_limit_display(
label: &str,
limit: &GroupSpendControlLimitSnapshot,
captured_at: DateTime<Local>,
) -> SpendControlLimitDisplay {
SpendControlLimitDisplay {
label: format!("{label}: {}", limit.group_name),
remaining_percent: f64::from(limit.details.remaining_percent),
resets_at: format_spend_control_reset(limit.details.reset_at, captured_at),
}
}
fn format_spend_control_reset(reset_at: i32, captured_at: DateTime<Local>) -> Option<String> {
DateTime::<Utc>::from_timestamp(i64::from(reset_at), 0)
.map(|dt| dt.with_timezone(&Local))
.map(|dt| format_reset_timestamp(dt, captured_at))
}
fn spend_control_limit_type_label(limit_type: SpendControlLimitType) -> String {
match limit_type {
SpendControlLimitType::Individual => "Individual spend control".to_string(),
SpendControlLimitType::GroupDefault => "Group default spend control".to_string(),
SpendControlLimitType::WorkspaceDefault => "Workspace default spend control".to_string(),
SpendControlLimitType::RoleBudget => "Role budget".to_string(),
SpendControlLimitType::GroupShared => "Group shared spend pool".to_string(),
SpendControlLimitType::WorkspaceShared => "Workspace shared spend pool".to_string(),
}
}
/// Renders a fixed-width progress bar from remaining percentage.
///
/// This function expects a remaining value in the `0..=100` range and clamps out-of-range input.
@@ -349,9 +494,12 @@ mod tests {
use super::CreditsSnapshotDisplay;
use super::RateLimitSnapshotDisplay;
use super::RateLimitWindowDisplay;
use super::SpendControlLimitDisplay;
use super::SpendControlSnapshotDisplay;
use super::StatusRateLimitData;
use super::compose_rate_limit_data_many;
use chrono::Local;
use codex_app_server_protocol::SpendControlLimitType;
use pretty_assertions::assert_eq;
fn window(used_percent: f64) -> RateLimitWindowDisplay {
@@ -375,6 +523,7 @@ mod tests {
unlimited: false,
balance: Some("25".to_string()),
}),
spend_control: None,
};
let other = RateLimitSnapshotDisplay {
limit_name: "codex-other".to_string(),
@@ -386,6 +535,7 @@ mod tests {
unlimited: false,
balance: Some("99".to_string()),
}),
spend_control: None,
};
let rows = match compose_rate_limit_data_many(&[codex, other], now) {
@@ -423,6 +573,7 @@ mod tests {
window_minutes: None,
}),
credits: None,
spend_control: None,
};
let rows = match compose_rate_limit_data_many(&[other], now) {
@@ -439,4 +590,39 @@ mod tests {
]
);
}
#[test]
fn spend_control_rows_show_active_limit_and_reached_type() {
let now = Local::now();
let codex = RateLimitSnapshotDisplay {
limit_name: "codex".to_string(),
captured_at: now,
primary: None,
secondary: None,
credits: None,
spend_control: Some(SpendControlSnapshotDisplay {
reached: true,
reached_limit_type: Some(SpendControlLimitType::GroupShared),
active_limit: Some(SpendControlLimitDisplay {
label: "Group shared spend pool: Engineering".to_string(),
remaining_percent: 18.0,
resets_at: Some("soon".to_string()),
}),
}),
};
let rows = match compose_rate_limit_data_many(&[codex], now) {
StatusRateLimitData::Available(rows) => rows,
other => panic!("unexpected status: {other:?}"),
};
let labels: Vec<String> = rows.iter().map(|row| row.label.clone()).collect();
assert_eq!(
labels,
vec![
"Group shared spend pool: Engineering".to_string(),
"Spend control reached".to_string(),
]
);
}
}

View File

@@ -19,11 +19,15 @@ use codex_app_server_protocol::FileSystemAccessMode;
use codex_app_server_protocol::FileSystemPath;
use codex_app_server_protocol::FileSystemSandboxEntry;
use codex_app_server_protocol::FileSystemSpecialPath;
use codex_app_server_protocol::GroupSpendControlLimitSnapshot;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::SpendControlLimitSnapshot;
use codex_app_server_protocol::SpendControlLimitType;
use codex_app_server_protocol::SpendControlSnapshot;
use codex_config::LoaderOverrides;
use codex_model_provider_info::ModelProviderAwsAuthInfo;
use codex_model_provider_info::ModelProviderInfo;
@@ -236,6 +240,7 @@ async fn status_snapshot_includes_reasoning_details() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)),
}),
credits: None,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -829,6 +834,87 @@ async fn status_snapshot_includes_monthly_limit() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = crate::legacy_core::test_support::get_model_offline(config.model.as_deref());
let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
account_display.as_ref(),
Some(&token_info),
&usage,
&None,
/*thread_name*/ None,
/*forked_from*/ None,
Some(&rate_display),
None,
captured_at,
&model_slug,
/*collaboration_mode*/ None,
/*reasoning_effort_override*/ None,
);
let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80));
if cfg!(windows) {
for line in &mut rendered_lines {
*line = line.replace('\\', "/");
}
}
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
}
#[tokio::test]
async fn status_snapshot_includes_group_shared_spend_control() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.model_provider_id = "openai".to_string();
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
let account_display = test_status_account_display();
let usage = TokenUsage {
input_tokens: 800,
cached_input_tokens: 0,
output_tokens: 400,
reasoning_output_tokens: 0,
total_tokens: 1_200,
};
let captured_at = chrono::Local
.with_ymd_and_hms(2024, 5, 6, 7, 8, 9)
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: None,
credits: None,
spend_control: Some(SpendControlSnapshot {
reached: true,
reached_limit_type: Some(SpendControlLimitType::GroupShared),
individual_limit: None,
group_default_limit: None,
workspace_default_limit: None,
role_budget_limit: None,
group_shared_limit: Some(GroupSpendControlLimitSnapshot {
group_id: "group-1".to_string(),
group_name: "Engineering".to_string(),
details: SpendControlLimitSnapshot {
limit: "200".to_string(),
used: "190".to_string(),
remaining: "10".to_string(),
used_percent: 95,
remaining_percent: 5,
reset_after_seconds: 86_400,
reset_at: reset_at_from(&captured_at, /*seconds*/ 86_400) as i32,
},
}),
workspace_shared_limit: None,
}),
plan_type: None,
rate_limit_reached_type: None,
};
@@ -881,6 +967,7 @@ async fn status_snapshot_shows_unlimited_credits() {
unlimited: true,
balance: None,
}),
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -931,6 +1018,7 @@ async fn status_snapshot_shows_positive_credits() {
unlimited: false,
balance: Some("12.5".to_string()),
}),
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -981,6 +1069,7 @@ async fn status_snapshot_hides_zero_credits() {
unlimited: false,
balance: Some("0".to_string()),
}),
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1029,6 +1118,7 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
unlimited: true,
balance: None,
}),
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1135,6 +1225,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1300,6 +1391,7 @@ async fn status_snapshot_shows_refreshing_limits_notice() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)),
}),
credits: None,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1371,6 +1463,7 @@ async fn status_snapshot_includes_credits_and_limits() {
unlimited: false,
balance: Some("37.5".to_string()),
}),
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1425,6 +1518,7 @@ async fn status_snapshot_shows_unavailable_limits_message() {
primary: None,
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1482,6 +1576,7 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() {
primary: None,
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1553,6 +1648,7 @@ async fn status_snapshot_shows_stale_limits_message() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_800)),
}),
credits: None,
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};
@@ -1624,6 +1720,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
unlimited: false,
balance: Some("80".to_string()),
}),
spend_control: None,
plan_type: None,
rate_limit_reached_type: None,
};