Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user roles, per-user folders plus admin-shared folders, read-only session sharing, event filtering per user - File manager with CodeMirror editor and git client (status, diff, stage, commit, branches, fetch/pull/push, log, clone/init) - Image generation via fal.ai: generate_image tool and image page - Budgets: monthly limits per provider, provider balances/costs (OpenRouter, Anthropic admin cost report, fal.ai), price discovery - Subagents on other models, background tasks, concurrency limits per host/model/subagent - MCP tools loaded on demand (mcp_search/mcp_call), read-only hints - Permission mode "auto", safe read-only commands always allowed - Persistent message queue with attachments, deletable - Live reasoning incl. <think> tags, activity animations, CSS themes, mobile layout, statistics per user, upload size handling - Docker: named volume for state, localhost-bound port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
@@ -0,0 +1,223 @@
|
||||
//! Budgets: monthly limits tracked from recorded costs, plus the real balance
|
||||
//! for providers that expose one (OpenRouter, fal.ai), and price discovery.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::config::{ProviderConfig, expand_env};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use chrono::{Datelike, TimeZone};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Start of the current month in local time (ms).
|
||||
pub fn month_start() -> i64 {
|
||||
let now = chrono::Local::now();
|
||||
chrono::Local
|
||||
.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0)
|
||||
.single()
|
||||
.map(|d| d.timestamp_millis())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn is_openrouter(p: &ProviderConfig) -> bool {
|
||||
p.base_url.contains("openrouter.ai")
|
||||
}
|
||||
|
||||
/// Month-to-date cost from Anthropic's cost report (organisation-wide, USD).
|
||||
async fn anthropic_cost(p: &ProviderConfig) -> Result<Value> {
|
||||
let admin = expand_env(&p.admin_key);
|
||||
let start = chrono::DateTime::from_timestamp_millis(month_start()).unwrap_or_default();
|
||||
let end = chrono::Utc::now() + chrono::Duration::days(1);
|
||||
let base = if p.base_url.is_empty() { "https://api.anthropic.com/v1".to_string() } else { expand_env(&p.base_url).trim_end_matches('/').to_string() };
|
||||
let mut total_cents = 0.0;
|
||||
let mut page: Option<String> = None;
|
||||
for _ in 0..10 {
|
||||
let mut url = reqwest::Url::parse(&format!("{base}/organizations/cost_report"))?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("starting_at", &start.format("%Y-%m-%dT00:00:00Z").to_string())
|
||||
.append_pair("ending_at", &end.format("%Y-%m-%dT00:00:00Z").to_string())
|
||||
.append_pair("limit", "31");
|
||||
if let Some(pg) = &page {
|
||||
url.query_pairs_mut().append_pair("page", pg);
|
||||
}
|
||||
let resp = crate::provider::http_client()
|
||||
.get(url)
|
||||
.header("x-api-key", &admin)
|
||||
.header("anthropic-version", "2026-10-21")
|
||||
.timeout(Duration::from_secs(20))
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(crate::provider::http_error(resp).await);
|
||||
}
|
||||
let v: Value = resp.json().await?;
|
||||
for bucket in v["data"].as_array().cloned().unwrap_or_default() {
|
||||
for r in bucket["results"].as_array().cloned().unwrap_or_default() {
|
||||
// Amounts are decimal strings in cents.
|
||||
total_cents += r["amount"].as_str().and_then(|a| a.parse::<f64>().ok()).or_else(|| r["amount"].as_f64()).unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
if v["has_more"].as_bool() != Some(true) {
|
||||
break;
|
||||
}
|
||||
page = v["next_page"].as_str().map(String::from);
|
||||
}
|
||||
Ok(json!({"currency": "USD", "spent_month": total_cents / 100.0, "scope": "organization"}))
|
||||
}
|
||||
|
||||
/// Balance or cost as reported by the provider, if it has an API for it.
|
||||
async fn remote_balance(p: &ProviderConfig) -> Option<Result<Value>> {
|
||||
if p.kind == crate::config::ProviderKind::Anthropic && !expand_env(&p.admin_key).is_empty() {
|
||||
return Some(anthropic_cost(p).await);
|
||||
}
|
||||
if !is_openrouter(p) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
async {
|
||||
let base = expand_env(&p.base_url).trim_end_matches('/').to_string();
|
||||
let resp = crate::provider::http_client()
|
||||
.get(format!("{base}/key"))
|
||||
.bearer_auth(expand_env(&p.api_key))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
let v: Value = resp.json().await?;
|
||||
let d = &v["data"];
|
||||
Ok(json!({
|
||||
"currency": "USD",
|
||||
"limit": d["limit"],
|
||||
"used": d["usage"],
|
||||
"remaining": d.get("limit_remaining").cloned().unwrap_or_else(|| match (d["limit"].as_f64(), d["usage"].as_f64()) {
|
||||
(Some(l), Some(u)) => json!(l - u),
|
||||
_ => Value::Null,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
async fn fal_balance(admin_key: &str) -> Result<Value> {
|
||||
let resp = crate::provider::http_client()
|
||||
.get("https://api.fal.ai/v1/account/billing?expand=credits")
|
||||
.header("Authorization", format!("Key {admin_key}"))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(crate::provider::http_error(resp).await);
|
||||
}
|
||||
let v: Value = resp.json().await?;
|
||||
Ok(json!({
|
||||
"currency": v["credits"]["currency"],
|
||||
"remaining": v["credits"]["current_balance"],
|
||||
}))
|
||||
}
|
||||
|
||||
fn entry(name: &str, budget: Option<f64>, spent: f64, warn: f64) -> Value {
|
||||
let share = budget.filter(|b| *b > 0.0).map(|b| spent / b);
|
||||
json!({
|
||||
"provider": name,
|
||||
"budget": budget,
|
||||
"spent": spent,
|
||||
"remaining": budget.map(|b| b - spent),
|
||||
"share": share,
|
||||
"warning": share.is_some_and(|s| s >= warn),
|
||||
"exceeded": share.is_some_and(|s| s >= 1.0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Budget overview for the current month.
|
||||
pub async fn overview(app: &App) -> Result<Value> {
|
||||
let cfg = app.config();
|
||||
let spent = app.store.cost_by_provider(month_start())?;
|
||||
let warn = cfg.server.budget_warning;
|
||||
let mut rows = Vec::new();
|
||||
let mut tasks = Vec::new();
|
||||
for (name, p) in &cfg.providers {
|
||||
if !p.enabled {
|
||||
continue;
|
||||
}
|
||||
rows.push(entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn));
|
||||
let p = p.clone();
|
||||
tasks.push(tokio::spawn(async move { remote_balance(&p).await }));
|
||||
}
|
||||
for (row, task) in rows.iter_mut().zip(tasks) {
|
||||
if let Ok(Some(res)) = task.await {
|
||||
row["remote"] = match res {
|
||||
Ok(v) => v,
|
||||
Err(e) => json!({"error": format!("{e:#}")}),
|
||||
};
|
||||
}
|
||||
}
|
||||
if cfg.images.enabled {
|
||||
let mut row = entry("fal", cfg.images.monthly_budget, spent.get("fal").copied().unwrap_or(0.0), warn);
|
||||
let admin = expand_env(&cfg.images.admin_key);
|
||||
if !admin.is_empty() {
|
||||
row["remote"] = match fal_balance(&admin).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => json!({"error": format!("{e:#}")}),
|
||||
};
|
||||
}
|
||||
row["images"] = json!(true);
|
||||
rows.push(row);
|
||||
}
|
||||
let total: f64 = spent.values().sum();
|
||||
let global = (cfg.server.monthly_budget > 0.0).then_some(cfg.server.monthly_budget);
|
||||
Ok(json!({
|
||||
"month_start": month_start(),
|
||||
"currency": cfg.server.currency,
|
||||
"total": entry("total", global, total, warn),
|
||||
"providers": rows,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Per-token prices from the provider's model list, converted to per 1M tokens.
|
||||
pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
||||
let cfg = app.config();
|
||||
let p = cfg.providers.get(provider).context("unknown provider")?;
|
||||
if !is_openrouter(p) {
|
||||
bail!("this provider does not publish prices via its API – enter them manually");
|
||||
}
|
||||
let base = expand_env(&p.base_url).trim_end_matches('/').to_string();
|
||||
let v: Value = crate::provider::http_client()
|
||||
.get(format!("{base}/models"))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
let per_m = |x: &Value| x.as_str().and_then(|s| s.parse::<f64>().ok()).or_else(|| x.as_f64()).map(|f| (f * 1_000_000.0 * 10_000.0).round() / 10_000.0);
|
||||
let list: Vec<Value> = v["data"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
"id": m["id"],
|
||||
"context": m["context_length"],
|
||||
"input_price": per_m(&m["pricing"]["prompt"]),
|
||||
"output_price": per_m(&m["pricing"]["completion"]),
|
||||
"cache_read_price": per_m(&m["pricing"]["input_cache_read"]),
|
||||
"cache_write_price": per_m(&m["pricing"]["input_cache_write"]),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"currency": "USD", "models": list}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn budget_entry_flags() {
|
||||
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
||||
assert_eq!(e["warning"], true);
|
||||
assert_eq!(e["exceeded"], false);
|
||||
assert_eq!(e["remaining"], 1.5);
|
||||
let none = super::entry("y", None, 3.0, 0.8);
|
||||
assert_eq!(none["warning"], false);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user