mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Show estimated thread usage in /status (#38281)
## What changed - Extend `account/usage/read` with an optional `threadId` request and a backward-compatible `threadUsage` response containing estimated credits, optional USD cost, and model, reasoning, speed, and token breakdowns. - Fetch thread usage for eligible Business and Enterprise plans when `/status` is opened, then update the rendered status card asynchronously. - Preserve terminal scrollback when replacing a visible status-card tail, and append a refreshed card when the original tail can no longer be updated. ## Testing - Add app-server coverage for authentication, canonical thread IDs, unavailable billing routes, and malformed IDs. - Add TUI coverage for usage formatting, stale responses, retries, history updates, terminal reflow, and scrollback preservation. GitOrigin-RevId: 49300914b3fba917c0f08e8595363e04e5ba6d37
This commit is contained in:
+20
-1
@@ -1503,6 +1503,18 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"GetAccountTokenUsageParams": {
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"description": "When present, read estimated usage for this thread instead of account-wide token activity.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"HookMigration": {
|
||||
"properties": {
|
||||
"name": {
|
||||
@@ -7262,7 +7274,14 @@
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/GetAccountTokenUsageParams"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
+139
-1
@@ -2062,7 +2062,14 @@
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/GetAccountTokenUsageParams"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -11377,6 +11384,18 @@
|
||||
"title": "GetAccountResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"GetAccountTokenUsageParams": {
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"description": "When present, read estimated usage for this thread instead of account-wide token activity.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"GetAccountTokenUsageResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -11391,6 +11410,18 @@
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/definitions/v2/AccountTokenUsageSummary"
|
||||
},
|
||||
"threadUsage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/ThreadUsage"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Estimated usage when a thread was requested and its billing route is available."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -14253,6 +14284,18 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NullableGetAccountTokenUsageParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/GetAccountTokenUsageParams"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Nullable_GetAccountTokenUsageParams"
|
||||
},
|
||||
"OverriddenMetadata": {
|
||||
"properties": {
|
||||
"effectiveValue": true,
|
||||
@@ -21817,6 +21860,101 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadUsage": {
|
||||
"properties": {
|
||||
"estimatedUsageCreditsMicros": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"estimatedUsageUsdMicros": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"groups": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/ThreadUsageBreakdownGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"estimatedUsageCreditsMicros",
|
||||
"groups",
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadUsageBreakdownGroup": {
|
||||
"properties": {
|
||||
"cachedInputTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"estimatedUsageCreditsMicros": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"inputTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"netNewInputTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"outputTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"speed": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"totalTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"estimatedUsageCreditsMicros"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TokenUsageBreakdown": {
|
||||
"properties": {
|
||||
"cacheWriteInputTokens": {
|
||||
|
||||
+139
-1
@@ -3252,7 +3252,14 @@
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/GetAccountTokenUsageParams"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -7646,6 +7653,18 @@
|
||||
"title": "GetAccountResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"GetAccountTokenUsageParams": {
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"description": "When present, read estimated usage for this thread instead of account-wide token activity.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"GetAccountTokenUsageResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -7660,6 +7679,18 @@
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/definitions/AccountTokenUsageSummary"
|
||||
},
|
||||
"threadUsage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ThreadUsage"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Estimated usage when a thread was requested and its billing route is available."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -10583,6 +10614,18 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"NullableGetAccountTokenUsageParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/GetAccountTokenUsageParams"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Nullable_GetAccountTokenUsageParams"
|
||||
},
|
||||
"OverriddenMetadata": {
|
||||
"properties": {
|
||||
"effectiveValue": true,
|
||||
@@ -19569,6 +19612,101 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ThreadUsage": {
|
||||
"properties": {
|
||||
"estimatedUsageCreditsMicros": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"estimatedUsageUsdMicros": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"groups": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ThreadUsageBreakdownGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"estimatedUsageCreditsMicros",
|
||||
"groups",
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadUsageBreakdownGroup": {
|
||||
"properties": {
|
||||
"cachedInputTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"estimatedUsageCreditsMicros": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"inputTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"netNewInputTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"outputTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"speed": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"totalTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"estimatedUsageCreditsMicros"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TokenUsageBreakdown": {
|
||||
"properties": {
|
||||
"cacheWriteInputTokens": {
|
||||
|
||||
+107
@@ -56,6 +56,101 @@
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadUsage": {
|
||||
"properties": {
|
||||
"estimatedUsageCreditsMicros": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"estimatedUsageUsdMicros": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"groups": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/ThreadUsageBreakdownGroup"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"estimatedUsageCreditsMicros",
|
||||
"groups",
|
||||
"threadId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ThreadUsageBreakdownGroup": {
|
||||
"properties": {
|
||||
"cachedInputTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"estimatedUsageCreditsMicros": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"inputTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"model": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"netNewInputTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"outputTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"reasoningEffort": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"speed": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"totalTokens": {
|
||||
"format": "int64",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"estimatedUsageCreditsMicros"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
@@ -70,6 +165,18 @@
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/definitions/AccountTokenUsageSummary"
|
||||
},
|
||||
"threadUsage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ThreadUsage"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Estimated usage when a thread was requested and its billing route is available."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/GetAccountTokenUsageParams"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"GetAccountTokenUsageParams": {
|
||||
"properties": {
|
||||
"threadId": {
|
||||
"description": "When present, read estimated usage for this thread instead of account-wide token activity.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"title": "Nullable_GetAccountTokenUsageParams"
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
+9
@@ -0,0 +1,9 @@
|
||||
// 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 GetAccountTokenUsageParams = {
|
||||
/**
|
||||
* When present, read estimated usage for this thread instead of account-wide token activity.
|
||||
*/
|
||||
threadId?: string | null, };
|
||||
+6
-1
@@ -3,5 +3,10 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AccountTokenUsageDailyBucket } from "./AccountTokenUsageDailyBucket";
|
||||
import type { AccountTokenUsageSummary } from "./AccountTokenUsageSummary";
|
||||
import type { ThreadUsage } from "./ThreadUsage";
|
||||
|
||||
export type GetAccountTokenUsageResponse = { summary: AccountTokenUsageSummary, dailyUsageBuckets: Array<AccountTokenUsageDailyBucket> | null, };
|
||||
export type GetAccountTokenUsageResponse = { summary: AccountTokenUsageSummary, dailyUsageBuckets: Array<AccountTokenUsageDailyBucket> | null,
|
||||
/**
|
||||
* Estimated usage when a thread was requested and its billing route is available.
|
||||
*/
|
||||
threadUsage?: ThreadUsage | null, };
|
||||
|
||||
@@ -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 { ThreadUsageBreakdownGroup } from "./ThreadUsageBreakdownGroup";
|
||||
|
||||
export type ThreadUsage = { threadId: string, estimatedUsageCreditsMicros: bigint, estimatedUsageUsdMicros: bigint | null, groups: Array<ThreadUsageBreakdownGroup>, };
|
||||
+5
@@ -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 ThreadUsageBreakdownGroup = { model: string | null, reasoningEffort: string | null, speed: string | null, estimatedUsageCreditsMicros: bigint, netNewInputTokens: bigint | null, cachedInputTokens: bigint | null, inputTokens: bigint | null, outputTokens: bigint | null, totalTokens: bigint | null, };
|
||||
@@ -175,6 +175,7 @@ export type { FsWriteFileResponse } from "./FsWriteFileResponse";
|
||||
export type { GetAccountParams } from "./GetAccountParams";
|
||||
export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse";
|
||||
export type { GetAccountResponse } from "./GetAccountResponse";
|
||||
export type { GetAccountTokenUsageParams } from "./GetAccountTokenUsageParams";
|
||||
export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse";
|
||||
export type { GetWorkspaceMessagesResponse } from "./GetWorkspaceMessagesResponse";
|
||||
export type { GitInfo } from "./GitInfo";
|
||||
@@ -516,6 +517,8 @@ export type { ThreadUnarchivedNotification } from "./ThreadUnarchivedNotificatio
|
||||
export type { ThreadUnsubscribeParams } from "./ThreadUnsubscribeParams";
|
||||
export type { ThreadUnsubscribeResponse } from "./ThreadUnsubscribeResponse";
|
||||
export type { ThreadUnsubscribeStatus } from "./ThreadUnsubscribeStatus";
|
||||
export type { ThreadUsage } from "./ThreadUsage";
|
||||
export type { ThreadUsageBreakdownGroup } from "./ThreadUsageBreakdownGroup";
|
||||
export type { TokenUsageBreakdown } from "./TokenUsageBreakdown";
|
||||
export type { ToolRequestUserInputAnswer } from "./ToolRequestUserInputAnswer";
|
||||
export type { ToolRequestUserInputOption } from "./ToolRequestUserInputOption";
|
||||
|
||||
@@ -2145,6 +2145,17 @@ mod tests {
|
||||
client_request_ts.contains("MockExperimentalMethodParams"),
|
||||
false
|
||||
);
|
||||
const LEGACY_ACCOUNT_USAGE_REQUEST: &str = concat!(
|
||||
"{ \"method\": \"account/usage/read\", id: RequestId, ",
|
||||
"params?: GetAccountTokenUsageParams | undefined, }"
|
||||
);
|
||||
assert!(client_request_ts.contains(LEGACY_ACCOUNT_USAGE_REQUEST));
|
||||
let account_usage_response_ts = std::str::from_utf8(
|
||||
fixture_tree
|
||||
.get(Path::new("v2/GetAccountTokenUsageResponse.ts"))
|
||||
.ok_or_else(|| anyhow::anyhow!("missing account usage response fixture"))?,
|
||||
)?;
|
||||
assert!(account_usage_response_ts.contains("threadUsage?: ThreadUsage | null"));
|
||||
let server_request_ts = std::str::from_utf8(
|
||||
fixture_tree
|
||||
.get(Path::new("ServerRequest.ts"))
|
||||
@@ -2217,7 +2228,12 @@ mod tests {
|
||||
});
|
||||
|
||||
let contents = std::str::from_utf8(contents)?;
|
||||
if contents.contains("| undefined") {
|
||||
// The stable usage RPC originally required `params: undefined`. Keep that exact
|
||||
// legacy value accepted while extending the same method with optional thread params.
|
||||
let legacy_account_usage_undefined = path == Path::new("ClientRequest.ts")
|
||||
&& contents.matches("| undefined").count() == 1
|
||||
&& contents.contains(LEGACY_ACCOUNT_USAGE_REQUEST);
|
||||
if contents.contains("| undefined") && !legacy_account_usage_undefined {
|
||||
undefined_offenders.push(path.clone());
|
||||
}
|
||||
|
||||
@@ -2329,9 +2345,14 @@ mod tests {
|
||||
|
||||
// If the last non-whitespace before ':' is '?', then this is an
|
||||
// optional field with a nullable type (i.e., "?: T | null").
|
||||
// These are only allowed in *Params types.
|
||||
// These are only allowed in *Params types, except the additive stable usage
|
||||
// response field, which older servers omit and newer servers return as null.
|
||||
let legacy_account_usage_response = path
|
||||
== Path::new("v2/GetAccountTokenUsageResponse.ts")
|
||||
&& field_prefix.trim() == "threadUsage?";
|
||||
if field_prefix.chars().rev().find(|c| !c.is_whitespace()) == Some('?')
|
||||
&& !allow_optional_nullable
|
||||
&& !legacy_account_usage_response
|
||||
{
|
||||
let line_number =
|
||||
contents[..abs_idx].chars().filter(|c| *c == '\n').count() + 1;
|
||||
|
||||
@@ -471,6 +471,19 @@ macro_rules! client_response_payload_from_impl {
|
||||
($variant:ident, $response:ty, manual) => {};
|
||||
}
|
||||
|
||||
/// Preserve explicit `undefined` accepted by the original stable usage request.
|
||||
///
|
||||
/// A Rust-based TypeScript proxy retains dependency discovery; a raw `#[ts(type = ...)]`
|
||||
/// override would silently omit the generated params import and schema fixture.
|
||||
#[allow(dead_code)]
|
||||
#[derive(TS)]
|
||||
#[ts(untagged)]
|
||||
enum GetAccountTokenUsageParamsTypeScript {
|
||||
Params(v2::GetAccountTokenUsageParams),
|
||||
#[ts(type = "undefined")]
|
||||
Undefined,
|
||||
}
|
||||
|
||||
client_request_definitions! {
|
||||
Initialize => "initialize" {
|
||||
params: v1::InitializeParams,
|
||||
@@ -1095,7 +1108,7 @@ client_request_definitions! {
|
||||
},
|
||||
|
||||
GetAccountTokenUsage => "account/usage/read" {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
params: #[ts(optional, as = "Option<GetAccountTokenUsageParamsTypeScript>", inline)] #[serde(default, skip_serializing_if = "Option::is_none")] v2::NullableGetAccountTokenUsageParams,
|
||||
serialization: None,
|
||||
response: v2::GetAccountTokenUsageResponse,
|
||||
},
|
||||
@@ -2850,6 +2863,56 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_get_account_thread_usage() -> Result<()> {
|
||||
let request = ClientRequest::GetAccountTokenUsage {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: Some(v2::GetAccountTokenUsageParams {
|
||||
thread_id: Some("thread-123".to_string()),
|
||||
}),
|
||||
};
|
||||
assert_eq!(
|
||||
json!({
|
||||
"method": "account/usage/read",
|
||||
"id": 1,
|
||||
"params": { "threadId": "thread-123" },
|
||||
}),
|
||||
serde_json::to_value(&request)?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_legacy_get_account_token_usage_response() -> Result<()> {
|
||||
let response: v2::GetAccountTokenUsageResponse = serde_json::from_value(json!({
|
||||
"summary": {
|
||||
"lifetimeTokens": null,
|
||||
"peakDailyTokens": null,
|
||||
"longestRunningTurnSec": null,
|
||||
"currentStreakDays": null,
|
||||
"longestStreakDays": null,
|
||||
},
|
||||
"dailyUsageBuckets": null,
|
||||
}))?;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
v2::GetAccountTokenUsageResponse {
|
||||
summary: v2::AccountTokenUsageSummary {
|
||||
lifetime_tokens: None,
|
||||
peak_daily_tokens: None,
|
||||
longest_running_turn_sec: None,
|
||||
current_streak_days: None,
|
||||
longest_streak_days: None,
|
||||
},
|
||||
daily_usage_buckets: None,
|
||||
thread_usage: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(serde_json::to_value(response)?["threadUsage"], json!(null));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_get_workspace_messages() -> Result<()> {
|
||||
let request = ClientRequest::GetWorkspaceMessages {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::ThreadUsage;
|
||||
use crate::JsonSchema;
|
||||
use crate::TS;
|
||||
use crate::protocol::common::AuthMode;
|
||||
@@ -383,12 +384,27 @@ pub enum ConsumeAccountRateLimitResetCreditOutcome {
|
||||
AlreadyRedeemed,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct GetAccountTokenUsageParams {
|
||||
/// When present, read estimated usage for this thread instead of account-wide token activity.
|
||||
#[ts(optional = nullable)]
|
||||
pub thread_id: Option<String>,
|
||||
}
|
||||
|
||||
pub type NullableGetAccountTokenUsageParams = Option<GetAccountTokenUsageParams>;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct GetAccountTokenUsageResponse {
|
||||
pub summary: AccountTokenUsageSummary,
|
||||
pub daily_usage_buckets: Option<Vec<AccountTokenUsageDailyBucket>>,
|
||||
/// Estimated usage when a thread was requested and its billing route is available.
|
||||
#[serde(default)]
|
||||
#[ts(optional, as = "Option<Option<ThreadUsage>>")]
|
||||
pub thread_usage: Option<ThreadUsage>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
|
||||
@@ -26,6 +26,7 @@ mod remote_control;
|
||||
mod review;
|
||||
mod thread;
|
||||
mod thread_data;
|
||||
mod thread_usage;
|
||||
mod turn;
|
||||
mod windows_sandbox;
|
||||
|
||||
@@ -56,6 +57,7 @@ pub use review::*;
|
||||
pub use shared::*;
|
||||
pub use thread::*;
|
||||
pub use thread_data::*;
|
||||
pub use thread_usage::*;
|
||||
pub use turn::*;
|
||||
pub use windows_sandbox::*;
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
use crate::JsonSchema;
|
||||
use crate::TS;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadUsage {
|
||||
pub thread_id: String,
|
||||
pub estimated_usage_credits_micros: i64,
|
||||
pub estimated_usage_usd_micros: Option<i64>,
|
||||
pub groups: Vec<ThreadUsageBreakdownGroup>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct ThreadUsageBreakdownGroup {
|
||||
pub model: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub speed: Option<String>,
|
||||
pub estimated_usage_credits_micros: i64,
|
||||
pub net_new_input_tokens: Option<i64>,
|
||||
pub cached_input_tokens: Option<i64>,
|
||||
pub input_tokens: Option<i64>,
|
||||
pub output_tokens: Option<i64>,
|
||||
pub total_tokens: Option<i64>,
|
||||
}
|
||||
@@ -2211,7 +2211,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
|
||||
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `bedrockApiKey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
|
||||
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, whether spend control has been reached, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only.
|
||||
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`.
|
||||
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
|
||||
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets, or pass a valid thread UUID as `threadId` to read estimated credits, optional cost, and usage breakdowns for one thread using the app-server's active account. The optional `threadUsage` response field is absent on older servers and `null` when the billing route is unavailable.
|
||||
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
|
||||
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
|
||||
`spendControlReached` is `true` or `false` when the backend reports spend-control state; `null` means unavailable and must not clear a previously observed value in a sparse update.
|
||||
|
||||
@@ -1409,8 +1409,8 @@ impl MessageProcessor {
|
||||
.consume_account_rate_limit_reset_credit(params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::GetAccountTokenUsage { .. } => {
|
||||
self.account_processor.get_account_token_usage().await
|
||||
ClientRequest::GetAccountTokenUsage { params, .. } => {
|
||||
self.account_processor.get_account_token_usage(params).await
|
||||
}
|
||||
ClientRequest::GetWorkspaceMessages { .. } => {
|
||||
self.account_processor.get_workspace_messages().await
|
||||
|
||||
@@ -80,6 +80,7 @@ use codex_app_server_protocol::FeedbackUploadResponse;
|
||||
use codex_app_server_protocol::GetAccountParams;
|
||||
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||
use codex_app_server_protocol::GetAccountResponse;
|
||||
use codex_app_server_protocol::GetAccountTokenUsageParams;
|
||||
use codex_app_server_protocol::GetAccountTokenUsageResponse;
|
||||
use codex_app_server_protocol::GetAuthStatusParams;
|
||||
use codex_app_server_protocol::GetAuthStatusResponse;
|
||||
|
||||
@@ -13,6 +13,7 @@ mod rate_limit_resets;
|
||||
// Duration before a browser ChatGPT login attempt is abandoned.
|
||||
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
|
||||
const THREAD_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 60);
|
||||
const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration =
|
||||
Duration::from_millis(/*millis*/ 1000);
|
||||
// Login overrides are intentionally available only in debug builds.
|
||||
@@ -148,8 +149,9 @@ impl AccountRequestProcessor {
|
||||
|
||||
pub(crate) async fn get_account_token_usage(
|
||||
&self,
|
||||
params: Option<GetAccountTokenUsageParams>,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.get_account_token_usage_response()
|
||||
self.get_account_token_usage_response(params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
@@ -1123,7 +1125,16 @@ impl AccountRequestProcessor {
|
||||
|
||||
async fn get_account_token_usage_response(
|
||||
&self,
|
||||
params: Option<GetAccountTokenUsageParams>,
|
||||
) -> Result<GetAccountTokenUsageResponse, JSONRPCErrorError> {
|
||||
let thread_id = params
|
||||
.and_then(|params| params.thread_id)
|
||||
.map(|thread_id| {
|
||||
ThreadId::from_string(&thread_id)
|
||||
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return Err(invalid_request(
|
||||
"codex account authentication required to read token usage",
|
||||
@@ -1141,6 +1152,61 @@ impl AccountRequestProcessor {
|
||||
&auth,
|
||||
self.config.http_client_factory(),
|
||||
);
|
||||
if let Some(thread_id) = thread_id {
|
||||
let thread_id = thread_id.to_string();
|
||||
let usage = tokio::time::timeout(
|
||||
THREAD_USAGE_FETCH_TIMEOUT,
|
||||
client.get_thread_usage(&thread_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| internal_error("thread usage fetch timed out"))?;
|
||||
let thread_usage = match usage {
|
||||
Ok(usage) => Some(codex_app_server_protocol::ThreadUsage {
|
||||
thread_id: usage.thread_id,
|
||||
estimated_usage_credits_micros: usage.estimated_usage_credits_micros,
|
||||
estimated_usage_usd_micros: usage.estimated_usage_usd_micros,
|
||||
groups: usage
|
||||
.groups
|
||||
.into_iter()
|
||||
.map(
|
||||
|group| codex_app_server_protocol::ThreadUsageBreakdownGroup {
|
||||
model: group.model,
|
||||
reasoning_effort: group.reasoning_effort,
|
||||
speed: group.speed,
|
||||
estimated_usage_credits_micros: group
|
||||
.estimated_usage_credits_micros,
|
||||
net_new_input_tokens: group.net_new_input_tokens,
|
||||
cached_input_tokens: group.cached_input_tokens,
|
||||
input_tokens: group.input_tokens,
|
||||
output_tokens: group.output_tokens,
|
||||
total_tokens: group.total_tokens,
|
||||
},
|
||||
)
|
||||
.collect(),
|
||||
}),
|
||||
Err(err)
|
||||
if matches!(err.status().map(|status| status.as_u16()), Some(403 | 404)) =>
|
||||
{
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(internal_error(format!(
|
||||
"failed to fetch thread usage: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
return Ok(GetAccountTokenUsageResponse {
|
||||
summary: AccountTokenUsageSummary {
|
||||
lifetime_tokens: None,
|
||||
peak_daily_tokens: None,
|
||||
longest_running_turn_sec: None,
|
||||
current_streak_days: None,
|
||||
longest_streak_days: None,
|
||||
},
|
||||
daily_usage_buckets: None,
|
||||
thread_usage,
|
||||
});
|
||||
}
|
||||
let profile = tokio::time::timeout(
|
||||
ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT,
|
||||
client.get_token_usage_profile(),
|
||||
@@ -1215,6 +1281,7 @@ impl AccountRequestProcessor {
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
thread_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1364,6 +1431,7 @@ mod tests {
|
||||
start_date: "2026-05-29".to_string(),
|
||||
tokens: 10,
|
||||
}]),
|
||||
thread_usage: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::ChatGptIdTokenClaims;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::encode_id_token;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::AccountTokenUsageSummary;
|
||||
use codex_app_server_protocol::GetAccountTokenUsageResponse;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadUsage;
|
||||
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::body_json;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 30);
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_thread_usage_uses_active_workspace_and_canonical_thread_ids() -> Result<()> {
|
||||
let thread_id = "019fc8ab-1fb2-7000-8000-000000000123";
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!("chatgpt_base_url = \"{}\"\n", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("active-token").account_id("active-workspace"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("different-token").account_id("different-workspace"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/usage/thread_usage/query"))
|
||||
.and(header("authorization", "Bearer active-token"))
|
||||
.and(header("chatgpt-account-id", "active-workspace"))
|
||||
.and(body_json(json!({ "thread_ids": [thread_id] })))
|
||||
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
|
||||
"threads": [{
|
||||
"thread_id": thread_id,
|
||||
"estimated_usage_credits_micros": 46_000_000,
|
||||
"estimated_usage_usd_micros": null,
|
||||
"groups": [{
|
||||
"model": "gpt-5.4",
|
||||
"reasoning_effort": "high",
|
||||
"speed": "fast",
|
||||
"estimated_usage_credits_micros": 46_000_000,
|
||||
"net_new_input_tokens": 80,
|
||||
"cached_input_tokens": 20,
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 40,
|
||||
"total_tokens": 140
|
||||
}]
|
||||
}]
|
||||
})))
|
||||
.expect(/*r*/ 1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let request_id = app_server
|
||||
.send_raw_request(
|
||||
"account/usage/read",
|
||||
Some(json!({ "threadId": "019FC8AB-1FB2-7000-8000-000000000123" })),
|
||||
)
|
||||
.await?;
|
||||
let response: GetAccountTokenUsageResponse =
|
||||
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
GetAccountTokenUsageResponse {
|
||||
summary: AccountTokenUsageSummary {
|
||||
lifetime_tokens: None,
|
||||
peak_daily_tokens: None,
|
||||
longest_running_turn_sec: None,
|
||||
current_streak_days: None,
|
||||
longest_streak_days: None,
|
||||
},
|
||||
daily_usage_buckets: None,
|
||||
thread_usage: Some(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 46_000_000,
|
||||
estimated_usage_usd_micros: None,
|
||||
groups: vec![ThreadUsageBreakdownGroup {
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
speed: Some("fast".to_string()),
|
||||
estimated_usage_credits_micros: 46_000_000,
|
||||
net_new_input_tokens: Some(80),
|
||||
cached_input_tokens: Some(20),
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(40),
|
||||
total_tokens: Some(140),
|
||||
}],
|
||||
}),
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_thread_usage_supports_externally_managed_authentication() -> Result<()> {
|
||||
let thread_id = "019fc8ab-1fb2-7000-8000-000000000456";
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!("chatgpt_base_url = \"{}\"\n", server.uri()),
|
||||
)?;
|
||||
let access_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
.email("external@example.com")
|
||||
.plan_type("business")
|
||||
.chatgpt_account_id("external-workspace"),
|
||||
)?;
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
let login_id = app_server
|
||||
.send_chatgpt_auth_tokens_login_request(
|
||||
access_token.clone(),
|
||||
"external-workspace".to_string(),
|
||||
Some("business".to_string()),
|
||||
)
|
||||
.await?;
|
||||
let login: LoginAccountResponse =
|
||||
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(login_id)).await??;
|
||||
assert_eq!(login, LoginAccountResponse::ChatgptAuthTokens {});
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/usage/thread_usage/query"))
|
||||
.and(header("authorization", format!("Bearer {access_token}")))
|
||||
.and(header("chatgpt-account-id", "external-workspace"))
|
||||
.and(body_json(json!({ "thread_ids": [thread_id] })))
|
||||
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
|
||||
"threads": [{
|
||||
"thread_id": thread_id,
|
||||
"estimated_usage_credits_micros": 21_000_000,
|
||||
"estimated_usage_usd_micros": 840_000
|
||||
}]
|
||||
})))
|
||||
.expect(/*r*/ 1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let request_id = app_server
|
||||
.send_raw_request("account/usage/read", Some(json!({ "threadId": thread_id })))
|
||||
.await?;
|
||||
let response: GetAccountTokenUsageResponse =
|
||||
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??;
|
||||
assert_eq!(
|
||||
response.thread_usage,
|
||||
Some(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 21_000_000,
|
||||
estimated_usage_usd_micros: Some(840_000),
|
||||
groups: Vec::new(),
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_thread_usage_hides_unavailable_billing_routes() -> Result<()> {
|
||||
let thread_id = "019fc8ab-1fb2-7000-8000-000000000789";
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!("chatgpt_base_url = \"{}\"\n", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("active-token").account_id("active-workspace"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/usage/thread_usage/query"))
|
||||
.respond_with(ResponseTemplate::new(/*s*/ 403))
|
||||
.expect(/*r*/ 1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
|
||||
let request_id = app_server
|
||||
.send_raw_request("account/usage/read", Some(json!({ "threadId": thread_id })))
|
||||
.await?;
|
||||
let response: GetAccountTokenUsageResponse =
|
||||
timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??;
|
||||
assert_eq!(response.thread_usage, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_thread_usage_rejects_malformed_thread_ids_before_backend_requests() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!("chatgpt_base_url = \"{}\"\n", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("active-token").account_id("active-workspace"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/usage/thread_usage/query"))
|
||||
.respond_with(ResponseTemplate::new(/*s*/ 200))
|
||||
.expect(/*r*/ 0)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
|
||||
.await?;
|
||||
|
||||
let request_id = app_server
|
||||
.send_raw_request(
|
||||
"account/usage/read",
|
||||
Some(json!({ "threadId": "not-a-thread-id" })),
|
||||
)
|
||||
.await?;
|
||||
let error: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
app_server.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(error.error.code, -32600);
|
||||
assert!(error.error.message.starts_with("invalid thread id:"));
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod account;
|
||||
mod account_thread_usage;
|
||||
mod analytics;
|
||||
mod app_installed;
|
||||
mod app_list;
|
||||
|
||||
@@ -530,6 +530,9 @@ pub(crate) struct App {
|
||||
pub(crate) file_search: FileSearchManager,
|
||||
|
||||
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
|
||||
last_rendered_history_tail: Option<history_ui::RenderedHistoryTail>,
|
||||
last_thread_usage_status_cell: Option<history_ui::ThreadUsageStatusHistory>,
|
||||
pub(crate) pending_thread_usage_history_refresh: bool,
|
||||
|
||||
// Pager overlay state (Transcript or Static like Diff)
|
||||
pub(crate) overlay: Option<Overlay>,
|
||||
@@ -1056,6 +1059,9 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
keymap: runtime_keymap,
|
||||
key_chord_matcher: KeyChordMatcher::default(),
|
||||
transcript_cells: Vec::new(),
|
||||
last_rendered_history_tail: None,
|
||||
last_thread_usage_status_cell: None,
|
||||
pending_thread_usage_history_refresh: false,
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
|
||||
@@ -111,6 +111,10 @@ impl App {
|
||||
return;
|
||||
}
|
||||
ServerNotification::AccountUpdated(notification) => {
|
||||
// Deferred terminal writes must never carry the previous account's billing into
|
||||
// the newly authenticated identity, even when both accounts share one thread.
|
||||
self.last_thread_usage_status_cell = None;
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
let has_codex_backend_auth = matches!(
|
||||
notification.auth_mode,
|
||||
Some(
|
||||
|
||||
@@ -8,18 +8,20 @@ use super::plugin_mentions::fetch_plugin_mentions;
|
||||
use super::*;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_info::app_info_from_api;
|
||||
use crate::chatwidget::ThreadUsageOutcome;
|
||||
use crate::config_update::format_config_error;
|
||||
use codex_app_server_protocol::AppsListParams;
|
||||
use codex_app_server_protocol::AppsListResponse;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams;
|
||||
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
|
||||
use codex_app_server_protocol::GetAccountTokenUsageParams;
|
||||
use codex_app_server_protocol::GetAccountTokenUsageResponse;
|
||||
use codex_app_server_protocol::MarketplaceAddParams;
|
||||
use codex_app_server_protocol::MarketplaceAddResponse;
|
||||
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::RequestId;
|
||||
|
||||
use crate::hooks_rpc::fetch_hooks_list;
|
||||
@@ -29,6 +31,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration =
|
||||
std::time::Duration::from_secs(/*secs*/ 15);
|
||||
const THREAD_USAGE_FETCH_TIMEOUT: std::time::Duration =
|
||||
std::time::Duration::from_secs(/*secs*/ 65);
|
||||
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration =
|
||||
std::time::Duration::from_secs(/*secs*/ 15);
|
||||
const WORKSPACE_HEADLINE_FETCH_TIMEOUT: std::time::Duration =
|
||||
@@ -124,6 +128,30 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn refresh_thread_usage(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
request_id: u64,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(
|
||||
THREAD_USAGE_FETCH_TIMEOUT,
|
||||
fetch_thread_usage(request_handle, thread_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "thread usage request timed out in TUI".to_string())
|
||||
.and_then(|result| result.map_err(|err| err.to_string()));
|
||||
app_event_tx.send(AppEvent::ThreadUsageLoaded {
|
||||
thread_id,
|
||||
request_id,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn consume_rate_limit_reset_credit(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
@@ -783,6 +811,26 @@ pub(super) async fn fetch_account_token_activity(
|
||||
.wrap_err("account/usage/read failed in TUI")
|
||||
}
|
||||
|
||||
async fn fetch_thread_usage(
|
||||
request_handle: AppServerRequestHandle,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<ThreadUsageOutcome> {
|
||||
let request_id = RequestId::String(format!("thread-usage-{}", Uuid::new_v4()));
|
||||
let response: GetAccountTokenUsageResponse = request_handle
|
||||
.request_typed(ClientRequest::GetAccountTokenUsage {
|
||||
request_id,
|
||||
params: Some(GetAccountTokenUsageParams {
|
||||
thread_id: Some(thread_id.to_string()),
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.wrap_err("account/usage/read failed for thread usage in TUI")?;
|
||||
Ok(response
|
||||
.thread_usage
|
||||
.map(ThreadUsageOutcome::Available)
|
||||
.unwrap_or(ThreadUsageOutcome::Disabled))
|
||||
}
|
||||
|
||||
pub(super) async fn consume_rate_limit_reset_credit_request(
|
||||
request_handle: AppServerRequestHandle,
|
||||
idempotency_key: String,
|
||||
@@ -1285,11 +1333,79 @@ pub(super) fn mcp_inventory_maps_from_statuses(statuses: Vec<McpServerStatus>) -
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::test_support::make_test_app;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::PluginMarketplaceEntry;
|
||||
use codex_app_server_protocol::ThreadUsage;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_protocol::mcp::Tool;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_thread_usage_uses_app_server_auth_after_persisted_account_changes() {
|
||||
let mut app = make_test_app().await;
|
||||
let server = wiremock::MockServer::start().await;
|
||||
let thread_id = ThreadId::new();
|
||||
app.config.chatgpt_base_url = server.uri();
|
||||
app.config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File;
|
||||
write_chatgpt_auth(
|
||||
app.config.codex_home.as_path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token").account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("write ChatGPT authentication");
|
||||
let app_server = crate::start_embedded_app_server_for_picker(&app.config)
|
||||
.await
|
||||
.expect("start authenticated embedded app server");
|
||||
write_chatgpt_auth(
|
||||
app.config.codex_home.as_path(),
|
||||
ChatGptAuthFixture::new("different-token").account_id("different-account"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("replace persisted ChatGPT authentication");
|
||||
|
||||
wiremock::Mock::given(wiremock::matchers::method("POST"))
|
||||
.and(wiremock::matchers::path(
|
||||
"/api/codex/usage/thread_usage/query",
|
||||
))
|
||||
.and(wiremock::matchers::header(
|
||||
"authorization",
|
||||
"Bearer chatgpt-token",
|
||||
))
|
||||
.and(wiremock::matchers::header(
|
||||
"chatgpt-account-id",
|
||||
"account-123",
|
||||
))
|
||||
.and(wiremock::matchers::body_json(serde_json::json!({
|
||||
"thread_ids": [thread_id.to_string()]
|
||||
})))
|
||||
.respond_with(wiremock::ResponseTemplate::new(/*s*/ 200).set_body_json(
|
||||
serde_json::json!({
|
||||
"threads": [{
|
||||
"thread_id": thread_id.to_string(),
|
||||
"estimated_usage_credits_micros": 46_000_000,
|
||||
"estimated_usage_usd_micros": 1_820_000
|
||||
}]
|
||||
}),
|
||||
))
|
||||
.expect(/*r*/ 1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
fetch_thread_usage(app_server.request_handle(), thread_id)
|
||||
.await
|
||||
.expect("read thread usage through the authenticated app server"),
|
||||
ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 46_000_000,
|
||||
estimated_usage_usd_micros: Some(1_820_000),
|
||||
groups: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn test_absolute_path(path: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path")
|
||||
}
|
||||
|
||||
@@ -983,6 +983,12 @@ impl App {
|
||||
AppEvent::RefreshTokenActivity { request_id } => {
|
||||
self.refresh_token_activity(app_server, request_id);
|
||||
}
|
||||
AppEvent::RefreshThreadUsage {
|
||||
thread_id,
|
||||
request_id,
|
||||
} => {
|
||||
self.refresh_thread_usage(app_server, thread_id, request_id);
|
||||
}
|
||||
AppEvent::RefreshStatusLineWorkspaceHeadline { request_id } => {
|
||||
self.refresh_status_line_workspace_headline(app_server, request_id);
|
||||
}
|
||||
@@ -1208,6 +1214,13 @@ impl App {
|
||||
self.insert_pending_usage_output_if_ready(tui);
|
||||
}
|
||||
}
|
||||
AppEvent::ThreadUsageLoaded {
|
||||
thread_id,
|
||||
request_id,
|
||||
result,
|
||||
} => {
|
||||
self.finish_thread_usage_refresh(tui, thread_id, request_id, result)?;
|
||||
}
|
||||
AppEvent::CommitPendingUsageOutput => {
|
||||
self.insert_pending_usage_output_if_ready(tui);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,22 @@
|
||||
//! state, and resetting transcript-related app state after `/clear` or Ctrl-L.
|
||||
|
||||
use super::*;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use std::sync::Weak;
|
||||
|
||||
const DESKTOP_THREAD_OPENED_MESSAGE: &str = "Opened this session in the Desktop app.";
|
||||
|
||||
pub(super) struct RenderedHistoryTail {
|
||||
pub(super) cell: Weak<dyn HistoryCell>,
|
||||
pub(super) lines: Vec<HyperlinkLine>,
|
||||
}
|
||||
|
||||
pub(super) struct ThreadUsageStatusHistory {
|
||||
thread_id: ThreadId,
|
||||
pub(super) cell: Weak<dyn HistoryCell>,
|
||||
pub(super) lines: Vec<HyperlinkLine>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn insert_history_cell(&mut self, tui: &mut tui::Tui, cell: Box<dyn HistoryCell>) {
|
||||
let cell: Arc<dyn HistoryCell> = cell.into();
|
||||
@@ -15,26 +28,155 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.transcript_cells.push(cell.clone());
|
||||
let width = self
|
||||
.chat_widget
|
||||
.history_wrap_width(tui.terminal.last_known_screen_size.width);
|
||||
let lines =
|
||||
cell.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode());
|
||||
if cell.as_any().is::<history_cell::CompositeHistoryCell>()
|
||||
&& lines.first().is_some_and(|line| {
|
||||
line.line.spans.len() == 1 && line.line.spans[0].content.as_ref() == "/status"
|
||||
})
|
||||
&& let Some(thread_id) = self.chat_widget.thread_id()
|
||||
{
|
||||
self.last_thread_usage_status_cell = Some(ThreadUsageStatusHistory {
|
||||
thread_id,
|
||||
cell: Arc::downgrade(&cell),
|
||||
lines: lines.clone(),
|
||||
});
|
||||
}
|
||||
if self.initial_history_replay_buffer.as_ref().is_some() {
|
||||
self.insert_history_cell_lines_with_initial_replay_buffer(
|
||||
tui,
|
||||
cell.as_ref(),
|
||||
self.chat_widget
|
||||
.history_wrap_width(tui.terminal.last_known_screen_size.width),
|
||||
);
|
||||
self.insert_history_cell_lines_with_initial_replay_buffer(tui, cell.as_ref(), width);
|
||||
self.last_rendered_history_tail = None;
|
||||
} else {
|
||||
self.insert_history_cell_lines(
|
||||
tui,
|
||||
cell.as_ref(),
|
||||
self.chat_widget
|
||||
.history_wrap_width(tui.terminal.last_known_screen_size.width),
|
||||
);
|
||||
self.insert_history_cell_lines(tui, cell.as_ref(), width);
|
||||
self.last_rendered_history_tail = if self.overlay.is_none() && !lines.is_empty() {
|
||||
Some(RenderedHistoryTail {
|
||||
cell: Arc::downgrade(&cell),
|
||||
lines,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
// A committed cell can unblock a settled /usage card that was waiting
|
||||
// behind a transient active cell or a provisional stream tail.
|
||||
self.chat_widget.request_pending_usage_output_insertion();
|
||||
}
|
||||
|
||||
pub(super) fn finish_thread_usage_refresh(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
thread_id: ThreadId,
|
||||
request_id: u64,
|
||||
result: std::result::Result<crate::chatwidget::ThreadUsageOutcome, String>,
|
||||
) -> Result<()> {
|
||||
let may_update_status = match &result {
|
||||
Ok(crate::chatwidget::ThreadUsageOutcome::Available(usage)) => {
|
||||
usage.thread_id == thread_id.to_string()
|
||||
}
|
||||
Ok(crate::chatwidget::ThreadUsageOutcome::Disabled) => true,
|
||||
Err(_) => false,
|
||||
};
|
||||
if !self
|
||||
.chat_widget
|
||||
.finish_thread_usage_refresh(thread_id, request_id, result)
|
||||
|| !may_update_status
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if self.overlay.is_some() || self.initial_history_replay_buffer.is_some() {
|
||||
self.pending_thread_usage_history_refresh = true;
|
||||
return Ok(());
|
||||
}
|
||||
self.refresh_thread_usage_history_tail(tui)
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_thread_usage_history_tail(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
||||
let Some(status_history) = self.last_thread_usage_status_cell.as_ref() else {
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
return Ok(());
|
||||
};
|
||||
if self.chat_widget.thread_id() != Some(status_history.thread_id) {
|
||||
self.last_thread_usage_status_cell = None;
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
return Ok(());
|
||||
}
|
||||
let Some(status_cell) = status_history.cell.upgrade() else {
|
||||
self.last_thread_usage_status_cell = None;
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let width = self
|
||||
.chat_widget
|
||||
.history_wrap_width(tui.terminal.last_known_screen_size.width);
|
||||
let updated_lines = status_cell
|
||||
.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode());
|
||||
if updated_lines == status_history.lines {
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
return Ok(());
|
||||
}
|
||||
let Some(rendered_tail) = self.last_rendered_history_tail.as_ref() else {
|
||||
self.insert_history_cell_lines(tui, status_cell.as_ref(), width);
|
||||
self.last_rendered_history_tail = Some(RenderedHistoryTail {
|
||||
cell: Arc::downgrade(&status_cell),
|
||||
lines: updated_lines.clone(),
|
||||
});
|
||||
if let Some(status_history) = self.last_thread_usage_status_cell.as_mut() {
|
||||
status_history.lines = updated_lines;
|
||||
}
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
return Ok(());
|
||||
};
|
||||
if !rendered_tail
|
||||
.cell
|
||||
.upgrade()
|
||||
.is_some_and(|cell| Arc::ptr_eq(&cell, &status_cell))
|
||||
{
|
||||
self.insert_history_cell_lines(tui, status_cell.as_ref(), width);
|
||||
self.last_rendered_history_tail = Some(RenderedHistoryTail {
|
||||
cell: Arc::downgrade(&status_cell),
|
||||
lines: updated_lines.clone(),
|
||||
});
|
||||
if let Some(status_history) = self.last_thread_usage_status_cell.as_mut() {
|
||||
status_history.lines = updated_lines;
|
||||
}
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let prefix_len = rendered_tail
|
||||
.lines
|
||||
.iter()
|
||||
.zip(&updated_lines)
|
||||
.take_while(|(previous, updated)| previous == updated)
|
||||
.count();
|
||||
if prefix_len == rendered_tail.lines.len() && prefix_len == updated_lines.len() {
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let wrap_policy = self.history_line_wrap_policy();
|
||||
if !tui.replace_visible_history_tail(
|
||||
&rendered_tail.lines[prefix_len..],
|
||||
&updated_lines[prefix_len..],
|
||||
wrap_policy,
|
||||
)? {
|
||||
self.insert_history_cell_lines(tui, status_cell.as_ref(), width);
|
||||
}
|
||||
self.last_rendered_history_tail = Some(RenderedHistoryTail {
|
||||
cell: Arc::downgrade(&status_cell),
|
||||
lines: updated_lines.clone(),
|
||||
});
|
||||
if let Some(status_history) = self.last_thread_usage_status_cell.as_mut() {
|
||||
status_history.lines = updated_lines;
|
||||
}
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn pending_usage_output_insertion_blocked(&self) -> bool {
|
||||
self.chat_widget.usage_history_insertion_blocked()
|
||||
|| self
|
||||
@@ -165,6 +307,9 @@ impl App {
|
||||
pub(super) fn reset_transcript_state_after_clear(&mut self) {
|
||||
self.overlay = None;
|
||||
self.transcript_cells.clear();
|
||||
self.last_rendered_history_tail = None;
|
||||
self.last_thread_usage_status_cell = None;
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
self.deferred_history_lines.clear();
|
||||
self.has_emitted_history_lines = false;
|
||||
self.transcript_reflow.clear();
|
||||
|
||||
@@ -81,6 +81,7 @@ impl App {
|
||||
pub(super) fn reset_history_emission_state(&mut self) {
|
||||
self.has_emitted_history_lines = false;
|
||||
self.deferred_history_lines.clear();
|
||||
self.last_rendered_history_tail = None;
|
||||
}
|
||||
|
||||
fn display_lines_for_history_insert(
|
||||
@@ -179,6 +180,11 @@ impl App {
|
||||
retained_lines,
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
if self.pending_thread_usage_history_refresh
|
||||
&& let Err(err) = self.refresh_thread_usage_history_tail(tui)
|
||||
{
|
||||
tracing::warn!(error = %err, "failed to refresh thread usage after initial replay");
|
||||
}
|
||||
self.request_scrollback_history_top_up(retained_rows);
|
||||
}
|
||||
|
||||
@@ -480,6 +486,25 @@ impl App {
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
}
|
||||
self.last_rendered_history_tail =
|
||||
self.transcript_cells
|
||||
.last()
|
||||
.map(|cell| super::history_ui::RenderedHistoryTail {
|
||||
cell: Arc::downgrade(cell),
|
||||
lines: cell.display_hyperlink_lines_for_mode(
|
||||
width,
|
||||
self.chat_widget.history_render_mode(),
|
||||
),
|
||||
});
|
||||
if let Some(status_history) = self.last_thread_usage_status_cell.as_mut()
|
||||
&& let Some(cell) = status_history.cell.upgrade()
|
||||
{
|
||||
status_history.lines = cell
|
||||
.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode());
|
||||
}
|
||||
if self.pending_thread_usage_history_refresh {
|
||||
self.refresh_thread_usage_history_tail(tui)?;
|
||||
}
|
||||
self.request_scrollback_history_top_up(reflowed_rows);
|
||||
|
||||
Ok(terminal_width)
|
||||
|
||||
@@ -33,6 +33,9 @@ pub(super) async fn make_test_app() -> App {
|
||||
runtime_permission_profile_override: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
last_rendered_history_tail: None,
|
||||
last_thread_usage_status_cell: None,
|
||||
pending_thread_usage_history_refresh: false,
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
|
||||
@@ -14,6 +14,8 @@ mod safety_buffering;
|
||||
mod session_lifecycle_requests;
|
||||
mod session_summary;
|
||||
mod startup;
|
||||
#[path = "tests/thread_usage.rs"]
|
||||
mod thread_usage;
|
||||
#[path = "tests/turn_submission.rs"]
|
||||
mod turn_submission;
|
||||
|
||||
@@ -4801,6 +4803,9 @@ async fn make_test_app() -> App {
|
||||
runtime_permission_profile_override: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
last_rendered_history_tail: None,
|
||||
last_thread_usage_status_cell: None,
|
||||
pending_thread_usage_history_refresh: false,
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
@@ -4871,6 +4876,9 @@ async fn make_test_app_with_channels() -> (
|
||||
runtime_permission_profile_override: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
last_rendered_history_tail: None,
|
||||
last_thread_usage_status_cell: None,
|
||||
pending_thread_usage_history_refresh: false,
|
||||
overlay: None,
|
||||
deferred_history_lines: Vec::new(),
|
||||
has_emitted_history_lines: false,
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
use super::*;
|
||||
use crate::chatwidget::ThreadUsageOutcome;
|
||||
use codex_app_server_client::AppServerEvent;
|
||||
use codex_app_server_protocol::AccountUpdatedNotification;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::ThreadUsage;
|
||||
use codex_protocol::account::PlanType;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::layout::Size;
|
||||
|
||||
async fn app_with_pending_thread_usage() -> Result<(App, AppServerSession, tui::Tui, ThreadId, u64)>
|
||||
{
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
let thread_id = ThreadId::new();
|
||||
app.chat_widget.handle_thread_session(test_thread_session(
|
||||
thread_id,
|
||||
test_path_buf("/tmp/project"),
|
||||
));
|
||||
app.chat_widget.update_account_state(
|
||||
/*status_account_display*/ None,
|
||||
Some(PlanType::Business),
|
||||
/*has_chatgpt_account*/ false,
|
||||
/*has_codex_backend_auth*/ true,
|
||||
);
|
||||
while app_event_rx.try_recv().is_ok() {}
|
||||
let mut app_server =
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.terminal.set_viewport_area(Rect::new(
|
||||
/*x*/ 0, /*y*/ 21, /*width*/ 80, /*height*/ 3,
|
||||
));
|
||||
|
||||
app.chat_widget.add_status_output(
|
||||
/*refreshing_rate_limits*/ false, /*request_id*/ None,
|
||||
);
|
||||
let initial_status = match app_event_rx.try_recv() {
|
||||
Ok(event @ AppEvent::InsertHistoryCell(_)) => event,
|
||||
other => panic!("expected immediate status card, got {other:?}"),
|
||||
};
|
||||
let request_id = match app_event_rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage {
|
||||
thread_id: requested_thread_id,
|
||||
request_id,
|
||||
}) if requested_thread_id == thread_id => request_id,
|
||||
other => panic!("expected asynchronous thread usage request, got {other:?}"),
|
||||
};
|
||||
app.handle_event(&mut tui, &mut app_server, initial_status)
|
||||
.await?;
|
||||
|
||||
Ok((app, app_server, tui, thread_id, request_id))
|
||||
}
|
||||
|
||||
fn successful_thread_usage(thread_id: ThreadId, request_id: u64) -> AppEvent {
|
||||
AppEvent::ThreadUsageLoaded {
|
||||
thread_id,
|
||||
request_id,
|
||||
result: Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: Some(1_820_000),
|
||||
groups: Vec::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_history_text(tui: &tui::Tui) -> String {
|
||||
tui.pending_history_lines_for_test()
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
line.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_updated_with_backend_only_auth_enables_thread_usage() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
let thread_id = ThreadId::new();
|
||||
app.chat_widget.handle_thread_session(test_thread_session(
|
||||
thread_id,
|
||||
test_path_buf("/tmp/project"),
|
||||
));
|
||||
while app_event_rx.try_recv().is_ok() {}
|
||||
let app_server =
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
|
||||
app.handle_app_server_event(
|
||||
&app_server,
|
||||
AppServerEvent::ServerNotification(Box::new(ServerNotification::AccountUpdated(
|
||||
AccountUpdatedNotification {
|
||||
auth_mode: Some(AuthMode::AgentIdentity),
|
||||
plan_type: Some(PlanType::Business),
|
||||
},
|
||||
))),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!app.chat_widget.has_chatgpt_account());
|
||||
assert!(app.chat_widget.has_codex_backend_auth());
|
||||
app.chat_widget.add_status_output(
|
||||
/*refreshing_rate_limits*/ false, /*request_id*/ None,
|
||||
);
|
||||
assert_matches!(app_event_rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
assert_matches!(
|
||||
app_event_rx.try_recv(),
|
||||
Ok(AppEvent::RefreshThreadUsage {
|
||||
thread_id: requested_thread_id,
|
||||
..
|
||||
}) if requested_thread_id == thread_id
|
||||
);
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_thread_usage_updates_status_without_scrollback_reflow() -> Result<()> {
|
||||
let (mut app, mut app_server, mut tui, thread_id, request_id) =
|
||||
app_with_pending_thread_usage().await?;
|
||||
assert!(!app.transcript_reflow.has_pending_reflow());
|
||||
assert!(pending_history_text(&tui).contains("/status"));
|
||||
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
successful_thread_usage(thread_id, request_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(!app.transcript_reflow.has_pending_reflow());
|
||||
assert!(
|
||||
tui.pending_history_lines_for_test().is_empty(),
|
||||
"a visible status tail should be replaced directly instead of appending a duplicate card"
|
||||
);
|
||||
let status = app
|
||||
.transcript_cells
|
||||
.last()
|
||||
.expect("status card should remain in terminal history");
|
||||
let rendered = lines_to_single_string(&status.display_lines(/*width*/ 90));
|
||||
assert!(rendered.contains("50 credits"), "{rendered}");
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_usage_after_intervening_history_appends_refreshed_status() -> Result<()> {
|
||||
let (mut app, mut app_server, mut tui, thread_id, request_id) =
|
||||
app_with_pending_thread_usage().await?;
|
||||
app.insert_history_cell(
|
||||
&mut tui,
|
||||
Box::new(PlainHistoryCell::new(vec![
|
||||
Line::from("/status"),
|
||||
Line::from("intervening model output"),
|
||||
])),
|
||||
);
|
||||
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
successful_thread_usage(thread_id, request_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let pending = pending_history_text(&tui);
|
||||
assert!(pending.contains("intervening model output"), "{pending}");
|
||||
assert!(pending.contains("50 credits"), "{pending}");
|
||||
assert!(!app.transcript_reflow.has_pending_reflow());
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_thread_usage_after_intervening_history_does_not_duplicate_status() -> Result<()> {
|
||||
let (mut app, mut app_server, mut tui, thread_id, request_id) =
|
||||
app_with_pending_thread_usage().await?;
|
||||
app.insert_history_cell(
|
||||
&mut tui,
|
||||
Box::new(PlainHistoryCell::new(vec![Line::from(
|
||||
"intervening model output",
|
||||
)])),
|
||||
);
|
||||
let pending_before_failure = pending_history_text(&tui);
|
||||
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::ThreadUsageLoaded {
|
||||
thread_id,
|
||||
request_id,
|
||||
result: Err("thread usage request timed out".to_string()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(pending_history_text(&tui), pending_before_failure);
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_usage_finishing_during_overlay_updates_history_after_close() -> Result<()> {
|
||||
let (mut app, mut app_server, mut tui, thread_id, request_id) =
|
||||
app_with_pending_thread_usage().await?;
|
||||
tui.set_alt_screen_enabled(/*enabled*/ false);
|
||||
app.open_transcript_overlay(&mut tui);
|
||||
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
successful_thread_usage(thread_id, request_id),
|
||||
)
|
||||
.await?;
|
||||
assert!(app.pending_thread_usage_history_refresh);
|
||||
|
||||
app.close_transcript_overlay(&mut tui);
|
||||
|
||||
assert!(!app.pending_thread_usage_history_refresh);
|
||||
assert!(tui.pending_history_lines_for_test().is_empty());
|
||||
assert!(
|
||||
app.last_rendered_history_tail
|
||||
.as_ref()
|
||||
.expect("updated status should remain the rendered history tail")
|
||||
.lines
|
||||
.iter()
|
||||
.any(|line| line.line.to_string().contains("50 credits"))
|
||||
);
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_change_discards_thread_usage_deferred_while_overlay_is_open() -> Result<()> {
|
||||
let (mut app, mut app_server, mut tui, thread_id, request_id) =
|
||||
app_with_pending_thread_usage().await?;
|
||||
tui.set_alt_screen_enabled(/*enabled*/ false);
|
||||
app.open_transcript_overlay(&mut tui);
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
successful_thread_usage(thread_id, request_id),
|
||||
)
|
||||
.await?;
|
||||
assert!(app.pending_thread_usage_history_refresh);
|
||||
|
||||
app.handle_app_server_event(
|
||||
&app_server,
|
||||
AppServerEvent::ServerNotification(Box::new(ServerNotification::AccountUpdated(
|
||||
AccountUpdatedNotification {
|
||||
auth_mode: Some(AuthMode::AgentIdentity),
|
||||
plan_type: Some(PlanType::Business),
|
||||
},
|
||||
))),
|
||||
)
|
||||
.await;
|
||||
assert!(!app.pending_thread_usage_history_refresh);
|
||||
assert!(app.last_thread_usage_status_cell.is_none());
|
||||
|
||||
app.close_transcript_overlay(&mut tui);
|
||||
|
||||
assert!(!pending_history_text(&tui).contains("50 credits"));
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_reflow_rebases_pending_status_update_to_new_width() -> Result<()> {
|
||||
let (mut app, mut app_server, mut tui, thread_id, request_id) =
|
||||
app_with_pending_thread_usage().await?;
|
||||
let original = app
|
||||
.last_rendered_history_tail
|
||||
.as_ref()
|
||||
.expect("initial rendered status")
|
||||
.lines
|
||||
.clone();
|
||||
let screen_size = Size::new(/*width*/ 52, /*height*/ 24);
|
||||
tui.terminal.last_known_screen_size = screen_size;
|
||||
let viewport = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 21,
|
||||
screen_size.width,
|
||||
/*height*/ 3,
|
||||
);
|
||||
tui.terminal.set_viewport_area(viewport);
|
||||
|
||||
app.reflow_transcript_now(&mut tui, screen_size.into())?;
|
||||
|
||||
let resized = app
|
||||
.last_rendered_history_tail
|
||||
.as_ref()
|
||||
.expect("resized rendered status")
|
||||
.lines
|
||||
.clone();
|
||||
assert_ne!(original, resized);
|
||||
// Bazel tests run without a TTY, so model the settled viewport explicitly
|
||||
// before simulating an asynchronous usage update after resize replay.
|
||||
tui.terminal.set_viewport_area(viewport);
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
successful_thread_usage(thread_id, request_id),
|
||||
)
|
||||
.await?;
|
||||
assert!(tui.pending_history_lines_for_test().is_empty());
|
||||
assert!(!app.transcript_reflow.has_pending_reflow());
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -241,6 +241,11 @@ impl App {
|
||||
);
|
||||
}
|
||||
self.overlay = None;
|
||||
if self.pending_thread_usage_history_refresh
|
||||
&& let Err(err) = self.refresh_thread_usage_history_tail(tui)
|
||||
{
|
||||
tracing::warn!(error = %err, "failed to refresh thread usage after closing overlay");
|
||||
}
|
||||
self.backtrack.overlay_preview_active = false;
|
||||
tui.frame_requester().schedule_frame();
|
||||
if was_backtrack {
|
||||
|
||||
@@ -47,6 +47,7 @@ use crate::app_server_session::AppServerStartedThread;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::StatusLineItem;
|
||||
use crate::bottom_pane::TerminalTitleItem;
|
||||
use crate::chatwidget::ThreadUsageOutcome;
|
||||
use crate::chatwidget::UserMessage;
|
||||
use crate::goal_files::GoalDraft;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
@@ -452,6 +453,19 @@ pub(crate) enum AppEvent {
|
||||
result: Result<GetAccountTokenUsageResponse, String>,
|
||||
},
|
||||
|
||||
/// Fetch backend-estimated usage for the currently visible enterprise thread.
|
||||
RefreshThreadUsage {
|
||||
thread_id: ThreadId,
|
||||
request_id: u64,
|
||||
},
|
||||
|
||||
/// Result of fetching backend-estimated usage for a specific thread.
|
||||
ThreadUsageLoaded {
|
||||
thread_id: ThreadId,
|
||||
request_id: u64,
|
||||
result: Result<ThreadUsageOutcome, String>,
|
||||
},
|
||||
|
||||
/// Fetch workspace messages for the status-line headline item.
|
||||
RefreshStatusLineWorkspaceHeadline {
|
||||
request_id: u64,
|
||||
|
||||
@@ -423,6 +423,8 @@ mod status_controls;
|
||||
mod status_surfaces;
|
||||
mod streaming;
|
||||
use self::status_surfaces::CachedProjectRootName;
|
||||
mod thread_usage;
|
||||
pub(crate) use self::thread_usage::ThreadUsageOutcome;
|
||||
mod tokens;
|
||||
pub(crate) use self::tokens::TokenActivityView;
|
||||
mod tool_lifecycle;
|
||||
@@ -764,6 +766,8 @@ pub(crate) struct ChatWidget {
|
||||
status_line_workspace_headline_last_requested_at: Option<Instant>,
|
||||
// Set after the backend reports the workspace-message feature gate is disabled.
|
||||
status_line_workspace_messages_disabled: bool,
|
||||
// Cached backend-estimated cost and bounded refresh state for the current thread.
|
||||
thread_usage: thread_usage::ThreadUsageState,
|
||||
// Current thread-goal status shown in the status line when plan mode is inactive.
|
||||
current_goal_status_indicator: Option<GoalStatusIndicator>,
|
||||
current_goal_status: Option<GoalStatusState>,
|
||||
|
||||
@@ -246,6 +246,7 @@ impl ChatWidget {
|
||||
next_status_line_workspace_headline_request_id: 0,
|
||||
status_line_workspace_headline_last_requested_at: None,
|
||||
status_line_workspace_messages_disabled: false,
|
||||
thread_usage: thread_usage::ThreadUsageState::default(),
|
||||
current_goal_status_indicator: None,
|
||||
current_goal_status: None,
|
||||
external_editor_state: ExternalEditorState::Closed,
|
||||
|
||||
@@ -24,6 +24,7 @@ impl ChatWidget {
|
||||
.set_queue_submissions(/*queue_submissions*/ false);
|
||||
if previous_thread_id != self.thread_id {
|
||||
self.review.recent_auto_review_denials = RecentAutoReviewDenials::default();
|
||||
self.clear_thread_usage_state();
|
||||
}
|
||||
self.refresh_plan_mode_nudge();
|
||||
self.turn_lifecycle.reset_thread();
|
||||
|
||||
@@ -236,6 +236,7 @@ impl ChatWidget {
|
||||
self.status_line_workspace_headline_pending_request_id = None;
|
||||
self.status_line_workspace_headline_last_requested_at = None;
|
||||
self.status_line_workspace_messages_disabled = false;
|
||||
self.clear_thread_usage_state();
|
||||
self.status_account_display = status_account_display;
|
||||
self.plan_type = plan_type;
|
||||
self.has_chatgpt_account = has_chatgpt_account;
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_command_tests.rs
|
||||
expression: thread_usage
|
||||
---
|
||||
│ Thread usage: 50 credits · ~$1.82 │
|
||||
│ Models: GPT-5.4 80%, GPT-5 Mini 20% │
|
||||
│ Reasoning: Medium 20%, High 80% │
|
||||
│ Speed: Fast mode 80%, Standard 20% │
|
||||
│ Billed tokens: 120 input (25 cached) + 50 output │
|
||||
╰──────────────────────────────────────────────────────────────────────╯
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_command_tests.rs
|
||||
expression: thread_usage
|
||||
---
|
||||
│ Thread usage: 50 credits │
|
||||
│ Models: GPT-5.4 100% │
|
||||
│ Reasoning: High 100% │
|
||||
│ Speed: Fast mode 100% │
|
||||
│ Billed tokens: 100 input (20 cached) + 40 output │
|
||||
╰──────────────────────────────────────────────────────────────────────╯
|
||||
@@ -245,9 +245,17 @@ impl ChatWidget {
|
||||
refreshing_rate_limits,
|
||||
);
|
||||
if let Some(request_id) = request_id {
|
||||
self.refreshing_status_outputs.push((request_id, handle));
|
||||
self.refreshing_status_outputs
|
||||
.push((request_id, handle.clone()));
|
||||
}
|
||||
if self.thread_usage_is_available() {
|
||||
handle.reserve_thread_usage_label_width();
|
||||
handle.set_thread_usage(self.estimated_thread_usage().cloned());
|
||||
self.add_to_history(cell);
|
||||
self.request_thread_usage_for_status(handle);
|
||||
} else {
|
||||
self.add_to_history(cell);
|
||||
}
|
||||
self.add_to_history(cell);
|
||||
}
|
||||
|
||||
pub(crate) fn finish_status_rate_limit_refresh(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::*;
|
||||
use crate::chatwidget::ThreadUsageOutcome;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_app_server_protocol::ThreadUsage;
|
||||
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -197,3 +200,417 @@ async fn account_update_rejects_stale_status_rate_limit_snapshots() {
|
||||
|
||||
assert!(chat.rate_limit_snapshots_by_limit_id.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_renders_immediately_and_updates_first_card_without_status_line() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let thread_id =
|
||||
ThreadId::from_string("019fc8ab-1fb2-7000-8000-000000000001").expect("valid thread id");
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
|
||||
let first_cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
event => panic!("expected immediate /status output, got {event:?}"),
|
||||
};
|
||||
let initial = lines_to_single_string(&first_cell.display_lines(/*width*/ 90));
|
||||
assert!(!initial.contains("Thread usage"));
|
||||
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage {
|
||||
thread_id: requested_thread_id,
|
||||
request_id,
|
||||
}) => {
|
||||
assert_eq!(requested_thread_id, thread_id);
|
||||
request_id
|
||||
}
|
||||
event => panic!("expected /status to request thread usage, got {event:?}"),
|
||||
};
|
||||
drain_insert_history(&mut rx);
|
||||
assert!(chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: Some(1_820_000),
|
||||
groups: vec![
|
||||
ThreadUsageBreakdownGroup {
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
speed: Some("fast".to_string()),
|
||||
estimated_usage_credits_micros: 40_000_000,
|
||||
net_new_input_tokens: Some(80),
|
||||
cached_input_tokens: Some(20),
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(40),
|
||||
total_tokens: Some(140),
|
||||
},
|
||||
ThreadUsageBreakdownGroup {
|
||||
model: Some("gpt-5-mini".to_string()),
|
||||
reasoning_effort: Some("medium".to_string()),
|
||||
speed: Some("standard".to_string()),
|
||||
estimated_usage_credits_micros: 10_000_000,
|
||||
net_new_input_tokens: Some(15),
|
||||
cached_input_tokens: Some(5),
|
||||
input_tokens: Some(20),
|
||||
output_tokens: Some(10),
|
||||
total_tokens: Some(30),
|
||||
},
|
||||
],
|
||||
})),
|
||||
));
|
||||
|
||||
let settled = lines_to_single_string(&first_cell.display_lines(/*width*/ 90));
|
||||
assert!(settled.contains("50 credits · ~$1.82"));
|
||||
assert!(settled.contains("GPT-5.4 80%, GPT-5 Mini 20%"));
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
event => panic!("expected /status output with cached thread usage, got {event:?}"),
|
||||
};
|
||||
let rendered =
|
||||
normalize_snapshot_paths(lines_to_single_string(&cell.display_lines(/*width*/ 90)));
|
||||
assert!(rendered.contains("50 credits · ~$1.82"));
|
||||
assert!(rendered.contains("GPT-5.4 80%, GPT-5 Mini 20%"));
|
||||
assert!(rendered.contains("Medium 20%, High 80%"));
|
||||
assert!(rendered.contains("Fast mode 80%, Standard 20%"));
|
||||
assert!(rendered.contains("120 input (25 cached) + 50 output"));
|
||||
assert!(
|
||||
rendered.find("Limits:") < rendered.find("Thread usage:"),
|
||||
"account limits should appear above the thread usage group: {rendered}"
|
||||
);
|
||||
let thread_usage = rendered
|
||||
.lines()
|
||||
.skip_while(|line| !line.contains("Thread usage:"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_chatwidget_snapshot!("status_command_estimated_thread_usage", thread_usage);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_omits_thread_usage_for_non_cbp_enterprise_plans() {
|
||||
for plan_type in [
|
||||
Some(PlanType::Enterprise),
|
||||
Some(PlanType::Ent26),
|
||||
Some(PlanType::Pro),
|
||||
Some(PlanType::Edu),
|
||||
Some(PlanType::SelfServeBusinessUsageBased),
|
||||
Some(PlanType::Unknown),
|
||||
None,
|
||||
] {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.plan_type = plan_type;
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
|
||||
let rendered = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
||||
lines_to_single_string(&cell.display_lines(/*width*/ 90))
|
||||
}
|
||||
event => panic!("expected immediate /status output, got {event:?}"),
|
||||
};
|
||||
assert!(!rendered.contains("Thread usage"));
|
||||
assert!(
|
||||
!std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.any(|event| matches!(event, AppEvent::RefreshThreadUsage { .. })),
|
||||
"unsupported plan {plan_type:?} must not request thread usage",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_stays_visible_and_updates_after_thread_usage_retry() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
event => panic!("expected immediate /status output, got {event:?}"),
|
||||
};
|
||||
assert!(!lines_to_single_string(&cell.display_lines(/*width*/ 90)).contains("Thread usage"));
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected thread usage request, got {event:?}"),
|
||||
};
|
||||
while rx.try_recv().is_ok() {}
|
||||
assert!(chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Err("thread usage temporarily unavailable".to_string()),
|
||||
));
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let retry_cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
event => panic!("expected another immediate /status output, got {event:?}"),
|
||||
};
|
||||
let retry_request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected thread usage retry, got {event:?}"),
|
||||
};
|
||||
assert!(chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
retry_request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: Some(1_820_000),
|
||||
groups: Vec::new(),
|
||||
})),
|
||||
));
|
||||
|
||||
for status_cell in [&cell, &retry_cell] {
|
||||
assert!(
|
||||
lines_to_single_string(&status_cell.display_lines(/*width*/ 90))
|
||||
.contains("50 credits · ~$1.82")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_renders_credits_and_breakdowns_without_usd_estimate() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let first_cell = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
|
||||
event => panic!("expected immediate /status output, got {event:?}"),
|
||||
};
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected thread usage request, got {event:?}"),
|
||||
};
|
||||
drain_insert_history(&mut rx);
|
||||
assert!(chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: None,
|
||||
groups: vec![ThreadUsageBreakdownGroup {
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
speed: Some("fast".to_string()),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
net_new_input_tokens: Some(80),
|
||||
cached_input_tokens: Some(20),
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(40),
|
||||
total_tokens: Some(140),
|
||||
}],
|
||||
})),
|
||||
));
|
||||
|
||||
let first_rendered = lines_to_single_string(&first_cell.display_lines(/*width*/ 90));
|
||||
assert!(first_rendered.contains("50 credits"));
|
||||
assert!(first_rendered.contains("GPT-5.4 100%"));
|
||||
assert!(!first_rendered.contains("~$"));
|
||||
drain_insert_history(&mut rx);
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let rendered = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
||||
lines_to_single_string(&cell.display_lines(/*width*/ 90))
|
||||
}
|
||||
event => panic!("expected cached credits-only /status output, got {event:?}"),
|
||||
};
|
||||
assert!(rendered.contains("50 credits"));
|
||||
assert!(rendered.contains("GPT-5.4 100%"));
|
||||
assert!(rendered.contains("High 100%"));
|
||||
assert!(rendered.contains("Fast mode 100%"));
|
||||
assert!(rendered.contains("100 input (20 cached) + 40 output"));
|
||||
assert!(!rendered.contains("~$"));
|
||||
let thread_usage = rendered
|
||||
.lines()
|
||||
.skip_while(|line| !line.contains("Thread usage:"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_chatwidget_snapshot!(
|
||||
"status_command_estimated_thread_usage_without_usd",
|
||||
thread_usage
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_drops_stale_usd_when_updated_usage_has_only_credits() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected initial thread usage request, got {event:?}"),
|
||||
};
|
||||
drain_insert_history(&mut rx);
|
||||
assert!(chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 40_000_000,
|
||||
estimated_usage_usd_micros: Some(1_820_000),
|
||||
groups: Vec::new(),
|
||||
})),
|
||||
));
|
||||
|
||||
drain_insert_history(&mut rx);
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let cached = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
||||
lines_to_single_string(&cell.display_lines(/*width*/ 90))
|
||||
}
|
||||
event => panic!("expected cached /status output, got {event:?}"),
|
||||
};
|
||||
assert!(cached.contains("40 credits · ~$1.82"));
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected refreshed thread usage request, got {event:?}"),
|
||||
};
|
||||
drain_insert_history(&mut rx);
|
||||
assert!(chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: None,
|
||||
groups: Vec::new(),
|
||||
})),
|
||||
));
|
||||
|
||||
drain_insert_history(&mut rx);
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
let refreshed = match rx.try_recv() {
|
||||
Ok(AppEvent::InsertHistoryCell(cell)) => {
|
||||
lines_to_single_string(&cell.display_lines(/*width*/ 90))
|
||||
}
|
||||
event => panic!("expected credits-only /status output, got {event:?}"),
|
||||
};
|
||||
assert!(refreshed.contains("50 credits"));
|
||||
assert!(!refreshed.contains("~$1.82"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_requests_thread_usage_for_remote_connection_metadata() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None,
|
||||
Some(PlanType::Business),
|
||||
/*has_chatgpt_account*/ false,
|
||||
/*has_codex_backend_auth*/ true,
|
||||
);
|
||||
chat.remote_connection = Some(crate::status::remote_connection::RemoteConnectionStatus {
|
||||
address: "wss://remote.example.com".to_string(),
|
||||
version: "v1.0.0".to_string(),
|
||||
});
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshThreadUsage { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_requests_thread_usage_with_backend_only_authentication() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None,
|
||||
Some(PlanType::Business),
|
||||
/*has_chatgpt_account*/ false,
|
||||
/*has_codex_backend_auth*/ true,
|
||||
);
|
||||
assert!(!chat.has_chatgpt_account());
|
||||
assert!(chat.has_codex_backend_auth());
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshThreadUsage { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_remains_visible_when_account_changes_during_usage_refresh() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected thread usage request, got {event:?}"),
|
||||
};
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None, /*plan_type*/ None,
|
||||
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false,
|
||||
);
|
||||
|
||||
assert!(!chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: Some(1_820_000),
|
||||
groups: Vec::new(),
|
||||
})),
|
||||
));
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_command_remains_visible_when_thread_changes_during_usage_refresh() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(thread_id);
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Status);
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_)));
|
||||
let request_id = match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id,
|
||||
event => panic!("expected thread usage request, got {event:?}"),
|
||||
};
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.clear_thread_usage_state();
|
||||
|
||||
assert!(!chat.finish_thread_usage_refresh(
|
||||
thread_id,
|
||||
request_id,
|
||||
Ok(ThreadUsageOutcome::Available(ThreadUsage {
|
||||
thread_id: thread_id.to_string(),
|
||||
estimated_usage_credits_micros: 50_000_000,
|
||||
estimated_usage_usd_micros: Some(1_820_000),
|
||||
groups: Vec::new(),
|
||||
})),
|
||||
));
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Demand-driven estimated-cost state for the currently visible enterprise thread.
|
||||
|
||||
use super::AppEvent;
|
||||
use super::ChatWidget;
|
||||
use super::PlanType;
|
||||
use super::ThreadId;
|
||||
use crate::status::StatusHistoryHandle;
|
||||
use codex_app_server_protocol::ThreadUsage;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ThreadUsageOutcome {
|
||||
Available(ThreadUsage),
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct ThreadUsageState {
|
||||
thread_id: Option<ThreadId>,
|
||||
estimate: Option<ThreadUsage>,
|
||||
pending_request_id: Option<u64>,
|
||||
next_request_id: u64,
|
||||
feature_disabled: bool,
|
||||
status_history_handles: Vec<StatusHistoryHandle>,
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn clear_thread_usage_state(&mut self) {
|
||||
let next_request_id = self.thread_usage.next_request_id;
|
||||
self.thread_usage = ThreadUsageState {
|
||||
next_request_id,
|
||||
..ThreadUsageState::default()
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) fn request_thread_usage_for_status(&mut self, handle: StatusHistoryHandle) {
|
||||
if !self.thread_usage_is_available() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(thread_id) = self.thread_id else {
|
||||
return;
|
||||
};
|
||||
if self.thread_usage.thread_id != Some(thread_id) {
|
||||
self.clear_thread_usage_state();
|
||||
self.thread_usage.thread_id = Some(thread_id);
|
||||
}
|
||||
|
||||
self.thread_usage.status_history_handles.push(handle);
|
||||
self.request_thread_usage();
|
||||
}
|
||||
|
||||
pub(crate) fn finish_thread_usage_refresh(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
request_id: u64,
|
||||
result: Result<ThreadUsageOutcome, String>,
|
||||
) -> bool {
|
||||
if self.thread_id != Some(thread_id)
|
||||
|| self.thread_usage.thread_id != Some(thread_id)
|
||||
|| self.thread_usage.pending_request_id != Some(request_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.thread_usage.pending_request_id = None;
|
||||
let mut usage_updated = false;
|
||||
match result {
|
||||
Ok(ThreadUsageOutcome::Disabled) => {
|
||||
self.thread_usage.feature_disabled = true;
|
||||
self.thread_usage.estimate = None;
|
||||
usage_updated = true;
|
||||
}
|
||||
Ok(ThreadUsageOutcome::Available(usage))
|
||||
if usage.thread_id != thread_id.to_string() =>
|
||||
{
|
||||
tracing::warn!(
|
||||
requested_thread_id = %thread_id,
|
||||
returned_thread_id = %usage.thread_id,
|
||||
"thread usage response referred to another thread"
|
||||
);
|
||||
}
|
||||
Ok(ThreadUsageOutcome::Available(usage)) => {
|
||||
self.thread_usage.estimate = Some(usage);
|
||||
usage_updated = true;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(error = %err, "failed to fetch estimated thread usage");
|
||||
}
|
||||
}
|
||||
if usage_updated && !self.thread_usage.status_history_handles.is_empty() {
|
||||
for handle in self.thread_usage.status_history_handles.drain(..) {
|
||||
handle.set_thread_usage(self.thread_usage.estimate.clone());
|
||||
}
|
||||
}
|
||||
self.request_redraw();
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn estimated_thread_usage(&self) -> Option<&ThreadUsage> {
|
||||
self.thread_usage.estimate.as_ref()
|
||||
}
|
||||
|
||||
fn request_thread_usage(&mut self) {
|
||||
let Some(thread_id) = self.thread_id else {
|
||||
return;
|
||||
};
|
||||
if !self.thread_usage_is_available() || self.thread_usage.pending_request_id.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let request_id = self.thread_usage.next_request_id;
|
||||
self.thread_usage.next_request_id =
|
||||
self.thread_usage.next_request_id.wrapping_add(/*rhs*/ 1);
|
||||
self.thread_usage.pending_request_id = Some(request_id);
|
||||
self.thread_usage.thread_id = Some(thread_id);
|
||||
self.app_event_tx.send(AppEvent::RefreshThreadUsage {
|
||||
thread_id,
|
||||
request_id,
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn thread_usage_is_available(&self) -> bool {
|
||||
self.has_codex_backend_auth
|
||||
&& !self.thread_usage.feature_disabled
|
||||
&& self.thread_id.is_some()
|
||||
&& matches!(
|
||||
self.plan_type,
|
||||
Some(
|
||||
PlanType::Business
|
||||
| PlanType::EnterpriseCbpUsageBased
|
||||
| PlanType::EnterpriseCbpAutomation
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,7 @@ fn summary_snapshot_left_aligns_and_splits_when_needed() {
|
||||
longest_streak_days: Some(54),
|
||||
},
|
||||
daily_usage_buckets: None,
|
||||
thread_usage: None,
|
||||
};
|
||||
let rendered = |width| {
|
||||
summary_lines(&response, graph_width(width))
|
||||
|
||||
@@ -21,6 +21,7 @@ fn loaded_state_freezes_chart_anchor_date_at_completion() {
|
||||
longest_streak_days: None,
|
||||
},
|
||||
daily_usage_buckets: None,
|
||||
thread_usage: None,
|
||||
}),
|
||||
today,
|
||||
);
|
||||
|
||||
@@ -129,36 +129,7 @@ where
|
||||
// - Non-URL lines also flow through adaptive wrapping; behavior is
|
||||
// equivalent to standard wrapping when no URL is present.
|
||||
let wrap_width = area.width.max(1) as usize;
|
||||
let mut wrapped = Vec::new();
|
||||
let mut wrapped_rows = 0usize;
|
||||
|
||||
for line in lines {
|
||||
let line_wrapped = match wrap_policy {
|
||||
HistoryLineWrapPolicy::Terminal => vec![line.clone()],
|
||||
HistoryLineWrapPolicy::PreWrap
|
||||
if line_contains_url_like(&line.line)
|
||||
&& !line_has_mixed_url_and_non_url_tokens(&line.line) =>
|
||||
{
|
||||
vec![line.clone()]
|
||||
}
|
||||
HistoryLineWrapPolicy::PreWrap => remap_wrapped_line(
|
||||
line,
|
||||
adaptive_wrap_line(
|
||||
&line.line,
|
||||
RtOptions::new(wrap_width)
|
||||
.subsequent_indent(leading_whitespace_prefix(&line.line)),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|line| line_to_static(&line))
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
wrapped_rows += line_wrapped
|
||||
.iter()
|
||||
.map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width))
|
||||
.sum::<usize>();
|
||||
wrapped.extend(line_wrapped);
|
||||
}
|
||||
let (wrapped, wrapped_rows) = wrap_history_hyperlink_lines(lines, wrap_width, wrap_policy);
|
||||
let wrapped_lines = wrapped_rows as u16;
|
||||
match mode {
|
||||
InsertHistoryMode::ZellijRaw => {
|
||||
@@ -256,6 +227,45 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn wrap_history_hyperlink_lines(
|
||||
lines: &[HyperlinkLine],
|
||||
wrap_width: usize,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> (Vec<HyperlinkLine>, usize) {
|
||||
let mut wrapped = Vec::new();
|
||||
let mut wrapped_rows = 0usize;
|
||||
|
||||
for line in lines {
|
||||
let line_wrapped = match wrap_policy {
|
||||
HistoryLineWrapPolicy::Terminal => vec![line.clone()],
|
||||
HistoryLineWrapPolicy::PreWrap
|
||||
if line_contains_url_like(&line.line)
|
||||
&& !line_has_mixed_url_and_non_url_tokens(&line.line) =>
|
||||
{
|
||||
vec![line.clone()]
|
||||
}
|
||||
HistoryLineWrapPolicy::PreWrap => remap_wrapped_line(
|
||||
line,
|
||||
adaptive_wrap_line(
|
||||
&line.line,
|
||||
RtOptions::new(wrap_width)
|
||||
.subsequent_indent(leading_whitespace_prefix(&line.line)),
|
||||
)
|
||||
.into_iter()
|
||||
.map(|line| line_to_static(&line))
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
wrapped_rows += line_wrapped
|
||||
.iter()
|
||||
.map(|wrapped_line| wrapped_line.width().max(/*other*/ 1).div_ceil(wrap_width))
|
||||
.sum::<usize>();
|
||||
wrapped.extend(line_wrapped);
|
||||
}
|
||||
|
||||
(wrapped, wrapped_rows)
|
||||
}
|
||||
|
||||
pub(crate) fn leading_whitespace_prefix(line: &Line<'_>) -> Line<'static> {
|
||||
let mut spans = Vec::new();
|
||||
for span in &line.spans {
|
||||
|
||||
@@ -47,6 +47,7 @@ use super::rate_limits::compose_rate_limit_data_many;
|
||||
use super::rate_limits::format_status_limit_summary;
|
||||
use super::rate_limits::render_status_limit_progress_bar;
|
||||
use super::remote_connection::RemoteConnectionStatus;
|
||||
use super::thread_usage::StatusThreadUsage;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_lines;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
@@ -79,9 +80,14 @@ struct StatusRateLimitState {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct StatusHistoryHandle {
|
||||
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
|
||||
thread_usage: StatusThreadUsage,
|
||||
}
|
||||
|
||||
impl StatusHistoryHandle {
|
||||
pub(crate) fn reserve_thread_usage_label_width(&self) {
|
||||
self.thread_usage.reserve_label_width();
|
||||
}
|
||||
|
||||
pub(crate) fn finish_rate_limit_refresh(
|
||||
&self,
|
||||
rate_limits: &[RateLimitSnapshotDisplay],
|
||||
@@ -100,6 +106,13 @@ impl StatusHistoryHandle {
|
||||
state.rate_limits = rate_limits;
|
||||
state.refreshing_rate_limits = false;
|
||||
}
|
||||
|
||||
pub(crate) fn set_thread_usage(
|
||||
&self,
|
||||
estimate: Option<codex_app_server_protocol::ThreadUsage>,
|
||||
) {
|
||||
self.thread_usage.set_estimate(estimate);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -119,6 +132,7 @@ struct StatusHistoryCell {
|
||||
forked_from: Option<String>,
|
||||
token_usage: StatusTokenUsageData,
|
||||
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
|
||||
thread_usage: StatusThreadUsage,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -350,6 +364,7 @@ impl StatusHistoryCell {
|
||||
refreshing_rate_limits,
|
||||
}));
|
||||
let agents_summary = Arc::new(RwLock::new(agents_summary));
|
||||
let thread_usage = StatusThreadUsage::default();
|
||||
|
||||
(
|
||||
Self {
|
||||
@@ -368,8 +383,12 @@ impl StatusHistoryCell {
|
||||
token_usage,
|
||||
agents_summary,
|
||||
rate_limit_state: rate_limit_state.clone(),
|
||||
thread_usage: thread_usage.clone(),
|
||||
},
|
||||
StatusHistoryHandle {
|
||||
rate_limit_state,
|
||||
thread_usage,
|
||||
},
|
||||
StatusHistoryHandle { rate_limit_state },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -770,8 +789,8 @@ impl HistoryCell for StatusHistoryCell {
|
||||
if self.token_usage.context_window.is_some() {
|
||||
push_label(&mut labels, &mut seen, "Context window");
|
||||
}
|
||||
|
||||
self.collect_rate_limit_labels(&rate_limit_state, &mut seen, &mut labels);
|
||||
self.thread_usage.push_labels(&mut labels, &mut seen);
|
||||
|
||||
let formatter = FieldFormatter::from_labels(labels.iter().map(String::as_str));
|
||||
let value_width = formatter.value_width(available_inner_width);
|
||||
@@ -860,6 +879,11 @@ impl HistoryCell for StatusHistoryCell {
|
||||
}
|
||||
|
||||
lines.extend(self.rate_limit_lines(&rate_limit_state, available_inner_width, &formatter));
|
||||
let thread_usage_lines = self.thread_usage.lines(&formatter, value_width);
|
||||
if !thread_usage_lines.is_empty() {
|
||||
lines.push(Line::from(Vec::<Span<'static>>::new()));
|
||||
lines.extend(thread_usage_lines);
|
||||
}
|
||||
|
||||
let content_width = lines.iter().map(line_width).max().unwrap_or(0);
|
||||
let inner_width = content_width.min(available_inner_width);
|
||||
|
||||
@@ -12,6 +12,7 @@ mod format;
|
||||
mod helpers;
|
||||
mod rate_limits;
|
||||
pub(crate) mod remote_connection;
|
||||
mod thread_usage;
|
||||
|
||||
pub(crate) use account::StatusAccountDisplay;
|
||||
pub(crate) use card::StatusHistoryHandle;
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Live-updating, thread-scoped billing details embedded in `/status` history cards.
|
||||
|
||||
use super::format::FieldFormatter;
|
||||
use super::format::push_label;
|
||||
use super::helpers::format_tokens_compact;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use codex_app_server_protocol::ThreadUsage;
|
||||
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
|
||||
use ratatui::prelude::Line;
|
||||
use ratatui::style::Stylize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
const REASONING_ORDER: [&str; 8] = [
|
||||
"None",
|
||||
"Minimal",
|
||||
"Light",
|
||||
"Medium",
|
||||
"High",
|
||||
"Extra High",
|
||||
"Max",
|
||||
"Ultra",
|
||||
];
|
||||
const SPEED_ORDER: [&str; 3] = ["Fast mode", "Ultrafast", "Standard"];
|
||||
const MODELS_LABEL: &str = " Models";
|
||||
const REASONING_LABEL: &str = " Reasoning";
|
||||
const SPEED_LABEL: &str = " Speed";
|
||||
const BILLED_TOKENS_LABEL: &str = " Billed tokens";
|
||||
|
||||
/// Shared state updates a committed `/status` card when its asynchronous estimate arrives.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct StatusThreadUsage {
|
||||
estimate: Arc<RwLock<Option<ThreadUsage>>>,
|
||||
reserve_label_width: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl StatusThreadUsage {
|
||||
pub(crate) fn reserve_label_width(&self) {
|
||||
self.reserve_label_width
|
||||
.store(/*val*/ true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn set_estimate(&self, estimate: Option<ThreadUsage>) {
|
||||
#[expect(clippy::expect_used)]
|
||||
let mut stored_estimate = self
|
||||
.estimate
|
||||
.write()
|
||||
.expect("status history thread-usage state poisoned");
|
||||
*stored_estimate = estimate;
|
||||
}
|
||||
|
||||
pub(crate) fn push_labels(&self, labels: &mut Vec<String>, seen: &mut BTreeSet<String>) {
|
||||
// Keep existing card rows stable when asynchronous billing details arrive. Reserving a
|
||||
// formatter label does not emit a loading placeholder or an empty billing row.
|
||||
if self.reserve_label_width.load(Ordering::Relaxed) {
|
||||
push_label(labels, seen, BILLED_TOKENS_LABEL);
|
||||
}
|
||||
#[expect(clippy::expect_used)]
|
||||
let stored_estimate = self
|
||||
.estimate
|
||||
.read()
|
||||
.expect("status history thread-usage state poisoned");
|
||||
let Some(estimate) = stored_estimate.as_ref() else {
|
||||
return;
|
||||
};
|
||||
push_label(labels, seen, "Thread usage");
|
||||
if !estimate.groups.is_empty() {
|
||||
push_label(labels, seen, MODELS_LABEL);
|
||||
push_label(labels, seen, REASONING_LABEL);
|
||||
push_label(labels, seen, SPEED_LABEL);
|
||||
if estimate
|
||||
.groups
|
||||
.iter()
|
||||
.any(|group| group.input_tokens.is_some() || group.output_tokens.is_some())
|
||||
{
|
||||
push_label(labels, seen, BILLED_TOKENS_LABEL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn lines(
|
||||
&self,
|
||||
formatter: &FieldFormatter,
|
||||
value_width: usize,
|
||||
) -> Vec<Line<'static>> {
|
||||
#[expect(clippy::expect_used)]
|
||||
let stored_estimate = self
|
||||
.estimate
|
||||
.read()
|
||||
.expect("status history thread-usage state poisoned");
|
||||
let Some(estimate) = stored_estimate.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let credits_micros = estimate.estimated_usage_credits_micros;
|
||||
let mut usage = vec![format!("{} credits", format_credit_micros(credits_micros)).into()];
|
||||
if let Some(cost) = estimate
|
||||
.estimated_usage_usd_micros
|
||||
.and_then(format_estimated_usd_micros)
|
||||
{
|
||||
usage.push(" · ".dim());
|
||||
usage.push(cost.into());
|
||||
}
|
||||
let mut lines = vec![formatter.line("Thread usage", usage)];
|
||||
|
||||
for (label, dimension) in [
|
||||
(MODELS_LABEL, BreakdownDimension::Model),
|
||||
(REASONING_LABEL, BreakdownDimension::Reasoning),
|
||||
(SPEED_LABEL, BreakdownDimension::Speed),
|
||||
] {
|
||||
let Some(value) = grouped_usage(&estimate.groups, dimension) else {
|
||||
continue;
|
||||
};
|
||||
let mut wrapped = word_wrap_lines(
|
||||
[Line::from(value)],
|
||||
RtOptions::new(value_width.max(/*other*/ 1)),
|
||||
)
|
||||
.into_iter();
|
||||
if let Some(first) = wrapped.next() {
|
||||
lines.push(formatter.line(label, first.spans));
|
||||
lines.extend(wrapped.map(|line| formatter.continuation(line.spans)));
|
||||
}
|
||||
}
|
||||
|
||||
let sum_tokens = |count: fn(&ThreadUsageBreakdownGroup) -> Option<i64>| {
|
||||
estimate
|
||||
.groups
|
||||
.iter()
|
||||
.try_fold(/*init*/ 0_i64, |total, group| {
|
||||
count(group).map(|tokens| total.saturating_add(tokens))
|
||||
})
|
||||
};
|
||||
let input_tokens = sum_tokens(|group| group.input_tokens);
|
||||
let cached_tokens = sum_tokens(|group| group.cached_input_tokens);
|
||||
let output_tokens = sum_tokens(|group| group.output_tokens);
|
||||
if !estimate.groups.is_empty() && (input_tokens.is_some() || output_tokens.is_some()) {
|
||||
let mut tokens = Vec::new();
|
||||
if let Some(input_tokens) = input_tokens {
|
||||
tokens.push(format!("{} input", format_tokens_compact(input_tokens)));
|
||||
if let Some(cached_tokens) = cached_tokens {
|
||||
tokens.push(format!("({} cached)", format_tokens_compact(cached_tokens)));
|
||||
}
|
||||
}
|
||||
if let Some(output_tokens) = output_tokens {
|
||||
if !tokens.is_empty() {
|
||||
tokens.push("+".to_string());
|
||||
}
|
||||
tokens.push(format!("{} output", format_tokens_compact(output_tokens)));
|
||||
}
|
||||
lines.push(formatter.line(BILLED_TOKENS_LABEL, vec![tokens.join(" ").into()]));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum BreakdownDimension {
|
||||
Model,
|
||||
Reasoning,
|
||||
Speed,
|
||||
}
|
||||
|
||||
fn grouped_usage(
|
||||
groups: &[ThreadUsageBreakdownGroup],
|
||||
dimension: BreakdownDimension,
|
||||
) -> Option<String> {
|
||||
let mut totals = BTreeMap::<String, i64>::new();
|
||||
for group in groups {
|
||||
if group.estimated_usage_credits_micros < 0 {
|
||||
continue;
|
||||
}
|
||||
let value = match dimension {
|
||||
BreakdownDimension::Model => group
|
||||
.model
|
||||
.as_deref()
|
||||
.map(format_model_display_name)
|
||||
.unwrap_or_else(|| "Other".to_string()),
|
||||
BreakdownDimension::Reasoning => match group.reasoning_effort.as_deref() {
|
||||
Some("none") => "None",
|
||||
Some("minimal") => "Minimal",
|
||||
Some("low") => "Light",
|
||||
Some("medium") => "Medium",
|
||||
Some("high") => "High",
|
||||
Some("xhigh") => "Extra High",
|
||||
Some("max") => "Max",
|
||||
Some("ultra") => "Ultra",
|
||||
Some(other) => other,
|
||||
None => "Other",
|
||||
}
|
||||
.to_string(),
|
||||
BreakdownDimension::Speed => match group.speed.as_deref() {
|
||||
Some("fast") => "Fast mode",
|
||||
Some("ultrafast") => "Ultrafast",
|
||||
Some("standard") => "Standard",
|
||||
Some(other) => other,
|
||||
None => "Other",
|
||||
}
|
||||
.to_string(),
|
||||
};
|
||||
let total = totals.entry(value).or_default();
|
||||
*total = total.saturating_add(group.estimated_usage_credits_micros);
|
||||
}
|
||||
if totals.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let total_credits_micros =
|
||||
totals.values().fold(
|
||||
/*init*/ 0_i64,
|
||||
|total, credits| total.saturating_add(*credits),
|
||||
);
|
||||
let mut totals = totals.into_iter().collect::<Vec<_>>();
|
||||
totals.sort_by(
|
||||
|(left_name, left_credits), (right_name, right_credits)| match dimension {
|
||||
BreakdownDimension::Model => right_credits
|
||||
.cmp(left_credits)
|
||||
.then_with(|| left_name.cmp(right_name)),
|
||||
BreakdownDimension::Reasoning => REASONING_ORDER
|
||||
.iter()
|
||||
.position(|value| value == left_name)
|
||||
.unwrap_or(usize::MAX)
|
||||
.cmp(
|
||||
&REASONING_ORDER
|
||||
.iter()
|
||||
.position(|value| value == right_name)
|
||||
.unwrap_or(usize::MAX),
|
||||
)
|
||||
.then_with(|| left_name.cmp(right_name)),
|
||||
BreakdownDimension::Speed => SPEED_ORDER
|
||||
.iter()
|
||||
.position(|value| value == left_name)
|
||||
.unwrap_or(usize::MAX)
|
||||
.cmp(
|
||||
&SPEED_ORDER
|
||||
.iter()
|
||||
.position(|value| value == right_name)
|
||||
.unwrap_or(usize::MAX),
|
||||
)
|
||||
.then_with(|| left_name.cmp(right_name)),
|
||||
},
|
||||
);
|
||||
Some(
|
||||
totals
|
||||
.into_iter()
|
||||
.map(|(name, credits)| {
|
||||
let percentage = if total_credits_micros <= 0 {
|
||||
0
|
||||
} else {
|
||||
(i128::from(credits) * 100 + i128::from(total_credits_micros) / 2)
|
||||
/ i128::from(total_credits_micros)
|
||||
};
|
||||
format!("{name} {percentage}%")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_model_display_name(model: &str) -> String {
|
||||
let Some((prefix, remainder)) = model.split_once('-') else {
|
||||
return model.to_string();
|
||||
};
|
||||
if !prefix.eq_ignore_ascii_case("gpt") || remainder.is_empty() {
|
||||
return model.to_string();
|
||||
}
|
||||
|
||||
let mut segments = remainder.split('-');
|
||||
let first = segments.next().unwrap_or_default();
|
||||
let mut display_name = format!("GPT-{first}");
|
||||
let numeric_model = first
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|character| character.is_ascii_digit());
|
||||
for segment in segments {
|
||||
display_name.push(if numeric_model { ' ' } else { '-' });
|
||||
if segment.eq_ignore_ascii_case("oai") {
|
||||
display_name.push_str("OAI");
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut characters = segment.chars();
|
||||
if let Some(first_character) = characters.next() {
|
||||
display_name.extend(first_character.to_uppercase());
|
||||
display_name.push_str(characters.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
display_name
|
||||
}
|
||||
|
||||
pub(crate) fn format_credit_micros(micros: i64) -> String {
|
||||
let micros = i128::from(micros.max(/*other*/ 0));
|
||||
let (unit_micros, suffix) = [
|
||||
(1_000_000_000_000_000_000_i128, "T"),
|
||||
(1_000_000_000_000_000_i128, "B"),
|
||||
(1_000_000_000_000_i128, "M"),
|
||||
(1_000_000_000_i128, "K"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|(unit, _)| micros >= *unit)
|
||||
.unwrap_or((1_000_000, ""));
|
||||
let tenths = (micros * 10 + unit_micros / 2) / unit_micros;
|
||||
let whole = tenths / 10;
|
||||
let fraction = tenths % 10;
|
||||
if fraction == 0 {
|
||||
format!("{whole}{suffix}")
|
||||
} else {
|
||||
format!("{whole}.{fraction}{suffix}")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_estimated_usd_micros(micros: i64) -> Option<String> {
|
||||
if micros < 0 {
|
||||
return None;
|
||||
}
|
||||
if micros > 0 && micros < 100 {
|
||||
return Some(format!("~$0.{micros:06}"));
|
||||
}
|
||||
if micros > 0 && micros < 10_000 {
|
||||
let ten_thousandths = micros.saturating_add(/*rhs*/ 50) / 100;
|
||||
return Some(format!("~$0.{ten_thousandths:04}"));
|
||||
}
|
||||
|
||||
let cents = micros.saturating_add(/*rhs*/ 5_000) / 10_000;
|
||||
Some(format!("~${}.{:02}", cents / 100, cents % 100))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thread_usage_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,70 @@
|
||||
use super::BreakdownDimension;
|
||||
use super::format_credit_micros;
|
||||
use super::format_model_display_name;
|
||||
use super::grouped_usage;
|
||||
use codex_app_server_protocol::ThreadUsageBreakdownGroup;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn thread_usage_breakdown_groups_aggregate_model_credit_percentages() {
|
||||
let group = ThreadUsageBreakdownGroup {
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
reasoning_effort: Some("high".to_string()),
|
||||
speed: Some("fast".to_string()),
|
||||
estimated_usage_credits_micros: 30_000_000,
|
||||
net_new_input_tokens: Some(60),
|
||||
cached_input_tokens: Some(10),
|
||||
input_tokens: Some(70),
|
||||
output_tokens: Some(20),
|
||||
total_tokens: Some(90),
|
||||
};
|
||||
let groups = vec![
|
||||
group.clone(),
|
||||
ThreadUsageBreakdownGroup {
|
||||
reasoning_effort: Some("medium".to_string()),
|
||||
speed: Some("standard".to_string()),
|
||||
estimated_usage_credits_micros: 10_000_000,
|
||||
..group.clone()
|
||||
},
|
||||
ThreadUsageBreakdownGroup {
|
||||
model: Some("gpt-5-mini".to_string()),
|
||||
reasoning_effort: Some("low".to_string()),
|
||||
speed: Some("standard".to_string()),
|
||||
estimated_usage_credits_micros: 10_000_000,
|
||||
..group
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
grouped_usage(&groups, BreakdownDimension::Model),
|
||||
Some("GPT-5.4 80%, GPT-5 Mini 20%".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
grouped_usage(&groups, BreakdownDimension::Reasoning),
|
||||
Some("Light 20%, Medium 20%, High 60%".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
grouped_usage(&groups, BreakdownDimension::Speed),
|
||||
Some("Fast mode 60%, Standard 40%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_usage_credit_formatting_rounds_to_one_decimal_place() {
|
||||
assert_eq!(format_credit_micros(/*micros*/ 0), "0");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 400_000), "0.4");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 5_200_000), "5.2");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 5_240_000), "5.2");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 5_250_000), "5.3");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 46_000_000), "46");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 1_240_000_000), "1.2K");
|
||||
assert_eq!(format_credit_micros(/*micros*/ 12_400_000_000), "12.4K");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_usage_model_names_match_desktop_display_names() {
|
||||
assert_eq!(format_model_display_name("gpt-5.4"), "GPT-5.4");
|
||||
assert_eq!(format_model_display_name("gpt-5-mini"), "GPT-5 Mini");
|
||||
assert_eq!(format_model_display_name("gpt-5.6-sol"), "GPT-5.6 Sol");
|
||||
assert_eq!(format_model_display_name("mewfour"), "mewfour");
|
||||
}
|
||||
@@ -25,9 +25,17 @@ pub struct VT100Backend {
|
||||
impl VT100Backend {
|
||||
/// Creates a new `TestBackend` with the specified width and height.
|
||||
pub fn new(width: u16, height: u16) -> Self {
|
||||
Self::with_scrollback(width, height, /*scrollback_len*/ 0)
|
||||
}
|
||||
|
||||
pub fn with_scrollback(width: u16, height: u16, scrollback_len: usize) -> Self {
|
||||
crossterm::style::force_color_output(true);
|
||||
Self {
|
||||
crossterm_backend: CrosstermBackend::new(vt100::Parser::new(height, width, 0)),
|
||||
crossterm_backend: CrosstermBackend::new(vt100::Parser::new(
|
||||
height,
|
||||
width,
|
||||
scrollback_len,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ use codex_config::types::NotificationMethod;
|
||||
mod event_stream;
|
||||
mod frame_rate_limiter;
|
||||
mod frame_requester;
|
||||
mod history_tail;
|
||||
#[cfg(unix)]
|
||||
mod job_control;
|
||||
mod keyboard_modes;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Update visible terminal-history tails without discarding terminal scrollback.
|
||||
|
||||
use super::Tui;
|
||||
use crate::custom_terminal::Terminal as CustomTerminal;
|
||||
use crate::insert_history::HistoryLineWrapPolicy;
|
||||
use crate::insert_history::InsertHistoryMode;
|
||||
use crate::insert_history::insert_history_hyperlink_lines_with_mode_and_wrap_policy;
|
||||
use crate::insert_history::wrap_history_hyperlink_lines;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use ratatui::backend::Backend;
|
||||
use ratatui::layout::Position;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
|
||||
impl Tui {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pending_history_lines_for_test(&self) -> Vec<HyperlinkLine> {
|
||||
self.pending_history_lines
|
||||
.iter()
|
||||
.flat_map(|batch| batch.lines.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn replace_visible_history_tail(
|
||||
&mut self,
|
||||
previous_lines: &[HyperlinkLine],
|
||||
replacement: &[HyperlinkLine],
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> io::Result<bool> {
|
||||
Self::flush_pending_history_lines(
|
||||
&mut self.terminal,
|
||||
&mut self.pending_history_lines,
|
||||
self.is_zellij,
|
||||
)?;
|
||||
let mode = if self.is_zellij && wrap_policy == HistoryLineWrapPolicy::Terminal {
|
||||
InsertHistoryMode::ZellijRaw
|
||||
} else {
|
||||
InsertHistoryMode::Standard
|
||||
};
|
||||
let replaced = replace_visible_terminal_history_tail(
|
||||
&mut self.terminal,
|
||||
previous_lines,
|
||||
replacement,
|
||||
mode,
|
||||
wrap_policy,
|
||||
)?;
|
||||
if replaced {
|
||||
self.frame_requester().schedule_frame();
|
||||
}
|
||||
Ok(replaced)
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_visible_terminal_history_tail<B>(
|
||||
terminal: &mut CustomTerminal<B>,
|
||||
previous_lines: &[HyperlinkLine],
|
||||
replacement: &[HyperlinkLine],
|
||||
mode: InsertHistoryMode,
|
||||
wrap_policy: HistoryLineWrapPolicy,
|
||||
) -> io::Result<bool>
|
||||
where
|
||||
B: Backend<Error = io::Error> + Write,
|
||||
{
|
||||
let mut viewport = terminal.viewport_area;
|
||||
let wrap_width = usize::from(viewport.width.max(/*other*/ 1));
|
||||
let (_, previous_rows) = wrap_history_hyperlink_lines(previous_lines, wrap_width, wrap_policy);
|
||||
let Ok(previous_rows) = u16::try_from(previous_rows) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if previous_rows == 0 || previous_rows > viewport.top() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
viewport.y -= previous_rows;
|
||||
terminal.clear_after_position(Position::new(/*x*/ 0, viewport.y))?;
|
||||
terminal.set_viewport_area(viewport);
|
||||
terminal.invalidate_viewport();
|
||||
insert_history_hyperlink_lines_with_mode_and_wrap_policy(
|
||||
terminal,
|
||||
replacement,
|
||||
mode,
|
||||
wrap_policy,
|
||||
)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "history_tail_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,144 @@
|
||||
use super::replace_visible_terminal_history_tail;
|
||||
use crate::custom_terminal::Terminal;
|
||||
use crate::insert_history::HistoryLineWrapPolicy;
|
||||
use crate::insert_history::InsertHistoryMode;
|
||||
use crate::insert_history::insert_history_lines;
|
||||
use crate::terminal_hyperlinks::plain_hyperlink_lines;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn replacing_visible_history_tail_preserves_existing_terminal_scrollback() {
|
||||
let width = 52;
|
||||
let height = 8;
|
||||
let backend = VT100Backend::with_scrollback(width, height, /*scrollback_len*/ 32);
|
||||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||||
for line in [
|
||||
"shell history that predates Codex",
|
||||
"shell output one",
|
||||
"shell output two",
|
||||
"shell output three",
|
||||
"shell output four",
|
||||
"shell output five",
|
||||
"shell output six",
|
||||
"shell output seven",
|
||||
] {
|
||||
writeln!(terminal.backend_mut(), "{line}\r").expect("seed shell history");
|
||||
}
|
||||
terminal.set_viewport_area(Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ height - 2,
|
||||
width,
|
||||
/*height*/ 2,
|
||||
));
|
||||
insert_history_lines(
|
||||
&mut terminal,
|
||||
vec![
|
||||
Line::from("/status"),
|
||||
Line::from("Account: business"),
|
||||
Line::from("old closing border"),
|
||||
],
|
||||
)
|
||||
.expect("insert initial status card");
|
||||
|
||||
let previous_lines = plain_hyperlink_lines(vec![Line::from("old closing border")]);
|
||||
let replacement = plain_hyperlink_lines(vec![
|
||||
Line::from("Thread usage: 50 credits"),
|
||||
Line::from("new closing border"),
|
||||
]);
|
||||
assert!(
|
||||
replace_visible_terminal_history_tail(
|
||||
&mut terminal,
|
||||
&previous_lines,
|
||||
&replacement,
|
||||
InsertHistoryMode::Standard,
|
||||
HistoryLineWrapPolicy::PreWrap,
|
||||
)
|
||||
.expect("replace status-card tail")
|
||||
);
|
||||
|
||||
let contents = terminal.backend().vt100().screen().contents();
|
||||
assert!(contents.contains("/status"), "{contents}");
|
||||
assert!(contents.contains("Account: business"), "{contents}");
|
||||
assert!(contents.contains("Thread usage: 50 credits"), "{contents}");
|
||||
assert!(!contents.contains("old closing border"), "{contents}");
|
||||
|
||||
let mut screen_with_scrollback = terminal.backend().vt100().screen().clone();
|
||||
screen_with_scrollback.set_scrollback(/*rows*/ usize::MAX);
|
||||
let scrollback = screen_with_scrollback.contents();
|
||||
assert!(
|
||||
scrollback.contains("shell history that predates Codex"),
|
||||
"existing terminal scrollback was discarded: {scrollback}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inaccessible_history_tail_is_preserved_for_non_destructive_append() {
|
||||
let width = 36;
|
||||
let height = 6;
|
||||
let backend = VT100Backend::with_scrollback(width, height, /*scrollback_len*/ 16);
|
||||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||||
terminal.set_viewport_area(Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 1,
|
||||
width,
|
||||
/*height*/ height - 1,
|
||||
));
|
||||
let before = terminal.backend().vt100().screen().contents();
|
||||
let previous_lines =
|
||||
plain_hyperlink_lines(vec![Line::from("first line"), Line::from("second line")]);
|
||||
let replacement = plain_hyperlink_lines(vec![Line::from("Thread usage: 50 credits")]);
|
||||
|
||||
assert!(
|
||||
!replace_visible_terminal_history_tail(
|
||||
&mut terminal,
|
||||
&previous_lines,
|
||||
&replacement,
|
||||
InsertHistoryMode::Standard,
|
||||
HistoryLineWrapPolicy::PreWrap,
|
||||
)
|
||||
.expect("leave inaccessible history untouched")
|
||||
);
|
||||
assert_eq!(terminal.backend().vt100().screen().contents(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_soft_wrapped_history_counts_physical_terminal_rows() {
|
||||
let width = 12;
|
||||
let height = 8;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
||||
terminal.set_viewport_area(Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ height - 2,
|
||||
width,
|
||||
/*height*/ 2,
|
||||
));
|
||||
let previous_lines = plain_hyperlink_lines(vec![Line::from("old-long-line-spanning-two-rows")]);
|
||||
crate::insert_history::insert_history_hyperlink_lines_with_mode_and_wrap_policy(
|
||||
&mut terminal,
|
||||
&previous_lines,
|
||||
InsertHistoryMode::Standard,
|
||||
HistoryLineWrapPolicy::Terminal,
|
||||
)
|
||||
.expect("insert soft-wrapped history");
|
||||
let replacement = plain_hyperlink_lines(vec![Line::from("new billing")]);
|
||||
|
||||
assert!(
|
||||
replace_visible_terminal_history_tail(
|
||||
&mut terminal,
|
||||
&previous_lines,
|
||||
&replacement,
|
||||
InsertHistoryMode::Standard,
|
||||
HistoryLineWrapPolicy::Terminal,
|
||||
)
|
||||
.expect("replace physically wrapped rows")
|
||||
);
|
||||
let contents = terminal.backend().vt100().screen().contents();
|
||||
assert!(contents.contains("new billing"), "{contents}");
|
||||
assert!(!contents.contains("old-long"), "{contents}");
|
||||
assert!(!contents.contains("spanning"), "{contents}");
|
||||
}
|
||||
Reference in New Issue
Block a user