Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ Relay also ships an optional **macOS menu bar app** so each developer can see **

Each tool (Claude Code, Codex CLI, Cursor, Gemini, …) gets its own tab with its own color. Build it from [`macos/RelayBarGlass`](macos/RelayBarGlass) (`./build.sh && open RelayBarGlass.app`).

The menu bar app reads usage over two Gateway routes: `/key/info` and `/user/daily/activity`. These are info/management routes, not `llm_api_routes`, so a default virtual key can't call them and the Gateway returns 403. Give the relay key access to both routes (for example `allowed_routes: ["llm_api_routes", "info_routes", "management_routes"]` on the key, or a scoped equivalent) or the tabs stay empty. `relay setup` probes both routes right after sign-in and prints a warning with the fix if the key can't read usage, so an under-scoped key is caught before the tabs ever look empty; RelayBar also surfaces the 403 (and any bad-config / unreachable-gateway error) in the card instead of showing a blank tab, so if a tab is empty read the message it prints. Also make sure `~/.litellm-relay/config.yaml` points `gateway.url` and `gateway.api_key` at your real Gateway; `gateway.url` defaults to `http://127.0.0.1:4000` and `gateway.api_key` is unset, which yields an empty app when the Gateway lives elsewhere or the key is missing.

## AI Tool Guides

Relay onboards each AI coding tool onto the LiteLLM AI Gateway with zero developer setup — the developer just launches the tool. Pick your tool for the step-by-step guide:
Expand Down
38 changes: 35 additions & 3 deletions macos/RelayBarGlass/Sources/RelayBarGlass/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ final class AppModel: ObservableObject {
@Published var keyAlias: String = ""
@Published var lastError: String = ""

/// True once a spend fetch has succeeded at least once. Until then the card
/// shows a loading (or error) state instead of the seeded placeholder data,
/// so a failed fetch is never mistaken for a genuinely idle relay key.
@Published var didLoadOnce: Bool = false

// MARK: - Per-key budget (from /key/info)

@Published var keySpend: Double = 0 // info.spend
Expand Down Expand Up @@ -105,6 +110,11 @@ final class AppModel: ObservableObject {

init() {
self.agents = AppModel.seedAgents()
// Drive polling from the model, not from a view modifier: the
// MenuBarExtra label's .onAppear does not fire under
// .menuBarExtraStyle(.window), which left the timer uncreated and the
// popover stuck on its seed values.
start()
}

// MARK: - Seed data
Expand Down Expand Up @@ -326,7 +336,8 @@ final class AppModel: ObservableObject {
self.apply(snap)
}
} catch {
await MainActor.run { self?.lastError = "\(error)" }
let message = AppModel.userFacingError(error)
await MainActor.run { self?.lastError = message }
}
}
}
Expand All @@ -352,6 +363,7 @@ final class AppModel: ObservableObject {
costPerRequest = snap.costPerRequest
costPerMTok = snap.costPerMTok
lastError = ""
didLoadOnce = true

let maxToolSpend = snap.toolMonthSpend.values.max() ?? 0
for i in agents.indices {
Expand Down Expand Up @@ -626,12 +638,32 @@ final class AppModel: ObservableObject {
case http(Int)
var description: String {
switch self {
case .missingConfig: return "gateway url or api_key missing from config.yaml"
case .http(let code): return "gateway returned HTTP \(code)"
case .missingConfig:
return "gateway url or api_key missing from config.yaml"
case .http(401), .http(403):
// /key/info and /user/daily/activity are info/management routes,
// not llm_api_routes, so a default virtual key can't read them.
return "This relay key can't read usage from the gateway. "
+ "Ask your admin to allow the /key/info and /user/daily/activity "
+ "routes on the key (allowed_routes)."
case .http(let code):
return "gateway returned HTTP \(code)"
}
}
}

/// Maps a thrown fetch error to a short, human-readable line for the card.
/// `SpendError` already carries actionable copy; `URLError` (no route to the
/// gateway, TLS, timeout) is collapsed to its localized description instead
/// of the noisy `Error Domain=…` interpolation.
nonisolated private static func userFacingError(_ error: Error) -> String {
if let spend = error as? SpendError { return spend.description }
if let urlError = error as? URLError {
return "Can't reach the gateway: \(urlError.localizedDescription)"
}
return error.localizedDescription
}

/// Fetches `/key/info` + `/user/daily/activity` and reduces them into a
/// `SpendSnapshot`. Runs entirely off the main actor.
nonisolated private static func computeSpend() async throws -> SpendSnapshot {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ struct RelayBarGlassApp: App {
PopoverView(model: model)
} label: {
Text("✨🚅")
.onAppear { model.start() }
}
.menuBarExtraStyle(.window)
}
Expand Down
42 changes: 41 additions & 1 deletion macos/RelayBarGlass/Sources/RelayBarGlass/UsageCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ struct UsageCard: View {
VStack(alignment: .leading, spacing: 16) {
header

if !selected.routedViaRelay {
if !model.didLoadOnce && model.lastError.isEmpty {
loadingState
} else if !model.didLoadOnce {
errorState
} else if !selected.routedViaRelay {
notRoutedState
} else if hasNoData {
emptyState
Expand Down Expand Up @@ -89,6 +93,42 @@ struct UsageCard: View {
.foregroundColor(Color.white.opacity(0.40))
}

// MARK: - Loading state

/// Shown before the first spend fetch completes, so the card isn't stuck on
/// seeded placeholder chrome that looks like a genuinely idle key.
private var loadingState: some View {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Loading usage…")
.font(GlassTheme.body)
.foregroundColor(GlassTheme.muted)
}
.frame(maxWidth: .infinity, alignment: .leading)
}

// MARK: - Error state

/// Shown when the first spend fetch fails (bad config, unreachable gateway,
/// or — most commonly — a key that lacks the info/management routes). The
/// old UI swallowed these and rendered an empty card indistinguishable from
/// an idle key, which is exactly what made it "look empty for no reason".
private var errorState: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Can't load usage")
.font(GlassTheme.body)
.foregroundColor(GlassTheme.muted)
.fixedSize(horizontal: false, vertical: true)

Text(model.lastError)
.font(GlassTheme.caption)
.foregroundColor(GlassTheme.textFaint)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
}

// MARK: - Empty state

private var emptyState: some View {
Expand Down
163 changes: 161 additions & 2 deletions src/setup.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
use std::io::{self, Write};
use std::{
io::{self, Write},
time::Duration,
};

use anyhow::{anyhow, Result};
use chrono::Utc;

use crate::{
ai_tools::{autoconfigure, AutoConfigureParams},
auth::GatewaySsoClient,
config::{load_settings, save_settings},
terminal::{print_setup_complete, print_setup_intro, print_step},
terminal::{print_setup_complete, print_setup_intro, print_step, print_usage_access_warning},
};

/// Routes the RelayBar menu bar app reads usage from. `/key/info` is an
/// info route and `/user/daily/activity` a management route, so neither is
/// covered by a default key scoped to `llm_api_routes`.
const KEY_INFO_ROUTE: &str = "/key/info";
const DAILY_ACTIVITY_ROUTE: &str = "/user/daily/activity";
const USAGE_ACCESS_SUGGESTION: &str = "Ask your Gateway admin to allow these routes on the key, \
e.g. set allowed_routes to include \"info_routes\" and \"management_routes\" (or add the two routes explicitly).";

pub async fn run_setup(gateway_url: Option<String>, api_key: Option<String>) -> Result<()> {
print_setup_intro();

Expand Down Expand Up @@ -48,6 +60,15 @@ pub async fn run_setup(gateway_url: Option<String>, api_key: Option<String>) ->
let config_path = save_settings(&settings)?;
print_setup_complete(&config_path, user_id.as_deref(), team_id.as_deref());

// Probe the routes RelayBar needs so an under-scoped key is caught here,
// with a fix, rather than silently showing empty tabs later.
let denied = probe_usage_access(&settings.gateway.url, settings.gateway.api_key.as_deref())
.await
.denied_routes();
if !denied.is_empty() {
print_usage_access_warning(&denied, USAGE_ACCESS_SUGGESTION);
}

println!();
print_step(4, 4, "Configure installed AI tools");
// Detect and wire up every AI tool on this device. `autoconfigure` reads
Expand Down Expand Up @@ -86,3 +107,141 @@ fn prompt(label: &str, default: &str) -> String {
value.to_string()
}
}

/// Access result for a single usage route.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RouteAccess {
Allowed,
Denied,
Unknown,
}

/// Outcome of probing the routes RelayBar reads usage from.
struct UsageAccess {
key_info: RouteAccess,
daily_activity: RouteAccess,
}

impl UsageAccess {
fn unknown() -> Self {
Self {
key_info: RouteAccess::Unknown,
daily_activity: RouteAccess::Unknown,
}
}

/// Routes that returned an auth failure. Only `Denied` is surfaced;
/// `Unknown` (network error or an inconclusive status) is left alone so a
/// flaky probe never nags about a key that may well be fine.
fn denied_routes(&self) -> Vec<&'static str> {
[
(KEY_INFO_ROUTE, self.key_info),
(DAILY_ACTIVITY_ROUTE, self.daily_activity),
]
.into_iter()
.filter(|(_, access)| *access == RouteAccess::Denied)
.map(|(route, _)| route)
.collect()
}
}

/// Maps an HTTP status to route access: 2xx is allowed, 401/403 is a permission
/// denial, anything else (400, 5xx, ...) is inconclusive.
fn classify_status(status: reqwest::StatusCode) -> RouteAccess {
if status.is_success() {
RouteAccess::Allowed
} else if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
RouteAccess::Denied
} else {
RouteAccess::Unknown
}
}

/// GETs `/key/info` and `/user/daily/activity` with the key to see whether it
/// can read usage. Best-effort: any transport failure resolves to `Unknown`.
async fn probe_usage_access(gateway_url: &str, api_key: Option<&str>) -> UsageAccess {
let Some(api_key) = api_key.map(str::trim).filter(|key| !key.is_empty()) else {
return UsageAccess::unknown();
};
let Ok(client) = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
else {
return UsageAccess::unknown();
};
let base = gateway_url.trim_end_matches('/');
let today = Utc::now().format("%Y-%m-%d");
UsageAccess {
key_info: probe_route(&client, &format!("{base}{KEY_INFO_ROUTE}"), api_key).await,
daily_activity: probe_route(
&client,
&format!("{base}{DAILY_ACTIVITY_ROUTE}?start_date={today}&end_date={today}"),
api_key,
)
.await,
}
}

async fn probe_route(client: &reqwest::Client, url: &str, api_key: &str) -> RouteAccess {
match client.get(url).bearer_auth(api_key).send().await {
Ok(response) => classify_status(response.status()),
Err(_) => RouteAccess::Unknown,
}
}

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

#[test]
fn should_classify_status_into_route_access() {
assert_eq!(classify_status(StatusCode::OK), RouteAccess::Allowed);
assert_eq!(classify_status(StatusCode::FORBIDDEN), RouteAccess::Denied);
assert_eq!(
classify_status(StatusCode::UNAUTHORIZED),
RouteAccess::Denied
);
assert_eq!(
classify_status(StatusCode::BAD_REQUEST),
RouteAccess::Unknown
);
assert_eq!(
classify_status(StatusCode::INTERNAL_SERVER_ERROR),
RouteAccess::Unknown
);
}

#[test]
fn should_report_only_denied_routes() {
let access = UsageAccess {
key_info: RouteAccess::Allowed,
daily_activity: RouteAccess::Denied,
};
assert_eq!(access.denied_routes(), vec![DAILY_ACTIVITY_ROUTE]);
}

#[test]
fn should_report_both_denied_routes() {
let access = UsageAccess {
key_info: RouteAccess::Denied,
daily_activity: RouteAccess::Denied,
};
assert_eq!(
access.denied_routes(),
vec![KEY_INFO_ROUTE, DAILY_ACTIVITY_ROUTE]
);
}

#[test]
fn should_not_warn_when_access_is_unknown_or_allowed() {
assert!(UsageAccess::unknown().denied_routes().is_empty());
let allowed = UsageAccess {
key_info: RouteAccess::Allowed,
daily_activity: RouteAccess::Allowed,
};
assert!(allowed.denied_routes().is_empty());
}
}
11 changes: 11 additions & 0 deletions src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ pub fn print_setup_complete(config_path: &Path, user_id: Option<&str>, team_id:
);
}

pub fn print_usage_access_warning(denied_routes: &[&str], suggestion: &str) {
println!();
println!(
"{}Heads up:{} this key can't read usage yet, so the RelayBar menu bar app will show empty tabs.",
color(YELLOW),
color(RESET)
);
println!(" Denied: {}", denied_routes.join(", "));
println!(" {suggestion}");
}

pub fn print_runtime_panel(config: &RelayConfig) {
print_banner();
println!("{}Relay is running{}", color(BOLD), color(RESET));
Expand Down
Loading