From bfa1f641d759c4f5dc687a73a76e5f469146143f Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Mon, 9 Nov 2026 10:19:15 +0100 Subject: [PATCH] opencode prices and Go limits, settings out of .env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fetch prices for opencode (Zen and Go) incl. limits, image input, reasoning, long-context tiers and peak-hour/date factors; costs apply them per request - Record the cost opencode reports per response; track Go allowance in 5h/7d/month windows against a provider plan limit (budget table, status overlay) - Settings → Config file: move values of ${NAME} references into the config - Examples, presets and forms keep keys in the config instead of .env Co-Authored-By: Claude Opus 5.5 --- .env.example | 13 +-- CHANGELOG.md | 20 ++++ README.md | 10 +- cli/src/app.rs | 1 + config.example.toml | 29 ++--- core/src/config.rs | 135 ++++++++++++++++++++++- core/src/provider/anthropic.rs | 1 + core/src/provider/openai.rs | 1 + core/src/types.rs | 18 +++ server/src/api.rs | 23 ++++ server/src/budget.rs | 177 +++++++++++++++++++++++++++++- server/src/cli_api.rs | 1 + server/src/config.rs | 73 +++++++++++- server/src/images.rs | 1 + server/src/runner.rs | 2 + server/src/store.rs | 16 ++- web/src/components/Budget.tsx | 62 ++++++++--- web/src/components/Settings.tsx | 113 +++++++++++++++++-- web/src/components/StatusLeds.tsx | 20 +++- web/src/i18n/de.ts | 15 ++- web/src/i18n/en.ts | 15 ++- web/src/styles.css | 34 ++++++ web/src/whatsnew.ts | 12 ++ 23 files changed, 723 insertions(+), 69 deletions(-) diff --git a/.env.example b/.env.example index 328e87f..fa13a2a 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ +# Deployment settings only. API keys, MCP tokens, SSO secrets and everything else +# are set in the web UI (Settings) and stored in the configuration file. + # Optional: create the first admin on first start. Otherwise the server logs a # one-time setup code and you create the admin in the web UI. CODECRAFT_ADMIN_USER= CODECRAFT_ADMIN_PASSWORD= -# Single sign-on client secret (see [auth.oidc] in config.toml). -OIDC_CLIENT_SECRET= - # Directory with your projects, mounted as /workspace. WORKSPACE=/path/to/your/projects @@ -17,13 +17,6 @@ BIND_ADDR=127.0.0.1 UID=1000 GID=1000 -# Provider keys – referenced in data/config.toml as ${NAME}. -ANTHROPIC_API_KEY= -GEMINI_API_KEY= -OPENCODE_API_KEY= -FAL_KEY= -OPENROUTER_API_KEY= - # Time zone for automations (cron schedules) and timestamps, e.g. Europe/Berlin TZ=UTC diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d448cf..78dc8bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **opencode prices:** *Fetch prices* reads prices, context and output limits, image input and + reasoning from opencode's model list (Zen and Go), including long-context tiers and + peak-hour or date-based factors. Costs apply them per request (`price_tiers`, + `price_multipliers` on a model). +- **opencode costs and Go limits:** the cost opencode reports per response is recorded instead + of an estimate, and with a *plan limit* on the provider the Go allowance is tracked in the + 5-hour, 7-day and monthly windows (budget table and status overlay; usage through CodeCraft + only). +- **Settings → Config file** lists `${NAME}` references and moves the values of set variables + into the configuration, so `.env` only needs deployment settings. + +### Changed + +- Examples and forms no longer suggest environment variables for keys; `.env.example` holds + deployment settings only. + ## [v202611-00001] – 2026-11-09 ### Added diff --git a/README.md b/README.md index f5c52a5..38c52ee 100644 --- a/README.md +++ b/README.md @@ -74,14 +74,16 @@ curl -fsSLO https://git.familie-zink.org/zink/codecraft/raw/branch/main/docker-c curl -fsSL --create-dirs -o docker/seccomp-sandbox.json \ https://git.familie-zink.org/zink/codecraft/raw/branch/main/docker/seccomp-sandbox.json curl -fsSL -o .env https://git.familie-zink.org/zink/codecraft/raw/branch/main/.env.example -$EDITOR .env # workspace path, provider keys, time zone +$EDITOR .env # workspace path, port, user id, time zone docker compose -f docker-compose.release.yml up -d docker compose -f docker-compose.release.yml logs codecraft # one-time setup code for the first admin ``` Open `http://localhost:8787`, enter the setup code and create the admin account. Then add a -provider under *Settings → Providers & models* – or put the keys into `.env` and reference -them in the config as `${NAME}`. +provider under *Settings → Providers & models*. `.env` only holds deployment settings; keys, +tokens and everything else are set in the UI and stored in the configuration file +(`${NAME}` references to environment variables still work, and *Settings → Config file* moves +their values into the file). **Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`. Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with @@ -153,6 +155,8 @@ output limit, reasoning effort, extra request options and prices per million tok model's fallback. - **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that process one at a time; waiting time is shown and never counted as model latency. +- **Prices:** *Fetch prices* fills in prices for OpenRouter and opencode (Zen and Go), including + long-context tiers and peak-hour factors, which are applied per request. - **Status:** every five minutes each provider's model list is fetched (no tokens used), so you see at a glance which hosts answer and which keys are rejected. Saving a provider checks it right away, and *Test connection* checks URL and key before you save. diff --git a/cli/src/app.rs b/cli/src/app.rs index 7b626c6..71fb0fe 100644 --- a/cli/src/app.rs +++ b/cli/src/app.rs @@ -721,6 +721,7 @@ fn provider_config(preset: &crate::models::Preset, base_url: &str, key: &str) -> enabled: true, max_concurrency: None, monthly_budget: None, + plan_limit: None, admin_key: String::new(), } } diff --git a/config.example.toml b/config.example.toml index 3c33729..9d64b75 100644 --- a/config.example.toml +++ b/config.example.toml @@ -8,9 +8,10 @@ # # Location: $CODECRAFT_CONFIG, otherwise /config.toml (Docker: /data/config.toml) # -# Secrets: any string value may reference environment variables with ${NAME}, -# e.g. api_key = "${ANTHROPIC_API_KEY}". Prefer this over literal keys – keys -# entered in the UI are stored here in plain text. +# Keys and tokens are entered in the web UI (Settings) and stored here, readable +# for admins and included in backups. Any string value may also reference an +# environment variable as ${NAME}; Settings → Config file moves such values +# into this file. # ============================================================================= [server] @@ -47,7 +48,7 @@ currency = "USD" # proxy's body limit (e.g. Nginx client_max_body_size) does not apply. max_upload_mb = 500 # Bearer token for Prometheus at GET /metrics (empty = /metrics disabled). -# metrics_token = "${CODECRAFT_METRICS_TOKEN}" +# metrics_token = "a-long-random-string" # ----------------------------------------------------------------------------- @@ -81,7 +82,7 @@ max_upload_mb = 500 [providers.anthropic] kind = "anthropic" base_url = "https://api.anthropic.com/v1" -api_key = "${ANTHROPIC_API_KEY}" +api_key = "" # set in Settings # Current Claude models think adaptively; "effort" controls depth and cost. # Prices: Anthropic list prices in USD per 1M tokens (Nov 2026) – adjust if needed. models = [ @@ -97,25 +98,25 @@ models = [ [providers.gemini] kind = "openai" base_url = "https://generativelanguage.googleapis.com/v1beta/openai" -api_key = "${GEMINI_API_KEY}" +api_key = "" # set in Settings models = [] # discovered automatically -enabled = false # set to true once GEMINI_API_KEY is available +enabled = false # enable once the key is set # opencode Go subscription (OpenAI-compatible endpoint for most models). # Current model list: https://opencode.ai/inference/go/v1/models [providers.opencode-go] kind = "openai" base_url = "https://opencode.ai/inference/go/openai/v1" -api_key = "${OPENCODE_API_KEY}" +api_key = "" # set in Settings models = [] enabled = false -# fal.ai – OpenRouter-backed LLM router. fal expects "Authorization: Key ". +# fal.ai – OpenRouter-backed LLM router. fal expects "Authorization: Key ". # NOTE: verify base_url against the fal docs for your account. [providers.fal] kind = "openai" base_url = "https://fal.run/openrouter/router/openai/v1" -api_key = "${FAL_KEY}" +api_key = "" # set in Settings auth_prefix = "Key " models = [ { id = "google/gemini-2.5-flash", context = 1000000, max_output = 32000, vision = true }, @@ -151,7 +152,7 @@ enabled = false # [mcp.trilium] # type = "http" # url = "https://notes.example.org/mcp" -# headers = { Authorization = "Bearer ${TRILIUM_TOKEN}" } +# headers = { Authorization = "Bearer " } # [mcp.filesystem] # type = "stdio" @@ -243,7 +244,7 @@ generate_image = "ask" # costs money per image [images] enabled = false base_url = "https://fal.run" -api_key = "${FAL_KEY}" +api_key = "" # set in Settings default_model = "fal-ai/flux/schnell" output_dir = "generated" # The agent drafts with a "preview" model, asks which draft to refine, then @@ -288,7 +289,7 @@ cookie_secure = "auto" enabled = false issuer = "https://auth.example.org/application/o/codecraft/" client_id = "codecraft" -client_secret = "${OIDC_CLIENT_SECRET}" +client_secret = "" # set in Settings scopes = ["openid", "profile", "email", "groups"] username_claim = "preferred_username" groups_claim = "groups" @@ -309,7 +310,7 @@ button_label = "Single sign-on" # ----------------------------------------------------------------------------- [search] # "duckduckgo" (no key, may be rate-limited), "searxng" (self-hosted, set url), -# "brave" or "tavily" (api_key, e.g. "${BRAVE_API_KEY}") +# "brave" or "tavily" (set api_key in Settings) provider = "duckduckgo" url = "" api_key = "" diff --git a/core/src/config.rs b/core/src/config.rs index e77ed75..1aabf1c 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -37,6 +37,10 @@ pub struct ProviderConfig { /// Monthly budget in the configured currency (tracked from recorded costs). #[serde(default, skip_serializing_if = "Option::is_none")] pub monthly_budget: Option, + /// Monthly usage limit of a subscription plan in USD (opencode Go). Usage is tracked + /// from the allowance each response reports: 5 hours 20 %, week 50 %, month 100 %. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan_limit: Option, /// Optional admin key to read the provider's own cost report /// (Anthropic: sk-ant-admin01-…, organisation-wide). #[serde(default, skip_serializing_if = "String::is_empty")] @@ -76,6 +80,13 @@ pub struct ModelConfig { pub cache_read_price: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_write_price: Option, + /// Other prices from a context size on (e.g. long-context pricing); the largest + /// threshold below the request's context wins, missing prices keep the base price. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub price_tiers: Vec, + /// Time-based price factors (peak hours, promotions); matching factors multiply. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub price_multipliers: Vec, /// Max. simultaneous requests to this model (None = unlimited). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_concurrency: Option, @@ -87,15 +98,127 @@ pub struct ModelConfig { pub options: serde_json::Map, } +/// Prices from a context size on (tokens of the whole prompt, cache included). +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] +pub struct PriceTier { + pub above_context: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_price: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_price: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_read_price: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_write_price: Option, +} + +/// A price factor that applies at certain times (all given conditions must hold, UTC). +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] +pub struct PriceMultiplier { + pub factor: f64, + /// Weekdays ("mon" … "sun"); empty = every day. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub days: Vec, + /// Time ranges ["HH:MM", "HH:MM") in UTC; empty = all day. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hours_utc: Vec<(String, String)>, + /// First day it applies (YYYY-MM-DD, UTC). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub from: Option, + /// First day it no longer applies (YYYY-MM-DD, UTC). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub until: Option, +} + +impl PriceMultiplier { + pub fn applies(&self, at: chrono::DateTime) -> bool { + use chrono::{Datelike, Timelike}; + let day = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][at.weekday().num_days_from_monday() as usize]; + if !self.days.is_empty() && !self.days.iter().any(|d| d.eq_ignore_ascii_case(day)) { + return false; + } + let minutes = |s: &str| s.split_once(':').and_then(|(h, m)| Some(h.trim().parse::().ok()? * 60 + m.trim().parse::().ok()?)); + let now = at.hour() * 60 + at.minute(); + if !self.hours_utc.is_empty() + && !self.hours_utc.iter().any(|(a, b)| matches!((minutes(a), minutes(b)), (Some(a), Some(b)) if now >= a && now < b)) + { + return false; + } + let date = at.format("%Y-%m-%d").to_string(); + if self.from.as_deref().is_some_and(|f| date.as_str() < f) || self.until.as_deref().is_some_and(|u| date.as_str() >= u) { + return false; + } + true + } +} + impl ModelConfig { - /// Cost of one request. Cache prices default to the input price. + /// Cost of one request now. Cache prices default to the input price. + /// The provider's own figure wins when it reports one (opencode). pub fn cost(&self, u: &crate::types::Usage) -> f64 { - let inp = self.input_price.unwrap_or(0.0); + u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now())) + } + + /// Cost of one request at a given time: context tiers and time factors applied. + pub fn cost_at(&self, u: &crate::types::Usage, at: chrono::DateTime) -> f64 { + let context = u.input_tokens + u.cache_read_tokens + u.cache_write_tokens; + let tier = self.price_tiers.iter().filter(|t| context > t.above_context).max_by_key(|t| t.above_context); + let pick = |base: Option, tiered: fn(&PriceTier) -> Option| tier.and_then(tiered).or(base); + let inp = pick(self.input_price, |t| t.input_price).unwrap_or(0.0); + let out = pick(self.output_price, |t| t.output_price).unwrap_or(0.0); + let read = pick(self.cache_read_price, |t| t.cache_read_price).unwrap_or(inp); + let write = pick(self.cache_write_price, |t| t.cache_write_price).unwrap_or(inp); let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0; - per(u.input_tokens, inp) - + per(u.output_tokens, self.output_price.unwrap_or(0.0)) - + per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp)) - + per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp)) + let factor: f64 = self.price_multipliers.iter().filter(|m| m.applies(at)).map(|m| m.factor).product(); + (per(u.input_tokens, inp) + per(u.output_tokens, out) + per(u.cache_read_tokens, read) + per(u.cache_write_tokens, write)) * factor + } +} + +#[cfg(test)] +mod price_tests { + use super::*; + use crate::types::Usage; + use chrono::TimeZone; + + fn usage(input: u64, output: u64, read: u64) -> Usage { + Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() } + } + + #[test] + fn tiers_and_multipliers() { + let m = ModelConfig { + input_price: Some(2.0), + output_price: Some(6.0), + cache_read_price: Some(0.5), + price_tiers: vec![PriceTier { above_context: 200_000, input_price: Some(4.0), output_price: Some(12.0), cache_read_price: Some(1.0), ..Default::default() }], + price_multipliers: vec![PriceMultiplier { + factor: 2.0, + days: vec!["mon".into(), "tue".into(), "wed".into(), "thu".into(), "fri".into()], + hours_utc: vec![("01:00".into(), "04:00".into()), ("06:00".into(), "10:00".into())], + ..Default::default() + }], + ..Default::default() + }; + let sat = chrono::Utc.with_ymd_and_hms(2026, 11, 7, 7, 0, 0).unwrap(); // Saturday 07:00 + let mon_peak = chrono::Utc.with_ymd_and_hms(2026, 11, 9, 7, 30, 0).unwrap(); // Monday 07:30 + let mon_off = chrono::Utc.with_ymd_and_hms(2026, 11, 9, 10, 0, 0).unwrap(); // Monday 10:00 (end excluded) + let small = usage(100_000, 1_000_000, 0); + assert!((m.cost_at(&small, sat) - (0.2 + 6.0)).abs() < 1e-9); + assert!((m.cost_at(&small, mon_peak) - 2.0 * (0.2 + 6.0)).abs() < 1e-9); + assert!((m.cost_at(&small, mon_off) - (0.2 + 6.0)).abs() < 1e-9); + // 150k fresh + 100k cached = 250k context: long-context tier. + let big = usage(150_000, 0, 100_000); + assert!((m.cost_at(&big, sat) - (0.6 + 0.1)).abs() < 1e-9); + } + + #[test] + fn date_ranges() { + let m = PriceMultiplier { factor: 2.0, from: Some("2026-12-01".into()), ..Default::default() }; + assert!(!m.applies(chrono::Utc.with_ymd_and_hms(2026, 11, 30, 23, 59, 0).unwrap())); + assert!(m.applies(chrono::Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap())); + let until = PriceMultiplier { factor: 0.5, until: Some("2026-12-01".into()), ..Default::default() }; + assert!(until.applies(chrono::Utc.with_ymd_and_hms(2026, 11, 30, 12, 0, 0).unwrap())); + assert!(!until.applies(chrono::Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap())); } } diff --git a/core/src/provider/anthropic.rs b/core/src/provider/anthropic.rs index e6ef439..d191da2 100644 --- a/core/src/provider/anthropic.rs +++ b/core/src/provider/anthropic.rs @@ -266,6 +266,7 @@ impl Provider for Anthropic { if let Some(o) = data["usage"]["output_tokens"].as_u64() { usage.output_tokens = o; } + usage.read_reported_cost(&data["usage"]); } "error" => { let msg = data["error"]["message"].as_str().unwrap_or("unknown error"); diff --git a/core/src/provider/openai.rs b/core/src/provider/openai.rs index 6005640..4533164 100644 --- a/core/src/provider/openai.rs +++ b/core/src/provider/openai.rs @@ -231,6 +231,7 @@ impl Provider for OpenAi { usage.input_tokens = u["prompt_tokens"].as_u64().unwrap_or(0).saturating_sub(cached); usage.cache_read_tokens = cached; usage.output_tokens = u["completion_tokens"].as_u64().unwrap_or(0); + usage.read_reported_cost(u); } let Some(choice) = data["choices"].get(0) else { continue }; let d = &choice["delta"]; diff --git a/core/src/types.rs b/core/src/types.rs index 1462df1..2943695 100644 --- a/core/src/types.rs +++ b/core/src/types.rs @@ -93,6 +93,24 @@ pub struct Usage { pub cache_read_tokens: u64, #[serde(default)] pub cache_write_tokens: u64, + /// Cost the provider reported for the request (USD; opencode: what was charged). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, + /// opencode Go: how much the request counted toward the plan's usage limits (USD). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowance: Option, +} + +impl Usage { + /// Read the cost fields gateways add to `usage` (opencode: `cost`, `opencode.allowance`). + pub fn read_reported_cost(&mut self, u: &serde_json::Value) { + if let Some(c) = u["cost"].as_f64() { + self.cost = Some(c); + } + if let Some(a) = u["opencode"]["allowance"].as_f64() { + self.allowance = Some(a); + } + } } impl Usage { diff --git a/server/src/api.rs b/server/src/api.rs index 34ab8e2..c646f06 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -66,6 +66,7 @@ pub fn router(app: AppState) -> Router { .route("/folders/{id}/access", put(set_folder_access)) .route("/config", get(get_config).patch(patch_config)) .route("/config/raw", get(get_config_raw).put(put_config_raw)) + .route("/config/inline-env", get(inline_env_preview).post(inline_env_apply)) .route("/providers/reload", post(reload_providers)) .route("/models", get(models)) .route("/agents", get(agents)) @@ -289,6 +290,24 @@ struct RawBody { text: String, } +/// Which ${VAR} references the config still has (and whether they are set). +async fn inline_env_preview(Extension(user): Extension, State(app): State) -> ApiResult { + require_admin(&user)?; + let (_, set, unset) = config::inline_env(&app.config_text()?)?; + Ok(Json(json!({"set": set, "unset": unset}))) +} + +/// Move the values of set environment variables into the config file. +async fn inline_env_apply(Extension(user): Extension, State(app): State) -> ApiResult { + require_admin(&user)?; + let (text, set, unset) = config::inline_env(&app.config_text()?)?; + if !set.is_empty() { + app.save_config(&text).await?; + app.audit(Some(&user), "config.inline_env", set.join(", "), ""); + } + Ok(Json(json!({"set": set, "unset": unset}))) +} + async fn put_config_raw(Extension(user): Extension, State(app): State, Json(body): Json) -> ApiResult { require_admin(&user)?; app.save_config(&body.text).await?; @@ -1192,6 +1211,10 @@ async fn status_overview(Extension(user): Extension, State(app): State json!({"state": "unknown", "models": models}), }; + let mut v = v; + if let Some(limit) = cfg.providers.get(name).and_then(|p| p.plan_limit).filter(|l| *l > 0.0) { + v["plan"] = crate::budget::plan_usage(&app, name, limit).unwrap_or(Value::Null); + } (name.clone(), v) }) .collect(); diff --git a/server/src/budget.rs b/server/src/budget.rs index e0f2d61..09cdc29 100644 --- a/server/src/budget.rs +++ b/server/src/budget.rs @@ -129,6 +129,28 @@ fn entry(name: &str, budget: Option, spent: f64, warn: f64) -> Value { } /// Budget overview for the current month. +/// Subscription windows of opencode Go, as share of the plan's monthly limit: rolling +/// 5 hours (20 %) and 7 days (50 %), and the calendar month in UTC (100 %). Only usage +/// through CodeCraft is counted. +pub fn plan_usage(app: &App, provider: &str, limit: f64) -> Result { + let now = chrono::Utc::now(); + let month = chrono::Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0).single().map(|d| d.timestamp_millis()).unwrap_or(0); + let ms = now.timestamp_millis(); + let window = |name: &str, from: i64, share: f64| -> Result { + let used = app.store.allowance_since(provider, from)?; + let cap = limit * share; + Ok(json!({"window": name, "used": used, "limit": cap, "ratio": if cap > 0.0 { used / cap } else { 0.0 }})) + }; + Ok(json!({ + "limit": limit, + "windows": [ + window("5h", ms - 5 * 3_600_000, 0.2)?, + window("week", ms - 7 * 86_400_000, 0.5)?, + window("month", month, 1.0)?, + ], + })) +} + pub async fn overview(app: &App) -> Result { let cfg = app.config(); let spent = app.store.cost_by_provider(month_start())?; @@ -139,7 +161,11 @@ pub async fn overview(app: &App) -> Result { if !p.enabled { continue; } - rows.push(entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn)); + let mut row = entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn); + if let Some(limit) = p.plan_limit.filter(|l| *l > 0.0) { + row["plan"] = plan_usage(app, name, limit)?; + } + rows.push(row); let p = p.clone(); tasks.push(tokio::spawn(async move { remote_balance(&p).await })); } @@ -174,13 +200,138 @@ pub async fn overview(app: &App) -> Result { } /// Per-token prices from the provider's model list, converted to per 1M tokens. +fn is_opencode(p: &ProviderConfig) -> bool { + p.base_url.contains("opencode.ai") +} + +async fn get_json(url: &str) -> Result { + Ok(crate::provider::http_client().get(url).timeout(Duration::from_secs(30)).send().await?.error_for_status()?.json().await?) +} + +/// One opencode condition object → (days, hours, from, until); None if it uses +/// something we do not understand. +type When = (Vec, Vec<(String, String)>, Option, Option); + +fn opencode_when(w: &Value) -> Option { + let obj = w.as_object()?; + let (mut days, mut hours, mut from, mut until) = (Vec::new(), Vec::new(), None, None); + // A pair of utc_hours bounds, given as {"all": [{"utc_hours": {"gte"}}, {"utc_hours": {"lt"}}]} + // or directly as {"utc_hours": {"gte", "lt"}}. + let range = |v: &Value| -> Option<(String, String)> { + if let Some(h) = v.get("utc_hours") { + return Some((h["gte"].as_str()?.to_string(), h["lt"].as_str()?.to_string())); + } + let all = v.get("all")?.as_array()?; + let get = |k: &str| all.iter().find_map(|x| x["utc_hours"][k].as_str()).map(String::from); + Some((get("gte")?, get("lt")?)) + }; + for (k, v) in obj { + match k.as_str() { + "utc_days" => days = v["in"].as_array()?.iter().map(|d| d.as_str().map(String::from)).collect::>()?, + "any" => hours = v.as_array()?.iter().map(range).collect::>()?, + "all" | "utc_hours" => hours = vec![range(w)?], + "date" => { + from = v["gte"].as_str().map(String::from); + until = v["lt"].as_str().map(String::from); + if from.is_none() && until.is_none() { + return None; + } + } + _ => return None, + } + } + Some((days, hours, from, until)) +} + +/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors). +fn opencode_model(m: &Value) -> Value { + let cost = &m["cost"]["text"]; + let (input, output) = (&cost["input"], &cost["output"]); + // Context tiers: input and output overrides share their thresholds. + let mut tiers: std::collections::BTreeMap> = Default::default(); + for (side, field) in [(input, "input_price"), (output, "output_price")] { + for o in side["overrides"].as_array().into_iter().flatten() { + let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue }; + let t = tiers.entry(gt).or_default(); + t.insert(field.into(), o["price"].clone()); + if field == "input_price" { + if o["cache"]["read"].is_number() { + t.insert("cache_read_price".into(), o["cache"]["read"].clone()); + } + if o["cache"]["write"].is_number() { + t.insert("cache_write_price".into(), o["cache"]["write"].clone()); + } + } + } + } + let price_tiers: Vec = tiers.into_iter().map(|(gt, mut t)| { + t.insert("above_context".into(), json!(gt)); + Value::Object(t) + }).collect(); + let mut notes = Vec::new(); + let price_multipliers: Vec = m["cost"]["multipliers"] + .as_array() + .into_iter() + .flatten() + .filter_map(|x| match opencode_when(&x["when"]) { + Some((days, hours, from, until)) => Some(json!({"factor": x["factor"], "days": days, "hours_utc": hours, "from": from, "until": until})), + None => { + notes.push(format!("unsupported price condition: {}", x["when"])); + None + } + }) + .map(|mut v| { + // Drop empty conditions so the config stays short. + v.as_object_mut().unwrap().retain(|_, x| !(x.is_null() || x.as_array().is_some_and(|a| a.is_empty()))); + v + }) + .collect(); + let images = m["modalities"]["input"].as_array().is_some_and(|a| a.iter().any(|x| x.as_str().is_some_and(|s| s.starts_with("image")))); + json!({ + "id": m["id"], + "context": m["limit"]["context"], + "max_output": m["limit"]["output"], + "vision": images, + "reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false), + "input_price": input["price"], + "output_price": output["price"], + "cache_read_price": input["cache"]["read"], + "cache_write_price": input["cache"]["write"], + "price_tiers": price_tiers, + "price_multipliers": price_multipliers, + "notes": notes, + }) +} + +/// opencode (Zen and Go) publishes prices in its model list. The Anthropic-protocol +/// endpoint lists models without them, so fall back to the gateway's own /v1/models. +async fn opencode_prices(base: &str) -> Result { + let mut urls = vec![format!("{base}/models")]; + for suffix in ["/openai/v1", "/anthropic/v1"] { + if let Some(root) = base.strip_suffix(suffix) { + urls.push(format!("{root}/v1/models")); + } + } + for url in &urls { + let Ok(v) = get_json(url).await else { continue }; + let models: Vec = v["data"].as_array().into_iter().flatten().filter(|m| m.get("cost").is_some()).map(opencode_model).collect(); + if !models.is_empty() { + return Ok(json!({"currency": "USD", "models": models})); + } + } + bail!("opencode did not return prices for this endpoint") +} + pub async fn discover_prices(app: &App, provider: &str) -> Result { let cfg = app.config(); let p = cfg.providers.get(provider).context("unknown provider")?; + let base = expand_env(&p.base_url).trim_end_matches('/').to_string(); + if is_opencode(p) { + return opencode_prices(&base).await; + } 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)) @@ -211,6 +362,28 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result { #[cfg(test)] mod tests { + #[test] + fn opencode_cost_conversion() { + let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000}, + "modalities":{"input":["text","image/png"]},"capabilities":{"reasoning":{"supported":true}}, + "cost":{"text":{"input":{"price":2,"cache":{"read":0.5},"overrides":[{"when":{"context":{"gt":200000}},"price":4,"cache":{"read":1}}]}, + "output":{"price":6,"overrides":[{"when":{"context":{"gt":200000}},"price":12}]}}, + "multipliers":[{"when":{"utc_days":{"in":["mon","tue"]},"any":[{"all":[{"utc_hours":{"gte":"01:00"}},{"utc_hours":{"lt":"04:00"}}]}]},"factor":2}, + {"when":{"date":{"gte":"2026-12-01"}},"factor":2}, + {"when":{"region":"eu"},"factor":3}]}}"#).unwrap(); + let r = super::opencode_model(&m); + assert_eq!(r["input_price"], 2); + assert_eq!(r["cache_read_price"], 0.5); + assert_eq!(r["vision"], true); + assert_eq!(r["price_tiers"][0], serde_json::json!({"above_context": 200000, "input_price": 4, "cache_read_price": 1, "output_price": 12})); + assert_eq!(r["price_multipliers"][0], serde_json::json!({"factor": 2, "days": ["mon", "tue"], "hours_utc": [["01:00", "04:00"]]})); + assert_eq!(r["price_multipliers"][1], serde_json::json!({"factor": 2, "from": "2026-12-01"})); + assert_eq!(r["notes"].as_array().unwrap().len(), 1, "unknown condition is reported, not guessed"); + // The result must deserialize into the model config. + let mc: crate::config::ModelConfig = serde_json::from_value(serde_json::json!({"id": "grok", "price_tiers": r["price_tiers"], "price_multipliers": r["price_multipliers"]})).unwrap(); + assert_eq!(mc.price_tiers[0].above_context, 200000); + } + #[test] fn budget_entry_flags() { let e = super::entry("x", Some(10.0), 8.5, 0.8); diff --git a/server/src/cli_api.rs b/server/src/cli_api.rs index 6c74b06..e3f3201 100644 --- a/server/src/cli_api.rs +++ b/server/src/cli_api.rs @@ -175,6 +175,7 @@ pub async fn llm_stream(Extension(user): Extension, State(app): State Result<(Config, String)> { Ok((cfg, text)) } +/// Replace `${NAME}` references in all string values with the environment's values, +/// so the settings live in the config only. Comments and formatting stay; references +/// to unset variables are kept. Returns (new text, replaced names, unset names). +pub fn inline_env(text: &str) -> Result<(String, Vec, Vec)> { + use std::collections::BTreeSet; + let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?; + let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap(); + let (mut done, mut missing) = (BTreeSet::new(), BTreeSet::new()); + let mut fix = |s: &str| -> Option { + if !re.is_match(s) { + return None; + } + let out = re.replace_all(s, |c: ®ex::Captures| match std::env::var(&c[1]).ok().filter(|v| !v.is_empty()) { + Some(v) => { + done.insert(c[1].to_string()); + v + } + None => { + missing.insert(c[1].to_string()); + c[0].to_string() + } + }); + (out != s).then(|| out.into_owned()) + }; + fn walk_value(v: &mut toml_edit::Value, fix: &mut dyn FnMut(&str) -> Option) { + match v { + toml_edit::Value::String(f) => { + if let Some(new) = fix(f.value()) { + let decor = f.decor().clone(); + let mut nv = toml_edit::Formatted::new(new); + *nv.decor_mut() = decor; + *f = nv; + } + } + toml_edit::Value::Array(a) => a.iter_mut().for_each(|x| walk_value(x, fix)), + toml_edit::Value::InlineTable(t) => t.iter_mut().for_each(|(_, x)| walk_value(x, fix)), + _ => {} + } + } + fn walk_item(i: &mut toml_edit::Item, fix: &mut dyn FnMut(&str) -> Option) { + match i { + toml_edit::Item::Value(v) => walk_value(v, fix), + toml_edit::Item::Table(t) => t.iter_mut().for_each(|(_, x)| walk_item(x, fix)), + toml_edit::Item::ArrayOfTables(a) => a.iter_mut().for_each(|t| t.iter_mut().for_each(|(_, x)| walk_item(x, fix))), + toml_edit::Item::None => {} + } + } + walk_item(doc.as_item_mut(), &mut fix); + Ok((doc.to_string(), done.into_iter().collect(), missing.into_iter().collect())) +} + /// A structured edit coming from the UI. Applied with toml_edit so comments survive. #[derive(Debug, Deserialize)] pub struct ConfigPatch { @@ -616,6 +667,26 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value { v } +#[cfg(test)] +mod inline_env_tests { + #[test] + fn replaces_set_variables_and_keeps_comments() { + // SAFETY: test-only, the variable names are unique to this test. + unsafe { + std::env::set_var("CC_TEST_INLINE_KEY", "sk-123"); + std::env::remove_var("CC_TEST_INLINE_UNSET"); + } + let text = "# keys: use ${NAME}\n[providers.a]\napi_key = \"${CC_TEST_INLINE_KEY}\" # stored\nheaders = { Authorization = \"Bearer ${CC_TEST_INLINE_KEY}\" }\nother = \"${CC_TEST_INLINE_UNSET}\"\n"; + let (out, done, missing) = super::inline_env(text).unwrap(); + assert!(out.contains("api_key = \"sk-123\" # stored"), "{out}"); + assert!(out.contains("Bearer sk-123")); + assert!(out.contains("# keys: use ${NAME}"), "comments untouched"); + assert!(out.contains("${CC_TEST_INLINE_UNSET}")); + assert_eq!(done, vec!["CC_TEST_INLINE_KEY"]); + assert_eq!(missing, vec!["CC_TEST_INLINE_UNSET"]); + } +} + #[cfg(test)] mod tests { use super::*; @@ -639,7 +710,7 @@ mod tests { ) .unwrap(); assert!(out.contains("# Google Gemini through its OpenAI-compatible endpoint.")); - assert!(out.contains("enabled = true # set to true once GEMINI_API_KEY is available")); + assert!(out.contains("enabled = true # enable once the key is set")); let cfg = Config::parse(&out).unwrap(); assert!(cfg.providers["gemini"].enabled); } diff --git a/server/src/images.rs b/server/src/images.rs index 7e86764..e407ab1 100644 --- a/server/src/images.rs +++ b/server/src/images.rs @@ -247,6 +247,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result, + /// opencode Go: share of the plan's usage limits (USD), as reported by the provider. + pub allowance: Option, } /// Aggregation SQL shared by all statistics queries. @@ -376,13 +379,14 @@ impl Store { conn.execute( "INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, - ttft_ms, duration_ms, cost, error, user_id) + ttft_ms, duration_ms, cost, error, user_id, allowance) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14, - COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)))", + COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)), ?16)", params![ u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent, u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64, - u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id + u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id, + u.allowance ], )?; Ok(()) @@ -886,6 +890,12 @@ impl Store { impl Store { /// Cost per provider since `from` (ms). + /// opencode Go: sum of reported allowance of one provider since `from` (ms). + pub fn allowance_since(&self, provider: &str, from: i64) -> Result { + let conn = self.conn.lock().unwrap(); + Ok(conn.query_row("SELECT COALESCE(SUM(allowance), 0) FROM usage WHERE provider = ?1 AND ts >= ?2", params![provider, from], |r| r.get(0))?) + } + pub fn cost_by_provider(&self, from: i64) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare("SELECT provider, COALESCE(SUM(cost), 0) FROM usage WHERE ts >= ?1 GROUP BY provider")?; diff --git a/web/src/components/Budget.tsx b/web/src/components/Budget.tsx index b7e23d5..16f96c3 100644 --- a/web/src/components/Budget.tsx +++ b/web/src/components/Budget.tsx @@ -1,3 +1,4 @@ +import { Fragment } from "react"; import { Icon } from "./Icon"; import { useFetch } from "../hooks"; import { fmtCost, fmtPercent, useT } from "../i18n"; @@ -11,9 +12,32 @@ interface BudgetRow { warning: boolean; exceeded: boolean; images?: boolean; + plan?: Plan; remote?: { currency?: string; remaining?: number | null; limit?: number | null; used?: number | null; spent_month?: number; scope?: string; error?: string }; } +/** opencode Go: usage windows against the plan's limits (USD). */ +export interface Plan { + limit: number; + windows: { window: "5h" | "week" | "month"; used: number; limit: number; ratio: number }[]; +} + +/** Three small meters (5 hours, week, month) for a subscription plan. */ +export function PlanUsage({ plan }: { plan: Plan }) { + const t = useT(); + return ( +
+ {plan.windows.map((w) => ( +
+ {t(`budget.window.${w.window}`)} + = 0.8} exceeded={w.ratio >= 1} /> + {fmtPercent(w.ratio)} +
+ ))} +
+ ); +} + interface BudgetData { currency: string; total: BudgetRow; @@ -62,20 +86,30 @@ export function BudgetPanel() { {rows.map((r) => ( - - - {r.provider === "total" ? {t("stats.total")} : r.provider} - {r.images && {t("nav.images")}} - - {fmtCost(r.spent, data.currency)} - {r.budget ? fmtCost(r.budget, data.currency) : "–"} - {r.share !== null && } - - {r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"} - {r.share !== null && ({fmtPercent(r.share)})} - - {r.provider === "total" ? null : } - + + + + {r.provider === "total" ? {t("stats.total")} : r.provider} + {r.images && {t("nav.images")}} + + {fmtCost(r.spent, data.currency)} + {r.budget ? fmtCost(r.budget, data.currency) : "–"} + {r.share !== null && } + + {r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"} + {r.share !== null && ({fmtPercent(r.share)})} + + {r.provider === "total" ? null : } + + {r.plan && ( + + {t("budget.plan", { v: fmtCost(r.plan.limit, "USD") })} + + + + + )} + ))} diff --git a/web/src/components/Settings.tsx b/web/src/components/Settings.tsx index 4e9ff3e..4020a9e 100644 --- a/web/src/components/Settings.tsx +++ b/web/src/components/Settings.tsx @@ -257,7 +257,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) { chromium: c.chromium ?? "chromium", widths: (c.widths ?? []).join(", "), images_enabled: cfg.config.images?.enabled ?? false, - images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? "${FAL_KEY}"), + images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? ""), images_model: cfg.config.images?.default_model ?? "fal-ai/flux/schnell", images_dir: cfg.config.images?.output_dir ?? "generated", }); @@ -344,7 +344,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) { setForm({ ...form, images_enabled: e.target.checked })} style={{ justifySelf: "start" }} /> - setForm({ ...form, images_key: e.target.value })} /> + setForm({ ...form, images_key: e.target.value })} /> setForm({ ...form, api_key: e.target.value })} />
{t("settings.apiKeyHint")}
@@ -541,10 +569,17 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re setForm({ ...form, auth_prefix: e.target.value })} /> setForm({ ...form, monthly_budget: e.target.value })} /> + {/opencode\.ai\/(inference|zen)\/go/.test(form.base_url ?? "") && ( + <> + + setForm({ ...form, plan_limit: e.target.value })} /> +
{t("settings.planLimitHint")}
+ + )} {form.kind === "anthropic" && ( <> - setForm({ ...form, admin_key: e.target.value })} /> + setForm({ ...form, admin_key: e.target.value })} />
{t("settings.adminKeyHint")}
)} @@ -555,7 +590,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re

{t("settings.models")} - {form.base_url?.includes("openrouter.ai") && ( + {(form.base_url?.includes("openrouter.ai") || form.base_url?.includes("opencode.ai")) && ( @@ -621,6 +656,11 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re {(["input_price", "output_price", "cache_read_price", "cache_write_price"] as const).map((k) => ( setModel(i, { [k]: num(e.target.value) })} /> + {k === "cache_write_price" && priceExtras(m) && ( + + +{(m.price_tiers?.length ?? 0) + (m.price_multipliers?.length ?? 0)} + + )} ))} @@ -974,6 +1014,56 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void // --- raw ---------------------------------------------------------------------- +/** ${VAR} references left in the config: move their values from the environment into it. */ +function EnvRefs({ onDone }: { onDone: () => void }) { + const t = useT(); + const { data, reload } = useFetch<{ set: string[]; unset: string[] }>("/config/inline-env"); + const [busy, setBusy] = useState(false); + if (!data || (data.set.length === 0 && data.unset.length === 0)) return null; + const apply = async () => { + if (!(await confirmDialog({ title: t("settings.envTitle"), message: t("settings.envConfirm", { names: data.set.join(", ") }), confirmLabel: t("settings.envApply") }))) return; + setBusy(true); + try { + const r = await post<{ set: string[] }>("/config/inline-env"); + toast(t("settings.envDone", { n: r.set.length }), "success"); + reload(); + onDone(); + } catch (e: any) { + toast(e.message); + } finally { + setBusy(false); + } + }; + return ( +
+

+ {t("settings.envTitle")} +

+
+ {t("settings.envHint")} +
+ {data.set.length > 0 && ( +
+ {data.set.map((n) => ( + + {n} + + ))} + + +
+ )} + {data.unset.length > 0 && ( +
+ {t("settings.envUnset")} {data.unset.join(", ")} +
+ )} +
+ ); +} + function Raw({ reload }: { reload: () => void }) { const t = useT(); const [text, setText] = useState(); @@ -984,6 +1074,7 @@ function Raw({ reload }: { reload: () => void }) { if (text === undefined) return
{t("common.loading")}
; return ( <> + get<{ text: string }>("/config/raw").then((r) => setText(r.text))} />
{t("settings.rawHint")}