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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 16:54:08 +01:00
1 parent f3a2fe3816
commit 49262c612c
70 files changed
+8495 -714

No files matched your search

+223
View File
@@ -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);
}
}