From b5c0acf3ed1deeb922448acccc173fc252af5628 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 15:01:14 +0700 Subject: [PATCH 1/2] Report Claude limits unavailable without quota windows A successful Claude Web answer with no five-hour or seven-day window and no spend now shows 'Limits not available' instead of 'No active 5h session'. Measured lanes, spend and prepaid balances keep their existing presentation. --- .../src/i18n/keyGroups/providerText.ts | 1 + .../src/lib/providerText.test.ts | 1 + apps/desktop-tauri/src/lib/providerText.ts | 1 + docs/PROVIDERS.md | 2 + rust/src/locale.rs | 1 + rust/src/locale/en-US.ftl | 1 + rust/src/locale/es-MX.ftl | 1 + rust/src/locale/ja-JP.ftl | 1 + rust/src/locale/ko-KR.ftl | 1 + rust/src/locale/pt-BR.ftl | 1 + rust/src/locale/ru-RU.ftl | 1 + rust/src/locale/tr-TR.ftl | 1 + rust/src/locale/uk-UA.ftl | 1 + rust/src/locale/zh-CN.ftl | 1 + rust/src/locale/zh-TW.ftl | 1 + rust/src/providers/claude/web_api.rs | 20 +++ rust/src/providers/claude/web_api_tests.rs | 117 +++++++++++++++++- 17 files changed, 152 insertions(+), 1 deletion(-) diff --git a/apps/desktop-tauri/src/i18n/keyGroups/providerText.ts b/apps/desktop-tauri/src/i18n/keyGroups/providerText.ts index ae2fd9d9f75..e7f0c6dd746 100644 --- a/apps/desktop-tauri/src/i18n/keyGroups/providerText.ts +++ b/apps/desktop-tauri/src/i18n/keyGroups/providerText.ts @@ -130,6 +130,7 @@ export const PROVIDER_TEXT_LOCALE_KEYS = [ "ProviderTextDailyUsage", "ProviderTextResetsDaily", "ProviderTextNoActiveSession", + "ProviderTextLimitsNotAvailable", "ProviderTextKeyQuota", "ProviderTextAccountBalance", "ProviderTextMonthlyBudget", diff --git a/apps/desktop-tauri/src/lib/providerText.test.ts b/apps/desktop-tauri/src/lib/providerText.test.ts index e4196443c9f..a655df3849f 100644 --- a/apps/desktop-tauri/src/lib/providerText.test.ts +++ b/apps/desktop-tauri/src/lib/providerText.test.ts @@ -77,6 +77,7 @@ describe("localizeProviderText", () => { ["Model: gpt-5 · Cost: $1.20", "Модель: gpt-5 · Стоимость: $1.20"], ["5 credits expire on Jan 5", "5 кредитов сгорят 5 янв."], ["No OpenAI API balance available", "Нет баланса API OpenAI"], + ["Limits not available", "Лимиты недоступны"], ])("translates %j", (text, expected) => { expect(localizeProviderText(text, ru)).toBe(expected); }); diff --git a/apps/desktop-tauri/src/lib/providerText.ts b/apps/desktop-tauri/src/lib/providerText.ts index ccf85c39127..916548620e3 100644 --- a/apps/desktop-tauri/src/lib/providerText.ts +++ b/apps/desktop-tauri/src/lib/providerText.ts @@ -32,6 +32,7 @@ const SENTENCES: Record = { "daily usage": "ProviderTextDailyUsage", "resets daily": "ProviderTextResetsDaily", "no active 5h session": "ProviderTextNoActiveSession", + "limits not available": "ProviderTextLimitsNotAvailable", "key quota": "ProviderTextKeyQuota", "account balance": "ProviderTextAccountBalance", "monthly budget": "ProviderTextMonthlyBudget", diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index d55efe6ac07..0573c6fd337 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -47,6 +47,8 @@ These three fields cross to the frontend only when a provider sets them. The CLI Claude reads `limits[]` entry by entry for both OAuth and Web (upstream #4614). A malformed entry or a field of the wrong type does not hide its valid model-scoped siblings, a non-object entry is skipped, and a JSON Boolean is never a percentage. +A successful Claude Web answer with no quota window and no spend (Claude Free returns null `five_hour` and `seven_day`) shows the informational session row `Limits not available` instead of `No active 5h session` (upstream #4687). A prepaid balance or any measured lane keeps the existing presentation. + Automatic ranks its candidate windows by depletion risk (`usage_metric.rs`, upstream #4491): an exhausted window first, then a window whose current pace runs out before its reset (the soonest first), then the highest used percentage. Equal ranks keep the earlier lane. The forecast needs authoritative pace (estimates never forecast), a window with its own length and reset, and at least 3% expected use, so a fresh window doesn't win on a noisy rate. The highest-usage tray icon ranks each provider's selected metric the same way; the forecast counts only when that provider's metric is Automatic. `Provider::automatic_metric_follows_lane_order()` opts out of the ranking for ordered tiers: the first exhausted core lane wins, otherwise the first reported lane. Gemini (Pro before Flash) and Kiro (plan credits before bonus credits) return `true`. `ProviderFetchResult::with_estimated_quota()` marks quota percentages that a provider computed locally instead of reading from the provider (upstream #4423). The bridge sends `quotaEstimated: true` only for such snapshots, and the tray then puts a `~` before the percentage in the percent icon, the tooltip and the provider status label. OpenCode Go sets it when its local SQLite estimate is shown; a console usage overlay clears it. diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 549b474e91d..9fcec03d41a 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1836,6 +1836,7 @@ locale_keys! { ProviderTextDailyUsage, ProviderTextResetsDaily, ProviderTextNoActiveSession, + ProviderTextLimitsNotAvailable, ProviderTextKeyQuota, ProviderTextAccountBalance, ProviderTextMonthlyBudget, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index d1c724b93a0..23a0ca8e8b0 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1421,6 +1421,7 @@ ProviderTextNoEditPredictions = No edit predictions included ProviderTextDailyUsage = Daily usage ProviderTextResetsDaily = resets daily ProviderTextNoActiveSession = No active 5h session +ProviderTextLimitsNotAvailable = Limits not available ProviderTextKeyQuota = Key quota ProviderTextAccountBalance = Account balance ProviderTextMonthlyBudget = Monthly budget diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 67b3e9daa4b..eb797b945d3 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1418,6 +1418,7 @@ ProviderTextNoEditPredictions = No incluye predicciones de edición ProviderTextDailyUsage = Uso diario ProviderTextResetsDaily = se restablece a diario ProviderTextNoActiveSession = No hay sesión de 5 h activa +ProviderTextLimitsNotAvailable = Límites no disponibles ProviderTextKeyQuota = Cuota de la clave ProviderTextAccountBalance = Saldo de la cuenta ProviderTextMonthlyBudget = Presupuesto mensual diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index dac8b77dfb3..20e5fcfe9a0 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1419,6 +1419,7 @@ ProviderTextNoEditPredictions = 編集予測は含まれていません ProviderTextDailyUsage = 日次の使用量 ProviderTextResetsDaily = 毎日リセット ProviderTextNoActiveSession = 有効な 5 時間セッションがありません +ProviderTextLimitsNotAvailable = 上限は利用できません ProviderTextKeyQuota = キーのクォータ ProviderTextAccountBalance = アカウント残高 ProviderTextMonthlyBudget = 月間予算 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 144ab686a4c..2eb8b66ec50 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1418,6 +1418,7 @@ ProviderTextNoEditPredictions = 편집 예측 미포함 ProviderTextDailyUsage = 일일 사용량 ProviderTextResetsDaily = 매일 초기화 ProviderTextNoActiveSession = 활성 5시간 세션 없음 +ProviderTextLimitsNotAvailable = 한도 정보 없음 ProviderTextKeyQuota = 키 할당량 ProviderTextAccountBalance = 계정 잔액 ProviderTextMonthlyBudget = 월간 예산 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 8d98f535491..90a1dec7821 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1423,6 +1423,7 @@ ProviderTextNoEditPredictions = Nenhuma previsão de edição incluída ProviderTextDailyUsage = Uso diário ProviderTextResetsDaily = redefine diariamente ProviderTextNoActiveSession = Nenhuma sessão de 5 h ativa +ProviderTextLimitsNotAvailable = Limites indisponíveis ProviderTextKeyQuota = Cota da chave ProviderTextAccountBalance = Saldo da conta ProviderTextMonthlyBudget = Orçamento mensal diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index aca02c77ab5..ada48c8a097 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1419,6 +1419,7 @@ ProviderTextNoEditPredictions = Подсказки правок не включ ProviderTextDailyUsage = Использование за день ProviderTextResetsDaily = сбрасывается ежедневно ProviderTextNoActiveSession = Нет активной 5-часовой сессии +ProviderTextLimitsNotAvailable = Лимиты недоступны ProviderTextKeyQuota = Квота ключа ProviderTextAccountBalance = Баланс аккаунта ProviderTextMonthlyBudget = Бюджет на месяц diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 872146dc7cd..56c23fa0fc8 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1419,6 +1419,7 @@ ProviderTextNoEditPredictions = Düzenleme tahmini dahil değil ProviderTextDailyUsage = Günlük kullanım ProviderTextResetsDaily = her gün sıfırlanır ProviderTextNoActiveSession = Etkin 5 saatlik oturum yok +ProviderTextLimitsNotAvailable = Limitler kullanılamıyor ProviderTextKeyQuota = Anahtar kotası ProviderTextAccountBalance = Hesap bakiyesi ProviderTextMonthlyBudget = Aylık bütçe diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 32ec3505db9..520db4d8b62 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1417,6 +1417,7 @@ ProviderTextNoEditPredictions = Підказки правок не включе ProviderTextDailyUsage = Використання за день ProviderTextResetsDaily = скидається щодня ProviderTextNoActiveSession = Немає активного 5-годинного сеансу +ProviderTextLimitsNotAvailable = Ліміти недоступні ProviderTextKeyQuota = Квота ключа ProviderTextAccountBalance = Баланс облікового запису ProviderTextMonthlyBudget = Бюджет на місяць diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 7cc45acb643..fe8c23fe06b 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1419,6 +1419,7 @@ ProviderTextNoEditPredictions = 不含编辑预测 ProviderTextDailyUsage = 每日用量 ProviderTextResetsDaily = 每日重置 ProviderTextNoActiveSession = 没有活动的 5 小时会话 +ProviderTextLimitsNotAvailable = 限额不可用 ProviderTextKeyQuota = 密钥配额 ProviderTextAccountBalance = 账户余额 ProviderTextMonthlyBudget = 每月预算 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 97caa0fdab4..f8ea10e97ac 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1419,6 +1419,7 @@ ProviderTextNoEditPredictions = 不含編輯預測 ProviderTextDailyUsage = 每日用量 ProviderTextResetsDaily = 每日重設 ProviderTextNoActiveSession = 沒有進行中的 5 小時會話 +ProviderTextLimitsNotAvailable = 限額無法使用 ProviderTextKeyQuota = 金鑰配額 ProviderTextAccountBalance = 帳戶餘額 ProviderTextMonthlyBudget = 每月預算 diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index 84fe1a8bb3b..f980228ce51 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -16,6 +16,8 @@ use super::spend_window; use super::subscription_details; const CLOUDFLARE_BODY_PREFIX_BYTES: usize = 64 * 1024; +/// Session-lane text for a successful answer with nothing measurable (upstream #4687). +const LIMITS_NOT_AVAILABLE: &str = "Limits not available"; fn is_cloudflare_challenge_response( status: StatusCode, @@ -462,6 +464,8 @@ impl ClaudeWebApiFetcher { cost = Some(apply_prepaid_balance(balance, cost)); } + mark_limits_unavailable_without_quota(&mut result.usage, cost.as_ref()); + if let Some(cost) = cost { result = result.with_cost(cost); } @@ -909,6 +913,22 @@ fn apply_prepaid_balance(balance: PrepaidBalance, existing: Option } } +/// Upstream #4687: a successful web answer with no measured quota window and no +/// spend (Claude Free returns null `five_hour` and `seven_day`) is limits +/// unavailable, not an idle session. Any measured lane or any cost (including a +/// prepaid balance) keeps the existing presentation. +fn mark_limits_unavailable_without_quota(usage: &mut UsageSnapshot, cost: Option<&CostSnapshot>) { + let has_measured_quota = !usage.primary.is_informational + || usage.secondary.is_some() + || usage.model_specific.is_some() + || usage.tertiary.is_some() + || !usage.extra_rate_windows.is_empty(); + if has_measured_quota || cost.is_some() { + return; + } + usage.primary = RateWindow::informational(LIMITS_NOT_AVAILABLE); +} + /// Push extras in upstream order: oauth-apps → scoped weekly → routines when present. fn append_web_extra_windows( snapshot: &mut UsageSnapshot, diff --git a/rust/src/providers/claude/web_api_tests.rs b/rust/src/providers/claude/web_api_tests.rs index 183fd440728..db379158950 100644 --- a/rust/src/providers/claude/web_api_tests.rs +++ b/rust/src/providers/claude/web_api_tests.rs @@ -2,7 +2,7 @@ use super::{ AccountResponse, ClaudeWebApiFetcher, UsageWindow, classify_web_http_error, cookie_value, describe_json_body_shape, is_cookie_authentication_failure, }; -use crate::core::ProviderError; +use crate::core::{CostSnapshot, ProviderError, UsageSnapshot}; use reqwest::StatusCode; use reqwest::header; use std::sync::{Mutex, OnceLock}; @@ -567,3 +567,118 @@ fn disabled_extra_usage_web_response_has_no_meter_or_cost() { assert!(snapshot.primary.is_informational); assert!(snapshot.primary_label.is_none()); } + +/// Build the usage snapshot the way `fetch_with_cookie_header` does, with the +/// optional prepaid balance applied before the availability decision. +fn web_snapshot_for( + json: &str, + prepaid: Option, +) -> (UsageSnapshot, Option) { + let usage: super::UsageResponse = serde_json::from_str(json).unwrap(); + let fetcher = ClaudeWebApiFetcher::new(); + let (mut snapshot, mut cost) = fetcher.build_usage_and_cost(&usage, usage.extra_usage.clone()); + if let Some(balance) = prepaid { + cost = Some(super::apply_prepaid_balance(balance, cost)); + } + super::mark_limits_unavailable_without_quota(&mut snapshot, cost.as_ref()); + (snapshot, cost) +} + +#[test] +fn null_quota_web_answer_reports_limits_unavailable() { + let (snapshot, cost) = web_snapshot_for(r#"{"five_hour":null,"seven_day":null}"#, None); + + assert!(cost.is_none()); + assert!(snapshot.primary.is_informational); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("Limits not available") + ); + assert!(snapshot.secondary.is_none()); + assert!(snapshot.model_specific.is_none()); + assert!(snapshot.tertiary.is_none()); + assert!(snapshot.extra_rate_windows.is_empty()); + assert!(snapshot.primary_label.is_none()); +} + +#[test] +fn disabled_extra_usage_without_quota_reports_limits_unavailable() { + let (snapshot, cost) = web_snapshot_for( + r#"{ + "five_hour": null, + "seven_day": null, + "extra_usage": { + "is_enabled": false, + "monthly_credit_limit": 5000, + "used_credits": 1234, + "currency": "USD" + } + }"#, + None, + ); + + assert!(cost.is_none()); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("Limits not available") + ); +} + +#[test] +fn weekly_only_web_answer_keeps_the_no_session_placeholder() { + let (snapshot, _) = web_snapshot_for(r#"{"seven_day":{"utilization":20}}"#, None); + + assert!(snapshot.primary.is_informational); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("No active 5h session") + ); + assert!(snapshot.secondary.is_some()); +} + +#[test] +fn measured_session_web_answer_stays_available() { + let (snapshot, _) = web_snapshot_for(r#"{"five_hour":{"utilization":12}}"#, None); + + assert!(!snapshot.primary.is_informational); + assert!((snapshot.primary.used_percent - 12.0).abs() < f64::EPSILON); +} + +#[test] +fn capped_spend_only_web_answer_keeps_the_projected_meter() { + let (snapshot, cost) = web_snapshot_for( + r#"{ + "extra_usage": { + "is_enabled": true, + "monthly_credit_limit": 5000, + "used_credits": 1234, + "currency": "USD" + } + }"#, + None, + ); + + assert!(cost.expect("spend cost").folded_into_primary); + assert!(!snapshot.primary.is_informational); + assert_ne!( + snapshot.primary.reset_description.as_deref(), + Some("Limits not available") + ); +} + +#[test] +fn prepaid_balance_alone_keeps_the_no_session_placeholder() { + let (snapshot, cost) = web_snapshot_for( + r#"{"five_hour":null,"seven_day":null}"#, + Some(super::PrepaidBalance { + amount_dollars: 4.25, + currency_code: "USD".to_string(), + }), + ); + + assert!(cost.is_some(), "balance-only cost stays visible"); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("No active 5h session") + ); +} From 03814dd3dc6d8c23dd3af6890432ba861538046a Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 00:42:27 +0700 Subject: [PATCH 2/2] Read the Claude web Sonnet weekly lane when Opus is absent --- rust/src/providers/claude/web_api.rs | 1 + rust/src/providers/claude/web_api_tests.rs | 26 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/rust/src/providers/claude/web_api.rs b/rust/src/providers/claude/web_api.rs index f980228ce51..7826164d2d3 100755 --- a/rust/src/providers/claude/web_api.rs +++ b/rust/src/providers/claude/web_api.rs @@ -755,6 +755,7 @@ impl ClaudeWebApiFetcher { let model_specific = usage .seven_day_opus .as_ref() + .or(usage.seven_day_sonnet.as_ref()) .map(|w| self.to_rate_window(w, Some(10080))); (primary, secondary, model_specific) diff --git a/rust/src/providers/claude/web_api_tests.rs b/rust/src/providers/claude/web_api_tests.rs index db379158950..f75bae2fa9b 100644 --- a/rust/src/providers/claude/web_api_tests.rs +++ b/rust/src/providers/claude/web_api_tests.rs @@ -636,6 +636,32 @@ fn weekly_only_web_answer_keeps_the_no_session_placeholder() { assert!(snapshot.secondary.is_some()); } +#[test] +fn sonnet_only_web_answer_keeps_its_weekly_lane_available() { + let (snapshot, _) = web_snapshot_for( + r#"{"five_hour":null,"seven_day":null,"seven_day_sonnet":{"utilization":37}}"#, + None, + ); + + let model = snapshot.model_specific.expect("sonnet weekly lane"); + assert!((model.used_percent - 37.0).abs() < f64::EPSILON); + assert_eq!( + snapshot.primary.reset_description.as_deref(), + Some("No active 5h session") + ); +} + +#[test] +fn opus_stays_the_model_lane_when_sonnet_is_also_reported() { + let (snapshot, _) = web_snapshot_for( + r#"{"seven_day_opus":{"utilization":11},"seven_day_sonnet":{"utilization":37}}"#, + None, + ); + + let model = snapshot.model_specific.expect("model lane"); + assert!((model.used_percent - 11.0).abs() < f64::EPSILON); +} + #[test] fn measured_session_web_answer_stays_available() { let (snapshot, _) = web_snapshot_for(r#"{"five_hour":{"utilization":12}}"#, None);