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
7 changes: 7 additions & 0 deletions assets/i18n/desktop/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,10 @@
"ChatRequestFailed": "Request failed",
"ChatSessionRefreshFailed": "Refreshing the session failed.",
"ChatSessionStatsCacheHit": "cache hit {percent}%",
"ChatSessionStatsCostEstimatedTooltip": "Estimated from each model's token counts and prices, subagents included.",
"ChatSessionStatsCostBilledTooltip": "The cost {agent} recorded.",
"ChatSessionStatsCostSubagents": "Subagents: {usd}",
"ChatSessionStatsCostUnpriced": "No price, left out: {models}",
"ChatSessionStatsTokens": "{tokens} tok",
"ChatSessionStatsTokensTooltip": "Tokens billed across this conversation. The cache hit is the share of the newest request's input served from cache.",
"ChatSessionStatsTurns": "{count} turns",
Expand Down Expand Up @@ -606,6 +610,9 @@
"TitlebarOpenFolder": "Open folder…",
"TitlebarNoProject": "No project",
"TitlebarSearchGoTo": "Go to",
"TitlebarSearchInConversations": "In chats",
"TitlebarSearchIndexing": "Indexing chats…",
"TitlebarSearchNoContentMatches": "No chat mentions these words",
"TitlebarSearchPlaceholder": "Search chats, files or screens",
"ExplorerNoProject": "Pick a project or open a folder in the title bar to browse its files.",
"ExplorerEmptyFolder": "This folder is empty",
Expand Down
7 changes: 7 additions & 0 deletions assets/i18n/desktop/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,10 @@
"ChatRequestFailed": "リクエストに失敗しました",
"ChatSessionRefreshFailed": "セッションの更新に失敗しました。",
"ChatSessionStatsCacheHit": "キャッシュヒット {percent}%",
"ChatSessionStatsCostEstimatedTooltip": "各モデルのトークン数と価格から推定した費用です。サブエージェントも含みます。",
"ChatSessionStatsCostBilledTooltip": "{agent} が記録した費用です。",
"ChatSessionStatsCostSubagents": "うちサブエージェント {usd}",
"ChatSessionStatsCostUnpriced": "価格がなく除外:{models}",
"ChatSessionStatsTokens": "{tokens} tok",
"ChatSessionStatsTokensTooltip": "この会話全体で課金されたトークン数。キャッシュヒットは最新リクエストの入力のうちキャッシュから読まれた割合です。",
"ChatSessionStatsTurns": "{count} ターン",
Expand Down Expand Up @@ -606,6 +610,9 @@
"TitlebarOpenFolder": "フォルダーを開く…",
"TitlebarNoProject": "プロジェクト未選択",
"TitlebarSearchGoTo": "移動",
"TitlebarSearchInConversations": "チャットの内容",
"TitlebarSearchIndexing": "チャットをインデックス中…",
"TitlebarSearchNoContentMatches": "これらの語を含むチャットはありません",
"TitlebarSearchPlaceholder": "会話・ファイル・画面を検索",
"ExplorerNoProject": "タイトルバーでプロジェクトを選ぶかフォルダーを開くと、ファイルを表示できます。",
"ExplorerEmptyFolder": "このフォルダーは空です",
Expand Down
7 changes: 7 additions & 0 deletions assets/i18n/desktop/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,10 @@
"ChatRequestFailed": "请求失败",
"ChatSessionRefreshFailed": "刷新会话失败。",
"ChatSessionStatsCacheHit": "缓存命中 {percent}%",
"ChatSessionStatsCostEstimatedTooltip": "按各模型的 token 数和价格估算,子代理也计算在内。",
"ChatSessionStatsCostBilledTooltip": "{agent} 记录的花费。",
"ChatSessionStatsCostSubagents": "其中子代理 {usd}",
"ChatSessionStatsCostUnpriced": "没有价格、未计入:{models}",
"ChatSessionStatsTokens": "{tokens} tok",
"ChatSessionStatsTokensTooltip": "本会话累计计费的 tokens。缓存命中是最新一次请求的输入中由缓存提供的比例。",
"ChatSessionStatsTurns": "{count} 轮",
Expand Down Expand Up @@ -606,6 +610,9 @@
"TitlebarOpenFolder": "打开文件夹…",
"TitlebarNoProject": "未选择项目",
"TitlebarSearchGoTo": "前往",
"TitlebarSearchInConversations": "会话内容",
"TitlebarSearchIndexing": "正在索引会话…",
"TitlebarSearchNoContentMatches": "没有会话提到这些词",
"TitlebarSearchPlaceholder": "搜索会话、文件或页面",
"ExplorerNoProject": "在标题栏选择项目或打开文件夹,即可浏览其中的文件。",
"ExplorerEmptyFolder": "此文件夹为空",
Expand Down
155 changes: 155 additions & 0 deletions crates/rebon-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,71 @@ pub struct ModelTokenPricing {
pub cache_write: f64,
}

impl ModelTokenPricing {
/// What one usage record, or a total of several, costs at these rates,
/// in USD.
///
/// The one place token counts become money: `/cost` and the desktop's
/// session cost both price through it, so the two cannot disagree.
pub fn usd_for(&self, usage: &rebon_types::Usage) -> f64 {
let billed = BilledTokens::of(usage);
let cost = |tokens: u64, rate: f64| tokens as f64 * rate / 1_000_000.0;
cost(billed.input, self.input)
+ cost(billed.output, self.output)
+ cost(billed.cache_read, self.cache_read)
+ cost(billed.cache_write, self.cache_write)
}
}

/// The four token counts a bill is made of, after the per-vendor shapes are
/// reconciled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct BilledTokens {
pub input: u64,
pub output: u64,
pub cache_read: u64,
pub cache_write: u64,
}

impl BilledTokens {
/// Split one usage record into what each rate applies to.
///
/// openai-shaped usage already counts hits and misses inside
/// `input_tokens`; only the hit slice moves to the cache-read rate, and
/// misses stay billed as plain input. Anthropic-shaped usage reports the
/// cache counts alongside `input_tokens` instead.
///
/// One function because two callers price the same session in two
/// currencies, and a second copy of this arithmetic would eventually
/// disagree with the first.
pub fn of(usage: &rebon_types::Usage) -> Self {
let openai_shaped = usage.input_tokens > 0
&& (usage.prompt_cache_hit_tokens > 0 || usage.prompt_cache_miss_tokens > 0);
Self {
input: u64::from(if openai_shaped {
usage
.input_tokens
.saturating_sub(usage.prompt_cache_hit_tokens)
} else {
usage.input_tokens
}),
output: u64::from(usage.output_tokens),
cache_read: u64::from(
usage
.cache_read_input_tokens
.saturating_add(usage.prompt_cache_hit_tokens),
),
cache_write: u64::from(if openai_shaped {
usage.cache_creation_input_tokens
} else {
usage
.cache_creation_input_tokens
.saturating_add(usage.prompt_cache_miss_tokens)
}),
}
}
}

pub type ModelPricingCatalog = BTreeMap<String, BTreeMap<String, ModelTokenPricing>>;

/// Built-in offline pricing.
Expand Down Expand Up @@ -268,6 +333,96 @@ pub fn resolve_model_pricing(
})
}

#[cfg(test)]
mod usage_pricing_tests {
use super::*;
use rebon_types::Usage;

const RATES: ModelTokenPricing = ModelTokenPricing {
input: 10.0,
output: 50.0,
cache_read: 1.0,
cache_write: 12.5,
};

fn usage(input: u32, output: u32, read: u32, write: u32, hit: u32, miss: u32) -> Usage {
Usage {
input_tokens: input,
output_tokens: output,
cache_read_input_tokens: read,
cache_creation_input_tokens: write,
prompt_cache_hit_tokens: hit,
prompt_cache_miss_tokens: miss,
..Usage::default()
}
}

fn billed(input: u64, output: u64, cache_read: u64, cache_write: u64) -> BilledTokens {
BilledTokens {
input,
output,
cache_read,
cache_write,
}
}

#[test]
fn anthropic_shape_bills_cache_counts_beside_input() {
assert_eq!(
BilledTokens::of(&usage(10, 20, 300, 40, 0, 0)),
billed(10, 20, 300, 40)
);
}

#[test]
fn openai_shape_moves_only_the_hit_slice_out_of_input() {
assert_eq!(
BilledTokens::of(&usage(1000, 5, 0, 0, 600, 400)),
billed(400, 5, 600, 0)
);
}

#[test]
fn openai_misses_alone_stay_plain_input() {
assert_eq!(
BilledTokens::of(&usage(1000, 5, 0, 7, 0, 400)),
billed(1000, 5, 0, 7)
);
}

#[test]
fn hits_without_input_are_anthropic_shaped() {
// No input to carve the hit slice out of: the miss counts as a write.
assert_eq!(
BilledTokens::of(&usage(0, 5, 2, 3, 600, 400)),
billed(0, 5, 602, 403)
);
}

#[test]
fn a_hit_larger_than_input_saturates() {
assert_eq!(BilledTokens::of(&usage(100, 0, 0, 0, 150, 0)).input, 0);
}

#[test]
fn usd_prices_every_category_per_million() {
let one_million_each = usage(1_000_000, 1_000_000, 1_000_000, 1_000_000, 0, 0);
assert!((RATES.usd_for(&one_million_each) - (10.0 + 50.0 + 1.0 + 12.5)).abs() < 1e-9);
assert_eq!(RATES.usd_for(&Usage::default()), 0.0);
}

#[test]
fn a_running_total_costs_what_its_records_cost() {
let records = [usage(10, 20, 300, 40, 0, 0), usage(7, 1, 900, 0, 0, 0)];
let mut total = Usage::default();
for r in records {
total.accumulate(r);
}
let separately: f64 = records.iter().map(|r| RATES.usd_for(r)).sum();
assert!((RATES.usd_for(&total) - separately).abs() < 1e-12);
}
}

#[cfg(test)]
mod model_pricing_tests {
use super::*;
Expand Down
62 changes: 3 additions & 59 deletions crates/rebon-session-runtime/src/commands/cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ fn codex_credit_summary(
continue;
};
priced_any = true;
credits += usd_for(usage, &price) * CODEX_CREDITS_PER_USD;
credits += price.usd_for(usage) * CODEX_CREDITS_PER_USD;
}
if !priced_any {
return None;
Expand Down Expand Up @@ -182,62 +182,6 @@ fn observed_cost_keys(
}
}

/// The four token counts a bill is made of, after the per-vendor shapes are
/// reconciled.
struct BilledTokens {
input: u64,
output: u64,
cache_read: u64,
cache_write: u64,
}

/// Split one usage record into what each rate applies to.
///
/// openai-shaped usage already counts hits and misses inside
/// `input_tokens`; only the hit slice moves to the cache-read rate, and
/// misses stay billed as plain input. Anthropic-shaped usage reports the
/// cache counts alongside `input_tokens` instead.
///
/// One function because two callers price the same session in two
/// currencies, and a second copy of this arithmetic would eventually
/// disagree with the first.
fn billed_tokens(usage: &rebon_types::Usage) -> BilledTokens {
let openai_shaped = usage.input_tokens > 0
&& (usage.prompt_cache_hit_tokens > 0 || usage.prompt_cache_miss_tokens > 0);
BilledTokens {
input: u64::from(if openai_shaped {
usage
.input_tokens
.saturating_sub(usage.prompt_cache_hit_tokens)
} else {
usage.input_tokens
}),
output: u64::from(usage.output_tokens),
cache_read: u64::from(
usage
.cache_read_input_tokens
.saturating_add(usage.prompt_cache_hit_tokens),
),
cache_write: u64::from(if openai_shaped {
usage.cache_creation_input_tokens
} else {
usage
.cache_creation_input_tokens
.saturating_add(usage.prompt_cache_miss_tokens)
}),
}
}

/// What one usage record costs at these rates, in USD.
fn usd_for(usage: &rebon_types::Usage, price: &crate::rebon_config::ModelTokenPricing) -> f64 {
let billed = billed_tokens(usage);
let cost = |tokens: u64, rate: f64| tokens as f64 * rate / 1_000_000.0;
cost(billed.input, price.input)
+ cost(billed.output, price.output)
+ cost(billed.cache_read, price.cache_read)
+ cost(billed.cache_write, price.cache_write)
}

pub fn format_historical_usd_cost(
usage_by_model: &std::collections::BTreeMap<(String, String), rebon_types::Usage>,
fallback: (&str, &str, rebon_types::Usage),
Expand All @@ -262,12 +206,12 @@ pub fn format_historical_usd_cost(
continue;
};

let BilledTokens {
let crate::rebon_config::BilledTokens {
input: input_tokens,
output: output_tokens,
cache_read: cache_read_tokens,
cache_write: cache_write_tokens,
} = billed_tokens(usage);
} = crate::rebon_config::BilledTokens::of(usage);
let cost = |tokens: u64, rate: f64| tokens as f64 * rate / 1_000_000.0;
let input = cost(input_tokens, price.input);
let output = cost(output_tokens, price.output);
Expand Down
50 changes: 6 additions & 44 deletions crates/rebon-session-runtime/src/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,23 @@ impl UsageLedger {
/// twice would double it -- one call site per surface, and no call from
/// anything that merely observes a turn.
///
/// The folding is [`add`], not `rebon_types::Usage::merge`: `merge` takes
/// the non-zero fields of the newer reading, which is what a stream of
/// deltas within one turn needs and the opposite of a running total.
/// The folding is [`Usage::accumulate`], not `rebon_types::Usage::merge`:
/// `merge` takes the non-zero fields of the newer reading, which is what a
/// stream of deltas within one turn needs and the opposite of a running
/// total.
pub fn add_turn(
&mut self,
provider: impl Into<String>,
model: impl Into<String>,
usage: Usage,
) {
self.last_turn = usage;
add(&mut self.total, usage);
self.total.accumulate(usage);
let bucket = self
.by_model
.entry((provider.into(), model.into()))
.or_default();
add(bucket, usage);
bucket.accumulate(usage);
}

/// What the model has reported so far *within* the turn that is running.
Expand Down Expand Up @@ -137,45 +138,6 @@ impl UsageLedger {
}
}

/// Add one turn's counters into a running total.
///
/// Saturating rather than wrapping: a session large enough to overflow these
/// counters does not exist, and a pinned ceiling is a better failure than a
/// total that wraps to zero and reads as a session that cost nothing.
///
/// The billed totals come from the `Usage` helpers rather than the reported
/// fields, because what a provider bills for an input is not always what it
/// reports as one.
fn add(target: &mut Usage, usage: Usage) {
target.input_tokens = target.input_tokens.saturating_add(usage.input_tokens);
target.output_tokens = target.output_tokens.saturating_add(usage.output_tokens);
target.total_input_tokens = target
.total_input_tokens
.saturating_add(usage.billed_input_tokens());
target.total_output_tokens = target
.total_output_tokens
.saturating_add(usage.billed_output_tokens());
target.cache_read_input_tokens = target
.cache_read_input_tokens
.saturating_add(usage.cache_read_input_tokens);
target.cache_creation_input_tokens = target
.cache_creation_input_tokens
.saturating_add(usage.cache_creation_input_tokens);
target.prompt_cache_hit_tokens = target
.prompt_cache_hit_tokens
.saturating_add(usage.prompt_cache_hit_tokens);
target.prompt_cache_miss_tokens = target
.prompt_cache_miss_tokens
.saturating_add(usage.prompt_cache_miss_tokens);
// The count this ledger replaced folded eight counters and dropped this
// one. Nothing in the cli renders reasoning tokens from a total, so
// folding it changes no output; what it changes is that the count exists
// at all for whoever asks next.
target.reasoning_tokens = target
.reasoning_tokens
.saturating_add(usage.reasoning_tokens);
}

#[cfg(any(test, feature = "test-support"))]
impl UsageLedger {
/// A ledger already holding numbers a fixture chose.
Expand Down
Loading
Loading