Compare commits

...

3 Commits

Author SHA1 Message Date
Felipe Coury
138376e6e1 feat(usage): add local token share report 2026-05-22 15:06:03 -03:00
Felipe Coury
55d98b262b fix(usage): keep usage attribution sqlite-only 2026-05-22 10:03:16 -03:00
Felipe Coury
afafcc98ec feat(tui): add usage report 2026-05-21 15:09:14 -03:00
56 changed files with 3367 additions and 13 deletions

View File

@@ -4030,6 +4030,24 @@
],
"type": "object"
},
"UsageRange": {
"enum": [
"day",
"week"
],
"type": "string"
},
"UsageReadParams": {
"properties": {
"range": {
"$ref": "#/definitions/UsageRange"
}
},
"required": [
"range"
],
"type": "object"
},
"UserInput": {
"oneOf": [
{
@@ -4829,6 +4847,30 @@
"title": "Plugin/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"usage/read"
],
"title": "Usage/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/UsageReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Usage/readRequest",
"type": "object"
},
{
"properties": {
"id": {

View File

@@ -829,6 +829,30 @@
"title": "Plugin/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"usage/read"
],
"title": "Usage/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/UsageReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Usage/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -18555,6 +18579,177 @@
"title": "TurnSteerResponse",
"type": "object"
},
"UsageContributorKind": {
"enum": [
"skill",
"subagent",
"agentTask",
"app",
"mcpServer",
"plugin"
],
"type": "string"
},
"UsageEntry": {
"properties": {
"attributedTokens": {
"format": "int64",
"type": "integer"
},
"id": {
"type": "string"
},
"kind": {
"$ref": "#/definitions/v2/UsageContributorKind"
},
"label": {
"type": "string"
},
"percentOfUsage": {
"format": "uint8",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"attributedTokens",
"id",
"kind",
"label",
"percentOfUsage"
],
"type": "object"
},
"UsageHeadline": {
"properties": {
"entry": {
"$ref": "#/definitions/v2/UsageEntry"
},
"note": {
"type": [
"string",
"null"
]
}
},
"required": [
"entry"
],
"type": "object"
},
"UsageRange": {
"enum": [
"day",
"week"
],
"type": "string"
},
"UsageReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"range": {
"$ref": "#/definitions/v2/UsageRange"
}
},
"required": [
"range"
],
"title": "UsageReadParams",
"type": "object"
},
"UsageReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"report": {
"$ref": "#/definitions/v2/UsageReport"
}
},
"required": [
"report"
],
"title": "UsageReadResponse",
"type": "object"
},
"UsageReport": {
"properties": {
"agentTasks": {
"items": {
"$ref": "#/definitions/v2/UsageEntry"
},
"type": "array"
},
"apps": {
"items": {
"$ref": "#/definitions/v2/UsageEntry"
},
"type": "array"
},
"generatedAt": {
"format": "int64",
"type": "integer"
},
"headline": {
"anyOf": [
{
"$ref": "#/definitions/v2/UsageHeadline"
},
{
"type": "null"
}
]
},
"mcpServers": {
"items": {
"$ref": "#/definitions/v2/UsageEntry"
},
"type": "array"
},
"plugins": {
"items": {
"$ref": "#/definitions/v2/UsageEntry"
},
"type": "array"
},
"range": {
"$ref": "#/definitions/v2/UsageRange"
},
"skills": {
"items": {
"$ref": "#/definitions/v2/UsageEntry"
},
"type": "array"
},
"subagents": {
"items": {
"$ref": "#/definitions/v2/UsageEntry"
},
"type": "array"
},
"totalTokens": {
"format": "int64",
"type": "integer"
},
"trackedFrom": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
"agentTasks",
"apps",
"generatedAt",
"mcpServers",
"plugins",
"range",
"skills",
"subagents",
"totalTokens"
],
"type": "object"
},
"UserInput": {
"oneOf": [
{

View File

@@ -1555,6 +1555,30 @@
"title": "Plugin/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"usage/read"
],
"title": "Usage/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/UsageReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Usage/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -16379,6 +16403,177 @@
"title": "TurnSteerResponse",
"type": "object"
},
"UsageContributorKind": {
"enum": [
"skill",
"subagent",
"agentTask",
"app",
"mcpServer",
"plugin"
],
"type": "string"
},
"UsageEntry": {
"properties": {
"attributedTokens": {
"format": "int64",
"type": "integer"
},
"id": {
"type": "string"
},
"kind": {
"$ref": "#/definitions/UsageContributorKind"
},
"label": {
"type": "string"
},
"percentOfUsage": {
"format": "uint8",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"attributedTokens",
"id",
"kind",
"label",
"percentOfUsage"
],
"type": "object"
},
"UsageHeadline": {
"properties": {
"entry": {
"$ref": "#/definitions/UsageEntry"
},
"note": {
"type": [
"string",
"null"
]
}
},
"required": [
"entry"
],
"type": "object"
},
"UsageRange": {
"enum": [
"day",
"week"
],
"type": "string"
},
"UsageReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"range": {
"$ref": "#/definitions/UsageRange"
}
},
"required": [
"range"
],
"title": "UsageReadParams",
"type": "object"
},
"UsageReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"report": {
"$ref": "#/definitions/UsageReport"
}
},
"required": [
"report"
],
"title": "UsageReadResponse",
"type": "object"
},
"UsageReport": {
"properties": {
"agentTasks": {
"items": {
"$ref": "#/definitions/UsageEntry"
},
"type": "array"
},
"apps": {
"items": {
"$ref": "#/definitions/UsageEntry"
},
"type": "array"
},
"generatedAt": {
"format": "int64",
"type": "integer"
},
"headline": {
"anyOf": [
{
"$ref": "#/definitions/UsageHeadline"
},
{
"type": "null"
}
]
},
"mcpServers": {
"items": {
"$ref": "#/definitions/UsageEntry"
},
"type": "array"
},
"plugins": {
"items": {
"$ref": "#/definitions/UsageEntry"
},
"type": "array"
},
"range": {
"$ref": "#/definitions/UsageRange"
},
"skills": {
"items": {
"$ref": "#/definitions/UsageEntry"
},
"type": "array"
},
"subagents": {
"items": {
"$ref": "#/definitions/UsageEntry"
},
"type": "array"
},
"totalTokens": {
"format": "int64",
"type": "integer"
},
"trackedFrom": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
"agentTasks",
"apps",
"generatedAt",
"mcpServers",
"plugins",
"range",
"skills",
"subagents",
"totalTokens"
],
"type": "object"
},
"UserInput": {
"oneOf": [
{

View File

@@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"UsageRange": {
"enum": [
"day",
"week"
],
"type": "string"
}
},
"properties": {
"range": {
"$ref": "#/definitions/UsageRange"
}
},
"required": [
"range"
],
"title": "UsageReadParams",
"type": "object"
}

View File

@@ -0,0 +1,160 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"UsageContributorKind": {
"enum": [
"skill",
"subagent",
"agentTask",
"app",
"mcpServer",
"plugin"
],
"type": "string"
},
"UsageEntry": {
"properties": {
"attributedTokens": {
"format": "int64",
"type": "integer"
},
"id": {
"type": "string"
},
"kind": {
"$ref": "#/definitions/UsageContributorKind"
},
"label": {
"type": "string"
},
"percentOfUsage": {
"format": "uint8",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"attributedTokens",
"id",
"kind",
"label",
"percentOfUsage"
],
"type": "object"
},
"UsageHeadline": {
"properties": {
"entry": {
"$ref": "#/definitions/UsageEntry"
},
"note": {
"type": [
"string",
"null"
]
}
},
"required": [
"entry"
],
"type": "object"
},
"UsageRange": {
"enum": [
"day",
"week"
],
"type": "string"
},
"UsageReport": {
"properties": {
"agentTasks": {
"items": {
"$ref": "#/definitions/UsageEntry"
},
"type": "array"
},
"apps": {
"items": {
"$ref": "#/definitions/UsageEntry"
},
"type": "array"
},
"generatedAt": {
"format": "int64",
"type": "integer"
},
"headline": {
"anyOf": [
{
"$ref": "#/definitions/UsageHeadline"
},
{
"type": "null"
}
]
},
"mcpServers": {
"items": {
"$ref": "#/definitions/UsageEntry"
},
"type": "array"
},
"plugins": {
"items": {
"$ref": "#/definitions/UsageEntry"
},
"type": "array"
},
"range": {
"$ref": "#/definitions/UsageRange"
},
"skills": {
"items": {
"$ref": "#/definitions/UsageEntry"
},
"type": "array"
},
"subagents": {
"items": {
"$ref": "#/definitions/UsageEntry"
},
"type": "array"
},
"totalTokens": {
"format": "int64",
"type": "integer"
},
"trackedFrom": {
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
"agentTasks",
"apps",
"generatedAt",
"mcpServers",
"plugins",
"range",
"skills",
"subagents",
"totalTokens"
],
"type": "object"
}
},
"properties": {
"report": {
"$ref": "#/definitions/UsageReport"
}
},
"required": [
"report"
],
"title": "UsageReadResponse",
"type": "object"
}

File diff suppressed because one or more lines are too long

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 UsageContributorKind = "skill" | "subagent" | "agentTask" | "app" | "mcpServer" | "plugin";

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 { UsageContributorKind } from "./UsageContributorKind";
export type UsageEntry = { kind: UsageContributorKind, id: string, label: string, attributedTokens: number, percentOfUsage: number, };

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 { UsageEntry } from "./UsageEntry";
export type UsageHeadline = { entry: UsageEntry, note: string | 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 UsageRange = "day" | "week";

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 { UsageRange } from "./UsageRange";
export type UsageReadParams = { range: UsageRange, };

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 { UsageReport } from "./UsageReport";
export type UsageReadResponse = { report: UsageReport, };

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 { UsageEntry } from "./UsageEntry";
import type { UsageHeadline } from "./UsageHeadline";
import type { UsageRange } from "./UsageRange";
export type UsageReport = { range: UsageRange, generatedAt: number, trackedFrom: number | null, totalTokens: number, headline: UsageHeadline | null, skills: Array<UsageEntry>, subagents: Array<UsageEntry>, agentTasks: Array<UsageEntry>, apps: Array<UsageEntry>, mcpServers: Array<UsageEntry>, plugins: Array<UsageEntry>, };

View File

@@ -448,6 +448,13 @@ export type { TurnStartedNotification } from "./TurnStartedNotification";
export type { TurnStatus } from "./TurnStatus";
export type { TurnSteerParams } from "./TurnSteerParams";
export type { TurnSteerResponse } from "./TurnSteerResponse";
export type { UsageContributorKind } from "./UsageContributorKind";
export type { UsageEntry } from "./UsageEntry";
export type { UsageHeadline } from "./UsageHeadline";
export type { UsageRange } from "./UsageRange";
export type { UsageReadParams } from "./UsageReadParams";
export type { UsageReadResponse } from "./UsageReadResponse";
export type { UsageReport } from "./UsageReport";
export type { UserInput } from "./UserInput";
export type { WarningNotification } from "./WarningNotification";
export type { WebSearchAction } from "./WebSearchAction";

View File

@@ -629,6 +629,11 @@ client_request_definitions! {
serialization: None,
response: v2::PluginListResponse,
},
UsageRead => "usage/read" {
params: v2::UsageReadParams,
serialization: None,
response: v2::UsageReadResponse,
},
PluginInstalled => "plugin/installed" {
params: v2::PluginInstalledParams,
serialization: None,

View File

@@ -24,6 +24,7 @@ mod review;
mod thread;
mod thread_data;
mod turn;
mod usage;
mod windows_sandbox;
pub use account::*;
@@ -51,6 +52,7 @@ pub use shared::*;
pub use thread::*;
pub use thread_data::*;
pub use turn::*;
pub use usage::*;
pub use windows_sandbox::*;
#[cfg(test)]

View File

@@ -0,0 +1,92 @@
use codex_protocol::protocol::UsageContributorKind as CoreUsageContributorKind;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum UsageRange {
Day,
Week,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct UsageReadParams {
pub range: UsageRange,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct UsageEntry {
pub kind: UsageContributorKind,
pub id: String,
pub label: String,
#[ts(type = "number")]
pub attributed_tokens: i64,
pub percent_of_usage: u8,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct UsageHeadline {
pub entry: UsageEntry,
pub note: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct UsageReport {
pub range: UsageRange,
#[ts(type = "number")]
pub generated_at: i64,
#[ts(type = "number | null")]
pub tracked_from: Option<i64>,
#[ts(type = "number")]
pub total_tokens: i64,
pub headline: Option<UsageHeadline>,
pub skills: Vec<UsageEntry>,
pub subagents: Vec<UsageEntry>,
pub agent_tasks: Vec<UsageEntry>,
pub apps: Vec<UsageEntry>,
pub mcp_servers: Vec<UsageEntry>,
pub plugins: Vec<UsageEntry>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct UsageReadResponse {
pub report: UsageReport,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum UsageContributorKind {
Skill,
Subagent,
AgentTask,
App,
McpServer,
Plugin,
}
impl From<CoreUsageContributorKind> for UsageContributorKind {
fn from(value: CoreUsageContributorKind) -> Self {
match value {
CoreUsageContributorKind::Skill => Self::Skill,
CoreUsageContributorKind::Subagent => Self::Subagent,
CoreUsageContributorKind::AgentTask => Self::AgentTask,
CoreUsageContributorKind::App => Self::App,
CoreUsageContributorKind::McpServer => Self::McpServer,
CoreUsageContributorKind::Plugin => Self::Plugin,
}
}
}

View File

@@ -206,6 +206,7 @@ Example with notification opt-out:
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
- `skills/changed` — notification emitted when watched local skill files change.
- `app/list` — list available apps.
- `usage/read` — read forward-only local token usage for a rolling `day` or `week` range, grouped by skills, subagents, agent tasks, apps, MCP servers, and plugins when Codex has tracked those contributors in sqlite.
- `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server.
- `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices.
- `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled.

View File

@@ -35,6 +35,7 @@ use crate::request_processors::SearchRequestProcessor;
use crate::request_processors::ThreadGoalRequestProcessor;
use crate::request_processors::ThreadRequestProcessor;
use crate::request_processors::TurnRequestProcessor;
use crate::request_processors::UsageRequestProcessor;
use crate::request_processors::WindowsSandboxRequestProcessor;
use crate::request_serialization::QueuedInitializedRequest;
use crate::request_serialization::RequestSerializationQueueKey;
@@ -181,6 +182,7 @@ pub(crate) struct MessageProcessor {
thread_goal_processor: ThreadGoalRequestProcessor,
thread_processor: ThreadRequestProcessor,
turn_processor: TurnRequestProcessor,
usage_processor: UsageRequestProcessor,
windows_sandbox_processor: WindowsSandboxRequestProcessor,
request_serialization_queues: RequestSerializationQueues,
}
@@ -423,9 +425,10 @@ impl MessageProcessor {
thread_watch_manager.clone(),
Arc::clone(&thread_list_state_permit),
thread_goal_processor.clone(),
state_db,
state_db.clone(),
Arc::clone(&skills_watcher),
);
let usage_processor = UsageRequestProcessor::new(state_db);
let turn_processor = TurnRequestProcessor::new(
auth_manager.clone(),
Arc::clone(&thread_manager),
@@ -502,6 +505,7 @@ impl MessageProcessor {
thread_goal_processor,
thread_processor,
turn_processor,
usage_processor,
windows_sandbox_processor,
request_serialization_queues: RequestSerializationQueues::default(),
}
@@ -1115,6 +1119,11 @@ impl MessageProcessor {
ClientRequest::PluginList { params, .. } => {
self.plugin_processor.plugin_list(params).await
}
ClientRequest::UsageRead { params, .. } => self
.usage_processor
.usage_read(params)
.await
.map(|response| Some(response.into())),
ClientRequest::PluginInstalled { params, .. } => {
self.plugin_processor.plugin_installed(params).await
}

View File

@@ -467,6 +467,7 @@ mod search;
mod thread_processor;
mod token_usage_replay;
mod turn_processor;
mod usage_processor;
mod windows_sandbox_processor;
pub(crate) use account_processor::AccountRequestProcessor;
@@ -489,6 +490,7 @@ pub(crate) use search::SearchRequestProcessor;
pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor;
pub(crate) use thread_processor::ThreadRequestProcessor;
pub(crate) use turn_processor::TurnRequestProcessor;
pub(crate) use usage_processor::UsageRequestProcessor;
pub(crate) use windows_sandbox_processor::WindowsSandboxRequestProcessor;
use crate::error_code::internal_error;

View File

@@ -0,0 +1,77 @@
use super::*;
use crate::error_code::internal_error;
use chrono::Utc;
use codex_app_server_protocol::UsageEntry;
use codex_app_server_protocol::UsageHeadline;
use codex_app_server_protocol::UsageRange;
use codex_app_server_protocol::UsageReadParams;
use codex_app_server_protocol::UsageReadResponse;
use codex_app_server_protocol::UsageReport;
use codex_rollout::StateDbHandle;
#[derive(Clone)]
pub(crate) struct UsageRequestProcessor {
state_db: Option<StateDbHandle>,
}
impl UsageRequestProcessor {
pub(crate) fn new(state_db: Option<StateDbHandle>) -> Self {
Self { state_db }
}
pub(crate) async fn usage_read(
&self,
params: UsageReadParams,
) -> Result<UsageReadResponse, JSONRPCErrorError> {
let state_db = self
.state_db
.as_ref()
.ok_or_else(|| internal_error("sqlite state db unavailable for usage"))?;
let report = state_db
.read_usage_report(state_usage_range(params.range), Utc::now().timestamp())
.await
.map_err(|err| internal_error(format!("failed to read usage report: {err}")))?;
Ok(UsageReadResponse {
report: UsageReport {
range: api_usage_range(report.range),
generated_at: report.generated_at,
tracked_from: report.tracked_from,
total_tokens: report.total_tokens,
headline: report.headline.map(|headline| UsageHeadline {
entry: usage_entry(headline.entry),
note: headline.note,
}),
skills: report.skills.into_iter().map(usage_entry).collect(),
subagents: report.subagents.into_iter().map(usage_entry).collect(),
agent_tasks: report.agent_tasks.into_iter().map(usage_entry).collect(),
apps: report.apps.into_iter().map(usage_entry).collect(),
mcp_servers: report.mcp_servers.into_iter().map(usage_entry).collect(),
plugins: report.plugins.into_iter().map(usage_entry).collect(),
},
})
}
}
fn usage_entry(entry: codex_state::UsageEntry) -> UsageEntry {
UsageEntry {
kind: entry.kind.into(),
id: entry.id,
label: entry.label,
attributed_tokens: entry.attributed_tokens,
percent_of_usage: entry.percent_of_usage,
}
}
fn state_usage_range(value: UsageRange) -> codex_state::UsageRange {
match value {
UsageRange::Day => codex_state::UsageRange::Day,
UsageRange::Week => codex_state::UsageRange::Week,
}
}
fn api_usage_range(value: codex_state::UsageRange) -> UsageRange {
match value {
codex_state::UsageRange::Day => UsageRange::Day,
codex_state::UsageRange::Week => UsageRange::Week,
}
}

View File

@@ -1,3 +1,4 @@
use crate::usage::UsagePromptAttribution;
pub use codex_api::ResponseEvent;
use codex_config::types::Personality;
use codex_protocol::error::Result;
@@ -33,6 +34,8 @@ pub struct Prompt {
/// Whether parallel tool calls are permitted for this prompt.
pub(crate) parallel_tool_calls: bool,
pub(crate) usage_attribution: UsagePromptAttribution,
pub base_instructions: BaseInstructions,
/// Optionally specify the personality of the model.
@@ -51,6 +54,7 @@ impl Default for Prompt {
input: Vec::new(),
tools: Vec::new(),
parallel_tool_calls: false,
usage_attribution: UsagePromptAttribution::default(),
base_instructions: BaseInstructions::default(),
personality: None,
output_schema: None,

View File

@@ -180,6 +180,7 @@ async fn run_remote_compact_task_inner_impl(
input: prompt_input,
tools: tool_router.model_visible_specs(),
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
usage_attribution: Default::default(),
base_instructions,
personality: turn_context.personality,
output_schema: None,

View File

@@ -188,6 +188,7 @@ async fn run_remote_compact_task_inner_impl(
input,
tools: tool_router.model_visible_specs(),
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
usage_attribution: Default::default(),
base_instructions,
personality: turn_context.personality,
output_schema: None,

View File

@@ -98,6 +98,7 @@ pub(crate) use skills::skills_load_input_from_config;
mod stream_events_utils;
pub mod test_support;
mod unified_exec;
mod usage;
pub mod windows_sandbox;
pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
pub use codex_protocol::config_types::ModelProviderAuthInfo;

View File

@@ -3001,6 +3001,36 @@ impl Session {
}
}
pub(crate) async fn record_usage_attribution(
&self,
turn_context: &TurnContext,
prompt: &crate::client_common::Prompt,
response_id: &str,
token_usage: Option<&TokenUsage>,
) {
let Some(token_usage) = token_usage else {
return;
};
let occurred_at = chrono::Utc::now().timestamp();
let attribution = prompt.usage_attribution.complete(
format!("{}:{response_id}", self.conversation_id),
turn_context.sub_id.clone(),
response_id.to_string(),
occurred_at,
token_usage.clone(),
);
if let Some(state_db) = self.state_db()
&& let Err(err) = state_db
.record_usage_sample(&codex_state::UsageSample {
thread_id: self.conversation_id,
attribution,
})
.await
{
warn!("failed to persist usage sample: {err}");
}
}
pub(crate) async fn recompute_token_usage(&self, turn_context: &TurnContext) {
let history = self.clone_history().await;
let base_instructions = self.get_base_instructions().await;

View File

@@ -889,10 +889,16 @@ pub(crate) fn build_prompt(
turn_context: &TurnContext,
base_instructions: BaseInstructions,
) -> Prompt {
let usage_attribution = crate::usage::UsagePromptAttribution::from_prompt(
&input,
router,
base_instructions.text.as_str(),
);
Prompt {
input,
tools: router.model_visible_specs(),
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
usage_attribution,
base_instructions,
personality: turn_context.personality,
output_schema: turn_context.final_output_json_schema.clone(),
@@ -2021,6 +2027,13 @@ async fn try_run_sampling_request(
&mut assistant_message_stream_parsers,
)
.await;
sess.record_usage_attribution(
&turn_context,
prompt,
response_id.as_str(),
token_usage.as_ref(),
)
.await;
sess.record_token_usage_info(&turn_context, token_usage.as_ref())
.await;
should_emit_token_count = true;

View File

@@ -18,6 +18,8 @@ use crate::tools::registry::ToolExposure;
use crate::tools::registry::ToolTelemetryTags;
use crate::tools::tool_search_entry::ToolSearchInfo;
use codex_mcp::ToolInfo;
use codex_protocol::protocol::UsageContributor;
use codex_protocol::protocol::UsageContributorKind;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolName;
@@ -131,6 +133,38 @@ impl ToolExecutor<ToolInvocation> for McpHandler {
}
impl CoreToolRuntime for McpHandler {
fn usage_contributors(&self) -> Vec<UsageContributor> {
let mut contributors = Vec::new();
if let Some(connector_id) = self.tool_info.connector_id.as_ref() {
contributors.push(UsageContributor {
kind: UsageContributorKind::App,
id: connector_id.clone(),
label: self
.tool_info
.connector_name
.clone()
.unwrap_or_else(|| connector_id.clone()),
});
} else {
contributors.push(UsageContributor {
kind: UsageContributorKind::McpServer,
id: self.tool_info.server_name.clone(),
label: self.tool_info.server_name.clone(),
});
}
contributors.extend(
self.tool_info
.plugin_display_names
.iter()
.map(|plugin_name| UsageContributor {
kind: UsageContributorKind::Plugin,
id: plugin_name.clone(),
label: plugin_name.clone(),
}),
);
contributors
}
fn search_info(&self) -> Option<ToolSearchInfo> {
let source_name = self
.tool_info

View File

@@ -320,6 +320,8 @@ mod tests {
let router = Arc::new(ToolRouter::from_parts(
ToolRegistry::from_tools([handler]),
Vec::new(),
Vec::new(),
std::collections::HashMap::new(),
));
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let runtime = ToolCallRuntime::new(router, session, turn_context, tracker);

View File

@@ -28,6 +28,7 @@ use crate::util::error_or_panic;
use codex_extension_api::ToolCallOutcome;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::UsageContributor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use futures::future::BoxFuture;
@@ -70,6 +71,10 @@ pub(crate) trait CoreToolRuntime: ToolExecutor<ToolInvocation> {
None
}
fn usage_contributors(&self) -> Vec<UsageContributor> {
Vec::new()
}
fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
None
}

View File

@@ -17,6 +17,7 @@ use codex_tools::ToolCall as ExtensionToolCall;
use codex_tools::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tokio_util::sync::CancellationToken;
@@ -34,6 +35,9 @@ pub struct ToolCall {
pub struct ToolRouter {
registry: ToolRegistry,
model_visible_specs: Vec<ToolSpec>,
usage_contributors: Vec<crate::usage::UsagePromptContributor>,
usage_contributors_by_tool_name:
HashMap<ToolName, Vec<codex_protocol::protocol::UsageContributor>>,
}
pub(crate) struct ToolRouterParams<'a> {
@@ -49,10 +53,20 @@ impl ToolRouter {
build_tool_router(turn_context, params)
}
pub(crate) fn from_parts(registry: ToolRegistry, model_visible_specs: Vec<ToolSpec>) -> Self {
pub(crate) fn from_parts(
registry: ToolRegistry,
model_visible_specs: Vec<ToolSpec>,
usage_contributors: Vec<crate::usage::UsagePromptContributor>,
usage_contributors_by_tool_name: HashMap<
ToolName,
Vec<codex_protocol::protocol::UsageContributor>,
>,
) -> Self {
Self {
registry,
model_visible_specs,
usage_contributors,
usage_contributors_by_tool_name,
}
}
@@ -60,6 +74,20 @@ impl ToolRouter {
self.model_visible_specs.clone()
}
pub(crate) fn usage_contributors(&self) -> Vec<crate::usage::UsagePromptContributor> {
self.usage_contributors.clone()
}
pub(crate) fn usage_contributors_for_tool_name(
&self,
tool_name: &ToolName,
) -> Vec<codex_protocol::protocol::UsageContributor> {
self.usage_contributors_by_tool_name
.get(tool_name)
.cloned()
.unwrap_or_default()
}
#[cfg(test)]
pub(crate) fn registered_tool_names_for_test(&self) -> Vec<ToolName> {
self.registry.tool_names_for_test()

View File

@@ -76,6 +76,7 @@ use codex_tools::request_user_input_available_modes;
use codex_tools::shell_command_backend_for_features;
use codex_tools::shell_type_for_model_and_features;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use tracing::warn;
@@ -123,14 +124,25 @@ pub(crate) fn build_tool_router(
turn_context: &TurnContext,
params: ToolRouterParams<'_>,
) -> ToolRouter {
let (model_visible_specs, registry) = build_tool_specs_and_registry(turn_context, params);
ToolRouter::from_parts(registry, model_visible_specs)
let (model_visible_specs, registry, usage_contributors, usage_contributors_by_tool_name) =
build_tool_specs_and_registry(turn_context, params);
ToolRouter::from_parts(
registry,
model_visible_specs,
usage_contributors,
usage_contributors_by_tool_name,
)
}
fn build_tool_specs_and_registry(
turn_context: &TurnContext,
params: ToolRouterParams<'_>,
) -> (Vec<ToolSpec>, ToolRegistry) {
) -> (
Vec<ToolSpec>,
ToolRegistry,
Vec<crate::usage::UsagePromptContributor>,
HashMap<ToolName, Vec<codex_protocol::protocol::UsageContributor>>,
) {
let ToolRouterParams {
mcp_tools,
deferred_mcp_tools,
@@ -160,12 +172,19 @@ fn build_tool_specs_and_registry(
fn build_model_visible_specs_and_registry(
turn_context: &TurnContext,
planned_tools: PlannedTools,
) -> (Vec<ToolSpec>, ToolRegistry) {
) -> (
Vec<ToolSpec>,
ToolRegistry,
Vec<crate::usage::UsagePromptContributor>,
HashMap<ToolName, Vec<codex_protocol::protocol::UsageContributor>>,
) {
let PlannedTools {
runtimes,
hosted_specs,
} = planned_tools;
let mut specs = Vec::new();
let mut usage_contributors = Vec::new();
let mut usage_contributors_by_tool_name = HashMap::new();
let mut seen_tool_names = HashSet::new();
for runtime in &runtimes {
let tool_name = runtime.tool_name();
@@ -177,6 +196,16 @@ fn build_model_visible_specs_and_registry(
&& !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure)
&& let Some(spec) = runtime.spec()
{
let estimated_tokens = crate::usage::estimate_serialized_tokens(&spec);
let runtime_usage_contributors = runtime.usage_contributors();
usage_contributors_by_tool_name
.insert(tool_name.clone(), runtime_usage_contributors.clone());
usage_contributors.extend(runtime_usage_contributors.into_iter().map(|contributor| {
crate::usage::UsagePromptContributor {
contributor,
source_estimated_tokens: estimated_tokens,
}
}));
specs.push(spec_for_model_request(turn_context, exposure, spec));
}
}
@@ -198,7 +227,12 @@ fn build_model_visible_specs_and_registry(
})
.collect();
(model_visible_specs, registry)
(
model_visible_specs,
registry,
usage_contributors,
usage_contributors_by_tool_name,
)
}
fn spec_for_model_request(

363
codex-rs/core/src/usage.rs Normal file
View File

@@ -0,0 +1,363 @@
use crate::tools::router::ToolRouter;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::UsageAttributionContributor;
use codex_protocol::protocol::UsageAttributionItem;
use codex_protocol::protocol::UsageContributor;
use codex_protocol::protocol::UsageContributorKind;
use codex_tools::ToolName;
use codex_utils_output_truncation::approx_token_count;
use std::collections::BTreeMap;
use std::collections::HashMap;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct UsagePromptAttribution {
pub(crate) prompt_estimated_tokens: i64,
pub(crate) contributors: Vec<UsagePromptContributor>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UsagePromptContributor {
pub(crate) contributor: UsageContributor,
pub(crate) source_estimated_tokens: i64,
}
impl UsagePromptAttribution {
pub(crate) fn from_prompt(
input: &[ResponseItem],
router: &ToolRouter,
base_instructions: &str,
) -> Self {
let mut contributors = skill_contributors(input);
contributors.extend(router.usage_contributors());
contributors.extend(tool_result_contributors(input, router));
let input_tokens = input
.iter()
.map(estimate_response_item_tokens)
.fold(0i64, i64::saturating_add);
let tool_tokens = router
.model_visible_specs()
.iter()
.map(estimate_serialized_tokens)
.fold(0i64, i64::saturating_add);
let base_tokens = i64::try_from(approx_token_count(base_instructions)).unwrap_or(i64::MAX);
Self {
prompt_estimated_tokens: base_tokens
.saturating_add(input_tokens)
.saturating_add(tool_tokens),
contributors: aggregate_contributors(contributors),
}
}
pub(crate) fn complete(
&self,
sample_id: String,
turn_id: String,
response_id: String,
occurred_at: i64,
token_usage: TokenUsage,
) -> UsageAttributionItem {
let non_cached_input = token_usage.non_cached_input();
let contributors = self
.contributors
.iter()
.map(|contributor| UsageAttributionContributor {
contributor: contributor.contributor.clone(),
source_estimated_tokens: contributor.source_estimated_tokens,
attributed_tokens: attributable_tokens(
non_cached_input,
contributor.source_estimated_tokens,
self.prompt_estimated_tokens,
),
})
.filter(|contributor| contributor.attributed_tokens > 0)
.collect();
UsageAttributionItem {
sample_id,
turn_id,
response_id,
occurred_at,
token_usage,
prompt_estimated_tokens: self.prompt_estimated_tokens,
contributors,
}
}
}
pub(crate) fn estimate_serialized_tokens<T: serde::Serialize>(value: &T) -> i64 {
serde_json::to_string(value)
.map(|serialized| i64::try_from(approx_token_count(&serialized)).unwrap_or(i64::MAX))
.unwrap_or(/*default*/ 0)
}
fn estimate_response_item_tokens(item: &ResponseItem) -> i64 {
estimate_serialized_tokens(item)
}
fn skill_contributors(input: &[ResponseItem]) -> Vec<UsagePromptContributor> {
input.iter().filter_map(skill_contributor).collect()
}
fn tool_result_contributors(
input: &[ResponseItem],
router: &ToolRouter,
) -> Vec<UsagePromptContributor> {
let contributors_by_call_id = input
.iter()
.filter_map(|item| tool_call_contributors(item, router))
.collect::<HashMap<_, _>>();
input
.iter()
.filter_map(|item| tool_result_contributor(item, &contributors_by_call_id))
.flatten()
.collect()
}
fn tool_call_contributors(
item: &ResponseItem,
router: &ToolRouter,
) -> Option<(String, Vec<UsageContributor>)> {
let (call_id, tool_name) = match item {
ResponseItem::FunctionCall {
call_id,
name,
namespace,
..
} => (call_id, ToolName::new(namespace.clone(), name)),
ResponseItem::CustomToolCall { call_id, name, .. } => (call_id, ToolName::plain(name)),
_ => return None,
};
let contributors = router.usage_contributors_for_tool_name(&tool_name);
(!contributors.is_empty()).then(|| (call_id.clone(), contributors))
}
fn tool_result_contributor(
item: &ResponseItem,
contributors_by_call_id: &HashMap<String, Vec<UsageContributor>>,
) -> Option<Vec<UsagePromptContributor>> {
let call_id = match item {
ResponseItem::FunctionCallOutput { call_id, .. }
| ResponseItem::CustomToolCallOutput { call_id, .. } => call_id,
_ => return None,
};
let source_estimated_tokens = estimate_response_item_tokens(item);
Some(
contributors_by_call_id
.get(call_id)?
.iter()
.cloned()
.map(|contributor| UsagePromptContributor {
contributor,
source_estimated_tokens,
})
.collect(),
)
}
fn skill_contributor(item: &ResponseItem) -> Option<UsagePromptContributor> {
let ResponseItem::Message { content, .. } = item else {
return None;
};
let text = content.iter().find_map(|content| match content {
ContentItem::InputText { text } if text.contains("<skill>") => Some(text.as_str()),
_ => None,
})?;
let name = tag_contents(text, "name")?;
let path = tag_contents(text, "path")?;
Some(UsagePromptContributor {
contributor: UsageContributor {
kind: UsageContributorKind::Skill,
id: path.to_string(),
label: name.to_string(),
},
source_estimated_tokens: i64::try_from(approx_token_count(text)).unwrap_or(i64::MAX),
})
}
fn tag_contents<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = text.find(open.as_str())? + open.len();
let end = text[start..].find(close.as_str())? + start;
Some(text[start..end].trim())
}
fn aggregate_contributors(
contributors: Vec<UsagePromptContributor>,
) -> Vec<UsagePromptContributor> {
let mut aggregated = BTreeMap::new();
for contributor in contributors {
let key = (
contributor.contributor.kind as u8,
contributor.contributor.id.clone(),
contributor.contributor.label.clone(),
);
aggregated
.entry(key)
.and_modify(|existing: &mut UsagePromptContributor| {
existing.source_estimated_tokens = existing
.source_estimated_tokens
.saturating_add(contributor.source_estimated_tokens);
})
.or_insert(contributor);
}
aggregated.into_values().collect()
}
fn attributable_tokens(non_cached_input: i64, source_tokens: i64, prompt_tokens: i64) -> i64 {
if non_cached_input <= 0 || source_tokens <= 0 || prompt_tokens <= 0 {
return 0;
}
non_cached_input
.saturating_mul(source_tokens)
.saturating_add(prompt_tokens / 2)
/ prompt_tokens
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::registry::ToolRegistry;
use codex_protocol::models::FunctionCallOutputPayload;
use pretty_assertions::assert_eq;
#[test]
fn complete_attributes_only_non_cached_input_tokens() {
let attribution = UsagePromptAttribution {
prompt_estimated_tokens: 100,
contributors: vec![
usage_prompt_contributor(
UsageContributorKind::Skill,
"/skills/tmux",
"tmux",
/*source_estimated_tokens*/ 25,
),
usage_prompt_contributor(
UsageContributorKind::App,
"slack",
"Slack",
/*source_estimated_tokens*/ 10,
),
],
};
let usage = attribution.complete(
"sample".to_string(),
"turn".to_string(),
"response".to_string(),
/*occurred_at*/ 1_700_000_000,
TokenUsage {
input_tokens: 100,
cached_input_tokens: 40,
output_tokens: 20,
reasoning_output_tokens: 0,
total_tokens: 120,
},
);
assert_eq!(
usage.contributors,
vec![
UsageAttributionContributor {
contributor: usage_contributor(
UsageContributorKind::Skill,
"/skills/tmux",
"tmux",
),
source_estimated_tokens: 25,
attributed_tokens: 15,
},
UsageAttributionContributor {
contributor: usage_contributor(UsageContributorKind::App, "slack", "Slack"),
source_estimated_tokens: 10,
attributed_tokens: 6,
},
]
);
}
#[test]
fn skill_contributors_use_skill_path_as_stable_id() {
let item = ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "<skill><name>tmux</name><path>/skills/tmux/SKILL.md</path></skill>"
.to_string(),
}],
phase: None,
};
assert_eq!(
skill_contributors(&[item]),
vec![UsagePromptContributor {
contributor: usage_contributor(
UsageContributorKind::Skill,
"/skills/tmux/SKILL.md",
"tmux",
),
source_estimated_tokens: i64::try_from(approx_token_count(
"<skill><name>tmux</name><path>/skills/tmux/SKILL.md</path></skill>",
))
.expect("skill prompt token estimate should fit in i64"),
}]
);
}
#[test]
fn tool_results_reuse_tool_usage_provenance() {
let contributor = usage_contributor(UsageContributorKind::App, "slack", "Slack");
let tool_name = ToolName::plain("mcp__slack__search");
let router = ToolRouter::from_parts(
ToolRegistry::from_tools(Vec::<
std::sync::Arc<dyn crate::tools::registry::CoreToolRuntime>,
>::new()),
Vec::new(),
Vec::new(),
HashMap::from([(tool_name.clone(), vec![contributor.clone()])]),
);
let tool_result = ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text("result".to_string()),
};
let input = vec![
ResponseItem::FunctionCall {
id: None,
name: tool_name.name,
namespace: tool_name.namespace,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
tool_result.clone(),
];
assert_eq!(
tool_result_contributors(&input, &router),
vec![UsagePromptContributor {
contributor,
source_estimated_tokens: estimate_response_item_tokens(&tool_result),
}]
);
}
fn usage_prompt_contributor(
kind: UsageContributorKind,
id: &str,
label: &str,
source_estimated_tokens: i64,
) -> UsagePromptContributor {
UsagePromptContributor {
contributor: usage_contributor(kind, id, label),
source_estimated_tokens,
}
}
fn usage_contributor(kind: UsageContributorKind, id: &str, label: &str) -> UsageContributor {
UsageContributor {
kind,
id: id.to_string(),
label: label.to_string(),
}
}
}

View File

@@ -1901,6 +1901,47 @@ pub struct TokenUsage {
pub total_tokens: i64,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
pub enum UsageContributorKind {
Skill,
Subagent,
AgentTask,
App,
McpServer,
Plugin,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct UsageContributor {
pub kind: UsageContributorKind,
pub id: String,
pub label: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct UsageAttributionContributor {
pub contributor: UsageContributor,
#[ts(type = "number")]
pub source_estimated_tokens: i64,
#[ts(type = "number")]
pub attributed_tokens: i64,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct UsageAttributionItem {
pub sample_id: String,
pub turn_id: String,
pub response_id: String,
#[ts(type = "number")]
pub occurred_at: i64,
pub token_usage: TokenUsage,
#[ts(type = "number")]
pub prompt_estimated_tokens: i64,
pub contributors: Vec<UsageAttributionContributor>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct TokenUsageInfo {
pub total_token_usage: TokenUsage,

View File

@@ -216,6 +216,66 @@ async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Resu
Ok(())
}
#[tokio::test]
async fn load_rollout_items_skips_legacy_usage_attribution_lines() -> std::io::Result<()> {
let home = TempDir::new().expect("temp dir");
let rollout_path = home.path().join("rollout.jsonl");
let mut file = File::create(&rollout_path)?;
let thread_id = ThreadId::new();
let ts = "2025-01-03T12:00:00Z";
writeln!(
file,
"{}",
serde_json::json!({
"timestamp": ts,
"type": "session_meta",
"payload": {
"id": thread_id,
"timestamp": ts,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "cli",
"model_provider": "test-provider",
},
})
)?;
writeln!(
file,
"{}",
serde_json::json!({
"timestamp": ts,
"type": "usage_attribution",
"payload": {
"sample_id": "sample",
"turn_id": "turn",
"response_id": "response",
"occurred_at": 1_700_000_000,
"token_usage": {
"input_tokens": 1,
"cached_input_tokens": 0,
"output_tokens": 1,
"reasoning_output_tokens": 0,
"total_tokens": 2,
},
"prompt_estimated_tokens": 1,
"contributors": [],
},
})
)?;
let (items, loaded_thread_id, parse_errors) =
RolloutRecorder::load_rollout_items(&rollout_path).await?;
assert_eq!(loaded_thread_id, Some(thread_id));
assert_eq!(parse_errors, 1);
assert_eq!(items.len(), 1);
assert!(matches!(items[0], RolloutItem::SessionMeta(_)));
Ok(())
}
#[tokio::test]
async fn load_rollout_items_preserves_legacy_guardian_assessment_lines() -> std::io::Result<()> {
let home = TempDir::new().expect("temp dir");

View File

@@ -0,0 +1,29 @@
CREATE TABLE usage_samples (
sample_id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
turn_id TEXT NOT NULL,
response_id TEXT NOT NULL,
occurred_at INTEGER NOT NULL,
input_tokens INTEGER NOT NULL,
cached_input_tokens INTEGER NOT NULL,
non_cached_input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
reasoning_output_tokens INTEGER NOT NULL,
total_tokens INTEGER NOT NULL,
blended_tokens INTEGER NOT NULL,
prompt_estimated_tokens INTEGER NOT NULL
);
CREATE TABLE usage_sample_contributors (
sample_id TEXT NOT NULL REFERENCES usage_samples(sample_id) ON DELETE CASCADE,
kind TEXT NOT NULL,
contributor_id TEXT NOT NULL,
label TEXT NOT NULL,
source_estimated_tokens INTEGER NOT NULL,
attributed_tokens INTEGER NOT NULL,
PRIMARY KEY (sample_id, kind, contributor_id)
);
CREATE INDEX idx_usage_samples_occurred_at ON usage_samples(occurred_at);
CREATE INDEX idx_usage_samples_thread_occurred_at ON usage_samples(thread_id, occurred_at);
CREATE INDEX idx_usage_sample_contributors_kind ON usage_sample_contributors(kind, contributor_id);

View File

@@ -48,6 +48,11 @@ pub use model::ThreadGoalStatus;
pub use model::ThreadMetadata;
pub use model::ThreadMetadataBuilder;
pub use model::ThreadsPage;
pub use model::UsageEntry;
pub use model::UsageHeadline;
pub use model::UsageRange;
pub use model::UsageReport;
pub use model::UsageSample;
pub use runtime::GoalAccountingMode;
pub use runtime::GoalAccountingOutcome;
pub use runtime::GoalStore;

View File

@@ -5,6 +5,7 @@ mod log;
mod memories;
mod thread_goal;
mod thread_metadata;
mod usage;
pub use agent_job::AgentJob;
pub use agent_job::AgentJobCreateParams;
@@ -34,6 +35,11 @@ pub use thread_metadata::SortKey;
pub use thread_metadata::ThreadMetadata;
pub use thread_metadata::ThreadMetadataBuilder;
pub use thread_metadata::ThreadsPage;
pub use usage::UsageEntry;
pub use usage::UsageHeadline;
pub use usage::UsageRange;
pub use usage::UsageReport;
pub use usage::UsageSample;
pub(crate) use agent_job::AgentJobItemRow;
pub(crate) use agent_job::AgentJobRow;

View File

@@ -0,0 +1,53 @@
use codex_protocol::protocol::UsageAttributionItem;
use codex_protocol::protocol::UsageContributorKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UsageRange {
Day,
Week,
}
impl UsageRange {
pub(crate) fn seconds(self) -> i64 {
match self {
Self::Day => 24 * 60 * 60,
Self::Week => 7 * 24 * 60 * 60,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageEntry {
pub kind: UsageContributorKind,
pub id: String,
pub label: String,
pub attributed_tokens: i64,
pub percent_of_usage: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageHeadline {
pub entry: UsageEntry,
pub note: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageReport {
pub range: UsageRange,
pub generated_at: i64,
pub tracked_from: Option<i64>,
pub total_tokens: i64,
pub headline: Option<UsageHeadline>,
pub skills: Vec<UsageEntry>,
pub subagents: Vec<UsageEntry>,
pub agent_tasks: Vec<UsageEntry>,
pub apps: Vec<UsageEntry>,
pub mcp_servers: Vec<UsageEntry>,
pub plugins: Vec<UsageEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageSample {
pub thread_id: codex_protocol::ThreadId,
pub attribution: UsageAttributionItem,
}

View File

@@ -65,6 +65,7 @@ mod remote_control;
#[cfg(test)]
mod test_support;
mod threads;
mod usage;
pub use goals::GoalAccountingMode;
pub use goals::GoalAccountingOutcome;
@@ -237,6 +238,12 @@ impl StateRuntime {
logs_path.display(),
);
}
if let Err(err) = runtime.run_usage_startup_maintenance().await {
warn!(
"failed to run startup maintenance for usage data in state db at {}: {err}",
state_path.display(),
);
}
Ok(runtime)
}

View File

@@ -0,0 +1,893 @@
use super::*;
use crate::UsageEntry;
use crate::UsageHeadline;
use crate::UsageRange;
use crate::UsageReport;
use crate::UsageSample;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::UsageContributorKind;
use serde_json::Value;
use std::collections::BTreeMap;
const USAGE_RETENTION_DAYS: i64 = 14;
const USAGE_RETENTION_SECONDS: i64 = USAGE_RETENTION_DAYS * 24 * 60 * 60;
impl StateRuntime {
pub async fn record_usage_sample(&self, sample: &UsageSample) -> anyhow::Result<()> {
let usage = &sample.attribution;
let token_usage = &usage.token_usage;
let retention_cutoff = usage_retention_cutoff(Utc::now().timestamp());
let mut tx = self.pool.begin().await?;
sqlx::query(
r#"
INSERT INTO usage_samples (
sample_id,
thread_id,
turn_id,
response_id,
occurred_at,
input_tokens,
cached_input_tokens,
non_cached_input_tokens,
output_tokens,
reasoning_output_tokens,
total_tokens,
blended_tokens,
prompt_estimated_tokens
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(sample_id) DO UPDATE SET
thread_id = excluded.thread_id,
turn_id = excluded.turn_id,
response_id = excluded.response_id,
occurred_at = excluded.occurred_at,
input_tokens = excluded.input_tokens,
cached_input_tokens = excluded.cached_input_tokens,
non_cached_input_tokens = excluded.non_cached_input_tokens,
output_tokens = excluded.output_tokens,
reasoning_output_tokens = excluded.reasoning_output_tokens,
total_tokens = excluded.total_tokens,
blended_tokens = excluded.blended_tokens,
prompt_estimated_tokens = excluded.prompt_estimated_tokens
"#,
)
.bind(usage.sample_id.as_str())
.bind(sample.thread_id.to_string())
.bind(usage.turn_id.as_str())
.bind(usage.response_id.as_str())
.bind(usage.occurred_at)
.bind(token_usage.input_tokens)
.bind(token_usage.cached_input_tokens)
.bind(token_usage.non_cached_input())
.bind(token_usage.output_tokens)
.bind(token_usage.reasoning_output_tokens)
.bind(token_usage.total_tokens)
.bind(token_usage.blended_total())
.bind(usage.prompt_estimated_tokens)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM usage_sample_contributors WHERE sample_id = ?")
.bind(usage.sample_id.as_str())
.execute(&mut *tx)
.await?;
for contributor in &usage.contributors {
sqlx::query(
r#"
INSERT INTO usage_sample_contributors (
sample_id,
kind,
contributor_id,
label,
source_estimated_tokens,
attributed_tokens
) VALUES (?, ?, ?, ?, ?, ?)
"#,
)
.bind(usage.sample_id.as_str())
.bind(usage_kind_key(contributor.contributor.kind))
.bind(contributor.contributor.id.as_str())
.bind(contributor.contributor.label.as_str())
.bind(contributor.source_estimated_tokens)
.bind(contributor.attributed_tokens)
.execute(&mut *tx)
.await?;
}
prune_usage_samples_before(retention_cutoff, &mut tx).await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn run_usage_startup_maintenance(&self) -> anyhow::Result<()> {
let mut tx = self.pool.begin().await?;
prune_usage_samples_before(usage_retention_cutoff(Utc::now().timestamp()), &mut tx).await?;
tx.commit().await?;
// PASSIVE checkpoints copy whatever is immediately available and skip
// frames that would require waiting on active readers or writers.
sqlx::query("PRAGMA wal_checkpoint(PASSIVE)")
.execute(self.pool.as_ref())
.await?;
// Reclaim any free pages left by retention pruning when incremental auto-vacuum is active.
sqlx::query("PRAGMA incremental_vacuum")
.execute(self.pool.as_ref())
.await?;
Ok(())
}
pub async fn read_usage_report(
&self,
range: UsageRange,
now: i64,
) -> anyhow::Result<UsageReport> {
let since = now.saturating_sub(range.seconds());
let total_tokens: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(blended_tokens), 0) FROM usage_samples WHERE occurred_at >= ?",
)
.bind(since)
.fetch_one(self.pool.as_ref())
.await?;
let tracked_from: Option<i64> =
sqlx::query_scalar("SELECT MIN(occurred_at) FROM usage_samples")
.fetch_one(self.pool.as_ref())
.await?;
let mut report = UsageReport {
range,
generated_at: now,
tracked_from,
total_tokens,
headline: None,
skills: self
.read_usage_contributors(since, UsageContributorKind::Skill, total_tokens)
.await?,
subagents: self.read_subagent_usage(since, total_tokens).await?,
agent_tasks: self.read_agent_task_usage(since, total_tokens).await?,
apps: self
.read_usage_contributors(since, UsageContributorKind::App, total_tokens)
.await?,
mcp_servers: self
.read_usage_contributors(since, UsageContributorKind::McpServer, total_tokens)
.await?,
plugins: self
.read_usage_contributors(since, UsageContributorKind::Plugin, total_tokens)
.await?,
};
report.headline = usage_headline(&report);
Ok(report)
}
async fn read_usage_contributors(
&self,
since: i64,
kind: UsageContributorKind,
total_tokens: i64,
) -> anyhow::Result<Vec<UsageEntry>> {
let rows = sqlx::query(
r#"
SELECT contributor_id, label, SUM(attributed_tokens) AS attributed_tokens
FROM usage_sample_contributors
JOIN usage_samples ON usage_samples.sample_id = usage_sample_contributors.sample_id
WHERE usage_samples.occurred_at >= ?
AND usage_sample_contributors.kind = ?
GROUP BY contributor_id, label
HAVING SUM(attributed_tokens) > 0
ORDER BY attributed_tokens DESC, label ASC
"#,
)
.bind(since)
.bind(usage_kind_key(kind))
.fetch_all(self.pool.as_ref())
.await?;
rows.into_iter()
.map(|row| {
let attributed_tokens = row.try_get("attributed_tokens")?;
Ok(UsageEntry {
kind,
id: row.try_get("contributor_id")?,
label: row.try_get("label")?,
attributed_tokens,
percent_of_usage: usage_percent(attributed_tokens, total_tokens),
})
})
.collect()
}
async fn read_subagent_usage(
&self,
since: i64,
total_tokens: i64,
) -> anyhow::Result<Vec<UsageEntry>> {
let rows = sqlx::query(
r#"
SELECT
COALESCE(NULLIF(threads.agent_role, ''), NULLIF(threads.agent_nickname, ''), 'default') AS label,
COALESCE(NULLIF(threads.agent_role, ''), NULLIF(threads.agent_nickname, ''), 'default') AS contributor_id,
SUM(usage_samples.blended_tokens) AS attributed_tokens
FROM usage_samples
JOIN threads ON threads.id = usage_samples.thread_id
WHERE usage_samples.occurred_at >= ?
AND threads.thread_source = 'subagent'
GROUP BY contributor_id, label
HAVING SUM(usage_samples.blended_tokens) > 0
ORDER BY attributed_tokens DESC, label ASC
"#,
)
.bind(since)
.fetch_all(self.pool.as_ref())
.await?;
rows.into_iter()
.map(|row| {
let attributed_tokens = row.try_get("attributed_tokens")?;
Ok(UsageEntry {
kind: UsageContributorKind::Subagent,
id: row.try_get("contributor_id")?,
label: row.try_get("label")?,
attributed_tokens,
percent_of_usage: usage_percent(attributed_tokens, total_tokens),
})
})
.collect()
}
async fn read_agent_task_usage(
&self,
since: i64,
total_tokens: i64,
) -> anyhow::Result<Vec<UsageEntry>> {
let rows = sqlx::query(
r#"
SELECT threads.source AS source, SUM(usage_samples.blended_tokens) AS attributed_tokens
FROM usage_samples
JOIN threads ON threads.id = usage_samples.thread_id
WHERE usage_samples.occurred_at >= ?
AND threads.thread_source = 'subagent'
GROUP BY threads.source
HAVING SUM(usage_samples.blended_tokens) > 0
"#,
)
.bind(since)
.fetch_all(self.pool.as_ref())
.await?;
let mut by_task = BTreeMap::<String, i64>::new();
for row in rows {
let label = agent_task_label(row.try_get("source")?);
let attributed_tokens: i64 = row.try_get("attributed_tokens")?;
by_task
.entry(label)
.and_modify(|tokens| {
*tokens = tokens.saturating_add(attributed_tokens);
})
.or_insert(attributed_tokens);
}
let mut entries = by_task
.into_iter()
.map(|(label, attributed_tokens)| UsageEntry {
kind: UsageContributorKind::AgentTask,
id: label.clone(),
label,
attributed_tokens,
percent_of_usage: usage_percent(attributed_tokens, total_tokens),
})
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.attributed_tokens
.cmp(&left.attributed_tokens)
.then_with(|| left.label.cmp(&right.label))
});
Ok(entries)
}
}
async fn prune_usage_samples_before(
cutoff_ts: i64,
tx: &mut SqliteConnection,
) -> anyhow::Result<u64> {
let result = sqlx::query("DELETE FROM usage_samples WHERE occurred_at < ?")
.bind(cutoff_ts)
.execute(&mut *tx)
.await?;
Ok(result.rows_affected())
}
fn usage_retention_cutoff(now: i64) -> i64 {
now.saturating_sub(USAGE_RETENTION_SECONDS)
}
fn usage_kind_key(kind: UsageContributorKind) -> &'static str {
match kind {
UsageContributorKind::Skill => "skill",
UsageContributorKind::Subagent => "subagent",
UsageContributorKind::AgentTask => "agent_task",
UsageContributorKind::App => "app",
UsageContributorKind::McpServer => "mcp_server",
UsageContributorKind::Plugin => "plugin",
}
}
fn usage_percent(attributed_tokens: i64, total_tokens: i64) -> u8 {
if attributed_tokens <= 0 || total_tokens <= 0 {
return 0;
}
let rounded = attributed_tokens
.saturating_mul(/*rhs*/ 100)
.saturating_add(total_tokens / 2)
/ total_tokens;
u8::try_from(rounded.max(/*other*/ 1).min(i64::from(u8::MAX))).unwrap_or(u8::MAX)
}
fn usage_headline(report: &UsageReport) -> Option<UsageHeadline> {
let entry = report
.skills
.iter()
.chain(report.subagents.iter())
.chain(report.agent_tasks.iter())
.chain(report.apps.iter())
.chain(report.mcp_servers.iter())
.chain(report.plugins.iter())
.max_by(|left, right| {
left.attributed_tokens
.cmp(&right.attributed_tokens)
.then_with(|| right.label.cmp(&left.label))
})?
.clone();
let note = matches!(
entry.kind,
UsageContributorKind::App | UsageContributorKind::McpServer
)
.then(|| {
"Tool results stay in context until compaction; compact or disable sources you do not need."
.to_string()
});
Some(UsageHeadline { entry, note })
}
fn agent_task_label(source: &str) -> String {
let parsed_source = serde_json::from_str(source)
.or_else(|_| serde_json::from_value::<SessionSource>(Value::String(source.to_string())));
match parsed_source.ok() {
Some(SessionSource::SubAgent(SubAgentSource::Review)) => "review".to_string(),
Some(SessionSource::SubAgent(SubAgentSource::Compact)) => "compact".to_string(),
Some(SessionSource::SubAgent(SubAgentSource::MemoryConsolidation)) => {
"memory-consolidation".to_string()
}
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })) => {
"thread-spawned".to_string()
}
Some(SessionSource::SubAgent(SubAgentSource::Other(other))) => other,
_ => "unknown".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::test_support::test_thread_metadata;
use crate::runtime::test_support::unique_temp_dir;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::UsageAttributionContributor;
use codex_protocol::protocol::UsageAttributionItem;
use codex_protocol::protocol::UsageContributor;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn usage_report_groups_forward_only_samples_by_range() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let user_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000901").expect("valid thread id");
let subagent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000902").expect("valid thread id");
let now = Utc::now().timestamp();
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
user_thread_id,
codex_home.clone(),
))
.await
.expect("user thread insert should succeed");
let mut subagent_metadata =
test_thread_metadata(&codex_home, subagent_thread_id, codex_home.clone());
subagent_metadata.thread_source = Some(ThreadSource::Subagent);
subagent_metadata.source =
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: user_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: Some("code-review".to_string()),
}))
.expect("thread spawn source should serialize");
subagent_metadata.agent_role = Some("code-review".to_string());
runtime
.upsert_thread(&subagent_metadata)
.await
.expect("subagent thread insert should succeed");
runtime
.record_usage_sample(&usage_sample(
user_thread_id,
"recent-user",
/*occurred_at*/ now - 100,
TokenUsage {
input_tokens: 100,
cached_input_tokens: 20,
output_tokens: 40,
reasoning_output_tokens: 0,
total_tokens: 140,
},
vec![
contributor(
UsageContributorKind::Skill,
"/skills/tmux",
"tmux",
/*attributed_tokens*/ 50,
),
contributor(
UsageContributorKind::App,
"slack",
"Slack",
/*attributed_tokens*/ 70,
),
],
))
.await
.expect("recent usage sample should persist");
runtime
.record_usage_sample(&usage_sample(
subagent_thread_id,
"recent-subagent",
/*occurred_at*/ now - 50,
TokenUsage {
input_tokens: 30,
cached_input_tokens: 0,
output_tokens: 10,
reasoning_output_tokens: 0,
total_tokens: 40,
},
Vec::new(),
))
.await
.expect("subagent usage sample should persist");
runtime
.record_usage_sample(&usage_sample(
user_thread_id,
"old-user",
/*occurred_at*/ now - UsageRange::Day.seconds() - 1,
TokenUsage {
input_tokens: 10,
cached_input_tokens: 0,
output_tokens: 0,
reasoning_output_tokens: 0,
total_tokens: 10,
},
vec![contributor(
UsageContributorKind::McpServer,
"old-mcp",
"old-mcp",
/*attributed_tokens*/ 10,
)],
))
.await
.expect("old usage sample should persist");
assert_eq!(
runtime
.read_usage_report(UsageRange::Day, now)
.await
.expect("usage report should load"),
UsageReport {
range: UsageRange::Day,
generated_at: now,
tracked_from: Some(now - UsageRange::Day.seconds() - 1),
total_tokens: 160,
headline: Some(UsageHeadline {
entry: UsageEntry {
kind: UsageContributorKind::App,
id: "slack".to_string(),
label: "Slack".to_string(),
attributed_tokens: 70,
percent_of_usage: 44,
},
note: Some(
"Tool results stay in context until compaction; compact or disable sources you do not need."
.to_string(),
),
}),
skills: vec![UsageEntry {
kind: UsageContributorKind::Skill,
id: "/skills/tmux".to_string(),
label: "tmux".to_string(),
attributed_tokens: 50,
percent_of_usage: 31,
}],
subagents: vec![UsageEntry {
kind: UsageContributorKind::Subagent,
id: "code-review".to_string(),
label: "code-review".to_string(),
attributed_tokens: 40,
percent_of_usage: 25,
}],
agent_tasks: vec![UsageEntry {
kind: UsageContributorKind::AgentTask,
id: "thread-spawned".to_string(),
label: "thread-spawned".to_string(),
attributed_tokens: 40,
percent_of_usage: 25,
}],
apps: vec![UsageEntry {
kind: UsageContributorKind::App,
id: "slack".to_string(),
label: "Slack".to_string(),
attributed_tokens: 70,
percent_of_usage: 44,
}],
mcp_servers: Vec::new(),
plugins: Vec::new(),
}
);
}
#[tokio::test]
async fn usage_report_labels_default_subagents_as_default() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let subagent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000905").expect("valid thread id");
let mut subagent_metadata =
test_thread_metadata(&codex_home, subagent_thread_id, codex_home.clone());
subagent_metadata.thread_source = Some(ThreadSource::Subagent);
runtime
.upsert_thread(&subagent_metadata)
.await
.expect("subagent thread insert should succeed");
let now = Utc::now().timestamp();
runtime
.record_usage_sample(&usage_sample(
subagent_thread_id,
"default-subagent",
/*occurred_at*/ now,
TokenUsage {
input_tokens: 10,
cached_input_tokens: 0,
output_tokens: 5,
reasoning_output_tokens: 0,
total_tokens: 15,
},
Vec::new(),
))
.await
.expect("subagent usage sample should persist");
let report = runtime
.read_usage_report(UsageRange::Day, now)
.await
.expect("usage report should load");
assert_eq!(
report.subagents,
vec![UsageEntry {
kind: UsageContributorKind::Subagent,
id: "default".to_string(),
label: "default".to_string(),
attributed_tokens: 15,
percent_of_usage: 100,
}]
);
assert_eq!(
report.agent_tasks,
vec![UsageEntry {
kind: UsageContributorKind::AgentTask,
id: "unknown".to_string(),
label: "unknown".to_string(),
attributed_tokens: 15,
percent_of_usage: 100,
}]
);
}
#[tokio::test]
async fn usage_report_groups_agent_tasks_by_subagent_source() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let parent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000906").expect("valid thread id");
let review_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000907").expect("valid thread id");
let guardian_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000908").expect("valid thread id");
let spawned_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000909").expect("valid thread id");
let unknown_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000910").expect("valid thread id");
let now = Utc::now().timestamp();
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
parent_thread_id,
codex_home.clone(),
))
.await
.expect("parent thread insert should succeed");
for (thread_id, source) in [
(
review_thread_id,
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::Review))
.expect("review source should serialize"),
),
(
guardian_thread_id,
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::Other(
"guardian".to_string(),
)))
.expect("guardian source should serialize"),
),
(
spawned_thread_id,
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: Some("Bacon".to_string()),
agent_role: None,
}))
.expect("thread spawn source should serialize"),
),
(unknown_thread_id, "not-json".to_string()),
] {
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.thread_source = Some(ThreadSource::Subagent);
metadata.source = source;
runtime
.upsert_thread(&metadata)
.await
.expect("subagent thread insert should succeed");
}
for (thread_id, sample_id, input_tokens) in [
(review_thread_id, "review-agent-task", 10),
(guardian_thread_id, "guardian-agent-task", 20),
(spawned_thread_id, "spawned-agent-task", 30),
(unknown_thread_id, "unknown-agent-task", 40),
] {
runtime
.record_usage_sample(&usage_sample(
thread_id,
sample_id,
/*occurred_at*/ now,
TokenUsage {
input_tokens,
cached_input_tokens: 0,
output_tokens: 0,
reasoning_output_tokens: 0,
total_tokens: input_tokens,
},
Vec::new(),
))
.await
.expect("usage sample should persist");
}
let report = runtime
.read_usage_report(UsageRange::Day, now)
.await
.expect("usage report should load");
assert_eq!(
report.agent_tasks,
vec![
UsageEntry {
kind: UsageContributorKind::AgentTask,
id: "unknown".to_string(),
label: "unknown".to_string(),
attributed_tokens: 40,
percent_of_usage: 40,
},
UsageEntry {
kind: UsageContributorKind::AgentTask,
id: "thread-spawned".to_string(),
label: "thread-spawned".to_string(),
attributed_tokens: 30,
percent_of_usage: 30,
},
UsageEntry {
kind: UsageContributorKind::AgentTask,
id: "guardian".to_string(),
label: "guardian".to_string(),
attributed_tokens: 20,
percent_of_usage: 20,
},
UsageEntry {
kind: UsageContributorKind::AgentTask,
id: "review".to_string(),
label: "review".to_string(),
attributed_tokens: 10,
percent_of_usage: 10,
},
]
);
}
#[tokio::test]
async fn record_usage_sample_prunes_samples_older_than_retention() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000903").expect("valid thread id");
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
thread_id,
codex_home.clone(),
))
.await
.expect("thread insert should succeed");
let now = Utc::now().timestamp();
runtime
.record_usage_sample(&usage_sample(
thread_id,
"stale",
/*occurred_at*/ now - USAGE_RETENTION_SECONDS - 1,
TokenUsage {
input_tokens: 10,
cached_input_tokens: 0,
output_tokens: 5,
reasoning_output_tokens: 0,
total_tokens: 15,
},
vec![contributor(
UsageContributorKind::Skill,
"/skills/stale",
"stale",
/*attributed_tokens*/ 10,
)],
))
.await
.expect("stale usage sample should persist then prune");
runtime
.record_usage_sample(&usage_sample(
thread_id,
"retained",
/*occurred_at*/ now - UsageRange::Week.seconds(),
TokenUsage {
input_tokens: 10,
cached_input_tokens: 0,
output_tokens: 5,
reasoning_output_tokens: 0,
total_tokens: 15,
},
vec![contributor(
UsageContributorKind::Skill,
"/skills/retained",
"retained",
/*attributed_tokens*/ 10,
)],
))
.await
.expect("retained usage sample should persist");
assert_eq!(usage_sample_count(&runtime).await, 1);
assert_eq!(usage_contributor_count(&runtime).await, 1);
}
#[tokio::test]
async fn usage_startup_maintenance_prunes_stale_samples() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000904").expect("valid thread id");
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
thread_id,
codex_home.clone(),
))
.await
.expect("thread insert should succeed");
let now = Utc::now().timestamp();
runtime
.record_usage_sample(&usage_sample(
thread_id,
"stale-after-write",
/*occurred_at*/ now,
TokenUsage {
input_tokens: 10,
cached_input_tokens: 0,
output_tokens: 5,
reasoning_output_tokens: 0,
total_tokens: 15,
},
vec![contributor(
UsageContributorKind::Skill,
"/skills/stale",
"stale",
/*attributed_tokens*/ 10,
)],
))
.await
.expect("usage sample should persist");
sqlx::query("UPDATE usage_samples SET occurred_at = ? WHERE sample_id = ?")
.bind(/*value*/ now - USAGE_RETENTION_SECONDS - 1)
.bind("stale-after-write")
.execute(runtime.pool.as_ref())
.await
.expect("usage sample should age");
runtime
.run_usage_startup_maintenance()
.await
.expect("usage startup maintenance should succeed");
assert_eq!(usage_sample_count(&runtime).await, 0);
assert_eq!(usage_contributor_count(&runtime).await, 0);
}
fn usage_sample(
thread_id: ThreadId,
sample_id: &str,
occurred_at: i64,
token_usage: TokenUsage,
contributors: Vec<UsageAttributionContributor>,
) -> UsageSample {
UsageSample {
thread_id,
attribution: UsageAttributionItem {
sample_id: sample_id.to_string(),
turn_id: format!("{sample_id}-turn"),
response_id: format!("{sample_id}-response"),
occurred_at,
token_usage,
prompt_estimated_tokens: 100,
contributors,
},
}
}
fn contributor(
kind: UsageContributorKind,
id: &str,
label: &str,
attributed_tokens: i64,
) -> UsageAttributionContributor {
UsageAttributionContributor {
contributor: UsageContributor {
kind,
id: id.to_string(),
label: label.to_string(),
},
source_estimated_tokens: attributed_tokens,
attributed_tokens,
}
}
async fn usage_sample_count(runtime: &StateRuntime) -> i64 {
sqlx::query_scalar("SELECT COUNT(*) FROM usage_samples")
.fetch_one(runtime.pool.as_ref())
.await
.expect("usage sample count should load")
}
async fn usage_contributor_count(runtime: &StateRuntime) -> i64 {
sqlx::query_scalar("SELECT COUNT(*) FROM usage_sample_contributors")
.fetch_one(runtime.pool.as_ref())
.await
.expect("usage contributor count should load")
}
}

View File

@@ -15,6 +15,9 @@ use codex_app_server_protocol::MarketplaceRemoveParams;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::MarketplaceUpgradeParams;
use codex_app_server_protocol::MarketplaceUpgradeResponse;
use codex_app_server_protocol::UsageRange;
use codex_app_server_protocol::UsageReadParams;
use codex_app_server_protocol::UsageReadResponse;
use codex_app_server_protocol::RequestId;
@@ -127,6 +130,22 @@ impl App {
});
}
pub(super) fn fetch_usage(
&mut self,
app_server: &AppServerSession,
request_id: u64,
range: UsageRange,
) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_usage(request_handle, range)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::UsageLoaded { request_id, result });
});
}
pub(super) fn fetch_hooks_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
@@ -577,6 +596,20 @@ impl App {
}
}
async fn fetch_usage(
request_handle: AppServerRequestHandle,
range: UsageRange,
) -> Result<UsageReadResponse> {
let request_id = RequestId::String(format!("usage-read-{}", uuid::Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::UsageRead {
request_id,
params: UsageReadParams { range },
})
.await
.map_err(Into::into)
}
pub(super) async fn fetch_all_mcp_server_statuses(
request_handle: AppServerRequestHandle,
detail: McpServerStatusDetail,

View File

@@ -436,6 +436,12 @@ impl App {
AppEvent::FetchPluginsList { cwd } => {
self.fetch_plugins_list(app_server, cwd);
}
AppEvent::FetchUsage { request_id, range } => {
self.fetch_usage(app_server, request_id, range);
}
AppEvent::UsageLoaded { request_id, result } => {
self.chat_widget.on_usage_loaded(request_id, result);
}
AppEvent::FetchHooksList { cwd } => {
self.fetch_hooks_list(app_server, cwd);
}

View File

@@ -371,6 +371,16 @@ pub(crate) enum AppEvent {
cwd: PathBuf,
},
FetchUsage {
request_id: u64,
range: codex_app_server_protocol::UsageRange,
},
UsageLoaded {
request_id: u64,
result: Result<codex_app_server_protocol::UsageReadResponse, String>,
},
/// Fetch lifecycle hook inventory for the provided working directory.
FetchHooksList {
cwd: PathBuf,

View File

@@ -297,6 +297,7 @@ mod tests {
SlashCommand::Diff,
SlashCommand::Mention,
SlashCommand::Status,
SlashCommand::Usage,
]
);
}

View File

@@ -361,6 +361,7 @@ use self::skills::collect_tool_mentions;
use self::skills::find_app_mentions;
use self::skills::find_skill_mentions_with_tool_mentions;
mod plugins;
mod usage;
use self::plugins::PluginInstallAuthFlowState;
use self::plugins::PluginListFetchState;
use self::plugins::PluginsCacheState;
@@ -537,6 +538,8 @@ pub(crate) struct ChatWidget {
rate_limit_snapshots_by_limit_id: BTreeMap<String, RateLimitSnapshotDisplay>,
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
next_status_refresh_request_id: u64,
next_usage_request_id: u64,
active_usage_request_id: Option<u64>,
plan_type: Option<PlanType>,
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
rate_limit_warnings: RateLimitWarningState,

View File

@@ -123,6 +123,8 @@ impl ChatWidget {
rate_limit_snapshots_by_limit_id: BTreeMap::new(),
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
next_usage_request_id: 0,
active_usage_request_id: None,
plan_type: initial_plan_type,
codex_rate_limit_reached_type: None,
rate_limit_warnings: RateLimitWarningState::default(),

View File

@@ -36,6 +36,13 @@ const GOAL_USAGE: &str = "Usage: /goal <objective>";
const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage";
const RAW_USAGE: &str = "Usage: /raw [on|off]";
fn usage_range_from_arg(arg: &str) -> Option<codex_app_server_protocol::UsageRange> {
match arg.to_ascii_lowercase().as_str() {
"week" | "weekly" => Some(codex_app_server_protocol::UsageRange::Week),
_ => None,
}
}
impl ChatWidget {
/// Dispatch a bare slash command and record its staged local-history entry.
///
@@ -382,6 +389,9 @@ impl ChatWidget {
);
}
}
SlashCommand::Usage => {
self.add_usage_output();
}
SlashCommand::Ide => {
self.handle_ide_command();
}
@@ -614,6 +624,10 @@ impl ChatWidget {
}
_ => self.add_error_message(RAW_USAGE.to_string()),
},
SlashCommand::Usage => match usage_range_from_arg(trimmed) {
Some(range) => self.add_usage_output_for_range(range),
None => self.add_error_message("Usage: /usage [week|weekly]".to_string()),
},
SlashCommand::Rename if !trimmed.is_empty() => {
if !self.ensure_thread_rename_allowed() {
return;
@@ -929,6 +943,7 @@ impl ChatWidget {
match cmd {
SlashCommand::Ide
| SlashCommand::Status
| SlashCommand::Usage
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop

View File

@@ -0,0 +1,30 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: rendered
---
/usage
╭───────────────────────────────────────────────────╮
│ Daily usage by token share │
│ Percent of consumed tokens in this selected range │
│ (/usage week for weekly) │
│ │
│ 11% of consumed tokens came from app "testmcp" │
│ Tool results stay in context until compaction; │
│ compact or disable sources you do not need. │
│ │
│ Skills │
│ ├─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% │
│ └─ /babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 6% │
│ │
│ Subagents │
│ ├─ babysit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% │
│ └─ code-review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 9% │
│ │
│ Agent tasks │
│ ├─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% │
│ └─ review ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 5% │
│ │
│ Apps │
│ └─ testmcp ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 11% │
╰───────────────────────────────────────────────────╯

View File

@@ -0,0 +1,14 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: rendered
---
/usage
╭───────────────────────────────────────────────────╮
│ Daily usage by token share │
│ Percent of consumed tokens in this selected range │
│ (/usage week for weekly) │
│ │
│ No attributed skills, subagents, agent tasks, │
│ apps, MCP servers, or plugins in this range. │
╰───────────────────────────────────────────────────╯

View File

@@ -0,0 +1,21 @@
---
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: rendered
---
/usage week
╭────────────────────────────────────────────────────────╮
│ Weekly usage by token share, Nov 7 to Nov 14 │
│ Percent of consumed tokens in this selected range │
│ │
│ 17% of consumed tokens came from agent task "guardian" │
│ │
│ Skills │
│ └─ /tmux ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 8% │
│ │
│ Subagents │
│ └─ default ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 13% │
│ │
│ Agent tasks │
│ └─ guardian ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ 17% │
╰────────────────────────────────────────────────────────╯

View File

@@ -116,6 +116,12 @@ pub(super) use codex_app_server_protocol::TurnCompletedNotification;
pub(super) use codex_app_server_protocol::TurnError as AppServerTurnError;
pub(super) use codex_app_server_protocol::TurnStartedNotification;
pub(super) use codex_app_server_protocol::TurnStatus as AppServerTurnStatus;
pub(super) use codex_app_server_protocol::UsageContributorKind;
pub(super) use codex_app_server_protocol::UsageEntry;
pub(super) use codex_app_server_protocol::UsageHeadline;
pub(super) use codex_app_server_protocol::UsageRange;
pub(super) use codex_app_server_protocol::UsageReadResponse;
pub(super) use codex_app_server_protocol::UsageReport;
pub(super) use codex_app_server_protocol::UserInput;
pub(super) use codex_app_server_protocol::UserInput as AppServerUserInput;
pub(super) use codex_app_server_protocol::WarningNotification;

View File

@@ -111,6 +111,201 @@ async fn plugins_popup_loading_state_snapshot() {
assert_chatwidget_snapshot!("plugins_popup_loading_state", popup);
}
#[tokio::test]
async fn usage_output_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.add_usage_output();
assert_matches!(
rx.try_recv(),
Ok(AppEvent::FetchUsage {
request_id: 0,
range: UsageRange::Day,
})
);
chat.on_usage_loaded(
/*request_id*/ 0,
Ok(UsageReadResponse {
report: UsageReport {
range: UsageRange::Day,
generated_at: 1_700_000_000,
tracked_from: Some(/*tracked_from*/ 1_699_999_000),
total_tokens: 100,
headline: Some(UsageHeadline {
entry: usage_entry(
UsageContributorKind::App,
"testmcp",
"testmcp",
/*percent_of_usage*/ 11,
),
note: Some(
"Tool results stay in context until compaction; compact or disable sources you do not need."
.to_string(),
),
}),
skills: vec![
usage_entry(
UsageContributorKind::Skill,
"/skills/tmux",
"/tmux",
/*percent_of_usage*/ 8,
),
usage_entry(
UsageContributorKind::Skill,
"/skills/babysit",
"/babysit",
/*percent_of_usage*/ 6,
),
],
subagents: vec![
usage_entry(
UsageContributorKind::Subagent,
"babysit",
"babysit",
/*percent_of_usage*/ 13,
),
usage_entry(
UsageContributorKind::Subagent,
"code-review",
"code-review",
/*percent_of_usage*/ 9,
),
],
agent_tasks: vec![
usage_entry(
UsageContributorKind::AgentTask,
"guardian",
"guardian",
/*percent_of_usage*/ 17,
),
usage_entry(
UsageContributorKind::AgentTask,
"review",
"review",
/*percent_of_usage*/ 5,
),
],
apps: vec![usage_entry(
UsageContributorKind::App,
"testmcp",
"testmcp",
/*percent_of_usage*/ 11,
)],
mcp_servers: Vec::new(),
plugins: Vec::new(),
},
}),
);
let rendered = drain_insert_history(&mut rx)
.into_iter()
.map(|lines| lines_to_single_string(&lines))
.collect::<Vec<_>>()
.join("\n\n");
assert_chatwidget_snapshot!("usage_output", rendered);
}
#[tokio::test]
async fn usage_output_reports_unattributed_usage() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.add_usage_output();
assert_matches!(
rx.try_recv(),
Ok(AppEvent::FetchUsage {
request_id: 0,
range: UsageRange::Day,
})
);
chat.on_usage_loaded(
/*request_id*/ 0,
Ok(UsageReadResponse {
report: UsageReport {
range: UsageRange::Day,
generated_at: 1_700_000_000,
tracked_from: Some(/*tracked_from*/ 1_699_999_000),
total_tokens: 100,
headline: None,
skills: Vec::new(),
subagents: Vec::new(),
agent_tasks: Vec::new(),
apps: Vec::new(),
mcp_servers: Vec::new(),
plugins: Vec::new(),
},
}),
);
let rendered = drain_insert_history(&mut rx)
.into_iter()
.map(|lines| lines_to_single_string(&lines))
.collect::<Vec<_>>()
.join("\n\n");
assert_chatwidget_snapshot!("usage_output_unattributed", rendered);
}
#[tokio::test]
async fn usage_output_weekly_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.dispatch_command_with_args(SlashCommand::Usage, "week".to_string(), Vec::new());
assert_matches!(
rx.try_recv(),
Ok(AppEvent::FetchUsage {
request_id: 0,
range: UsageRange::Week,
})
);
chat.on_usage_loaded(
/*request_id*/ 0,
Ok(UsageReadResponse {
report: UsageReport {
range: UsageRange::Week,
generated_at: 1_700_000_000,
tracked_from: Some(/*tracked_from*/ 1_699_395_200),
total_tokens: 100,
headline: Some(UsageHeadline {
entry: usage_entry(
UsageContributorKind::AgentTask,
"guardian",
"guardian",
/*percent_of_usage*/ 17,
),
note: None,
}),
skills: vec![usage_entry(
UsageContributorKind::Skill,
"/skills/tmux",
"/tmux",
/*percent_of_usage*/ 8,
)],
subagents: vec![usage_entry(
UsageContributorKind::Subagent,
"default",
"default",
/*percent_of_usage*/ 13,
)],
agent_tasks: vec![usage_entry(
UsageContributorKind::AgentTask,
"guardian",
"guardian",
/*percent_of_usage*/ 17,
)],
apps: Vec::new(),
mcp_servers: Vec::new(),
plugins: Vec::new(),
},
}),
);
let rendered = drain_insert_history(&mut rx)
.into_iter()
.map(|lines| lines_to_single_string(&lines))
.collect::<Vec<_>>()
.join("\n\n");
assert_chatwidget_snapshot!("usage_output_weekly", rendered);
}
#[tokio::test]
async fn marketplace_upgrade_loading_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -131,6 +326,21 @@ async fn marketplace_upgrade_loading_popup_snapshot() {
);
}
fn usage_entry(
kind: UsageContributorKind,
id: &str,
label: &str,
percent_of_usage: u8,
) -> UsageEntry {
UsageEntry {
kind,
id: id.to_string(),
label: label.to_string(),
attributed_tokens: i64::from(percent_of_usage),
percent_of_usage,
}
}
#[tokio::test]
async fn marketplace_upgrade_failure_includes_backend_messages_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

View File

@@ -0,0 +1,531 @@
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::color::blend;
use crate::color::is_light;
use crate::history_cell::CompositeHistoryCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::plain_lines;
use crate::history_cell::with_border_with_inner_width;
use crate::style::accent_style;
use crate::terminal_palette::best_color;
use crate::terminal_palette::default_bg;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
use chrono::DateTime;
use chrono::Utc;
use codex_app_server_protocol::UsageContributorKind;
use codex_app_server_protocol::UsageEntry;
use codex_app_server_protocol::UsageRange;
use codex_app_server_protocol::UsageReadResponse;
use codex_app_server_protocol::UsageReport;
use ratatui::prelude::*;
use ratatui::style::Stylize;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
const USAGE_CARD_MAX_INNER_WIDTH: usize = 72;
const USAGE_BAR_WIDTH: usize = 20;
const USAGE_BAR_MIN_LABEL_WIDTH: usize = 8;
const USAGE_BAR_GLYPH: &str = "";
const USAGE_TITLE: &str = "Usage by token share";
const USAGE_SUBTITLE: &str = "Percent of consumed tokens in this selected range";
impl ChatWidget {
pub(crate) fn add_usage_output(&mut self) {
self.add_usage_output_for_range(UsageRange::Day);
}
pub(crate) fn add_usage_output_for_range(&mut self, range: UsageRange) {
self.request_usage(range);
}
fn request_usage(&mut self, range: UsageRange) {
let request_id = self.next_usage_request_id;
self.next_usage_request_id = self.next_usage_request_id.saturating_add(/*rhs*/ 1);
self.active_usage_request_id = Some(request_id);
self.app_event_tx
.send(AppEvent::FetchUsage { request_id, range });
}
pub(crate) fn on_usage_loaded(
&mut self,
request_id: u64,
result: Result<UsageReadResponse, String>,
) {
if self.active_usage_request_id != Some(request_id) {
return;
}
self.active_usage_request_id = None;
let cell = match result {
Ok(response) => new_usage_output(response.report),
Err(err) => new_usage_error_output(err),
};
self.add_to_history(cell);
}
}
#[derive(Debug)]
struct UsageHistoryCell {
report: UsageReport,
}
#[derive(Debug)]
struct UsageErrorHistoryCell {
error: String,
}
fn new_usage_output(report: UsageReport) -> CompositeHistoryCell {
let command = PlainHistoryCell::new(vec![usage_command_label(report.range).magenta().into()]);
CompositeHistoryCell::new(vec![
Box::new(command),
Box::new(UsageHistoryCell { report }),
])
}
fn new_usage_error_output(error: String) -> CompositeHistoryCell {
let command = PlainHistoryCell::new(vec!["/usage".magenta().into()]);
CompositeHistoryCell::new(vec![
Box::new(command),
Box::new(UsageErrorHistoryCell { error }),
])
}
impl HistoryCell for UsageHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
usage_report_lines(&self.report, width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
}
impl HistoryCell for UsageErrorHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let Some(available_width) = usage_card_available_width(width) else {
return Vec::new();
};
let lines = vec![
Line::from(USAGE_TITLE.bold()),
Line::from(USAGE_SUBTITLE.dim()),
Line::default(),
Line::from(format!(" Failed to load usage: {}", self.error)),
];
let inner_width = lines
.iter()
.map(line_display_width)
.max()
.unwrap_or(0)
.min(available_width);
with_border_with_inner_width(lines, inner_width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
}
fn usage_report_lines(report: &UsageReport, width: u16) -> Vec<Line<'static>> {
let Some(available_width) = usage_card_available_width(width) else {
return Vec::new();
};
let sections = [
("Skills", report.skills.as_slice()),
("Subagents", report.subagents.as_slice()),
("Agent tasks", report.agent_tasks.as_slice()),
("Apps", report.apps.as_slice()),
("MCP servers", report.mcp_servers.as_slice()),
("Plugins", report.plugins.as_slice()),
];
let label_column_width = usage_label_column_width(&sections);
let inner_width = usage_content_width(report, &sections, label_column_width, available_width);
let mut lines = usage_header_lines(report, inner_width);
if let Some(headline) = report.headline.as_ref() {
lines.push(Line::default());
push_wrapped_line(
&mut lines,
vec![
Span::from(format!(
"{}% of consumed tokens came from {} \"{}\"",
headline.entry.percent_of_usage,
contributor_kind_label(headline.entry.kind),
headline.entry.label
))
.italic()
.dim(),
],
inner_width,
);
if let Some(note) = headline.note.as_ref() {
push_wrapped_text(&mut lines, format!(" {note}"), inner_width);
}
}
if report.total_tokens == 0 {
lines.push(Line::default());
push_wrapped_text(
&mut lines,
" No tracked usage in this range yet.",
inner_width,
);
return with_border_with_inner_width(lines, inner_width);
}
if sections.iter().all(|(_, entries)| entries.is_empty()) {
lines.push(Line::default());
push_wrapped_text(
&mut lines,
" No attributed skills, subagents, agent tasks, apps, MCP servers, or plugins in this range.",
inner_width,
);
return with_border_with_inner_width(lines, inner_width);
}
for (label, entries) in sections {
push_section(&mut lines, label, entries, label_column_width, inner_width);
}
with_border_with_inner_width(lines, inner_width)
}
fn usage_card_available_width(width: u16) -> Option<usize> {
(width >= 4).then(|| {
usize::from(width.saturating_sub(/*rhs*/ 4)).min(USAGE_CARD_MAX_INNER_WIDTH)
})
}
fn usage_label_column_width(sections: &[(&'static str, &[UsageEntry])]) -> usize {
sections
.iter()
.flat_map(|(_, entries)| entries.iter())
.map(|entry| UnicodeWidthStr::width(entry.label.as_str()))
.max()
.unwrap_or(0)
.saturating_add(/*rhs*/ 3)
}
fn usage_content_width(
report: &UsageReport,
sections: &[(&'static str, &[UsageEntry])],
label_column_width: usize,
available_width: usize,
) -> usize {
let mut width = 0usize;
for line in usage_header_lines(report, available_width) {
width = width.max(line_display_width(&line));
}
if let Some(headline) = report.headline.as_ref() {
width = width.max(
text_width(
format!(
"{}% of consumed tokens came from {} \"{}\"",
headline.entry.percent_of_usage,
contributor_kind_label(headline.entry.kind),
headline.entry.label
)
.as_str(),
)
.min(available_width),
);
}
if report.total_tokens == 0 {
return width.min(available_width);
}
if sections.iter().all(|(_, entries)| entries.is_empty()) {
return width.min(available_width);
}
for (section_label, entries) in sections {
if entries.is_empty() {
continue;
}
width = width.max(text_width(format!(" {section_label}").as_str()));
}
let prefix_width = text_width(" ├─ ");
let percent_width = text_width("100%");
let row_width_with_bar =
prefix_width + label_column_width + USAGE_BAR_WIDTH + 2 + percent_width;
let can_show_bar =
available_width >= row_width_with_bar && label_column_width >= USAGE_BAR_MIN_LABEL_WIDTH;
let row_width = if can_show_bar {
row_width_with_bar
} else {
sections
.iter()
.flat_map(|(_, entries)| entries.iter())
.map(|entry| prefix_width + text_width(entry.label.as_str()) + 1 + percent_width)
.max()
.unwrap_or(0)
};
width.max(row_width).min(available_width)
}
fn usage_header_lines(report: &UsageReport, inner_width: usize) -> Vec<Line<'static>> {
match report.range {
UsageRange::Day => vec![
Line::from(usage_title(report.range).bold()),
Line::from(USAGE_SUBTITLE.dim()),
vec![
"(".dim(),
Span::styled("/usage week", accent_style()),
" for weekly)".dim(),
]
.into(),
],
UsageRange::Week => {
let Some(period) = usage_period_label(report) else {
return vec![
Line::from(usage_title(report.range).bold()),
Line::from(USAGE_SUBTITLE.dim()),
];
};
let combined_title = format!("{}, {period}", usage_title(report.range));
if text_width(&combined_title) <= inner_width {
vec![
Line::from(combined_title.bold()),
Line::from(USAGE_SUBTITLE.dim()),
]
} else {
vec![
Line::from(usage_title(report.range).bold()),
Line::from(period),
Line::from(USAGE_SUBTITLE.dim()),
]
}
}
}
}
fn usage_title(range: UsageRange) -> &'static str {
match range {
UsageRange::Day => "Daily usage by token share",
UsageRange::Week => "Weekly usage by token share",
}
}
fn usage_command_label(range: UsageRange) -> &'static str {
match range {
UsageRange::Day => "/usage",
UsageRange::Week => "/usage week",
}
}
fn usage_period_label(report: &UsageReport) -> Option<String> {
let range_start = report
.generated_at
.saturating_sub(usage_range_seconds(report.range));
let start = format_usage_date(range_start)?;
let end = format_usage_date(report.generated_at)?;
let label = format!("{start} to {end}");
Some(label)
}
fn usage_range_seconds(range: UsageRange) -> i64 {
match range {
UsageRange::Day => 24 * 60 * 60,
UsageRange::Week => 7 * 24 * 60 * 60,
}
}
fn format_usage_date(seconds: i64) -> Option<String> {
DateTime::<Utc>::from_timestamp(seconds, /*nsecs*/ 0)
.map(|timestamp| timestamp.format("%b %-d").to_string())
}
fn push_wrapped_text(lines: &mut Vec<Line<'static>>, text: impl Into<String>, inner_width: usize) {
push_wrapped_line(lines, Line::from(text.into()), inner_width);
}
fn push_wrapped_line(
lines: &mut Vec<Line<'static>>,
line: impl Into<Line<'static>>,
inner_width: usize,
) {
lines.extend(word_wrap_lines(
[line.into()],
RtOptions::new(inner_width.max(/*other*/ 1)).subsequent_indent(" ".into()),
));
}
fn push_section(
lines: &mut Vec<Line<'static>>,
label: &'static str,
entries: &[UsageEntry],
label_column_width: usize,
inner_width: usize,
) {
if entries.is_empty() {
return;
}
lines.push(Line::default());
lines.push(Line::from(Span::styled(
format!(" {label}"),
accent_style(),
)));
for (index, entry) in entries.iter().enumerate() {
let is_last = index + 1 == entries.len();
lines.push(usage_entry_line(
entry,
is_last,
label_column_width,
inner_width,
));
}
}
fn usage_entry_line(
entry: &UsageEntry,
is_last: bool,
label_column_width: usize,
inner_width: usize,
) -> Line<'static> {
let prefix = if is_last { " └─ " } else { " ├─ " };
let percent = format!("{:>3}%", entry.percent_of_usage);
let prefix_width = UnicodeWidthStr::width(prefix);
let percent_width = UnicodeWidthStr::width(percent.as_str());
let trailing_bar_width = USAGE_BAR_WIDTH + 2 + percent_width;
let include_bar = inner_width >= prefix_width + label_column_width + trailing_bar_width
&& label_column_width >= USAGE_BAR_MIN_LABEL_WIDTH;
let label_width = if include_bar {
label_column_width
} else {
inner_width.saturating_sub(prefix_width + percent_width + 1)
};
let label = truncate_to_width(&entry.label, label_width);
let used_width = if include_bar {
prefix_width + label_column_width + trailing_bar_width
} else {
prefix_width + UnicodeWidthStr::width(label.as_str()) + percent_width
};
let spacer_width = if include_bar {
0
} else {
inner_width.saturating_sub(used_width)
};
let mut spans = vec![
Span::from(prefix).dim(),
Span::from(label),
Span::from(" ".repeat(spacer_width)).dim(),
];
if include_bar {
let label_padding = label_column_width
.saturating_sub(UnicodeWidthStr::width(entry.label.as_str()).min(label_width));
spans.push(Span::from(" ".repeat(label_padding)).dim());
let (filled, empty) = usage_bar_segments(entry.percent_of_usage);
spans.push(usage_bar_filled_span(USAGE_BAR_GLYPH.repeat(filled)));
spans.push(usage_bar_empty_span(USAGE_BAR_GLYPH.repeat(empty)));
spans.push(Span::from(" ").dim());
}
spans.push(Span::from(percent));
Line::from(spans)
}
fn usage_bar_segments(percent: u8) -> (usize, usize) {
let filled = if percent == 0 {
0
} else {
((usize::from(percent) * USAGE_BAR_WIDTH).saturating_add(99)) / 100
}
.min(USAGE_BAR_WIDTH);
(filled, USAGE_BAR_WIDTH.saturating_sub(filled))
}
fn usage_bar_filled_span(content: String) -> Span<'static> {
Span::styled(content, usage_bar_filled_style())
}
fn usage_bar_empty_span(content: String) -> Span<'static> {
Span::styled(content, usage_bar_empty_style())
}
fn usage_bar_filled_style() -> Style {
usage_bar_filled_style_for(default_bg())
}
fn usage_bar_empty_style() -> Style {
usage_bar_empty_style_for(default_bg())
}
fn usage_bar_filled_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style {
let Some(bg) = terminal_bg else {
return Style::default().fg(Color::Cyan).bold();
};
if is_light(bg) {
Style::default()
.fg(usage_best_color(/*target*/ (0, 110, 125), Color::Cyan))
.bold()
} else {
Style::default()
.fg(usage_best_color(
/*target*/ (170, 210, 218),
Color::Cyan,
))
.bold()
}
}
fn usage_bar_empty_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style {
let Some(bg) = terminal_bg else {
return Style::default().fg(Color::DarkGray);
};
if is_light(bg) {
Style::default().fg(usage_best_color(
/*target*/ blend(/*fg*/ (0, 0, 0), bg, /*alpha*/ 0.18),
Color::Gray,
))
} else {
Style::default().fg(usage_best_color(
/*target*/ blend(/*fg*/ (255, 255, 255), bg, /*alpha*/ 0.3),
Color::DarkGray,
))
}
}
fn usage_best_color(target: (u8, u8, u8), fallback: Color) -> Color {
let color = best_color(target);
if color == Color::default() {
fallback
} else {
color
}
}
fn truncate_to_width(value: &str, width: usize) -> String {
let mut out = String::new();
let mut used = 0usize;
for ch in value.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > width {
break;
}
out.push(ch);
used += ch_width;
}
out
}
fn line_display_width(line: &Line<'static>) -> usize {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum()
}
fn text_width(value: &str) -> usize {
UnicodeWidthStr::width(value)
}
fn contributor_kind_label(kind: UsageContributorKind) -> &'static str {
match kind {
UsageContributorKind::Skill => "skill",
UsageContributorKind::Subagent => "subagent",
UsageContributorKind::AgentTask => "agent task",
UsageContributorKind::App => "app",
UsageContributorKind::McpServer => "MCP server",
UsageContributorKind::Plugin => "plugin",
}
}

View File

@@ -168,12 +168,15 @@ impl HistoryCell for DeprecationNoticeCell {
}
}
pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHistoryCell {
let mut line = vec!["".dim(), message.into()];
if let Some(hint) = hint {
line.push(" ".into());
line.push(hint.dark_gray());
let mut lines = raw_lines_from_source(&message);
if lines.is_empty() {
lines.push(Line::from(""));
}
lines[0].spans.insert(/*index*/ 0, "".dim());
if let Some(hint) = hint {
lines[0].spans.push(" ".into());
lines[0].spans.push(hint.dark_gray());
}
let lines: Vec<Line<'static>> = vec![line.into()];
PlainHistoryCell { lines }
}

View File

@@ -44,6 +44,7 @@ pub enum SlashCommand {
Diff,
Mention,
Status,
Usage,
DebugConfig,
Title,
Statusline,
@@ -96,6 +97,9 @@ impl SlashCommand {
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
SlashCommand::Hooks => "view and manage lifecycle hooks",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Usage => {
"show local token usage by skills, subagents, apps, MCP servers, and plugins"
}
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
SlashCommand::Title => "configure which items appear in the terminal title",
SlashCommand::Statusline => "configure which items appear in the status line",
@@ -155,6 +159,7 @@ impl SlashCommand {
| SlashCommand::Keymap
| SlashCommand::Mcp
| SlashCommand::Raw
| SlashCommand::Usage
| SlashCommand::Pets
| SlashCommand::Side
| SlashCommand::Btw
@@ -172,6 +177,7 @@ impl SlashCommand {
| SlashCommand::Diff
| SlashCommand::Mention
| SlashCommand::Status
| SlashCommand::Usage
| SlashCommand::Ide
)
}
@@ -207,6 +213,7 @@ impl SlashCommand {
| SlashCommand::Skills
| SlashCommand::Hooks
| SlashCommand::Status
| SlashCommand::Usage
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop