Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151a92959d | ||
|
|
bfa1f641d7 |
No files matched your search
+3
-10
@@ -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
|
# 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.
|
# one-time setup code and you create the admin in the web UI.
|
||||||
CODECRAFT_ADMIN_USER=
|
CODECRAFT_ADMIN_USER=
|
||||||
CODECRAFT_ADMIN_PASSWORD=
|
CODECRAFT_ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Single sign-on client secret (see [auth.oidc] in config.toml).
|
|
||||||
OIDC_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Directory with your projects, mounted as /workspace.
|
# Directory with your projects, mounted as /workspace.
|
||||||
WORKSPACE=/path/to/your/projects
|
WORKSPACE=/path/to/your/projects
|
||||||
|
|
||||||
@@ -17,13 +17,6 @@ BIND_ADDR=127.0.0.1
|
|||||||
UID=1000
|
UID=1000
|
||||||
GID=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
|
# Time zone for automations (cron schedules) and timestamps, e.g. Europe/Berlin
|
||||||
TZ=UTC
|
TZ=UTC
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,26 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
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
|
## [v202611-00001] – 2026-11-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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="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="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">
|
<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 \
|
curl -fsSL --create-dirs -o docker/seccomp-sandbox.json \
|
||||||
https://git.familie-zink.org/zink/codecraft/raw/branch/main/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
|
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 up -d
|
||||||
docker compose -f docker-compose.release.yml logs codecraft # one-time setup code for the first admin
|
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
|
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
|
provider under *Settings → Providers & models*. `.env` only holds deployment settings; keys,
|
||||||
them in the config as `${NAME}`.
|
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`.
|
**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
|
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.
|
model's fallback.
|
||||||
- **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that
|
- **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.
|
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
|
- **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
|
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.
|
it right away, and *Test connection* checks URL and key before you save.
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ fn provider_config(preset: &crate::models::Preset, base_url: &str, key: &str) ->
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
max_concurrency: None,
|
max_concurrency: None,
|
||||||
monthly_budget: None,
|
monthly_budget: None,
|
||||||
|
plan_limit: None,
|
||||||
admin_key: String::new(),
|
admin_key: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-14
@@ -8,9 +8,10 @@
|
|||||||
#
|
#
|
||||||
# Location: $CODECRAFT_CONFIG, otherwise <data_dir>/config.toml (Docker: /data/config.toml)
|
# Location: $CODECRAFT_CONFIG, otherwise <data_dir>/config.toml (Docker: /data/config.toml)
|
||||||
#
|
#
|
||||||
# Secrets: any string value may reference environment variables with ${NAME},
|
# Keys and tokens are entered in the web UI (Settings) and stored here, readable
|
||||||
# e.g. api_key = "${ANTHROPIC_API_KEY}". Prefer this over literal keys – keys
|
# for admins and included in backups. Any string value may also reference an
|
||||||
# entered in the UI are stored here in plain text.
|
# environment variable as ${NAME}; Settings → Config file moves such values
|
||||||
|
# into this file.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
@@ -47,7 +48,7 @@ currency = "USD"
|
|||||||
# proxy's body limit (e.g. Nginx client_max_body_size) does not apply.
|
# proxy's body limit (e.g. Nginx client_max_body_size) does not apply.
|
||||||
max_upload_mb = 500
|
max_upload_mb = 500
|
||||||
# Bearer token for Prometheus at GET /metrics (empty = /metrics disabled).
|
# 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]
|
[providers.anthropic]
|
||||||
kind = "anthropic"
|
kind = "anthropic"
|
||||||
base_url = "https://api.anthropic.com/v1"
|
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.
|
# Current Claude models think adaptively; "effort" controls depth and cost.
|
||||||
# Prices: Anthropic list prices in USD per 1M tokens (Nov 2026) – adjust if needed.
|
# Prices: Anthropic list prices in USD per 1M tokens (Nov 2026) – adjust if needed.
|
||||||
models = [
|
models = [
|
||||||
@@ -97,25 +98,25 @@ models = [
|
|||||||
[providers.gemini]
|
[providers.gemini]
|
||||||
kind = "openai"
|
kind = "openai"
|
||||||
base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
|
base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||||
api_key = "${GEMINI_API_KEY}"
|
api_key = "" # set in Settings
|
||||||
models = [] # discovered automatically
|
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).
|
# opencode Go subscription (OpenAI-compatible endpoint for most models).
|
||||||
# Current model list: https://opencode.ai/inference/go/v1/models
|
# Current model list: https://opencode.ai/inference/go/v1/models
|
||||||
[providers.opencode-go]
|
[providers.opencode-go]
|
||||||
kind = "openai"
|
kind = "openai"
|
||||||
base_url = "https://opencode.ai/inference/go/openai/v1"
|
base_url = "https://opencode.ai/inference/go/openai/v1"
|
||||||
api_key = "${OPENCODE_API_KEY}"
|
api_key = "" # set in Settings
|
||||||
models = []
|
models = []
|
||||||
enabled = false
|
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.
|
# NOTE: verify base_url against the fal docs for your account.
|
||||||
[providers.fal]
|
[providers.fal]
|
||||||
kind = "openai"
|
kind = "openai"
|
||||||
base_url = "https://fal.run/openrouter/router/openai/v1"
|
base_url = "https://fal.run/openrouter/router/openai/v1"
|
||||||
api_key = "${FAL_KEY}"
|
api_key = "" # set in Settings
|
||||||
auth_prefix = "Key "
|
auth_prefix = "Key "
|
||||||
models = [
|
models = [
|
||||||
{ id = "google/gemini-2.5-flash", context = 1000000, max_output = 32000, vision = true },
|
{ id = "google/gemini-2.5-flash", context = 1000000, max_output = 32000, vision = true },
|
||||||
@@ -151,7 +152,7 @@ enabled = false
|
|||||||
# [mcp.trilium]
|
# [mcp.trilium]
|
||||||
# type = "http"
|
# type = "http"
|
||||||
# url = "https://notes.example.org/mcp"
|
# url = "https://notes.example.org/mcp"
|
||||||
# headers = { Authorization = "Bearer ${TRILIUM_TOKEN}" }
|
# headers = { Authorization = "Bearer <token>" }
|
||||||
|
|
||||||
# [mcp.filesystem]
|
# [mcp.filesystem]
|
||||||
# type = "stdio"
|
# type = "stdio"
|
||||||
@@ -243,7 +244,7 @@ generate_image = "ask" # costs money per image
|
|||||||
[images]
|
[images]
|
||||||
enabled = false
|
enabled = false
|
||||||
base_url = "https://fal.run"
|
base_url = "https://fal.run"
|
||||||
api_key = "${FAL_KEY}"
|
api_key = "" # set in Settings
|
||||||
default_model = "fal-ai/flux/schnell"
|
default_model = "fal-ai/flux/schnell"
|
||||||
output_dir = "generated"
|
output_dir = "generated"
|
||||||
# The agent drafts with a "preview" model, asks which draft to refine, then
|
# The agent drafts with a "preview" model, asks which draft to refine, then
|
||||||
@@ -288,7 +289,7 @@ cookie_secure = "auto"
|
|||||||
enabled = false
|
enabled = false
|
||||||
issuer = "https://auth.example.org/application/o/codecraft/"
|
issuer = "https://auth.example.org/application/o/codecraft/"
|
||||||
client_id = "codecraft"
|
client_id = "codecraft"
|
||||||
client_secret = "${OIDC_CLIENT_SECRET}"
|
client_secret = "" # set in Settings
|
||||||
scopes = ["openid", "profile", "email", "groups"]
|
scopes = ["openid", "profile", "email", "groups"]
|
||||||
username_claim = "preferred_username"
|
username_claim = "preferred_username"
|
||||||
groups_claim = "groups"
|
groups_claim = "groups"
|
||||||
@@ -309,7 +310,7 @@ button_label = "Single sign-on"
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
[search]
|
[search]
|
||||||
# "duckduckgo" (no key, may be rate-limited), "searxng" (self-hosted, set url),
|
# "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"
|
provider = "duckduckgo"
|
||||||
url = ""
|
url = ""
|
||||||
api_key = ""
|
api_key = ""
|
||||||
|
|||||||
+129
-6
@@ -37,6 +37,10 @@ pub struct ProviderConfig {
|
|||||||
/// Monthly budget in the configured currency (tracked from recorded costs).
|
/// Monthly budget in the configured currency (tracked from recorded costs).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub monthly_budget: Option<f64>,
|
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
|
/// Optional admin key to read the provider's own cost report
|
||||||
/// (Anthropic: sk-ant-admin01-…, organisation-wide).
|
/// (Anthropic: sk-ant-admin01-…, organisation-wide).
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
@@ -76,6 +80,13 @@ pub struct ModelConfig {
|
|||||||
pub cache_read_price: Option<f64>,
|
pub cache_read_price: Option<f64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cache_write_price: Option<f64>,
|
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).
|
/// Max. simultaneous requests to this model (None = unlimited).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub max_concurrency: Option<usize>,
|
pub max_concurrency: Option<usize>,
|
||||||
@@ -87,15 +98,127 @@ pub struct ModelConfig {
|
|||||||
pub options: serde_json::Map<String, serde_json::Value>,
|
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 {
|
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 {
|
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;
|
let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0;
|
||||||
per(u.input_tokens, inp)
|
let factor: f64 = self.price_multipliers.iter().filter(|m| m.applies(at)).map(|m| m.factor).product();
|
||||||
+ per(u.output_tokens, self.output_price.unwrap_or(0.0))
|
(per(u.input_tokens, inp) + per(u.output_tokens, out) + per(u.cache_read_tokens, read) + per(u.cache_write_tokens, write)) * factor
|
||||||
+ per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp))
|
}
|
||||||
+ per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp))
|
}
|
||||||
|
|
||||||
|
#[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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ impl Provider for Anthropic {
|
|||||||
if let Some(o) = data["usage"]["output_tokens"].as_u64() {
|
if let Some(o) = data["usage"]["output_tokens"].as_u64() {
|
||||||
usage.output_tokens = o;
|
usage.output_tokens = o;
|
||||||
}
|
}
|
||||||
|
usage.read_reported_cost(&data["usage"]);
|
||||||
}
|
}
|
||||||
"error" => {
|
"error" => {
|
||||||
let msg = data["error"]["message"].as_str().unwrap_or("unknown error");
|
let msg = data["error"]["message"].as_str().unwrap_or("unknown error");
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ impl Provider for OpenAi {
|
|||||||
usage.input_tokens = u["prompt_tokens"].as_u64().unwrap_or(0).saturating_sub(cached);
|
usage.input_tokens = u["prompt_tokens"].as_u64().unwrap_or(0).saturating_sub(cached);
|
||||||
usage.cache_read_tokens = cached;
|
usage.cache_read_tokens = cached;
|
||||||
usage.output_tokens = u["completion_tokens"].as_u64().unwrap_or(0);
|
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 Some(choice) = data["choices"].get(0) else { continue };
|
||||||
let d = &choice["delta"];
|
let d = &choice["delta"];
|
||||||
|
|||||||
@@ -93,6 +93,24 @@ pub struct Usage {
|
|||||||
pub cache_read_tokens: u64,
|
pub cache_read_tokens: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache_write_tokens: u64,
|
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 {
|
impl Usage {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/folders/{id}/access", put(set_folder_access))
|
.route("/folders/{id}/access", put(set_folder_access))
|
||||||
.route("/config", get(get_config).patch(patch_config))
|
.route("/config", get(get_config).patch(patch_config))
|
||||||
.route("/config/raw", get(get_config_raw).put(put_config_raw))
|
.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("/providers/reload", post(reload_providers))
|
||||||
.route("/models", get(models))
|
.route("/models", get(models))
|
||||||
.route("/agents", get(agents))
|
.route("/agents", get(agents))
|
||||||
@@ -289,6 +290,24 @@ struct RawBody {
|
|||||||
text: String,
|
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 {
|
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
||||||
require_admin(&user)?;
|
require_admin(&user)?;
|
||||||
app.save_config(&body.text).await?;
|
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}),
|
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)
|
(name.clone(), v)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
+175
-2
@@ -129,6 +129,28 @@ fn entry(name: &str, budget: Option<f64>, spent: f64, warn: f64) -> Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Budget overview for the current month.
|
/// 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> {
|
pub async fn overview(app: &App) -> Result<Value> {
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let spent = app.store.cost_by_provider(month_start())?;
|
let spent = app.store.cost_by_provider(month_start())?;
|
||||||
@@ -139,7 +161,11 @@ pub async fn overview(app: &App) -> Result<Value> {
|
|||||||
if !p.enabled {
|
if !p.enabled {
|
||||||
continue;
|
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();
|
let p = p.clone();
|
||||||
tasks.push(tokio::spawn(async move { remote_balance(&p).await }));
|
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.
|
/// 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> {
|
pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let p = cfg.providers.get(provider).context("unknown provider")?;
|
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) {
|
if !is_openrouter(p) {
|
||||||
bail!("this provider does not publish prices via its API – enter them manually");
|
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()
|
let v: Value = crate::provider::http_client()
|
||||||
.get(format!("{base}/models"))
|
.get(format!("{base}/models"))
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
@@ -211,6 +362,28 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn budget_entry_flags() {
|
fn budget_entry_flags() {
|
||||||
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppS
|
|||||||
cost: model.cost(&usage),
|
cost: model.cost(&usage),
|
||||||
error,
|
error,
|
||||||
user_id: Some(user.id.clone()),
|
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)) });
|
let stream = futures::stream::unfold(out_rx, |mut rx| async move { rx.recv().await.map(|ev| (ev, rx)) });
|
||||||
|
|||||||
+72
-1
@@ -470,6 +470,57 @@ pub fn load_or_init(path: &Path) -> Result<(Config, String)> {
|
|||||||
Ok((cfg, text))
|
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: ®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<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.
|
/// A structured edit coming from the UI. Applied with toml_edit so comments survive.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ConfigPatch {
|
pub struct ConfigPatch {
|
||||||
@@ -616,6 +667,26 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
|
|||||||
v
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -639,7 +710,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(out.contains("# Google Gemini through its OpenAI-compatible endpoint."));
|
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();
|
let cfg = Config::parse(&out).unwrap();
|
||||||
assert!(cfg.providers["gemini"].enabled);
|
assert!(cfg.providers["gemini"].enabled);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
|||||||
cost,
|
cost,
|
||||||
error: false,
|
error: false,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
|
allowance: None,
|
||||||
});
|
});
|
||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -882,6 +882,7 @@ async fn stream_step(
|
|||||||
cost: req.model.cost(usage),
|
cost: req.model.cost(usage),
|
||||||
error,
|
error,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
|
allowance: usage.allowance,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = result {
|
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),
|
cost: model.cost(usage),
|
||||||
error: false,
|
error: false,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
|
allowance: usage.allowance,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -162,6 +162,7 @@ impl Store {
|
|||||||
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
||||||
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
||||||
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
|
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
|
||||||
|
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
|
||||||
] {
|
] {
|
||||||
let exists: bool = conn
|
let exists: bool = conn
|
||||||
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
|
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
|
||||||
@@ -331,6 +332,8 @@ pub struct UsageRecord {
|
|||||||
pub error: bool,
|
pub error: bool,
|
||||||
/// User to bill when there is no session (CLI requests); else the session owner.
|
/// User to bill when there is no session (CLI requests); else the session owner.
|
||||||
pub user_id: Option<String>,
|
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.
|
/// Aggregation SQL shared by all statistics queries.
|
||||||
@@ -376,13 +379,14 @@ impl Store {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent,
|
"INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent,
|
||||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
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,
|
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![
|
params![
|
||||||
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
|
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.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(())
|
Ok(())
|
||||||
@@ -886,6 +890,12 @@ impl Store {
|
|||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
/// Cost per provider since `from` (ms).
|
/// 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>> {
|
pub fn cost_by_provider(&self, from: i64) -> Result<std::collections::BTreeMap<String, f64>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
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")?;
|
let mut stmt = conn.prepare("SELECT provider, COALESCE(SUM(cost), 0) FROM usage WHERE ts >= ?1 GROUP BY provider")?;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { useFetch } from "../hooks";
|
import { useFetch } from "../hooks";
|
||||||
import { fmtCost, fmtPercent, useT } from "../i18n";
|
import { fmtCost, fmtPercent, useT } from "../i18n";
|
||||||
@@ -11,9 +12,32 @@ interface BudgetRow {
|
|||||||
warning: boolean;
|
warning: boolean;
|
||||||
exceeded: boolean;
|
exceeded: boolean;
|
||||||
images?: boolean;
|
images?: boolean;
|
||||||
|
plan?: Plan;
|
||||||
remote?: { currency?: string; remaining?: number | null; limit?: number | null; used?: number | null; spent_month?: number; scope?: string; error?: string };
|
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 {
|
interface BudgetData {
|
||||||
currency: string;
|
currency: string;
|
||||||
total: BudgetRow;
|
total: BudgetRow;
|
||||||
@@ -62,20 +86,30 @@ export function BudgetPanel() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<tr key={r.provider}>
|
<Fragment key={r.provider}>
|
||||||
<td>
|
<tr>
|
||||||
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
|
<td>
|
||||||
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
|
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
|
||||||
</td>
|
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
|
||||||
<td className="num">{fmtCost(r.spent, data.currency)}</td>
|
</td>
|
||||||
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
|
<td className="num">{fmtCost(r.spent, data.currency)}</td>
|
||||||
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
|
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
|
||||||
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
|
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
|
||||||
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
|
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
|
||||||
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
|
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
|
||||||
</td>
|
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
|
||||||
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
|
</td>
|
||||||
</tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+102
-11
@@ -257,7 +257,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
chromium: c.chromium ?? "chromium",
|
chromium: c.chromium ?? "chromium",
|
||||||
widths: (c.widths ?? []).join(", "),
|
widths: (c.widths ?? []).join(", "),
|
||||||
images_enabled: cfg.config.images?.enabled ?? false,
|
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_model: cfg.config.images?.default_model ?? "fal-ai/flux/schnell",
|
||||||
images_dir: cfg.config.images?.output_dir ?? "generated",
|
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>
|
<label>{t("settings.imagesEnabled")}</label>
|
||||||
<input type="checkbox" checked={form.images_enabled} onChange={(e) => setForm({ ...form, images_enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
<input type="checkbox" checked={form.images_enabled} onChange={(e) => setForm({ ...form, images_enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||||
<label>{t("settings.imagesKey")}</label>
|
<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>
|
<label>{t("settings.imagesDefault")}</label>
|
||||||
<select value={form.images_model} onChange={(e) => setForm({ ...form, images_model: e.target.value })}>
|
<select value={form.images_model} onChange={(e) => setForm({ ...form, images_model: e.target.value })}>
|
||||||
{(cfg.config.images?.models ?? []).map((m: any) => (
|
{(cfg.config.images?.models ?? []).map((m: any) => (
|
||||||
@@ -385,14 +385,16 @@ interface ModelRow {
|
|||||||
max_concurrency?: number;
|
max_concurrency?: number;
|
||||||
fallback?: string;
|
fallback?: string;
|
||||||
options?: Record<string, unknown>;
|
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 }> = {
|
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}" },
|
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: "${GEMINI_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: "${OPENCODE_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: "${FAL_KEY}", auth_prefix: "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: "${OPENROUTER_API_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: "" },
|
ollama: { kind: "openai", base_url: "http://host.docker.internal:11434/v1", api_key: "" },
|
||||||
custom: { kind: "openai", base_url: "http://", 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 }));
|
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) {
|
function cleanModel(m: ModelRow) {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(m)) {
|
for (const [k, v] of Object.entries(m)) {
|
||||||
if (v === undefined || v === "" || (typeof v === "number" && Number.isNaN(v))) continue;
|
if (v === undefined || v === "" || (typeof v === "number" && Number.isNaN(v))) continue;
|
||||||
if (v === false && (k === "reasoning" || k === "vision")) continue;
|
if (v === false && (k === "reasoning" || k === "vision")) continue;
|
||||||
|
if (Array.isArray(v) && v.length === 0) continue;
|
||||||
out[k] = v;
|
out[k] = v;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -428,7 +442,20 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
|||||||
const f = byId.get(m.id);
|
const f = byId.get(m.id);
|
||||||
if (!f) return m;
|
if (!f) return m;
|
||||||
n++;
|
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 }));
|
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, "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, "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, "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.
|
// 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 });
|
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
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={form.api_key}
|
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 })}
|
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<div className="hint">{t("settings.apiKeyHint")}</div>
|
<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 })} />
|
<input value={form.auth_prefix ?? ""} placeholder={form.kind === "openai" ? "Bearer " : ""} onChange={(e) => setForm({ ...form, auth_prefix: e.target.value })} />
|
||||||
<label>{t("settings.monthlyBudget")}</label>
|
<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 })} />
|
<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" && (
|
{form.kind === "anthropic" && (
|
||||||
<>
|
<>
|
||||||
<label>{t("settings.adminKey")}</label>
|
<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>
|
<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 }}>
|
<h3 style={{ marginTop: 16 }}>
|
||||||
{t("settings.models")}
|
{t("settings.models")}
|
||||||
<span className="spacer" />
|
<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}>
|
<button className="small" onClick={fetchPrices}>
|
||||||
{t("settings.fetchPrices")}
|
{t("settings.fetchPrices")}
|
||||||
</button>
|
</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) => (
|
{(["input_price", "output_price", "cache_read_price", "cache_write_price"] as const).map((k) => (
|
||||||
<td key={k}>
|
<td key={k}>
|
||||||
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
|
<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>
|
||||||
))}
|
))}
|
||||||
<td>
|
<td>
|
||||||
@@ -974,6 +1014,56 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void
|
|||||||
|
|
||||||
// --- raw ----------------------------------------------------------------------
|
// --- 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 }) {
|
function Raw({ reload }: { reload: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [text, setText] = useState<string>();
|
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>;
|
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EnvRefs onDone={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))} />
|
||||||
<div className="notice">{t("settings.rawHint")}</div>
|
<div className="notice">{t("settings.rawHint")}</div>
|
||||||
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
||||||
{view}
|
{view}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { get, post } from "../api";
|
import { get, post } from "../api";
|
||||||
import { useT, type Key } from "../i18n";
|
import { fmtPercent, useT, type Key } from "../i18n";
|
||||||
import { navigate } from "../router";
|
import { navigate } from "../router";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
import type { Plan } from "./Budget";
|
||||||
|
|
||||||
type Level = "ok" | "warn" | "err" | "off";
|
type Level = "ok" | "warn" | "err" | "off";
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ interface ProviderState {
|
|||||||
checked_at?: number;
|
checked_at?: number;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
models: number;
|
models: number;
|
||||||
|
plan?: Plan | null;
|
||||||
}
|
}
|
||||||
interface McpEntry {
|
interface McpEntry {
|
||||||
state: "connected" | "connecting" | "failed";
|
state: "connected" | "connecting" | "failed";
|
||||||
@@ -38,7 +40,16 @@ function combine(levels: Level[]): Level {
|
|||||||
return "err";
|
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");
|
const mcpLevel = (m: McpEntry): Level => (m.state === "connected" ? "ok" : m.state === "connecting" ? "warn" : "err");
|
||||||
|
|
||||||
function Led({ level, title }: { level: Level; title?: string }) {
|
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}>
|
<div key={name} className="status-row" title={p.error ?? undefined}>
|
||||||
<Led level={providerLevel(p)} />
|
<Led level={providerLevel(p)} />
|
||||||
<span className="grow mono">{name}</span>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+14
-1
@@ -40,6 +40,13 @@ export const de: Messages = {
|
|||||||
"settings.imagesDefault": "Standardmodell",
|
"settings.imagesDefault": "Standardmodell",
|
||||||
"settings.imagesDir": "Ordner im Projekt",
|
"settings.imagesDir": "Ordner im Projekt",
|
||||||
"settings.imagesHint": "Modelle und Preise pro Bild werden in der Config-Datei bearbeitet ([images]).",
|
"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.title": "Budgets in diesem Monat",
|
||||||
"budget.spent": "Verbraucht",
|
"budget.spent": "Verbraucht",
|
||||||
"budget.budget": "Budget",
|
"budget.budget": "Budget",
|
||||||
@@ -58,6 +65,12 @@ export const de: Messages = {
|
|||||||
"settings.budgetWarning": "Warnen ab",
|
"settings.budgetWarning": "Warnen ab",
|
||||||
"settings.adminKey": "Admin-Key (optional)",
|
"settings.adminKey": "Admin-Key (optional)",
|
||||||
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
|
"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.fetchPrices": "Preise abrufen",
|
||||||
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
|
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
|
||||||
"stats.perMillion": "pro 1 Mio. Tok.",
|
"stats.perMillion": "pro 1 Mio. Tok.",
|
||||||
@@ -647,7 +660,7 @@ export const de: Messages = {
|
|||||||
"settings.baseUrl": "Basis-URL",
|
"settings.baseUrl": "Basis-URL",
|
||||||
"settings.apiKey": "API-Key",
|
"settings.apiKey": "API-Key",
|
||||||
"settings.apiKeyStored": "Gespeichert – leer lassen zum Beibehalten",
|
"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.authPrefix": "Auth-Präfix",
|
||||||
"settings.enabled": "Aktiv",
|
"settings.enabled": "Aktiv",
|
||||||
"settings.modelCount": "{n} Modelle",
|
"settings.modelCount": "{n} Modelle",
|
||||||
|
|||||||
+14
-1
@@ -39,6 +39,13 @@ export const en = {
|
|||||||
"settings.imagesDefault": "Default model",
|
"settings.imagesDefault": "Default model",
|
||||||
"settings.imagesDir": "Folder in the project",
|
"settings.imagesDir": "Folder in the project",
|
||||||
"settings.imagesHint": "Models and prices per image are edited in the config file ([images]).",
|
"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.title": "Budgets this month",
|
||||||
"budget.spent": "Spent",
|
"budget.spent": "Spent",
|
||||||
"budget.budget": "Budget",
|
"budget.budget": "Budget",
|
||||||
@@ -57,6 +64,12 @@ export const en = {
|
|||||||
"settings.budgetWarning": "Warn at",
|
"settings.budgetWarning": "Warn at",
|
||||||
"settings.adminKey": "Admin key (optional)",
|
"settings.adminKey": "Admin key (optional)",
|
||||||
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
|
"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.fetchPrices": "Fetch prices",
|
||||||
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
|
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
|
||||||
"stats.perMillion": "per 1M tok",
|
"stats.perMillion": "per 1M tok",
|
||||||
@@ -646,7 +659,7 @@ export const en = {
|
|||||||
"settings.baseUrl": "Base URL",
|
"settings.baseUrl": "Base URL",
|
||||||
"settings.apiKey": "API key",
|
"settings.apiKey": "API key",
|
||||||
"settings.apiKeyStored": "Stored – leave empty to keep",
|
"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.authPrefix": "Auth prefix",
|
||||||
"settings.enabled": "Enabled",
|
"settings.enabled": "Enabled",
|
||||||
"settings.modelCount": "{n} models",
|
"settings.modelCount": "{n} models",
|
||||||
|
|||||||
@@ -3569,3 +3569,37 @@ button.danger-solid {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,18 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
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": {
|
"202611-00001": {
|
||||||
en: [
|
en: [
|
||||||
"Status LEDs in the sidebar for server, models and MCP – click them for details",
|
"Status LEDs in the sidebar for server, models and MCP – click them for details",
|
||||||
|
|||||||
Reference in new issue
Block a user