fix(cost): exempt BYOK inference from the managed-credit budget (#5218)

This commit is contained in:
Mega Mind
2026-07-27 21:42:44 +05:30
committed by GitHub
parent ae9124e909
commit 06394789f4
6 changed files with 591 additions and 13 deletions
+9 -2
View File
@@ -27,11 +27,18 @@ pub struct CostConfig {
#[serde(default = "default_cost_enabled")]
pub enabled: bool,
/// Daily spending limit in USD (default: 10.00)
/// Daily spending limit in USD (default: 10.00).
///
/// Applies to **managed (OpenHuman-credit) inference only** — see
/// [`crate::openhuman::cost::route`]. Bring-your-own-key and local
/// inference is billed by the user's own provider, so it is recorded for
/// the dashboard but never counted against this limit and can never
/// refuse a request (#5016).
#[serde(default = "default_daily_limit")]
pub daily_limit_usd: f64,
/// Monthly spending limit in USD (default: 100.00)
/// Monthly spending limit in USD (default: 100.00). Managed-route only,
/// on the same terms as [`Self::daily_limit_usd`].
#[serde(default = "default_monthly_limit")]
pub monthly_limit_usd: f64,
+2
View File
@@ -1,5 +1,6 @@
pub mod catalog;
mod global;
pub mod route;
mod rpc;
mod schemas;
pub mod tools;
@@ -7,6 +8,7 @@ pub mod tracker;
pub mod types;
pub use global::{init_global, record_embedding_usage, record_provider_usage, try_global};
pub use route::{route_for_model, CostRoute};
pub use schemas::{
all_controller_schemas as all_cost_controller_schemas,
all_registered_controllers as all_cost_registered_controllers,
+229
View File
@@ -0,0 +1,229 @@
//! Which billing route a recorded cost belongs to (issue #5016).
//!
//! The local `[cost]` daily/monthly limits exist to cap spend against
//! **OpenHuman-managed credits**. They were being enforced against *every*
//! recorded call, including bring-your-own-key (BYOK) and local inference that
//! OpenHuman never bills for. A BYOK user therefore accumulated phantom spend
//! — priced locally from [`super::catalog`] because their provider echoes no
//! `charged_amount_usd` — until they tripped the default $10/day cap and got
//! "You're out of credits", despite OpenHuman having charged them nothing.
//!
//! The route is derived from the recorded model id rather than threaded
//! through as a new parameter, which matters for two reasons:
//!
//! 1. Every recording site already has the model id, so no call site has to
//! learn about routing.
//! 2. Records persisted by older builds carry a model id too, so history
//! classifies correctly with **no migration and no schema change** — a
//! stored-route field would have had to guess a default for every existing
//! record and would keep mis-gating real users for up to a month.
/// The billing route a cost record belongs to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CostRoute {
/// Served by the OpenHuman managed backend and paid for with OpenHuman
/// credits. Counts toward — and is gated by — the local `[cost]` limits.
Managed,
/// Served by the user's own key (OpenRouter, Anthropic, a self-hosted
/// OpenAI-compatible gateway, …) or a local model. OpenHuman bills nothing
/// for it, so it is recorded for the dashboard but never gated.
Byok,
}
impl CostRoute {
/// Whether spend on this route counts toward the local `[cost]` budget.
pub fn counts_toward_budget(self) -> bool {
matches!(self, CostRoute::Managed)
}
}
/// Model ids the managed backend serves. These are the backend's own tier
/// slugs (`crate::openhuman::config::MODEL_*_V1`) — a BYOK
/// provider is always addressed by its real model id (`minimax/minimax-m3`,
/// `anthropic/claude-sonnet-4-20250514`, `llama3:8b`), never by a tier slug,
/// because the tier vocabulary only means something to the managed backend.
///
/// Kept as a local list, deliberately: this is the set of ids that imply
/// *managed billing*, which is a narrower question than "is this a known tier
/// constant". A new tier must be added here consciously, and the
/// `managed_tier_slugs_stay_in_sync` test fails if one is added upstream
/// without doing so.
const MANAGED_MODEL_SLUGS: &[&str] = &[
"chat-v1",
"reasoning-v1",
"reasoning-quick-v1",
"agentic-v1",
"burst-v1",
"coding-v1",
"vision-v1",
"summarization-v1",
];
/// Classify a recorded model id into its billing route.
///
/// Unknown ids classify as [`CostRoute::Byok`], which is the safe direction:
/// the failure mode is under-enforcing a *local* convenience cap, not
/// over-charging. Real managed-credit exhaustion is still enforced
/// server-side, where the backend returns its own out-of-credits error — that
/// path is untouched by this classification.
pub fn route_for_model(model: &str) -> CostRoute {
let normalized = normalize_model_id(model);
if MANAGED_MODEL_SLUGS.contains(&normalized.as_str()) {
CostRoute::Managed
} else {
CostRoute::Byok
}
}
/// Lower-case, trim, and strip the decorations a model id can pick up on its
/// way to a cost record: a `hint:` prefix from the tier-resolution helpers and
/// an `openhuman/` provider qualifier.
///
/// Stripping loops until neither prefix applies, so the two decorations are
/// **order-independent**. A single fixed-order pass classified
/// `openhuman/hint:chat-v1` as BYOK (it stripped `openhuman/`, leaving
/// `hint:chat-v1`, which is not a managed slug) while `hint:openhuman/chat-v1`
/// classified as Managed — meaning managed spend could silently stop counting
/// toward the cap depending only on which decoration a recording site applied
/// first.
fn normalize_model_id(model: &str) -> String {
let mut current = model.trim().to_ascii_lowercase();
loop {
let stripped = current
.strip_prefix("hint:")
.or_else(|| current.strip_prefix("openhuman/"));
match stripped {
Some(rest) => current = rest.to_string(),
None => return current,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Both decorations, in BOTH orders, must normalize to the same slug.
/// A fixed-order strip classified `openhuman/hint:…` as BYOK, so managed
/// spend silently stopped counting toward the cap depending only on which
/// prefix a recording site applied first (review, #5016).
#[test]
fn decoration_prefixes_are_order_independent() {
for id in [
"chat-v1",
"hint:chat-v1",
"openhuman/chat-v1",
"hint:openhuman/chat-v1",
"openhuman/hint:chat-v1",
" HINT:OpenHuman/Chat-V1 ",
] {
assert_eq!(
route_for_model(id),
CostRoute::Managed,
"{id} must classify as managed"
);
}
}
#[test]
fn decorations_do_not_promote_a_byok_model_to_managed() {
for id in [
"openhuman/hint:llama3:8b",
"hint:openhuman/anthropic/claude-sonnet-4-20250514",
"ollama:chat-v1-not-a-slug",
] {
assert_eq!(route_for_model(id), CostRoute::Byok, "{id} must stay BYOK");
}
}
use crate::openhuman::config::{
MODEL_AGENTIC_V1, MODEL_BURST_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1,
MODEL_REASONING_V1, MODEL_SUMMARIZATION_V1, MODEL_VISION_V1,
};
#[test]
fn managed_tier_slugs_are_managed() {
for slug in MANAGED_MODEL_SLUGS {
assert_eq!(
route_for_model(slug),
CostRoute::Managed,
"tier slug {slug} must bill as managed"
);
}
}
/// The local slug list must not drift from the backend tier constants. If a
/// new tier is introduced upstream, this fails until it is classified here
/// — otherwise managed spend on the new tier would silently stop counting
/// toward the budget.
#[test]
fn managed_tier_slugs_stay_in_sync() {
let upstream = [
MODEL_CHAT_V1,
MODEL_REASONING_V1,
MODEL_REASONING_QUICK_V1,
MODEL_AGENTIC_V1,
MODEL_BURST_V1,
MODEL_CODING_V1,
MODEL_VISION_V1,
MODEL_SUMMARIZATION_V1,
];
for tier in upstream {
assert!(
MANAGED_MODEL_SLUGS.contains(&tier),
"backend tier {tier} is not classified as managed"
);
}
assert_eq!(
MANAGED_MODEL_SLUGS.len(),
upstream.len(),
"MANAGED_MODEL_SLUGS has an entry with no matching backend tier constant"
);
}
#[test]
fn byok_and_local_models_are_not_managed() {
// The exact models from #5016 / #5127 (OpenRouter + a self-hosted
// OpenAI-compatible gateway) and other common BYOK / local shapes.
for model in [
"minimax/minimax-m3",
"anthropic/claude-sonnet-4-20250514",
"openai/gpt-4o",
"llama3:8b",
"ollama:gemma3:1b-it-qat",
"lmstudio:qwen2.5-coder",
"",
] {
assert_eq!(
route_for_model(model),
CostRoute::Byok,
"{model} must not bill as managed"
);
}
}
#[test]
fn normalizes_case_whitespace_and_prefixes() {
assert_eq!(route_for_model(" Chat-V1 "), CostRoute::Managed);
assert_eq!(route_for_model("hint:chat-v1"), CostRoute::Managed);
assert_eq!(
route_for_model("openhuman/reasoning-v1"),
CostRoute::Managed
);
}
#[test]
fn a_byok_model_merely_containing_a_tier_name_is_not_managed() {
// Substring matching would misclassify these and silently re-introduce
// the phantom limit for the user.
for model in ["vendor/chat-v1-turbo", "my-chat-v1", "chat-v1x"] {
assert_eq!(route_for_model(model), CostRoute::Byok, "{model}");
}
}
#[test]
fn only_managed_counts_toward_budget() {
assert!(CostRoute::Managed.counts_toward_budget());
assert!(!CostRoute::Byok.counts_toward_budget());
}
}
+105 -5
View File
@@ -1,3 +1,4 @@
use super::route::route_for_model;
use super::types::{
BudgetCheck, BudgetStatus, CostDashboard, CostRecord, CostSummary, DailyCostEntry, ModelStats,
TokenUsage, UsagePeriod,
@@ -51,6 +52,17 @@ impl CostTracker {
}
/// Check if a request is within budget.
///
/// Only **managed-route** spend is considered (#5016). The local `[cost]`
/// limits cap spend against OpenHuman credits; bring-your-own-key and
/// local inference is billed by the user's own provider, so counting it
/// here produced a phantom limit — a pure-BYOK user accrued locally
/// *estimated* spend they were never charged for and got "You're out of
/// credits" at the default $10/day. See [`super::route`].
///
/// A pure-BYOK user therefore has zero managed spend and can never trip
/// this gate. Real managed-credit exhaustion is unaffected: it is enforced
/// server-side by the backend, which returns its own billing error.
pub fn check_budget(&self, estimated_cost_usd: f64) -> Result<BudgetCheck> {
if !self.config.enabled {
return Ok(BudgetCheck::Allowed);
@@ -63,7 +75,23 @@ impl CostTracker {
}
let mut storage = self.lock_storage();
let (daily_cost, monthly_cost) = storage.get_aggregated_costs()?;
let (daily_cost, monthly_cost) = storage.get_aggregated_managed_costs()?;
// The all-routes totals exist purely to make the managed-vs-BYOK split
// visible in a debug log. `tracing` evaluates field expressions eagerly,
// so compute them only when that level is actually enabled rather than
// on every budget check in production.
if tracing::enabled!(tracing::Level::DEBUG) {
let (daily_all, monthly_all) = storage.get_aggregated_costs()?;
tracing::debug!(
daily_managed_usd = daily_cost,
monthly_managed_usd = monthly_cost,
daily_all_routes_usd = daily_all,
monthly_all_routes_usd = monthly_all,
daily_limit_usd = self.config.daily_limit_usd,
monthly_limit_usd = self.config.monthly_limit_usd,
"[cost] budget check against managed-route spend only (BYOK excluded, #5016)"
);
}
// Check daily limit
let projected_daily = daily_cost + estimated_cost_usd;
@@ -183,12 +211,20 @@ impl CostTracker {
storage.get_cost_for_date(date)
}
/// Get the monthly cost for a specific month.
/// Get the monthly cost for a specific month, across all routes.
pub fn get_monthly_cost(&self, year: i32, month: u32) -> Result<f64> {
let storage = self.lock_storage();
storage.get_cost_for_month(year, month)
}
/// Get the **managed-route** monthly cost for a specific month — the
/// portion of spend the local `[cost]` budget applies to (#5016). BYOK and
/// local inference is excluded because OpenHuman never bills for it.
pub fn get_managed_monthly_cost(&self, year: i32, month: u32) -> Result<f64> {
let storage = self.lock_storage();
storage.get_managed_cost_for_month(year, month)
}
/// Get a daily cost/token history covering the last `days` calendar days,
/// ending on today (UTC) inclusive. Days with no recorded usage are
/// returned as zero-filled entries so callers can render the chart bars
@@ -300,9 +336,16 @@ impl CostTracker {
let budget_limit_monthly_usd = self.config.monthly_limit_usd.max(0.0);
let now = Utc::now();
// `month_to_date_usd` stays an all-route total: users want to see what
// their BYOK providers cost them. Budget utilisation and status,
// however, describe a limit that only gates *managed* spend (#5016) —
// driving them off the total made a pure-BYOK user's gauge fill to
// 100% against a cap that can never fire, which is the phantom limit
// reported in the issue.
let month_to_date_usd = self.get_monthly_cost(now.year(), now.month())?;
let managed_month_to_date_usd = self.get_managed_monthly_cost(now.year(), now.month())?;
let budget_utilization = if budget_limit_monthly_usd > 0.0 {
(month_to_date_usd / budget_limit_monthly_usd).clamp(0.0, 1.0)
(managed_month_to_date_usd / budget_limit_monthly_usd).clamp(0.0, 1.0)
} else {
0.0
};
@@ -312,7 +355,7 @@ impl CostTracker {
} else {
let warn = warn_threshold.clamp(0.0, 1.0);
let alert = alert_threshold.clamp(0.0, 1.0).max(warn);
let utilization_raw = month_to_date_usd / budget_limit_monthly_usd;
let utilization_raw = managed_month_to_date_usd / budget_limit_monthly_usd;
if utilization_raw >= alert {
BudgetStatus::Exceeded
} else if utilization_raw >= warn {
@@ -415,6 +458,12 @@ struct CostStorage {
path: PathBuf,
daily_cost_usd: f64,
monthly_cost_usd: f64,
/// Managed-route subset of `daily_cost_usd` — the only spend the local
/// `[cost]` budget may gate (#5016). BYOK/local spend is still counted in
/// the totals above so the dashboard shows it, but never here.
daily_managed_cost_usd: f64,
/// Managed-route subset of `monthly_cost_usd`. See above.
monthly_managed_cost_usd: f64,
cached_day: NaiveDate,
cached_year: i32,
cached_month: u32,
@@ -433,6 +482,8 @@ impl CostStorage {
path: path.to_path_buf(),
daily_cost_usd: 0.0,
monthly_cost_usd: 0.0,
daily_managed_cost_usd: 0.0,
monthly_managed_cost_usd: 0.0,
cached_day: now.date_naive(),
cached_year: now.year(),
cached_month: now.month(),
@@ -491,21 +542,34 @@ impl CostStorage {
fn rebuild_aggregates(&mut self, day: NaiveDate, year: i32, month: u32) -> Result<()> {
let mut daily_cost = 0.0;
let mut monthly_cost = 0.0;
let mut daily_managed_cost = 0.0;
let mut monthly_managed_cost = 0.0;
self.for_each_record(|record| {
let timestamp = record.usage.timestamp.naive_utc();
// Classified from the record's own model id, so records written by
// builds that predate #5016 are routed correctly with no migration.
let counts = route_for_model(&record.usage.model).counts_toward_budget();
if timestamp.date() == day {
daily_cost += record.usage.cost_usd;
if counts {
daily_managed_cost += record.usage.cost_usd;
}
}
if timestamp.year() == year && timestamp.month() == month {
monthly_cost += record.usage.cost_usd;
if counts {
monthly_managed_cost += record.usage.cost_usd;
}
}
})?;
self.daily_cost_usd = daily_cost;
self.monthly_cost_usd = monthly_cost;
self.daily_managed_cost_usd = daily_managed_cost;
self.monthly_managed_cost_usd = monthly_managed_cost;
self.cached_day = day;
self.cached_year = year;
self.cached_month = month;
@@ -547,22 +611,39 @@ impl CostStorage {
self.ensure_period_cache_current()?;
let timestamp = record.usage.timestamp.naive_utc();
let counts = route_for_model(&record.usage.model).counts_toward_budget();
if timestamp.date() == self.cached_day {
self.daily_cost_usd += record.usage.cost_usd;
if counts {
self.daily_managed_cost_usd += record.usage.cost_usd;
}
}
if timestamp.year() == self.cached_year && timestamp.month() == self.cached_month {
self.monthly_cost_usd += record.usage.cost_usd;
if counts {
self.monthly_managed_cost_usd += record.usage.cost_usd;
}
}
Ok(())
}
/// Get aggregated costs for current day and month.
/// Get aggregated costs for current day and month, across **all** routes.
/// This is the dashboard/summary figure — use
/// [`Self::get_aggregated_managed_costs`] for anything that gates a
/// request.
fn get_aggregated_costs(&mut self) -> Result<(f64, f64)> {
self.ensure_period_cache_current()?;
Ok((self.daily_cost_usd, self.monthly_cost_usd))
}
/// Get aggregated **managed-route** costs for the current day and month —
/// the only spend the local `[cost]` budget may gate (#5016).
fn get_aggregated_managed_costs(&mut self) -> Result<(f64, f64)> {
self.ensure_period_cache_current()?;
Ok((self.daily_managed_cost_usd, self.monthly_managed_cost_usd))
}
/// Get cost for a specific date.
fn get_cost_for_date(&self, date: NaiveDate) -> Result<f64> {
let mut cost = 0.0;
@@ -589,6 +670,25 @@ impl CostStorage {
Ok(cost)
}
/// Get **managed-route** cost for a specific month — the figure the budget
/// gauge should reflect, since only managed spend can trip the limit
/// (#5016).
fn get_managed_cost_for_month(&self, year: i32, month: u32) -> Result<f64> {
let mut cost = 0.0;
self.for_each_record(|record| {
let timestamp = record.usage.timestamp.naive_utc();
if timestamp.year() == year
&& timestamp.month() == month
&& route_for_model(&record.usage.model).counts_toward_budget()
{
cost += record.usage.cost_usd;
}
})?;
Ok(cost)
}
}
#[cfg(test)]
+226 -5
View File
@@ -9,6 +9,14 @@ fn enabled_config() -> CostConfig {
}
}
/// A managed-backend tier slug — spend on this route is billed to OpenHuman
/// credits and so is the only kind the local budget may gate (#5016).
const MANAGED_MODEL: &str = "chat-v1";
/// A bring-your-own-key model id, as reported in #5016 (OpenRouter). Spend
/// here is billed by the user's own provider and must never gate a request.
const BYOK_MODEL: &str = "minimax/minimax-m3";
#[test]
fn cost_tracker_initialization() {
let tmp = TempDir::new().unwrap();
@@ -54,8 +62,9 @@ fn budget_exceeded_daily_limit() {
let tracker = CostTracker::new(config, tmp.path()).unwrap();
// Record a usage that exceeds the limit
let usage = TokenUsage::new("test/model", 10000, 5000, 1.0, 2.0); // ~0.02 USD
// Record managed-route usage that exceeds the limit. Only managed spend
// gates a request (#5016), so the model id has to be a backend tier slug.
let usage = TokenUsage::new(MANAGED_MODEL, 10000, 5000, 1.0, 2.0); // ~0.02 USD
tracker.record_usage(usage).unwrap();
let check = tracker.check_budget(0.01).unwrap();
@@ -225,7 +234,7 @@ fn budget_monthly_exceeded() {
};
let tracker = CostTracker::new(config, tmp.path()).unwrap();
let usage = TokenUsage::new("test/model", 10000, 5000, 1.0, 2.0);
let usage = TokenUsage::new(MANAGED_MODEL, 10000, 5000, 1.0, 2.0);
tracker.record_usage(usage).unwrap();
let check = tracker.check_budget(0.01).unwrap();
@@ -238,6 +247,216 @@ fn budget_monthly_exceeded() {
));
}
// ── BYOK budget exemption (#5016 / #5127) ──────────────────────────────
//
// The reported bug: a user with no OpenHuman credits configured, routing all
// inference through their own OpenRouter key, accumulated locally *estimated*
// spend until they tripped the default $10/day cap and were told "You're out
// of credits" — for inference OpenHuman never billed them for.
#[test]
fn byok_spend_never_exceeds_the_daily_limit() {
let tmp = TempDir::new().unwrap();
let config = CostConfig {
enabled: true,
daily_limit_usd: 0.01,
..Default::default()
};
let tracker = CostTracker::new(config, tmp.path()).unwrap();
// Far past the $0.01 daily cap — and irrelevant, because it is BYOK.
let usage = TokenUsage::new(BYOK_MODEL, 10_000_000, 5_000_000, 1.0, 2.0);
tracker.record_usage(usage).unwrap();
// Estimate 0.0, matching what `CostBudgetMiddleware::before_model` actually
// passes. Charging the whole $0.01 limit to the *current* request would trip
// the 80% warning on that request's own projected cost, which says nothing
// about whether the recorded BYOK history leaked into the budget.
let check = tracker.check_budget(0.0).unwrap();
assert!(
matches!(check, BudgetCheck::Allowed),
"BYOK spend must never gate a request, got {check:?}"
);
// `Exceeded` is the only variant that actually blocks a request, so pin it
// separately: even a request that would consume the entire remaining limit
// must not be blocked by BYOK history.
assert!(
!matches!(
tracker.check_budget(0.01).unwrap(),
BudgetCheck::Exceeded { .. }
),
"BYOK history must never push a request over the managed cap"
);
}
#[test]
fn byok_spend_never_exceeds_the_monthly_limit() {
let tmp = TempDir::new().unwrap();
let config = CostConfig {
enabled: true,
daily_limit_usd: 1000.0,
monthly_limit_usd: 0.01,
..Default::default()
};
let tracker = CostTracker::new(config, tmp.path()).unwrap();
let usage = TokenUsage::new(BYOK_MODEL, 10_000_000, 5_000_000, 1.0, 2.0);
tracker.record_usage(usage).unwrap();
// Estimate 0.0, as `CostBudgetMiddleware::before_model` passes: charging the
// whole $0.01 limit to the current request would trip the 80% warning on
// that request's own cost, which says nothing about the BYOK history.
let check = tracker.check_budget(0.0).unwrap();
assert!(
matches!(check, BudgetCheck::Allowed),
"BYOK spend must never gate a request, got {check:?}"
);
assert!(
!matches!(
tracker.check_budget(0.01).unwrap(),
BudgetCheck::Exceeded { .. }
),
"BYOK history must never push a request over the managed monthly cap"
);
}
#[test]
fn byok_spend_does_not_trip_the_warning_threshold_either() {
let tmp = TempDir::new().unwrap();
let config = CostConfig {
enabled: true,
daily_limit_usd: 10.0,
warn_at_percent: 80,
monthly_limit_usd: 1000.0,
..Default::default()
};
let tracker = CostTracker::new(config, tmp.path()).unwrap();
let mut usage = TokenUsage::new(BYOK_MODEL, 1000, 500, 1.0, 1.0);
usage.cost_usd = 9.5; // 95% of the daily limit, if it counted
tracker.record_usage(usage).unwrap();
let check = tracker.check_budget(0.0).unwrap();
assert!(
matches!(check, BudgetCheck::Allowed),
"BYOK spend must not raise a budget warning, got {check:?}"
);
}
#[test]
fn byok_spend_is_still_recorded_for_the_dashboard() {
// Exempting BYOK from the *budget* must not hide it from usage reporting:
// the user in #5016 explicitly wanted to understand the counter.
let tmp = TempDir::new().unwrap();
let tracker = CostTracker::new(enabled_config(), tmp.path()).unwrap();
let mut usage = TokenUsage::new(BYOK_MODEL, 1000, 500, 1.0, 1.0);
usage.cost_usd = 4.25;
tracker.record_usage(usage).unwrap();
let summary = tracker.get_summary().unwrap();
assert_eq!(summary.request_count, 1);
assert!((summary.daily_cost_usd - 4.25).abs() < 0.0001);
assert!((summary.session_cost_usd - 4.25).abs() < 0.0001);
}
#[test]
fn managed_spend_still_gates_when_byok_spend_is_also_present() {
// A mixed user: BYOK for chat, managed for background workloads. Only the
// managed portion may push them over the limit.
let tmp = TempDir::new().unwrap();
let config = CostConfig {
enabled: true,
daily_limit_usd: 5.0,
monthly_limit_usd: 1000.0,
..Default::default()
};
let tracker = CostTracker::new(config, tmp.path()).unwrap();
let mut byok = TokenUsage::new(BYOK_MODEL, 1000, 500, 1.0, 1.0);
byok.cost_usd = 100.0; // dwarfs the limit, and must be ignored
tracker.record_usage(byok).unwrap();
let mut managed = TokenUsage::new(MANAGED_MODEL, 1000, 500, 1.0, 1.0);
managed.cost_usd = 2.0; // under the $5 limit on its own
tracker.record_usage(managed).unwrap();
assert!(
matches!(tracker.check_budget(0.0).unwrap(), BudgetCheck::Allowed),
"managed spend is under the limit; BYOK spend must not push it over"
);
let mut more_managed = TokenUsage::new(MANAGED_MODEL, 1000, 500, 1.0, 1.0);
more_managed.cost_usd = 4.0; // 2.0 + 4.0 = 6.0 > 5.0
tracker.record_usage(more_managed).unwrap();
assert!(
matches!(
tracker.check_budget(0.0).unwrap(),
BudgetCheck::Exceeded { .. }
),
"managed spend over the limit must still gate"
);
}
#[test]
fn legacy_byok_records_are_exempt_after_an_aggregate_rebuild() {
// Records persisted by builds that predate #5016 carry no route field. The
// route is derived from the model id they already store, so a tracker that
// rebuilds its aggregates from disk classifies them correctly with no
// migration — this is what unblocks an affected user on upgrade rather
// than making them wait out the window.
let tmp = TempDir::new().unwrap();
let today = Utc::now();
write_raw_record(tmp.path(), &dated_record("legacy", BYOK_MODEL, 50.0, today));
let config = CostConfig {
enabled: true,
daily_limit_usd: 10.0,
monthly_limit_usd: 10.0,
..Default::default()
};
let tracker = CostTracker::new(config, tmp.path()).unwrap();
assert!(
matches!(tracker.check_budget(0.0).unwrap(), BudgetCheck::Allowed),
"pre-existing BYOK history must not keep an upgraded user blocked"
);
// …while still showing up in the usage figures.
let now = Utc::now();
let monthly = tracker.get_monthly_cost(now.year(), now.month()).unwrap();
assert!((monthly - 50.0).abs() < 0.0001);
let managed_monthly = tracker
.get_managed_monthly_cost(now.year(), now.month())
.unwrap();
assert!(managed_monthly.abs() < f64::EPSILON);
}
#[test]
fn dashboard_budget_gauge_reflects_managed_spend_only() {
// The phantom "$10/day limit" in the issue was also visible as a budget
// gauge filling up from BYOK spend against a cap that could never fire.
let tmp = TempDir::new().unwrap();
let today = Utc::now();
write_raw_record(tmp.path(), &dated_record("s1", BYOK_MODEL, 95.0, today));
let config = CostConfig {
enabled: true,
monthly_limit_usd: 100.0,
..Default::default()
};
let tracker = CostTracker::new(config, tmp.path()).unwrap();
let dash = tracker.get_dashboard("USD", 0.8, 0.95).unwrap();
// Usage is still reported…
assert!((dash.month_to_date_usd - 95.0).abs() < 0.0001);
assert!((dash.period_total_usd - 95.0).abs() < 0.0001);
// …but the budget gauge stays empty, because none of it is gateable.
assert_eq!(dash.budget_status, BudgetStatus::Normal);
assert!(dash.budget_utilization.abs() < f64::EPSILON);
}
#[test]
fn get_daily_cost_for_today() {
let tmp = TempDir::new().unwrap();
@@ -369,7 +588,9 @@ fn get_dashboard_computes_period_total_and_monthly_pace() {
fn get_dashboard_budget_status_warning_and_exceeded() {
let tmp = TempDir::new().unwrap();
let today = Utc::now();
write_raw_record(tmp.path(), &dated_record("s1", "model-a", 85.0, today));
// Managed-route spend: the budget gauge only tracks what can be gated
// (#5016), so these have to be managed tier ids to move the status.
write_raw_record(tmp.path(), &dated_record("s1", MANAGED_MODEL, 85.0, today));
let config = CostConfig {
enabled: true,
@@ -380,7 +601,7 @@ fn get_dashboard_budget_status_warning_and_exceeded() {
let warn_dash = tracker.get_dashboard("USD", 0.8, 0.95).unwrap();
assert_eq!(warn_dash.budget_status, BudgetStatus::Warning);
write_raw_record(tmp.path(), &dated_record("s1", "model-a", 15.0, today));
write_raw_record(tmp.path(), &dated_record("s1", MANAGED_MODEL, 15.0, today));
let tracker2 = CostTracker::new(config, tmp.path()).unwrap();
let alert_dash = tracker2.get_dashboard("USD", 0.8, 0.95).unwrap();
assert_eq!(alert_dash.budget_status, BudgetStatus::Exceeded);
+20 -1
View File
@@ -1936,12 +1936,31 @@ impl Middleware<()> for CostBudgetMiddleware {
&self,
_ctx: &mut RunContext<()>,
_state: &(),
_request: &mut ModelRequest,
request: &mut ModelRequest,
) -> TaResult<()> {
use crate::openhuman::cost::types::BudgetCheck;
let Some(tracker) = crate::openhuman::cost::try_global() else {
return Ok(());
};
// #5016: exempt the CURRENT request when it is BYOK, not just BYOK
// history. Excluding BYOK from the managed totals alone still refuses a
// mixed-route user's own-key calls once their managed spend has
// legitimately crossed the cap — managed exhaustion would disable the
// provider OpenHuman never bills for, which is the whole bug. Classify
// this call's route and skip the gate when OpenHuman is not the biller.
if let Some(model) = request.model.as_deref() {
let route = crate::openhuman::cost::route::route_for_model(model);
if !route.counts_toward_budget() {
tracing::debug!(
%model,
?route,
"[tinyagents::mw] BYOK/local route — skipping the managed budget gate (#5016)"
);
return Ok(());
}
}
// Pass 0.0 to test whether we are *already* over budget before spending
// more (rather than projecting this call's cost, which needs a token
// estimate).