Compare commits

...
2 Commits
Author SHA1 Message Date
Richard Zink 151a92959d Release v202611-00002 2026-11-09 10:19:17 +01:00
Richard ZinkandClaude Opus 5.5 bfa1f641d7 opencode prices and Go limits, settings out of .env
- 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 <noreply@anthropic.com>
2026-11-09 10:19:15 +01:00
23 changed files with 724 additions and 70 deletions

No files matched your search

+3 -10
View File
@@ -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
+20
View File
@@ -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.
## [v202611-00002] – 2026-11-09
### 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
+8 -4
View File
@@ -11,7 +11,7 @@
</p>
<p align="center">
<img alt="Version v202611-00001" src="https://img.shields.io/badge/version-v202611--00001-d69e2e?style=flat-square">
<img alt="Version v202611-00002" src="https://img.shields.io/badge/version-v202611--00002-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
@@ -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.
+1
View File
@@ -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(),
}
}
+15 -14
View File
@@ -8,9 +8,10 @@
#
# Location: $CODECRAFT_CONFIG, otherwise <data_dir>/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_KEY>".
# fal.ai – OpenRouter-backed LLM router. fal expects "Authorization: Key <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 <token>" }
# [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 = ""
+129 -6
View File
@@ -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<f64>,
/// 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<f64>,
/// 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<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_price: Option<f64>,
/// 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<PriceTier>,
/// Time-based price factors (peak hours, promotions); matching factors multiply.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub price_multipliers: Vec<PriceMultiplier>,
/// Max. simultaneous requests to this model (None = unlimited).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrency: Option<usize>,
@@ -87,15 +98,127 @@ pub struct ModelConfig {
pub options: serde_json::Map<String, serde_json::Value>,
}
/// 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<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_price: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_price: Option<f64>,
}
/// 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<String>,
/// 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<String>,
/// First day it no longer applies (YYYY-MM-DD, UTC).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<String>,
}
impl PriceMultiplier {
pub fn applies(&self, at: chrono::DateTime<chrono::Utc>) -> 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::<u32>().ok()? * 60 + m.trim().parse::<u32>().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<chrono::Utc>) -> 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<f64>, tiered: fn(&PriceTier) -> Option<f64>| 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()));
}
}
+1
View File
@@ -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");
+1
View File
@@ -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"];
+18
View File
@@ -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<f64>,
/// 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<f64>,
}
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 {
+23
View File
@@ -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<User>, State(app): State<AppState>) -> 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<User>, State(app): State<AppState>) -> 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<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
require_admin(&user)?;
app.save_config(&body.text).await?;
@@ -1192,6 +1211,10 @@ async fn status_overview(Extension(user): Extension<User>, State(app): State<App
}),
None => 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();
+175 -2
View File
@@ -129,6 +129,28 @@ fn entry(name: &str, budget: Option<f64>, 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<Value> {
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<Value> {
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<Value> {
let cfg = app.config();
let spent = app.store.cost_by_provider(month_start())?;
@@ -139,7 +161,11 @@ pub async fn overview(app: &App) -> Result<Value> {
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<Value> {
}
/// 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<Value> {
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<String>, Vec<(String, String)>, Option<String>, Option<String>);
fn opencode_when(w: &Value) -> Option<When> {
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::<Option<_>>()?,
"any" => hours = v.as_array()?.iter().map(range).collect::<Option<_>>()?,
"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<u64, serde_json::Map<String, Value>> = 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<Value> = 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<Value> = 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<Value> {
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<Value> = 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<Value> {
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<Value> {
#[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);
+1
View File
@@ -175,6 +175,7 @@ pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppS
cost: model.cost(&usage),
error,
user_id: Some(user.id.clone()),
allowance: usage.allowance,
});
});
let stream = futures::stream::unfold(out_rx, |mut rx| async move { rx.recv().await.map(|ev| (ev, rx)) });
+72 -1
View File
@@ -470,6 +470,57 @@ pub fn load_or_init(path: &Path) -> 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<String>, Vec<String>)> {
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<String> {
if !re.is_match(s) {
return None;
}
let out = re.replace_all(s, |c: &regex::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<String>) {
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<String>) {
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);
}
+1
View File
@@ -247,6 +247,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
cost,
error: false,
user_id: None,
allowance: None,
});
Ok(records)
}
+2
View File
@@ -882,6 +882,7 @@ async fn stream_step(
cost: req.model.cost(usage),
error,
user_id: None,
allowance: usage.allowance,
};
if let Err(e) = result {
@@ -967,6 +968,7 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
cost: model.cost(usage),
error: false,
user_id: None,
allowance: usage.allowance,
});
}
+13 -3
View File
@@ -162,6 +162,7 @@ impl Store {
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
] {
let exists: bool = conn
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
@@ -331,6 +332,8 @@ pub struct UsageRecord {
pub error: bool,
/// User to bill when there is no session (CLI requests); else the session owner.
pub user_id: Option<String>,
/// opencode Go: share of the plan's usage limits (USD), as reported by the provider.
pub allowance: Option<f64>,
}
/// 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<f64> {
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<std::collections::BTreeMap<String, f64>> {
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")?;
+48 -14
View File
@@ -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 (
<div className="plan-usage">
{plan.windows.map((w) => (
<div key={w.window} className="plan-window" title={`${fmtCost(w.used, "USD")} / ${fmtCost(w.limit, "USD")}`}>
<span className="plan-label">{t(`budget.window.${w.window}`)}</span>
<Bar share={w.ratio} warning={w.ratio >= 0.8} exceeded={w.ratio >= 1} />
<span className="plan-pct">{fmtPercent(w.ratio)}</span>
</div>
))}
</div>
);
}
interface BudgetData {
currency: string;
total: BudgetRow;
@@ -62,20 +86,30 @@ export function BudgetPanel() {
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.provider}>
<td>
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
</td>
<td className="num">{fmtCost(r.spent, data.currency)}</td>
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
</td>
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
</tr>
<Fragment key={r.provider}>
<tr>
<td>
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
</td>
<td className="num">{fmtCost(r.spent, data.currency)}</td>
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
</td>
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
</tr>
{r.plan && (
<tr className="plan-row">
<td className="muted">{t("budget.plan", { v: fmtCost(r.plan.limit, "USD") })}</td>
<td colSpan={5}>
<PlanUsage plan={r.plan} />
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
+102 -11
View File
@@ -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 }) {
<label>{t("settings.imagesEnabled")}</label>
<input type="checkbox" checked={form.images_enabled} onChange={(e) => setForm({ ...form, images_enabled: e.target.checked })} style={{ justifySelf: "start" }} />
<label>{t("settings.imagesKey")}</label>
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "${FAL_KEY}"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "fal.ai API key"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
<label>{t("settings.imagesDefault")}</label>
<select value={form.images_model} onChange={(e) => setForm({ ...form, images_model: e.target.value })}>
{(cfg.config.images?.models ?? []).map((m: any) => (
@@ -385,14 +385,16 @@ interface ModelRow {
max_concurrency?: number;
fallback?: string;
options?: Record<string, unknown>;
price_tiers?: { above_context: number; input_price?: number; output_price?: number; cache_read_price?: number; cache_write_price?: number }[];
price_multipliers?: { factor: number; days?: string[]; hours_utc?: [string, string][]; from?: string; until?: string }[];
}
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "${ANTHROPIC_API_KEY}" },
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "${GEMINI_API_KEY}" },
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "${OPENCODE_API_KEY}" },
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "${FAL_KEY}", auth_prefix: "Key " },
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "${OPENROUTER_API_KEY}" },
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "" },
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "" },
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "" },
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "", auth_prefix: "Key " },
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "" },
ollama: { kind: "openai", base_url: "http://host.docker.internal:11434/v1", api_key: "" },
custom: { kind: "openai", base_url: "http://", api_key: "" },
};
@@ -401,11 +403,23 @@ function normalizeModels(models: Json[]): ModelRow[] {
return (models ?? []).map((m) => (typeof m === "string" ? { id: m } : { ...m }));
}
/** Readable summary of long-context tiers and time factors (tooltip); empty if there are none. */
function priceExtras(m: ModelRow): string {
const lines = [
...(m.price_tiers ?? []).map((t) => `> ${Math.round(t.above_context / 1000)}k context: ${[t.input_price, t.output_price].map((x) => x ?? "–").join(" / ")}`),
...(m.price_multipliers ?? []).map((x) =>
[`×${x.factor}`, x.days?.join(","), x.hours_utc?.map(([a, b]) => `${a}–${b} UTC`).join(", "), x.from && `from ${x.from}`, x.until && `until ${x.until}`].filter(Boolean).join(" "),
),
];
return lines.join("\n");
}
function cleanModel(m: ModelRow) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(m)) {
if (v === undefined || v === "" || (typeof v === "number" && Number.isNaN(v))) continue;
if (v === false && (k === "reasoning" || k === "vision")) continue;
if (Array.isArray(v) && v.length === 0) continue;
out[k] = v;
}
return out;
@@ -428,7 +442,20 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
const f = byId.get(m.id);
if (!f) return m;
n++;
return { ...m, input_price: f.input_price ?? m.input_price, output_price: f.output_price ?? m.output_price, cache_read_price: f.cache_read_price ?? m.cache_read_price, cache_write_price: f.cache_write_price ?? m.cache_write_price, context: m.context ?? f.context };
return {
...m,
input_price: f.input_price ?? m.input_price,
output_price: f.output_price ?? m.output_price,
cache_read_price: f.cache_read_price ?? m.cache_read_price,
cache_write_price: f.cache_write_price ?? m.cache_write_price,
// Long-context tiers and time factors replace the stored ones when the provider publishes them.
price_tiers: f.price_tiers ?? m.price_tiers,
price_multipliers: f.price_multipliers ?? m.price_multipliers,
context: m.context ?? f.context,
max_output: m.max_output ?? f.max_output,
vision: m.vision || !!f.vision,
reasoning: m.reasoning || !!f.reasoning,
};
}),
);
setPriceNote(t("settings.pricesFetched", { n }));
@@ -454,6 +481,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
{ path: ["providers", name, "auth_prefix"], value: form.auth_prefix ? form.auth_prefix : null },
{ path: ["providers", name, "max_concurrency"], value: form.max_concurrency ? Number(form.max_concurrency) : null },
{ path: ["providers", name, "monthly_budget"], value: form.monthly_budget ? Number(form.monthly_budget) : null },
{ path: ["providers", name, "plan_limit"], value: form.plan_limit ? Number(form.plan_limit) : null },
];
// Empty key field = keep the stored (masked) key.
if (form.api_key !== "" || p.api_key !== MASK) patches.push({ path: ["providers", name, "api_key"], value: form.api_key });
@@ -533,7 +561,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
<input
type="password"
value={form.api_key}
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : "${ENV_VAR}"}
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : ""}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
/>
<div className="hint">{t("settings.apiKeyHint")}</div>
@@ -541,10 +569,17 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
<input value={form.auth_prefix ?? ""} placeholder={form.kind === "openai" ? "Bearer " : ""} onChange={(e) => setForm({ ...form, auth_prefix: e.target.value })} />
<label>{t("settings.monthlyBudget")}</label>
<input type="number" min={0} step="1" value={form.monthly_budget ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
{/opencode\.ai\/(inference|zen)\/go/.test(form.base_url ?? "") && (
<>
<label>{t("settings.planLimit")}</label>
<input type="number" min={0} step="1" value={form.plan_limit ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, plan_limit: e.target.value })} />
<div className="hint">{t("settings.planLimitHint")}</div>
</>
)}
{form.kind === "anthropic" && (
<>
<label>{t("settings.adminKey")}</label>
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "${ANTHROPIC_ADMIN_KEY}"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "sk-ant-admin01-…"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
<div className="hint">{t("settings.adminKeyHint")}</div>
</>
)}
@@ -555,7 +590,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
<h3 style={{ marginTop: 16 }}>
{t("settings.models")}
<span className="spacer" />
{form.base_url?.includes("openrouter.ai") && (
{(form.base_url?.includes("openrouter.ai") || form.base_url?.includes("opencode.ai")) && (
<button className="small" onClick={fetchPrices}>
{t("settings.fetchPrices")}
</button>
@@ -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) => (
<td key={k}>
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
{k === "cache_write_price" && priceExtras(m) && (
<span className="chip price-extra" title={priceExtras(m)}>
+{(m.price_tiers?.length ?? 0) + (m.price_multipliers?.length ?? 0)}
</span>
)}
</td>
))}
<td>
@@ -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 (
<div className="panel" style={{ marginBottom: 12 }}>
<h3>
<Icon name="file-text" /> {t("settings.envTitle")}
</h3>
<div className="hint" style={{ marginBottom: 8 }}>
{t("settings.envHint")}
</div>
{data.set.length > 0 && (
<div className="row" style={{ gap: 8, flexWrap: "wrap", alignItems: "center" }}>
{data.set.map((n) => (
<span key={n} className="chip mono">
{n}
</span>
))}
<span className="spacer" />
<button className="primary small" onClick={apply} disabled={busy}>
{t("settings.envApply")}
</button>
</div>
)}
{data.unset.length > 0 && (
<div className="hint" style={{ marginTop: 8 }}>
{t("settings.envUnset")} <span className="mono">{data.unset.join(", ")}</span>
</div>
)}
</div>
);
}
function Raw({ reload }: { reload: () => void }) {
const t = useT();
const [text, setText] = useState<string>();
@@ -984,6 +1074,7 @@ function Raw({ reload }: { reload: () => void }) {
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
return (
<>
<EnvRefs onDone={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))} />
<div className="notice">{t("settings.rawHint")}</div>
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
{view}
+17 -3
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { get, post } from "../api";
import { useT, type Key } from "../i18n";
import { fmtPercent, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import { Icon } from "./Icon";
import type { Plan } from "./Budget";
type Level = "ok" | "warn" | "err" | "off";
@@ -13,6 +14,7 @@ interface ProviderState {
checked_at?: number;
error?: string | null;
models: number;
plan?: Plan | null;
}
interface McpEntry {
state: "connected" | "connecting" | "failed";
@@ -38,7 +40,16 @@ function combine(levels: Level[]): Level {
return "err";
}
const providerLevel = (p: ProviderState): Level => (p.state === "ok" ? "ok" : p.state === "unknown" ? "warn" : "err");
/** The fullest window of a subscription plan (5 hours, week, month). */
const fullest = (plan?: Plan | null) => plan?.windows.reduce((a, b) => (b.ratio > a.ratio ? b : a));
const providerLevel = (p: ProviderState): Level => {
const health: Level = p.state === "ok" ? "ok" : p.state === "unknown" ? "warn" : "err";
const w = fullest(p.plan);
// A used-up plan blocks requests just like a rejected key.
const plan: Level = !w ? "ok" : w.ratio >= 1 ? "err" : w.ratio >= 0.8 ? "warn" : "ok";
return health === "err" || plan === "err" ? "err" : health === "warn" || plan === "warn" ? "warn" : "ok";
};
const mcpLevel = (m: McpEntry): Level => (m.state === "connected" ? "ok" : m.state === "connecting" ? "warn" : "err");
function Led({ level, title }: { level: Level; title?: string }) {
@@ -164,7 +175,10 @@ export function StatusLeds() {
<div key={name} className="status-row" title={p.error ?? undefined}>
<Led level={providerLevel(p)} />
<span className="grow mono">{name}</span>
<span className="hint">{providerText(p)}</span>
<span className="hint">
{providerText(p)}
{fullest(p.plan) && ` · ${t("status.plan", { pct: fmtPercent(fullest(p.plan)!.ratio), window: t(`budget.window.${fullest(p.plan)!.window}` as Key) })}`}
</span>
</div>
))}
</div>
+14 -1
View File
@@ -40,6 +40,13 @@ export const de: Messages = {
"settings.imagesDefault": "Standardmodell",
"settings.imagesDir": "Ordner im Projekt",
"settings.imagesHint": "Modelle und Preise pro Bild werden in der Config-Datei bearbeitet ([images]).",
"budget.plan": "Go-Plan ({v}/Monat)",
"budget.window.5h": "5 Stunden",
"budget.window.week": "7 Tage",
"budget.window.month": "Monat",
"settings.planLimit": "Planlimit (USD/Monat)",
"settings.planLimitHint": "opencode Go: das monatliche Nutzungslimit deines Plans. CodeCraft zählt den Verbrauch, den jede Antwort meldet (5 Stunden 20 %, Woche 50 %, Monat 100 %) – nur Nutzung über CodeCraft.",
"status.plan": "Go {pct} ({window})",
"budget.title": "Budgets in diesem Monat",
"budget.spent": "Verbraucht",
"budget.budget": "Budget",
@@ -58,6 +65,12 @@ export const de: Messages = {
"settings.budgetWarning": "Warnen ab",
"settings.adminKey": "Admin-Key (optional)",
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
"settings.envTitle": "Einstellungen aus Umgebungsvariablen",
"settings.envHint": "Diese ${NAME}-Verweise holen ihre Werte aus der Server-Umgebung (.env). Übernimm die Werte in die Konfiguration, damit alles hier eingestellt ist – danach können die Variablen aus der .env raus.",
"settings.envApply": "In die Konfiguration übernehmen",
"settings.envConfirm": "Die aktuellen Werte von {names} in die Konfigurationsdatei schreiben? Die Keys liegen dann in der Config (und in ihren Backups) statt in der Umgebung.",
"settings.envDone": "{n} Werte in die Konfiguration übernommen. Du kannst sie jetzt aus der .env entfernen.",
"settings.envUnset": "Verwendet, aber nicht gesetzt (bleiben unverändert):",
"settings.fetchPrices": "Preise abrufen",
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
"stats.perMillion": "pro 1 Mio. Tok.",
@@ -647,7 +660,7 @@ export const de: Messages = {
"settings.baseUrl": "Basis-URL",
"settings.apiKey": "API-Key",
"settings.apiKeyStored": "Gespeichert – leer lassen zum Beibehalten",
"settings.apiKeyHint": "Am besten ${ENV_VAR}-Verweise nutzen; direkte Keys stehen im Klartext in der Config-Datei.",
"settings.apiKeyHint": "Wird in der Konfigurationsdatei gespeichert (für Admins lesbar, in Backups enthalten). ${NAME}-Verweise auf Umgebungsvariablen funktionieren weiterhin.",
"settings.authPrefix": "Auth-Präfix",
"settings.enabled": "Aktiv",
"settings.modelCount": "{n} Modelle",
+14 -1
View File
@@ -39,6 +39,13 @@ export const en = {
"settings.imagesDefault": "Default model",
"settings.imagesDir": "Folder in the project",
"settings.imagesHint": "Models and prices per image are edited in the config file ([images]).",
"budget.plan": "Go plan ({v}/month)",
"budget.window.5h": "5 hours",
"budget.window.week": "7 days",
"budget.window.month": "Month",
"settings.planLimit": "Plan limit (USD/month)",
"settings.planLimitHint": "opencode Go: the monthly usage limit of your plan. CodeCraft tracks the allowance each response reports (5 hours 20 %, week 50 %, month 100 %) – only usage through CodeCraft.",
"status.plan": "Go {pct} ({window})",
"budget.title": "Budgets this month",
"budget.spent": "Spent",
"budget.budget": "Budget",
@@ -57,6 +64,12 @@ export const en = {
"settings.budgetWarning": "Warn at",
"settings.adminKey": "Admin key (optional)",
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
"settings.envTitle": "Settings from environment variables",
"settings.envHint": "These ${NAME} references take their values from the server's environment (.env). Move the values into the configuration so everything is set here – then remove the variables from .env.",
"settings.envApply": "Move into the configuration",
"settings.envConfirm": "Write the current values of {names} into the configuration file? Keys are then stored in the config (and in its backups) instead of the environment.",
"settings.envDone": "{n} values moved into the configuration. You can now remove them from .env.",
"settings.envUnset": "Referenced but not set (left as they are):",
"settings.fetchPrices": "Fetch prices",
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
"stats.perMillion": "per 1M tok",
@@ -646,7 +659,7 @@ export const en = {
"settings.baseUrl": "Base URL",
"settings.apiKey": "API key",
"settings.apiKeyStored": "Stored – leave empty to keep",
"settings.apiKeyHint": "Prefer ${ENV_VAR} references; literal keys are stored in plain text in the config file.",
"settings.apiKeyHint": "Stored in the configuration file (readable for admins, included in backups). ${NAME} references to environment variables still work.",
"settings.authPrefix": "Auth prefix",
"settings.enabled": "Enabled",
"settings.modelCount": "{n} models",
+34
View File
@@ -3569,3 +3569,37 @@ button.danger-solid {
border-color: var(--accent);
color: var(--accent);
}
.chip.price-extra {
margin-left: 4px;
font-size: 11px;
cursor: help;
}
.plan-row td {
border-top: none;
padding-top: 0;
}
.plan-usage {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.plan-window {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
min-width: 170px;
}
.plan-window .budget-bar {
flex: 1;
min-width: 60px;
}
.plan-label {
color: var(--muted);
white-space: nowrap;
}
.plan-pct {
font-variant-numeric: tabular-nums;
}
+12
View File
@@ -3,6 +3,18 @@
* under "unreleased"; scripts/release.sh renames the key to the release version.
*/
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00002": {
en: [
"opencode: “Fetch prices” fills in prices including long-context and peak-hour pricing",
"Settings → Config file: move keys from environment variables into the configuration",
"opencode: real costs per request; Go plan usage (5 hours, week, month) in budgets and status",
],
de: [
"opencode: „Preise holen“ übernimmt Preise inklusive Long-Context- und Spitzenzeit-Preisen",
"Einstellungen → Config-Datei: Keys aus Umgebungsvariablen in die Konfiguration übernehmen",
"opencode: echte Kosten pro Anfrage; Go-Plan-Verbrauch (5 Stunden, Woche, Monat) in Budget und Status",
],
},
"202611-00001": {
en: [
"Status LEDs in the sidebar for server, models and MCP – click them for details",