Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/i18n/keyGroups/providerText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export const PROVIDER_TEXT_LOCALE_KEYS = [
"ProviderTextDailyUsage",
"ProviderTextResetsDaily",
"ProviderTextNoActiveSession",
"ProviderTextLimitsNotAvailable",
"ProviderTextKeyQuota",
"ProviderTextAccountBalance",
"ProviderTextMonthlyBudget",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/lib/providerText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/lib/providerText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const SENTENCES: Record<string, LocaleKey> = {
"daily usage": "ProviderTextDailyUsage",
"resets daily": "ProviderTextResetsDaily",
"no active 5h session": "ProviderTextNoActiveSession",
"limits not available": "ProviderTextLimitsNotAvailable",
"key quota": "ProviderTextKeyQuota",
"account balance": "ProviderTextAccountBalance",
"monthly budget": "ProviderTextMonthlyBudget",
Expand Down
2 changes: 2 additions & 0 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions rust/src/locale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1836,6 +1836,7 @@ locale_keys! {
ProviderTextDailyUsage,
ProviderTextResetsDaily,
ProviderTextNoActiveSession,
ProviderTextLimitsNotAvailable,
ProviderTextKeyQuota,
ProviderTextAccountBalance,
ProviderTextMonthlyBudget,
Expand Down
1 change: 1 addition & 0 deletions rust/src/locale/en-US.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions rust/src/locale/es-MX.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions rust/src/locale/ja-JP.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,7 @@ ProviderTextNoEditPredictions = 編集予測は含まれていません
ProviderTextDailyUsage = 日次の使用量
ProviderTextResetsDaily = 毎日リセット
ProviderTextNoActiveSession = 有効な 5 時間セッションがありません
ProviderTextLimitsNotAvailable = 上限は利用できません
ProviderTextKeyQuota = キーのクォータ
ProviderTextAccountBalance = アカウント残高
ProviderTextMonthlyBudget = 月間予算
Expand Down
1 change: 1 addition & 0 deletions rust/src/locale/ko-KR.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -1418,6 +1418,7 @@ ProviderTextNoEditPredictions = 편집 예측 미포함
ProviderTextDailyUsage = 일일 사용량
ProviderTextResetsDaily = 매일 초기화
ProviderTextNoActiveSession = 활성 5시간 세션 없음
ProviderTextLimitsNotAvailable = 한도 정보 없음
ProviderTextKeyQuota = 키 할당량
ProviderTextAccountBalance = 계정 잔액
ProviderTextMonthlyBudget = 월간 예산
Expand Down
1 change: 1 addition & 0 deletions rust/src/locale/pt-BR.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions rust/src/locale/ru-RU.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,7 @@ ProviderTextNoEditPredictions = Подсказки правок не включ
ProviderTextDailyUsage = Использование за день
ProviderTextResetsDaily = сбрасывается ежедневно
ProviderTextNoActiveSession = Нет активной 5-часовой сессии
ProviderTextLimitsNotAvailable = Лимиты недоступны
ProviderTextKeyQuota = Квота ключа
ProviderTextAccountBalance = Баланс аккаунта
ProviderTextMonthlyBudget = Бюджет на месяц
Expand Down
1 change: 1 addition & 0 deletions rust/src/locale/tr-TR.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions rust/src/locale/uk-UA.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -1417,6 +1417,7 @@ ProviderTextNoEditPredictions = Підказки правок не включе
ProviderTextDailyUsage = Використання за день
ProviderTextResetsDaily = скидається щодня
ProviderTextNoActiveSession = Немає активного 5-годинного сеансу
ProviderTextLimitsNotAvailable = Ліміти недоступні
ProviderTextKeyQuota = Квота ключа
ProviderTextAccountBalance = Баланс облікового запису
ProviderTextMonthlyBudget = Бюджет на місяць
Expand Down
1 change: 1 addition & 0 deletions rust/src/locale/zh-CN.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,7 @@ ProviderTextNoEditPredictions = 不含编辑预测
ProviderTextDailyUsage = 每日用量
ProviderTextResetsDaily = 每日重置
ProviderTextNoActiveSession = 没有活动的 5 小时会话
ProviderTextLimitsNotAvailable = 限额不可用
ProviderTextKeyQuota = 密钥配额
ProviderTextAccountBalance = 账户余额
ProviderTextMonthlyBudget = 每月预算
Expand Down
1 change: 1 addition & 0 deletions rust/src/locale/zh-TW.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -1419,6 +1419,7 @@ ProviderTextNoEditPredictions = 不含編輯預測
ProviderTextDailyUsage = 每日用量
ProviderTextResetsDaily = 每日重設
ProviderTextNoActiveSession = 沒有進行中的 5 小時會話
ProviderTextLimitsNotAvailable = 限額無法使用
ProviderTextKeyQuota = 金鑰配額
ProviderTextAccountBalance = 帳戶餘額
ProviderTextMonthlyBudget = 每月預算
Expand Down
21 changes: 21 additions & 0 deletions rust/src/providers/claude/web_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -751,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)
Expand Down Expand Up @@ -909,6 +914,22 @@ fn apply_prepaid_balance(balance: PrepaidBalance, existing: Option<CostSnapshot>
}
}

/// 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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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,
Expand Down
143 changes: 142 additions & 1 deletion rust/src/providers/claude/web_api_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -567,3 +567,144 @@ 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<super::PrepaidBalance>,
) -> (UsageSnapshot, Option<CostSnapshot>) {
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 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);

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")
);
}