+ {current === "tokens" && (
+
+ )}
{current === "cost" && (
string;
+}
+
+/**
+ * Tokens chart mode (upstream 0.50.0 #2930): exact local token totals per
+ * day, defaulting Codex to this view. An incomplete backfill shows a
+ * "Refreshing" marker instead of silently missing days.
+ */
+export function TokensHistoryChart({
+ data,
+ title,
+ ariaLabel,
+ providerId,
+ animations,
+ emptyMessage,
+ incomplete,
+ t,
+}: Props) {
+ const recent = data.slice(-30);
+ const points = recent.map((p) => ({ label: p.date, value: p.tokens }));
+ return (
+
+
+ {title}
+ {incomplete && (
+
+ {t("DetailChartRefreshing")}
+
+ )}
+
+
Intl.NumberFormat().format(v)}
+ animations={animations}
+ emptyMessage={emptyMessage}
+ />
+
+ );
+}
diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts
index e0bbe1bde5..cb3019d795 100644
--- a/apps/desktop-tauri/src/types/bridge.ts
+++ b/apps/desktop-tauri/src/types/bridge.ts
@@ -593,6 +593,12 @@ export interface DailyCostPoint {
value: number;
}
+/** Exact local token totals per day (upstream 0.50.0 #2930). */
+export interface DailyTokenPoint {
+ date: string;
+ tokens: number;
+}
+
export interface ServiceUsagePoint {
service: string;
creditsUsed: number;
@@ -620,6 +626,8 @@ export interface ProviderChartData {
creditsHistory: DailyCostPoint[];
usageBreakdown: DailyUsageBreakdown[];
localUsage: ProviderLocalUsageSummary | null;
+ tokensHistory: DailyTokenPoint[];
+ tokensIncomplete: boolean;
}
// ── Token account types ──────────────────────────────────────────────
diff --git a/rust/src/cost_scanner.rs b/rust/src/cost_scanner.rs
index 3cd1e458e9..74c00b3000 100755
--- a/rust/src/cost_scanner.rs
+++ b/rust/src/cost_scanner.rs
@@ -1008,6 +1008,101 @@ pub fn get_daily_cost_history(provider: &str, days: u32) -> Vec<(String, f64)> {
result
}
+/// Daily token totals (input + output) for the Tokens chart mode, plus
+/// whether local history looks incomplete at the old edge of the window
+/// (Codex backfill still in progress → the chart shows a "Refreshing"
+/// marker; upstream 0.50.0 #2930).
+pub fn get_daily_token_history(provider: &str, days: u32) -> (Vec<(String, u64)>, bool) {
+ let scanner = CostScanner::new(days);
+ let today = Local::now().date_naive();
+ let mut daily_tokens: HashMap = HashMap::new();
+ let mut covered_days: HashSet = HashSet::new();
+
+ // Initialize all days with 0
+ for days_ago in 0..days {
+ let date = today - Duration::days(days_ago as i64);
+ let date_str = date.format("%Y-%m-%d").to_string();
+ daily_tokens.insert(date_str, 0);
+ }
+
+ match provider {
+ "codex" => {
+ // Warm/refresh the disk cache, then read exact local token totals
+ // from packed days through the same summary path the cost chart
+ // uses.
+ let _ = scanner.scan_codex();
+ let cache = JsonlScanner::load_cache(ProviderId::Codex, scanner.cache_root.as_deref());
+ for (day_key, models) in &cache.days {
+ if !daily_tokens.contains_key(day_key) {
+ continue;
+ }
+ let Some(day) = CostUsageDayRange::parse_day_key(day_key) else {
+ continue;
+ };
+ let day_range = CostUsageDayRange::new(day, day);
+ let mut one_day = HashMap::new();
+ one_day.insert(day_key.clone(), models.clone());
+ let mut scratch = CostSummary::default();
+ add_codex_days_map_to_summary(&mut scratch, &one_day, &day_range);
+ if let Some(slot) = daily_tokens.get_mut(day_key) {
+ *slot = scratch.input_tokens + scratch.output_tokens;
+ }
+ covered_days.insert(day_key.clone());
+ }
+ }
+ "claude" => {
+ // Per-day token breakdown from the same de-duplicated record walk
+ // as the cost chart. The full walk is authoritative, so the
+ // Refreshing marker never applies here.
+ let projects_dir = scanner.get_claude_projects_dir();
+ if projects_dir.exists() {
+ let cutoff = Utc::now() - Duration::days(days as i64);
+ let mut seen = HashSet::new();
+ let mut handle_file = |path: &Path| {
+ for_each_claude_usage_record(path, &cutoff, &mut seen, None, |record| {
+ add_claude_record_to_daily_tokens(&mut daily_tokens, record);
+ });
+ };
+ scanner.walk_claude_files(&projects_dir, &cutoff, None, &mut handle_file);
+ }
+ }
+ _ => {}
+ }
+
+ // Convert to sorted vector
+ let mut result: Vec<(String, u64)> = daily_tokens.into_iter().collect();
+ result.sort_by(|a, b| a.0.cmp(&b.0));
+
+ // Codex only: the bounded catch-up may not have reached the requested
+ // depth yet. Incomplete = history exists but the oldest quarter of the
+ // window has no scanned day.
+ let incomplete = provider == "codex"
+ && !covered_days.is_empty()
+ && covered_days.len() < days as usize
+ && result[..(result.len() / 4).max(1)]
+ .iter()
+ .any(|(date, _)| !covered_days.contains(date));
+
+ (result, incomplete)
+}
+
+fn add_claude_record_to_daily_tokens(
+ daily_tokens: &mut HashMap,
+ record: &ClaudeUsageRecord,
+) {
+ let Some(timestamp) = record.timestamp else {
+ return;
+ };
+ let date_str = timestamp
+ .with_timezone(&Local)
+ .date_naive()
+ .format("%Y-%m-%d")
+ .to_string();
+ if let Some(slot) = daily_tokens.get_mut(&date_str) {
+ *slot += record.input + record.output;
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/rust/src/locale.rs b/rust/src/locale.rs
index 423bfb71b8..90a5573d6c 100644
--- a/rust/src/locale.rs
+++ b/rust/src/locale.rs
@@ -784,6 +784,8 @@ locale_keys! {
DetailCostBalance,
DetailCostResets,
DetailChartCost,
+ DetailChartTokens,
+ DetailChartRefreshing,
DetailChartCredits,
DetailChartUsageBreakdown,
DetailChartEmpty,
diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl
index cb0ab1ddbd..5788955e17 100644
--- a/rust/src/locale/en-US.ftl
+++ b/rust/src/locale/en-US.ftl
@@ -512,6 +512,8 @@ DetailCostRemaining = Remaining
DetailCostBalance = Balance
DetailCostResets = Resets
DetailChartCost = Cost (30 days)
+DetailChartTokens = Tokens (30 days)
+DetailChartRefreshing = Refreshing…
DetailChartCredits = Credits used (30 days)
DetailChartUsageBreakdown = Usage by service (30 days)
DetailChartEmpty = No chart data yet.
diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl
index 88f5cc2e08..d6c86feff6 100644
--- a/rust/src/locale/es-MX.ftl
+++ b/rust/src/locale/es-MX.ftl
@@ -511,6 +511,8 @@ DetailCostRemaining = Restante
DetailCostBalance = Saldo
DetailCostResets = Reinicia
DetailChartCost = Costo (30 días)
+DetailChartTokens = Tokens (30 días)
+DetailChartRefreshing = Actualizando…
DetailChartCredits = Créditos usados (30 días)
DetailChartUsageBreakdown = Uso por servicio (30 días)
DetailChartEmpty = Sin datos de gráfico aún.
diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl
index d319fed896..5c9d697989 100644
--- a/rust/src/locale/ja-JP.ftl
+++ b/rust/src/locale/ja-JP.ftl
@@ -493,6 +493,8 @@ DetailCostRemaining = 残り
DetailCostBalance = 残高
DetailCostResets = リセット
DetailChartCost = コスト(30日間)
+DetailChartTokens = トークン(30日間)
+DetailChartRefreshing = 更新中…
DetailChartCredits = 使用クレジット(30日間)
DetailChartUsageBreakdown = サービス別使用量(30日間)
DetailChartEmpty = まだチャートデータはありません。
diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl
index 7f71c4d019..fb197295a2 100644
--- a/rust/src/locale/ko-KR.ftl
+++ b/rust/src/locale/ko-KR.ftl
@@ -498,6 +498,8 @@ DetailCostRemaining = 남음
DetailCostBalance = 잔액
DetailCostResets = 초기화
DetailChartCost = 비용 (30일)
+DetailChartTokens = 토큰 (30일)
+DetailChartRefreshing = 새로 고치는 중…
DetailChartCredits = 사용 크레딧 (30일)
DetailChartUsageBreakdown = 서비스별 사용량 (30일)
DetailChartEmpty = 아직 차트 데이터가 없습니다.
diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl
index 97d879d7ea..f1305358ba 100644
--- a/rust/src/locale/ru-RU.ftl
+++ b/rust/src/locale/ru-RU.ftl
@@ -477,6 +477,8 @@ DetailCostRemaining = Осталось
DetailCostBalance = Баланс
DetailCostResets = Сбрасывает
DetailChartCost = Стоимость (30 дней)
+DetailChartTokens = Токены (30 дней)
+DetailChartRefreshing = Обновление…
DetailChartCredits = Использовано кредитов (30 дней)
DetailChartUsageBreakdown = Использование службой (30 дней)
DetailChartEmpty = Данных диаграммы пока нет.
diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl
index 3b866b9e56..024c406b96 100644
--- a/rust/src/locale/zh-CN.ftl
+++ b/rust/src/locale/zh-CN.ftl
@@ -492,6 +492,8 @@ DetailCostRemaining = 剩余
DetailCostBalance = 余额
DetailCostResets = 重置
DetailChartCost = 费用(30 天)
+DetailChartTokens = Token(30 天)
+DetailChartRefreshing = 刷新中…
DetailChartCredits = 已用额度(30 天)
DetailChartUsageBreakdown = 按服务划分的用量(30 天)
DetailChartEmpty = 暂无图表数据。
diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl
index 22c4142d3b..a4ccde3ff2 100644
--- a/rust/src/locale/zh-TW.ftl
+++ b/rust/src/locale/zh-TW.ftl
@@ -492,6 +492,8 @@ DetailCostRemaining = 剩餘
DetailCostBalance = 餘額
DetailCostResets = 重置
DetailChartCost = 費用(30 天)
+DetailChartTokens = Token(30 天)
+DetailChartRefreshing = 重新整理中…
DetailChartCredits = 已用額度(30 天)
DetailChartUsageBreakdown = 按服務劃分的用量(30 天)
DetailChartEmpty = 暫無圖表資料。
diff --git a/rust/src/providers/codex/api.rs b/rust/src/providers/codex/api.rs
index bc7c730972..828de5308d 100755
--- a/rust/src/providers/codex/api.rs
+++ b/rust/src/providers/codex/api.rs
@@ -147,6 +147,16 @@ impl CodexApi {
let auth_path = self.get_auth_path();
if !auth_path.exists() {
+ // Upstream 0.50.0 #2679: when the CLI targets Amazon Bedrock or
+ // another custom backend without ChatGPT auth, sign-in guidance
+ // is wrong — rate limits simply are not available there.
+ if self.uses_custom_backend() {
+ return Err(ProviderError::NotInstalled(
+ "Codex uses a custom backend (chatgpt_base_url / model_provider) without \
+ ChatGPT auth. ChatGPT rate limits are unavailable for this setup."
+ .to_string(),
+ ));
+ }
return Err(ProviderError::NotInstalled(
"Codex auth.json not found. Run `codex login` in a terminal to sign in."
.to_string(),
@@ -271,6 +281,15 @@ impl CodexApi {
DEFAULT_BASE_URL.to_string()
}
+ /// Whether config.toml points the CLI at a backend that does not
+ /// authenticate against ChatGPT (Bedrock / other custom providers).
+ fn uses_custom_backend(&self) -> bool {
+ let Ok(content) = std::fs::read_to_string(self.codex_dir().join("config.toml")) else {
+ return false;
+ };
+ parse_chatgpt_base_url(&content).is_some() || config_uses_non_chatgpt_provider(&content)
+ }
+
fn build_result_from_json(
&self,
json: &serde_json::Value,
@@ -974,6 +993,21 @@ fn format_reset_countdown(reset_at: Option>) -> Option {
}
}
+/// Whether config.toml selects a non-ChatGPT model provider (e.g. Bedrock),
+/// meaning the CLI never authenticates against ChatGPT.
+fn config_uses_non_chatgpt_provider(config_content: &str) -> bool {
+ config_content.lines().any(|line| {
+ let Some((key, value)) = line.trim().split_once('=') else {
+ return false;
+ };
+ if !key.trim().eq_ignore_ascii_case("model_provider") {
+ return false;
+ }
+ let provider = value.trim().trim_matches('"').trim_matches('\'');
+ !provider.is_empty() && !provider.eq_ignore_ascii_case("openai")
+ })
+}
+
fn parse_chatgpt_base_url(config_content: &str) -> Option {
for line in config_content.lines() {
// Skip comments
@@ -1035,6 +1069,24 @@ mod tests {
use super::*;
use serde_json::json;
+ #[test]
+ fn non_chatgpt_model_provider_is_detected_for_guidance() {
+ // Upstream 0.50.0 #2679: Bedrock and other custom backends get
+ // rate-limit guidance instead of login instructions.
+ assert!(config_uses_non_chatgpt_provider(
+ "model_provider = \"bedrock\"\n"
+ ));
+ assert!(config_uses_non_chatgpt_provider(
+ "# relay\nmodel_provider = 'ollama'"
+ ));
+ assert!(!config_uses_non_chatgpt_provider(
+ "model_provider = \"openai\""
+ ));
+ assert!(!config_uses_non_chatgpt_provider(
+ "model = \"gpt-5\"\napproval_policy = \"never\""
+ ));
+ }
+
#[test]
fn parses_codex_credentials_without_retaining_refresh_token() {
let credentials = CodexApi::parse_credentials_json(
diff --git a/rust/src/providers/cursor/app_auth.rs b/rust/src/providers/cursor/app_auth.rs
new file mode 100644
index 0000000000..a123ebe68b
--- /dev/null
+++ b/rust/src/providers/cursor/app_auth.rs
@@ -0,0 +1,220 @@
+//! Cursor desktop app auth session (upstream 0.50.0 #2398).
+//!
+//! Reads Cursor's own read-only local session database
+//! (`%APPDATA%\Cursor\User\globalStorage\state.vscdb`) and rebuilds the
+//! `WorkosCursorSessionToken` cookie from the stored access token, so
+//! Automatic mode prefers the signed-in app over browser cookies. The
+//! database is only ever opened read-only; an idle WAL database whose
+//! sidecars vanished is retried in SQLite immutable mode (never while a WAL
+//! exists — that would ignore live uncheckpointed Cursor state).
+
+use rusqlite::OpenFlags;
+
+/// Default `state.vscdb` location. Windows: `%APPDATA%\Cursor\…`; the
+/// upstream macOS/Linux layouts differ per OS.
+pub fn app_auth_db_path() -> Option {
+ let base = dirs::config_dir()?;
+ Some(
+ base.join("Cursor")
+ .join("User")
+ .join("globalStorage")
+ .join("state.vscdb"),
+ )
+}
+
+/// Read the stored `cursorAuth/accessToken` from Cursor's app database.
+pub fn load_app_auth_access_token() -> Option {
+ let db_path = app_auth_db_path()?;
+ if !db_path.exists() {
+ return None;
+ }
+ match read_item_table_value(&db_path, "cursorAuth/accessToken", false) {
+ Ok(value) => value,
+ Err(err) => {
+ // Immutable retry only when both WAL sidecars are gone — an idle
+ // WAL database can retain WAL mode in its header after the
+ // sidecars disappear, and immutable mode reads the main file
+ // without recreating them.
+ let wal_missing = !wal_sidecar(&db_path).exists() && !shm_sidecar(&db_path).exists();
+ if !wal_missing {
+ tracing::debug!("Cursor app auth read failed: {err}");
+ return None;
+ }
+ read_item_table_value(&db_path, "cursorAuth/accessToken", true)
+ .ok()
+ .flatten()
+ .or_else(|| {
+ tracing::debug!("Cursor app auth immutable read failed: {err}");
+ None
+ })
+ }
+ }
+}
+
+fn wal_sidecar(db_path: &std::path::Path) -> std::path::PathBuf {
+ let mut name = db_path.as_os_str().to_os_string();
+ name.push("-wal");
+ std::path::PathBuf::from(name)
+}
+
+fn shm_sidecar(db_path: &std::path::Path) -> std::path::PathBuf {
+ let mut name = db_path.as_os_str().to_os_string();
+ name.push("-shm");
+ std::path::PathBuf::from(name)
+}
+
+/// Rebuild the `WorkosCursorSessionToken` cookie header from the app's
+/// access token (`{userID}::{token}`, URL-encoded separator).
+pub fn app_session_cookie_header(access_token: &str) -> Option {
+ let token = access_token.trim();
+ if token.is_empty() {
+ return None;
+ }
+ let user_id = crate::codex_accounts::api::jwt_payload(token)
+ .and_then(|payload| {
+ payload
+ .get("sub")
+ .and_then(|value| value.as_str())
+ .map(str::to_string)
+ })
+ .unwrap_or_default();
+ Some(format!("WorkosCursorSessionToken={user_id}%3A%3A{token}"))
+}
+
+/// Read one `ItemTable` value from the app database.
+fn read_item_table_value(
+ db_path: &std::path::Path,
+ key: &str,
+ immutable: bool,
+) -> rusqlite::Result