Skip to content
Closed
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
76 changes: 76 additions & 0 deletions apps/desktop-tauri/src-tauri/src/usage_metric.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,20 @@ fn automatic_window(
return None;
}

if policy.prefers_secondary_window {
let primary = non_informational(Some(&snapshot.primary));
let secondary = non_informational(snapshot.secondary.as_ref());
let preferred = primary
.into_iter()
.chain(secondary)
.find(|window| automatic_window_is_exhausted(window))
.or(secondary)
.or(primary);
if let Some(window) = preferred {
return Some(window.clone());
}
}

let mut windows = Vec::with_capacity(4 + snapshot.extra_rate_windows.len());
windows.push(&snapshot.primary);
windows.extend(snapshot.secondary.iter());
Expand Down Expand Up @@ -181,6 +195,9 @@ struct AutomaticMetricPolicy {
/// is a dead end for Automatic selection. False for providers whose
/// fallback lanes (seat credits) should still be considered.
missing_core_is_terminal: bool,
/// Whether the secondary lane represents the provider unless a core lane
/// is exhausted (upstream's LiteLLM team-budget resolver).
prefers_secondary_window: bool,
}

fn automatic_metric_policy(provider: Option<ProviderId>) -> AutomaticMetricPolicy {
Expand All @@ -190,12 +207,16 @@ fn automatic_metric_policy(provider: Option<ProviderId>) -> AutomaticMetricPolic
let missing_core_is_terminal = |id: ProviderId| {
codexbar::core::instantiate_provider(id).automatic_metric_missing_core_is_terminal()
};
let prefers_secondary = |id: ProviderId| {
codexbar::core::instantiate_provider(id).automatic_metric_prefers_secondary_window()
};
match provider {
Some(ProviderId::Antigravity) => AutomaticMetricPolicy {
prefers_available_window: true,
prioritizes_exhausted_window: false,
uses_extra_windows: false,
missing_core_is_terminal: true,
prefers_secondary_window: false,
},
// Cursor's monthly Auto lane is the semantic weekly pace. Grok Bot is
// a named extra allowance and must stay available through the explicit
Expand All @@ -205,18 +226,21 @@ fn automatic_metric_policy(provider: Option<ProviderId>) -> AutomaticMetricPolic
prioritizes_exhausted_window: prioritizes(ProviderId::Cursor),
uses_extra_windows: false,
missing_core_is_terminal: true,
prefers_secondary_window: false,
},
Some(id) => AutomaticMetricPolicy {
prefers_available_window: false,
prioritizes_exhausted_window: prioritizes(id),
uses_extra_windows: true,
missing_core_is_terminal: missing_core_is_terminal(id),
prefers_secondary_window: prefers_secondary(id),
},
None => AutomaticMetricPolicy {
prefers_available_window: false,
prioritizes_exhausted_window: true,
uses_extra_windows: true,
missing_core_is_terminal: false,
prefers_secondary_window: false,
},
}
}
Expand Down Expand Up @@ -682,4 +706,56 @@ mod tests {
);
assert!(restored.hidden_usage_item_ids.is_empty());
}

fn litellm_budgets(personal: f64, team: Option<f64>) -> ProviderUsageSnapshot {
let mut snapshot = snapshot();
snapshot.provider_id = "litellm".to_string();
snapshot.primary = window(personal);
snapshot.secondary = team.map(window);
snapshot
}

#[test]
fn litellm_automatic_prefers_the_team_budget_over_a_fuller_personal_budget() {
let snapshot = litellm_budgets(60.0, Some(7.0));

assert_eq!(
selected_usage_window(&snapshot, &Settings::default()).used_percent,
7.0
);
let (selected, companion) = selected_usage_icon_windows(&snapshot, &Settings::default());
assert_eq!(selected.used_percent, 7.0);
assert_eq!(companion.map(|window| window.used_percent), Some(60.0));
}

#[test]
fn litellm_automatic_shows_an_exhausted_budget_first() {
let personal_exhausted = litellm_budgets(100.0, Some(7.0));
assert_eq!(
selected_usage_window(&personal_exhausted, &Settings::default()).used_percent,
100.0
);

let team_exhausted = litellm_budgets(40.0, Some(100.0));
assert_eq!(
selected_usage_window(&team_exhausted, &Settings::default()).used_percent,
100.0
);
}

#[test]
fn litellm_automatic_uses_the_only_budget_and_explicit_choices_still_win() {
let personal_only = litellm_budgets(25.0, None);
assert_eq!(
selected_usage_window(&personal_only, &Settings::default()).used_percent,
25.0
);

let mut settings = Settings::default();
settings.set_provider_metric(ProviderId::LiteLLM, MetricPreference::Session);
assert_eq!(
selected_usage_window(&litellm_budgets(60.0, Some(7.0)), &settings).used_percent,
60.0
);
}
}
6 changes: 6 additions & 0 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ z.ai Coding Plans accept both `TOKENS_LIMIT` and `CREDIT_LIMIT` rows. The shorte

Upstream's independent **WidgetKit** provider-widget configuration has no Windows analogue in this repository. Win-CodexBar has no WidgetKit extension; provider cards and tray entries are already independent Windows/Tauri surfaces.

### LiteLLM budgets

LiteLLM reads a virtual key's own budgets from the proxy's management routes. Set the base URL and key in Settings → Providers → LiteLLM, or use `LITELLM_BASE_URL` and `LITELLM_API_KEY`. A trailing `/v1` is dropped. The base URL must use HTTPS unless it names `localhost`, a `.local` host, or a loopback, RFC 1918, link-local or IPv6 unique-local address, and it must not embed credentials, because the key is sent as a bearer token.

The provider calls `GET /key/info`, then `GET /user/info?user_id=…` for a user-bound key or `GET /team/info?team_id=…` for a team-only key, and rejects a response whose user or team ID differs from the key's. The personal budget fills the primary lane and the key's matching team budget fills the secondary lane. When only one budget exists, it takes the primary lane under its own label, and a key without any budget shows "No budget set". Amounts such as `$25.00 / $100.00` are detail lines, shown apart from a real reset date. The Automatic tray and float bar metric shows the team budget unless a budget is exhausted. Spend without a budget stays visible as API spend, and no pace is derived from budget resets.

## Upstream doc warning

Upstream `docs/providers.md` is a large auto-strategy matrix (60+ providers) for the macOS app. Use it as **inspiration** when porting a provider. For runtime truth on Windows:
Expand Down
7 changes: 7 additions & 0 deletions rust/src/core/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,13 @@ pub trait Provider: Send + Sync {
true
}

/// Whether Automatic metric selection shows the secondary lane whenever
/// neither core lane is exhausted, instead of the fuller lane. An
/// exhausted primary or secondary lane still wins, primary first.
fn automatic_metric_prefers_secondary_window(&self) -> bool {
false
}

/// Whether browser-cookie discovery/recovery is owned by the provider.
fn owns_browser_cookie_resolution(&self) -> bool {
false
Expand Down
2 changes: 1 addition & 1 deletion rust/src/locale/en-US.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,7 @@ OpenAiProjectIdHelp = Leave blank for organization-wide usage. Set a project ID
LiteLlmApiTitle = LiteLLM API
LiteLlmBaseUrlLabel = Base URL
LiteLlmBaseUrlPlaceholder = https://litellm.example.com
LiteLlmBaseUrlHelp = Used with the saved API key for LiteLLM /key/info.
LiteLlmBaseUrlHelp = Used with the saved API key for LiteLLM key, user, and team info. Use HTTPS, or HTTP on a loopback or private-network address.
DevinApiTitle = Devin API
DevinOrganizationLabel = Organization
DevinOrganizationPlaceholder = org/acme
Expand Down
178 changes: 178 additions & 0 deletions rust/src/providers/litellm/endpoint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
//! LiteLLM base-URL policy and management-route URLs.
//!
//! Upstream `litellm.ts` declares the `LITELLM_BASE_URL` endpoint with the
//! `https-or-private-network-http` policy: HTTPS anywhere, plain HTTP only for
//! loopback, RFC 1918, link-local, IPv6 unique-local, and `.local` hosts.

use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

use reqwest::Url;

use crate::core::ProviderError;

const INVALID_BASE: &str = "LiteLLM base URL must use HTTPS, or HTTP on a loopback or private-network address, without embedded credentials.";

/// Validate a LiteLLM base URL. A scheme-less value is treated as HTTPS.
pub(crate) fn validated_base_url(raw: &str) -> Result<Url, ProviderError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(ProviderError::Other("LiteLLM base URL is empty".into()));
}
let lower = trimmed.to_ascii_lowercase();
if ["%2f", "%5c", "%3f", "%23", "%40", "%3a"]
.iter()
.any(|encoded| lower.contains(encoded))
{
return Err(ProviderError::Other(
"LiteLLM base URL must not contain encoded host delimiters".into(),
));
}
let candidate = if trimmed.contains("://") {
trimmed.to_string()
} else {
format!("https://{trimmed}")
};
let url = Url::parse(&candidate)
.map_err(|e| ProviderError::Other(format!("Invalid LiteLLM base URL: {e}")))?;
let host = url
.host_str()
.ok_or_else(|| ProviderError::Other("LiteLLM base URL must include a host".into()))?;
let scheme_ok = match url.scheme() {
"https" => true,
"http" => is_private_network_host(host),
_ => false,
};
if !scheme_ok
|| !url.username().is_empty()
|| url.password().is_some()
|| host.contains('%')
|| host.chars().any(|c| c.is_control() || c.is_whitespace())
{
return Err(ProviderError::Other(INVALID_BASE.into()));
}
Ok(url)
}

/// Build `{base}/{path}` for a management route. A trailing `/v1` on the base
/// is dropped, and the base path and query are otherwise preserved. `query`
/// replaces the base query when given.
pub(super) fn management_url(
base: &str,
path: &str,
query: Option<(&str, &str)>,
) -> Result<Url, ProviderError> {
let mut url = validated_base_url(base)?;
let trimmed = url.path().trim_end_matches('/');
let root = trimmed.strip_suffix("/v1").unwrap_or(trimmed).to_string();
url.set_path(&format!("{root}/{path}"));
url.set_fragment(None);
if let Some((key, value)) = query {
url.query_pairs_mut().clear().append_pair(key, value);
}
Ok(url)
}

/// Upstream `isPrivateNetworkHost`: `localhost`, `.local` names, and IP
/// literals that are loopback, RFC 1918, link-local, or IPv6 unique-local.
/// Other names (including `*.localhost`) and IPv4-mapped IPv6 literals stay
/// HTTPS-only, because a bearer key would otherwise cross the network in
/// plain text if the name resolved somewhere public.
fn is_private_network_host(host: &str) -> bool {
let normalized = host.trim_end_matches('.').to_ascii_lowercase();
if normalized == "localhost"
|| normalized
.strip_suffix(".local")
.is_some_and(|label| !label.is_empty())
{
return true;
}
let ip_candidate = normalized
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(&normalized);
match ip_candidate.parse::<IpAddr>() {
Ok(IpAddr::V4(ip)) => is_private_ipv4(ip),
Ok(IpAddr::V6(ip)) => is_private_ipv6(ip),
Err(_) => false,
}
}

fn is_private_ipv4(ip: Ipv4Addr) -> bool {
ip.is_loopback() || ip.is_private() || ip.is_link_local()
}

fn is_private_ipv6(ip: Ipv6Addr) -> bool {
ip.is_loopback()
|| (ip.segments()[0] & 0xfe00) == 0xfc00
|| (ip.segments()[0] & 0xffc0) == 0xfe80
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn allows_https_anywhere_and_private_network_http() {
for value in [
"https://litellm.example.com",
"litellm.example.com",
"http://localhost:4000",
"http://127.0.0.1:4000",
"http://[::1]:4000",
"http://10.1.2.3",
"http://172.16.0.9",
"http://192.168.1.20:4000",
"http://169.254.10.10",
"http://[fd12:3456::1]",
"http://[fe80::1]",
"http://proxy.local:4000",
] {
assert!(validated_base_url(value).is_ok(), "rejected {value}");
}
}

#[test]
fn rejects_public_http_credentials_and_encoded_delimiters() {
for value in [
"",
"http://litellm.example.com",
"http://8.8.8.8",
"http://172.32.0.1",
"http://[2001:db8::1]",
"http://example.com.evil.test",
"ftp://10.0.0.1",
"https://user:pass@litellm.example.com",
"http://user@10.0.0.1",
"https://example.com%2f.evil.test",
"http://app.localhost:4000",
"http://[::ffff:10.0.0.1]",
"http://.local:4000",
] {
assert!(validated_base_url(value).is_err(), "accepted {value}");
}
}

#[test]
fn management_url_strips_v1_and_keeps_subpath() {
let url = management_url("https://h.example.com/litellm/v1/", "key/info", None).unwrap();
assert_eq!(url.as_str(), "https://h.example.com/litellm/key/info");
let url = management_url("http://10.0.0.2:4000/v1", "key/info", None).unwrap();
assert_eq!(url.as_str(), "http://10.0.0.2:4000/key/info");
}

#[test]
fn management_url_encodes_query_and_replaces_base_query() {
let url = management_url(
"https://h.example.com?token=abc",
"user/info",
Some(("user_id", "a b&c")),
)
.unwrap();
assert_eq!(
url.as_str(),
"https://h.example.com/user/info?user_id=a+b%26c"
);
let kept = management_url("https://h.example.com?token=abc", "key/info", None).unwrap();
assert_eq!(kept.as_str(), "https://h.example.com/key/info?token=abc");
}
}
Loading