Compare commits

..
6 Commits
Author SHA1 Message Date
Richard ZinkandClaude Opus 5.5 242b80605c File manager: open a chat in a folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:38:39 +01:00
Richard ZinkandClaude Opus 5.5 c9f553dc60 Remove the one-time env-to-config migration from the settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:38:06 +01:00
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
Richard Zink 70367211c1 Release v202611-00001 2026-11-09 09:31:31 +01:00
Richard ZinkandClaude Opus 5.5 1f69d63b09 Status LEDs, image model selection, backup restore, provider test, monthly versions
- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status)
- Images: preview stage, purpose-based model choice and reference images
  (Nano Banana Pro edit refines the chosen preview)
- Settings: restore a stored or uploaded backup (staged, applied on restart)
- Provider form: test connection; saving or reloading re-checks the status at once
- opencode Zen/Go: send x-opencode-session with a stable id per conversation
- Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes
- Fix: backups made in the same second no longer overwrite each other

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 09:31:24 +01:00
42 changed files with 1734 additions and 136 deletions

No files matched your search

+4 -11
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,17 +17,10 @@ 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
# Image version for docker-compose.release.yml (e.g. 0.1.0); default: latest
# Image version for docker-compose.release.yml (e.g. v202611-00001); default: latest
CODECRAFT_VERSION=latest
# Log format: empty for text, "json" for one JSON object per line
+1
View File
@@ -9,3 +9,4 @@
*.db-wal
*.db-shm
docker-compose.override.yml
.env.bak*
+60
View File
@@ -3,6 +3,66 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes.
## [Unreleased]
### Added
- File manager: *Open chat here* in a folder's menu starts a chat in that folder.
### Removed
- *Settings → Config file* no longer offers to move `${NAME}` values into the configuration (a
one-time migration); `${NAME}` references keep working.
## [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
- **Image generation picks the model:** the agent drafts cheap previews, asks which one to
refine (with the price of the final model), then creates the final image with the best model
for the purpose (photo, illustration, text, design). Models that take a reference image
(Nano Banana Pro edit) refine the chosen draft. Known fal.ai models come with sensible
defaults; `stage`, `good_for` and `reference_param` in `[images].models` override them.
- **Restore a backup** in *Settings → Audit*: a stored backup or an uploaded zip. The current
state is backed up first (`…-before-restore.zip`), then CodeCraft restarts with the restored
database and configuration.
- **Test connection** in the provider form checks URL and key before saving.
- **Status LEDs** in the sidebar for server connection, models and MCP servers (green, yellow,
red in vivid variants of the theme colors); a click opens an overview of every provider and
MCP server with its state, latency and tool count.
- `scripts/release.sh`: tag, multi-arch image and Gitea release in one step.
### Changed
- Saving a provider (or reloading providers) checks its status right away instead of at the
next five-minute check.
### Fixed
- opencode Zen/Go: requests carry the session in `x-opencode-session`; opencode Go rejected
them without it ("Request is missing x-opencode-session").
- Two backups in the same second no longer overwrite each other.
## [0.1.0] – 2026-11-09
First public release.
+3
View File
@@ -45,6 +45,9 @@ COPY server/resources-cli ./server/resources-cli
COPY cli/src ./cli/src
ARG GIT_COMMIT=""
ENV CODECRAFT_COMMIT=${GIT_COMMIT}
# Release version (YYYYMM-NNNNN, set by scripts/release.sh); empty = development build.
ARG CODECRAFT_VERSION=""
ENV CODECRAFT_VERSION=${CODECRAFT_VERSION}
RUN touch core/src/lib.rs server/src/main.rs \
&& cargo zigbuild --release -p codecraft --target "$(cat /target).2.36" \
&& cp "target/$(cat /target)/release/codecraft" /codecraft
+24 -7
View File
@@ -11,7 +11,7 @@
</p>
<p align="center">
<img alt="Version 0.1.0" src="https://img.shields.io/badge/version-0.1.0-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,17 +74,19 @@ 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).
**Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`.
Pin a version with `CODECRAFT_VERSION=0.1.0` in `.env`. Admins see a notice when a new release is out.
Releases are named `vYYYYMM-NNNNN` (a counter per month). Pin one with
`CODECRAFT_VERSION=v202611-00001` in `.env`. Admins see a notice when a new release is out.
**From source:** `git clone https://git.familie-zink.org/zink/codecraft.git && cd codecraft`,
`cp .env.example .env`, then `docker compose up -d --build`.
@@ -152,8 +154,14 @@ 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.
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.
- **Images (fal.ai):** the agent drafts cheap previews (FLUX schnell), asks which one to
refine, and creates the final image with the best model for the purpose – Nano Banana Pro
refines the chosen draft, Ideogram handles lettering, Recraft design work.
## Terminal client
@@ -199,7 +207,9 @@ shared secret or a Gitea/GitHub HMAC signature, and its JSON payload is handed t
- **JSON logs:** `CODECRAFT_LOG_FORMAT=json` writes one JSON object per line.
- **Backups:** daily at 03:00 into `/data/backups` (the last 30 are kept), optionally copied to
any S3-compatible bucket (AWS, MinIO, Garage, Backblaze B2, Hetzner). *Settings → Audit*
shows them, creates one on demand and downloads them.
shows them, creates one on demand, downloads them and restores one – a stored backup or an
uploaded zip, e.g. to move to another server. The current state is backed up first, then
CodeCraft restarts with the restored database and configuration.
- **Health:** `GET /api/health` for monitoring, with version and commit.
## Users and security
@@ -245,6 +255,7 @@ place and keep your comments. Secrets belong in the environment and are referenc
| `[auth]`, `[auth.oidc]` | sign-in, public URL, single sign-on |
| `[sandbox]` | shell sandbox for non-admins (`auto`, `always`, `off`) |
| `[backup]`, `[backup.s3]` | daily backups, retention, S3 target |
| `[images]` | fal.ai key, budget, `models` with `stage` (`preview` / `final`), `good_for` and `reference_param` |
| `[craft]` | headless browser, screen widths, clean room |
Compound shell commands (`&&`, `;`, `|`) are only auto-allowed when every part is allowed.
@@ -329,6 +340,12 @@ The Docker build cross-compiles the server with
quickly on either architecture:
`docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/codecraft:<version> --push .`
Releases are named `vYYYYMM-NNNNN`: year and month, then a counter that starts at 00001 every
month. Collect changes under `## [Unreleased]` in the changelog (and under `unreleased` in
`web/src/whatsnew.ts`), then `scripts/release.sh` picks the next number, renames both, commits,
tags, builds and pushes the image (`:vYYYYMM-NNNNN` and `:latest`) and publishes the Gitea
release (`--dry-run` shows the steps). The `Cargo.toml` versions are not used for releases.
Translations: copy `web/src/i18n/en.ts`, translate the values and register the language in
`web/src/i18n/index.ts` – the type checker reports missing keys.
+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(),
}
}
+5 -6
View File
@@ -29,7 +29,7 @@ use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
#[derive(Parser)]
#[command(name = "codecraft", version, about = "CodeCraft in the terminal – a local coding agent")]
#[command(name = "codecraft", version = codecraft_core::version::VERSION, about = "CodeCraft in the terminal – a local coding agent")]
struct Cli {
/// Start with this prompt
prompt: Option<String>,
@@ -164,8 +164,7 @@ fn prompt_line(label: &str, secret: bool) -> Result<String> {
}
fn version_newer(server: &str, ours: &str) -> bool {
let parse = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
parse(server) > parse(ours)
codecraft_core::version::newer(server, ours)
}
fn target() -> String {
@@ -178,8 +177,8 @@ async fn self_update() -> Result<()> {
let http = server::http();
let info: Value = http.get(format!("{}/api/info", login.url)).send().await?.json().await?;
let latest = info["version"].as_str().unwrap_or("0");
if !version_newer(latest, env!("CARGO_PKG_VERSION")) {
println!("codecraft {} is up to date.", env!("CARGO_PKG_VERSION"));
if !version_newer(latest, codecraft_core::version::VERSION) {
println!("codecraft {} is up to date.", codecraft_core::version::VERSION);
return Ok(());
}
let url = format!("{}/cli/download/{}", login.url, target());
@@ -365,7 +364,7 @@ async fn main() -> Result<()> {
// Notice when the server has a newer CLI.
let mut status = String::new();
if let Some(v) = env.server_cfg.as_ref().and_then(|s| s["version"].as_str()) {
if version_newer(v, env!("CARGO_PKG_VERSION")) {
if version_newer(v, codecraft_core::version::VERSION) {
status = format!("update available: {v} – run `codecraft update`");
}
}
+1 -1
View File
@@ -105,7 +105,7 @@ pub async fn stream(
mc.effort = Some(e);
}
let max_tokens = mc.max_output.unwrap_or(16_000);
let req = ChatRequest { model: mc, system, messages, tools, max_tokens, reasoning: true };
let req = ChatRequest { model: mc, system, messages, tools, max_tokens, reasoning: true, session_id: Some(session.to_string()) };
provider.stream(req, tx, cancel).await
}
+1 -1
View File
@@ -19,7 +19,7 @@ pub struct Server {
pub fn http() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(concat!("codecraft-cli/", env!("CARGO_PKG_VERSION")))
.user_agent(format!("codecraft-cli/{}", codecraft_core::version::VERSION))
.connect_timeout(std::time::Duration::from_secs(15))
.build()
.expect("http client")
+22 -15
View File
@@ -8,9 +8,9 @@
#
# 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}.
# =============================================================================
[server]
@@ -47,7 +47,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 +81,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 +97,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 +151,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,11 +243,18 @@ 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
# creates the final image with the best "final" model for the purpose
# (good_for: photo, illustration, text, design), passing the draft as reference
# to models with reference_param. The models below have these values built in;
# set stage / good_for / reference_param / price to override or for other models.
models = [
{ id = "fal-ai/flux/schnell", name = "FLUX.1 schnell – fast", price = 0.003 },
{ id = "fal-ai/flux/schnell", name = "FLUX.1 schnell – fast previews", price = 0.003 },
{ id = "fal-ai/nano-banana-pro/edit", name = "Nano Banana Pro – refine an image", price = 0.15 },
{ id = "fal-ai/nano-banana-pro", name = "Nano Banana Pro", price = 0.15 },
{ id = "fal-ai/flux-2-pro", name = "FLUX.2 pro" },
{ id = "fal-ai/ideogram/v3", name = "Ideogram 3 – text in images", size_param = "aspect_ratio" },
{ id = "fal-ai/recraft/v4/pro/text-to-image", name = "Recraft V4 pro – design" },
@@ -281,7 +288,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"
@@ -302,7 +309,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
@@ -6,3 +6,4 @@ pub mod permission;
pub mod prompts;
pub mod provider;
pub mod types;
pub mod version;
+3 -1
View File
@@ -168,7 +168,8 @@ impl Provider for Anthropic {
for (k, v) in &req.model.options {
body[k] = v.clone();
}
let resp = self.request(reqwest::Method::POST, "/messages").json(&body).send().await?;
let rb = self.request(reqwest::Method::POST, "/messages").json(&body);
let resp = self.ep.with_session(rb, req.session_id.as_deref()).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
@@ -265,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");
+38 -1
View File
@@ -30,6 +30,9 @@ pub struct ChatRequest {
pub max_tokens: u64,
/// Enable extended thinking / reasoning when the model supports it.
pub reasoning: bool,
/// Stable id of the conversation; sent to gateways that route by session
/// (opencode: `x-opencode-session`) for routing and prompt caching.
pub session_id: Option<String>,
}
#[derive(Debug, Clone)]
@@ -84,6 +87,16 @@ impl Endpoint {
}
}
/// Session header for gateways that need one (opencode Zen/Go reject requests without it).
pub fn with_session(&self, rb: reqwest::RequestBuilder, session_id: Option<&str>) -> reqwest::RequestBuilder {
match session_id {
Some(id) if self.base_url.contains("opencode.ai") && !self.headers.keys().any(|k| k.eq_ignore_ascii_case("x-opencode-session")) => {
rb.header("x-opencode-session", id)
}
_ => rb,
}
}
pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if !self.api_key.is_empty() {
rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key));
@@ -99,7 +112,7 @@ impl Endpoint {
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(20))
.user_agent(concat!("codecraft/", env!("CARGO_PKG_VERSION")))
.user_agent(format!("codecraft/{}", crate::version::VERSION))
.build()
.expect("http client")
}
@@ -128,3 +141,27 @@ pub async fn http_error(resp: reqwest::Response) -> anyhow::Error {
.unwrap_or_else(|| body.chars().take(500).collect());
anyhow!("provider returned HTTP {status}: {detail}")
}
#[cfg(test)]
mod session_header_tests {
use super::*;
fn ep(base: &str) -> Endpoint {
Endpoint { base_url: base.into(), api_key: String::new(), auth_header: "Authorization".into(), auth_prefix: "Bearer ".into(), headers: BTreeMap::new() }
}
fn header(ep: &Endpoint, id: Option<&str>) -> Option<String> {
let rb = ep.with_session(reqwest::Client::new().post(format!("{}/chat/completions", ep.base_url)), id);
rb.build().unwrap().headers().get("x-opencode-session").map(|v| v.to_str().unwrap().to_string())
}
#[test]
fn opencode_gets_the_session_header() {
assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), Some("s1")).as_deref(), Some("s1"));
assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), None), None);
assert_eq!(header(&ep("https://api.openai.com/v1"), Some("s1")), None);
let mut custom = ep("https://opencode.ai/zen/v1");
custom.headers.insert("X-Opencode-Session".into(), "fixed".into());
assert_eq!(header(&custom, Some("s1")), None, "a configured header wins");
}
}
+2 -1
View File
@@ -198,7 +198,7 @@ impl Provider for OpenAi {
body[k] = v.clone();
}
let rb = self.client.post(format!("{}/chat/completions", self.ep.base_url)).json(&body);
let resp = self.ep.apply(rb).send().await?;
let resp = self.ep.with_session(self.ep.apply(rb), req.session_id.as_deref()).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
@@ -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 {
+43
View File
@@ -0,0 +1,43 @@
//! Release version. Releases are named vYYYYMM-NNNNN (a counter per month, from 00001);
//! the release build sets CODECRAFT_VERSION (without the leading "v"). Builds without it
//! (development) fall back to the crate version.
pub const VERSION: &str = match option_env!("CODECRAFT_VERSION") {
Some(v) if !v.is_empty() => v,
_ => env!("CARGO_PKG_VERSION"),
};
/// Sort key: monthly releases (YYYYMM-NNNNN) come after the old semantic versions.
fn key(v: &str) -> (u8, Vec<u64>) {
let v = v.trim().trim_start_matches('v');
if let Some((month, n)) = v.split_once('-')
&& month.len() == 6
&& month.chars().all(|c| c.is_ascii_digit())
&& !n.is_empty()
&& n.chars().all(|c| c.is_ascii_digit())
{
return (1, vec![month.parse().unwrap_or(0), n.parse().unwrap_or(0)]);
}
(0, v.split(['.', '-']).take(3).map(|x| x.parse::<u64>().unwrap_or(0)).collect())
}
/// Is version `a` newer than `b`? Accepts both schemes, with or without "v".
pub fn newer(a: &str, b: &str) -> bool {
key(a) > key(b)
}
#[cfg(test)]
mod tests {
use super::newer;
#[test]
fn compares_monthly_and_semver() {
assert!(newer("v202611-00002", "202611-00001"));
assert!(newer("v202612-00001", "v202611-00042"));
assert!(!newer("v202611-00001", "202611-00001"));
assert!(newer("v202611-00001", "0.1.0"));
assert!(!newer("0.9.9", "v202611-00001"));
assert!(newer("v0.2.0", "0.1.9"));
assert!(newer("1.0.0", "0.9.9"));
}
}
+1 -1
View File
@@ -15,7 +15,7 @@
# /data/home home directory of the agent (git config, caches, ...)
services:
codecraft:
# Pin a version (e.g. :0.1.0) or follow :latest.
# Pin a version (e.g. :v202611-00001) or follow :latest.
image: git.familie-zink.org/zink/codecraft:${CODECRAFT_VERSION:-latest}
container_name: codecraft
restart: unless-stopped
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Publish a CodeCraft release named vYYYYMM-NNNNN (year, month, then a counter that
# starts at 00001 every month):
# 1. pick the next number from the existing tags of this month,
# 2. rename "## [Unreleased]" in CHANGELOG.md and the "unreleased" entry in
# web/src/whatsnew.ts to the version, update the README badge, commit,
# 3. tag and push branch and tag,
# 4. build the multi-arch image (amd64 + arm64) with the version embedded and push
# :vYYYYMM-NNNNN (and :latest),
# 5. create the Gitea release with the changelog section as notes.
#
# Usage: scripts/release.sh [vYYYYMM-NNNNN] [--dry-run] [--no-latest] [--skip-image]
# (without a version the next number of the current month is used)
#
# Environment:
# REGISTRY image name without tag (default git.familie-zink.org/zink/codecraft)
# GITEA_URL Gitea base URL (default https://git.familie-zink.org)
# GITEA_REPO owner/repo (default zink/codecraft)
# GITEA_TOKEN API token; if empty, the git credential helper is asked
# PLATFORMS buildx platforms (default linux/amd64,linux/arm64)
# BUILDER buildx builder name (default codecraft-multi, created if missing)
#
# Requires: git, docker with buildx (logged in to the registry), curl, jq.
# Building for a foreign architecture needs QEMU/binfmt:
# docker run --privileged --rm tonistiigi/binfmt --install all
set -euo pipefail
REGISTRY="${REGISTRY:-git.familie-zink.org/zink/codecraft}"
GITEA_URL="${GITEA_URL:-https://git.familie-zink.org}"
GITEA_REPO="${GITEA_REPO:-zink/codecraft}"
PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
BUILDER="${BUILDER:-codecraft-multi}"
tag="" dry=0 latest=1 image=1
for arg in "$@"; do
case "$arg" in
--dry-run) dry=1 ;;
--no-latest) latest=0 ;;
--skip-image) image=0 ;;
-h|--help) sed -n '2,26p' "$0"; exit 0 ;;
-*) echo "unknown option $arg" >&2; exit 2 ;;
*) tag="$arg" ;;
esac
done
cd "$(git rev-parse --show-toplevel)"
step() { printf '\n==> %s\n' "$*"; }
run() { if (( dry )); then echo "[dry-run] $*"; else "$@"; fi; }
fail() { echo "error: $*" >&2; exit 1; }
step "Checking the working tree"
for cmd in git docker curl jq; do command -v "$cmd" >/dev/null || fail "$cmd is not installed"; done
[[ -z "$(git status --porcelain --untracked-files=no)" ]] || fail "uncommitted changes – commit or stash them first"
run git fetch --tags --quiet origin
if [[ -z "$tag" ]]; then
month=$(date +%Y%m)
last=$(git tag -l "v$month-*" | sed -n "s/^v$month-\([0-9]\{5\}\)$/\1/p" | sort -n | tail -1)
tag=$(printf 'v%s-%05d' "$month" $((10#${last:-0} + 1)))
fi
[[ "$tag" =~ ^v[0-9]{6}-[0-9]{5}$ ]] || fail "version must look like vYYYYMM-NNNNN, got $tag"
git rev-parse -q --verify "refs/tags/$tag" >/dev/null && fail "tag $tag already exists"
version=${tag#v}
today=$(date +%Y-%m-%d)
echo "release $tag"
# Release notes: the [Unreleased] section of the changelog (without its heading).
notes=$(awk '/^## \[Unreleased\]/ { found=1; next } found && /^## \[/ { exit } found { print }' CHANGELOG.md)
[[ -n "${notes//[[:space:]]/}" ]] || fail "CHANGELOG.md has no (or an empty) \"## [Unreleased]\" section"
notes+=$'\n\n'"Image: \`$REGISTRY:$tag\` ($(tr ',' ' ' <<<"$PLATFORMS"))."
step "Naming the release in CHANGELOG.md, whatsnew.ts and README.md"
if (( dry )); then
echo "[dry-run] ## [Unreleased] -> ## [$tag] – $today; whatsnew unreleased -> \"$version\"; README badge"
else
sed -i "s/^## \[Unreleased\].*/## [$tag] – $today/" CHANGELOG.md
grep -q '^ unreleased: {' web/src/whatsnew.ts && sed -i "s/^ unreleased: {/ \"$version\": {/" web/src/whatsnew.ts
# shields.io escapes "-" as "--".
sed -i -E "s|(alt=\"Version )[^\"]*(\" src=\"https://img.shields.io/badge/version-)[^?]*-([0-9a-f]{6}\?)|\1$tag\2${tag//-/--}-\3|" README.md
git add CHANGELOG.md web/src/whatsnew.ts README.md
git commit -q -m "Release $tag"
fi
step "Tagging $tag and pushing"
branch=$(git rev-parse --abbrev-ref HEAD)
run git tag -a "$tag" -m "CodeCraft $tag"
run git push origin "$branch" "$tag"
if (( image )); then
step "Building and pushing $REGISTRY:$tag for $PLATFORMS"
docker buildx inspect "$BUILDER" >/dev/null 2>&1 || run docker buildx create --name "$BUILDER" --driver docker-container >/dev/null
tags=(-t "$REGISTRY:$tag")
(( latest )) && tags+=(-t "$REGISTRY:latest")
run docker buildx build --builder "$BUILDER" --platform "$PLATFORMS" \
--build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" --build-arg CODECRAFT_VERSION="$version" \
"${tags[@]}" --push .
fi
step "Creating the Gitea release $tag"
token="${GITEA_TOKEN:-}"
user=""
if [[ -z "$token" ]]; then
host=${GITEA_URL#*://}
creds=$(printf 'protocol=%s\nhost=%s\n\n' "${GITEA_URL%%://*}" "${host%%/*}" | git credential fill 2>/dev/null || true)
user=$(sed -n 's/^username=//p' <<<"$creds")
token=$(sed -n 's/^password=//p' <<<"$creds")
fi
[[ -n "$token" ]] || fail "no Gitea credentials – set GITEA_TOKEN"
auth=(-H "Authorization: token $token")
[[ -n "$user" ]] && auth=(-u "$user:$token")
body=$(jq -n --arg tag "$tag" --arg name "CodeCraft $tag" --arg body "$notes" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
if (( dry )); then
echo "[dry-run] POST $GITEA_URL/api/v1/repos/$GITEA_REPO/releases"
else
url=$(curl -fsS "${auth[@]}" -H 'Content-Type: application/json' -X POST \
"$GITEA_URL/api/v1/repos/$GITEA_REPO/releases" -d "$body" | jq -r .html_url)
echo "published: $url"
fi
step "Done"
echo "Update a deployment: set CODECRAFT_VERSION=$tag in .env, then docker compose pull && docker compose up -d"
+111
View File
@@ -119,11 +119,15 @@ pub fn router(app: AppState) -> Router {
.route("/backup", get(backup))
.route("/backups", get(list_backups).post(run_backup))
.route("/backups/{name}", get(download_backup))
.route("/backups/{name}/restore", post(restore_stored_backup))
.route("/backups/restore", post(restore_uploaded_backup).layer(axum::extract::DefaultBodyLimit::max(4 * 1024 * 1024 * 1024)))
.route("/health", get(health))
.route("/tools", get(crate::reverse::status))
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
.route("/providers/{name}/prices", get(provider_prices))
.route("/providers/status", get(provider_status).post(recheck_providers))
.route("/providers/test", post(test_provider))
.route("/status", get(status_overview))
.route("/images", get(list_images).post(create_images))
.route("/images/config", get(images_config))
.route("/images/{id}", delete(delete_image))
@@ -1116,6 +1120,33 @@ async fn run_backup(Extension(user): Extension<User>, State(app): State<AppState
Ok(Json(json!(res)))
}
/// Stage a restore and restart: the database is replaced before it is opened again.
/// In Docker the restart policy brings the server back up.
async fn restore_and_restart(app: &App, user: &User, bytes: Vec<u8>, source: &str) -> ApiResult {
crate::backup::stage_restore(app, bytes).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
app.audit(Some(user), "backup.restore", source, "");
tracing::warn!(source, "backup staged for restore – restarting");
tokio::spawn(async {
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
std::process::exit(0);
});
Ok(Json(json!({"ok": true, "restart": true})))
}
async fn restore_stored_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> ApiResult {
require_admin(&user)?;
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
return Err(ApiError(StatusCode::NOT_FOUND, "no such backup".into()));
}
let bytes = tokio::fs::read(crate::backup::backup_dir(&app).join(&name)).await?;
restore_and_restart(&app, &user, bytes, &name).await
}
async fn restore_uploaded_backup(Extension(user): Extension<User>, State(app): State<AppState>, body: axum::body::Bytes) -> ApiResult {
require_admin(&user)?;
restore_and_restart(&app, &user, body.to_vec(), &format!("upload ({} bytes)", body.len())).await
}
async fn download_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> Result<Response, ApiError> {
require_admin(&user)?;
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
@@ -1138,10 +1169,89 @@ async fn health(State(app): State<AppState>) -> Json<Value> {
Json(json!({"ok": true, "version": crate::update::VERSION, "commit": crate::update::commit(), "sandbox": crate::sandbox::available(), "providers": app.registry().providers.len()}))
}
/// Status overlay: every enabled provider and MCP server with its state. Error texts can
/// contain URLs or hosts, so only admins get them.
async fn status_overview(Extension(user): Extension<User>, State(app): State<AppState>) -> Json<Value> {
let admin = user.is_admin();
let cfg = app.config();
let registry = app.registry();
let health = app.health.all();
let providers: serde_json::Map<String, Value> = cfg
.providers
.iter()
.filter(|(_, p)| p.enabled)
.map(|(name, _)| {
let models = registry.providers.get(name).map(|e| e.models.len()).unwrap_or(0);
let v = match health.get(name) {
Some(h) => json!({
"state": h.state,
"latency_ms": h.latency_ms,
"checked_at": h.checked_at,
"error": if admin { h.error.clone() } else { None },
"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)
})
.collect();
let mcp: serde_json::Map<String, Value> = app
.mcp
.states()
.into_iter()
.filter(|(_, st)| !matches!(st, crate::mcp::McpState::Disabled))
.map(|(name, st)| {
let v = match st {
crate::mcp::McpState::Connected { tools } => json!({"state": "connected", "tools": tools}),
crate::mcp::McpState::Failed { error } => json!({"state": "failed", "error": if admin { Some(error) } else { None }}),
_ => json!({"state": "connecting"}),
};
(name, v)
})
.collect();
Json(json!({"version": crate::update::VERSION, "providers": providers, "mcp": mcp}))
}
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
Json(json!(app.health.all()))
}
#[derive(Deserialize)]
struct TestProviderBody {
/// Existing provider: its stored key is used when `api_key` is empty.
#[serde(default)]
name: Option<String>,
kind: String,
base_url: String,
#[serde(default)]
api_key: String,
#[serde(default)]
auth_prefix: Option<String>,
}
/// Check URL and key from the edit form without saving them.
async fn test_provider(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<TestProviderBody>) -> ApiResult {
require_admin(&user)?;
let stored = body.name.as_ref().and_then(|n| app.config().providers.get(n).cloned());
let mut value = stored.as_ref().map(|p| serde_json::to_value(p)).transpose()?.unwrap_or_else(|| json!({}));
value["kind"] = json!(body.kind);
value["base_url"] = json!(body.base_url);
if !body.api_key.is_empty() {
value["api_key"] = json!(body.api_key);
}
if let Some(prefix) = body.auth_prefix {
value["auth_prefix"] = if prefix.is_empty() { Value::Null } else { json!(prefix) };
}
let pc: crate::config::ProviderConfig = serde_json::from_value(value).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("invalid provider: {e}")))?;
let provider = crate::provider::build(crate::provider::http_client(), &pc);
let (status, models) = crate::health::probe(provider).await;
Ok(Json(json!({"status": status, "models": models})))
}
async fn recheck_providers(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
require_admin(&user)?;
crate::health::check_all(&app).await;
@@ -1220,6 +1330,7 @@ async fn create_images(Extension(user): Extension<User>, State(app): State<AppSt
dir,
user_id: Some(user.id.clone()),
session_id: None,
references: vec![],
},
)
.await?;
+6
View File
@@ -247,11 +247,16 @@ impl App {
std::fs::rename(&tmp, &self.config_path)?;
let registry = Registry::build(&cfg).await;
let mcp_changed = serde_json::to_value(&cfg.mcp)? != serde_json::to_value(&self.config().mcp)?;
let providers_changed = serde_json::to_value(&cfg.providers)? != serde_json::to_value(&self.config().providers)?;
*self.config.write().unwrap() = Arc::new(cfg);
*self.registry.write().unwrap() = Arc::new(registry);
if mcp_changed {
self.clone().start_mcp();
}
// A new key or URL should show its status right away, not at the next 5-minute check.
if providers_changed {
crate::health::check_all(self).await;
}
Ok(())
}
@@ -263,6 +268,7 @@ impl App {
pub async fn reload_providers(&self) {
let registry = Registry::build(&self.config()).await;
*self.registry.write().unwrap() = Arc::new(registry);
crate::health::check_all(self).await;
}
// --- events ---------------------------------------------------------
+63 -1
View File
@@ -56,6 +56,56 @@ pub async fn create_zip(app: &App) -> Result<Vec<u8>> {
.await?
}
/// Directory a restore is staged in; applied at the next start (see `apply_pending_restore`).
fn pending_dir(data_dir: &std::path::Path) -> PathBuf {
data_dir.join("restore-pending")
}
/// Read config.toml and codecraft.db from a backup zip and check both.
fn unpack(bytes: &[u8]) -> Result<(String, Vec<u8>)> {
use std::io::Read;
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).context("not a zip file")?;
let mut config = String::new();
z.by_name("config.toml").context("config.toml missing in the backup")?.read_to_string(&mut config)?;
let mut db = Vec::new();
z.by_name("codecraft.db").context("codecraft.db missing in the backup")?.read_to_end(&mut db)?;
if !db.starts_with(b"SQLite format 3\0") {
bail!("codecraft.db in the backup is not an SQLite database");
}
crate::config::Config::parse(&config).context("config.toml in the backup is invalid")?;
Ok((config, db))
}
/// Check a backup, back up the current state, and stage the backup for the next start.
pub async fn stage_restore(app: &App, bytes: Vec<u8>) -> Result<()> {
let (config, db) = tokio::task::spawn_blocking(move || unpack(&bytes)).await??;
// Safety net: the current state as a normal backup file.
run_named(app, "-before-restore").await.context("could not back up the current state first")?;
let dir = pending_dir(&app.config().data_dir());
let _ = tokio::fs::remove_dir_all(&dir).await;
tokio::fs::create_dir_all(&dir).await?;
tokio::fs::write(dir.join("codecraft.db"), db).await?;
tokio::fs::write(dir.join("config.toml"), config).await?;
Ok(())
}
/// Called at startup before the database is opened: move a staged restore into place.
pub fn apply_pending_restore(data_dir: &std::path::Path, config_path: &std::path::Path) -> Result<bool> {
let dir = pending_dir(data_dir);
let (db, cfg) = (dir.join("codecraft.db"), dir.join("config.toml"));
if !db.is_file() || !cfg.is_file() {
return Ok(false);
}
let target = data_dir.join("codecraft.db");
for ext in ["db-wal", "db-shm"] {
let _ = std::fs::remove_file(target.with_extension(ext));
}
std::fs::rename(&db, &target)?;
std::fs::copy(&cfg, config_path)?;
std::fs::remove_dir_all(&dir)?;
Ok(true)
}
pub fn backup_dir(app: &App) -> PathBuf {
let cfg = app.config();
if cfg.backup.dir.is_empty() { cfg.data_dir().join("backups") } else { PathBuf::from(expand_env(&cfg.backup.dir)) }
@@ -89,12 +139,24 @@ pub fn list(app: &App) -> Vec<BackupFile> {
/// Create a backup now: local file (+ pruning) and S3 upload if configured.
pub async fn run(app: &App) -> Result<LastRun> {
run_named(app, "").await
}
/// Like `run`, with a suffix in the file name (e.g. "-before-restore").
pub async fn run_named(app: &App, suffix: &str) -> Result<LastRun> {
let _guard = app.backup.running.lock().await;
let cfg = app.config().backup.clone();
let dir = backup_dir(app);
tokio::fs::create_dir_all(&dir).await.with_context(|| format!("create {}", dir.display()))?;
let bytes = create_zip(app).await?;
let name = format!("codecraft-backup-{}.zip", Local::now().format("%Y%m%d-%H%M%S"));
// Names have second resolution: never overwrite a backup made in the same second.
let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let mut name = format!("codecraft-backup-{stamp}{suffix}.zip");
let mut n = 1;
while dir.join(&name).exists() {
n += 1;
name = format!("codecraft-backup-{stamp}{suffix}-{n}.zip");
}
let path = dir.join(&name);
tokio::fs::write(&path, &bytes).await.with_context(|| format!("write {}", path.display()))?;
// Restrictive permissions: the zip contains the config with API keys.
+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);
+3 -2
View File
@@ -47,7 +47,7 @@ pub async fn config(Extension(user): Extension<User>, State(app): State<AppState
let memory: String = app.store.user_setting(&user.id, "memory")?;
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
Ok(Json(json!({
"version": env!("CARGO_PKG_VERSION"),
"version": codecraft_core::version::VERSION,
"user": user,
"models": app.registry().models_json(),
"default_model": default_model,
@@ -115,8 +115,8 @@ pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppS
let provider_name = b.model.split_once('/').map(|(p, _)| p.to_string()).unwrap_or_default();
crate::budget::check(&app, &provider_name, Some(&user.id)).map_err(|e| ApiError(StatusCode::PAYMENT_REQUIRED, format!("{e:#}")))?;
let max_tokens = b.max_tokens.or(model.max_output).unwrap_or(16_000);
let req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning };
let session = format!("cli:{}", b.session.unwrap_or_else(|| "default".into()));
let req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning, session_id: Some(session.clone()) };
let agent = format!("cli:{}", b.agent.unwrap_or_else(|| "build".into()));
let spec = b.model.clone();
let (out_tx, out_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(256);
@@ -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)) });
+10 -1
View File
@@ -193,6 +193,15 @@ pub struct ImageModel {
/// Extra request fields sent as-is.
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
pub options: serde_json::Map<String, serde_json::Value>,
/// "preview" (cheap drafts) or "final" (best quality). Known fal.ai models have defaults.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stage: Option<String>,
/// What the model is good at: photo, illustration, text, design.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub good_for: Vec<String>,
/// Request field for reference images (e.g. "image_urls"); set for models that refine an image.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference_param: Option<String>,
}
impl Default for ImagesConfig {
@@ -630,7 +639,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);
}
+16 -13
View File
@@ -33,19 +33,14 @@ impl Health {
}
}
pub async fn check_all(app: &App) {
let registry = app.registry();
let mut found = BTreeMap::new();
let checks = registry.providers.iter().map(|(name, entry)| {
let provider = entry.provider.clone();
let name = name.clone();
async move {
/// Fetch the model list once and classify the answer.
pub async fn probe(provider: Arc<dyn crate::provider::Provider>) -> (ProviderStatus, usize) {
let start = Instant::now();
let res = tokio::time::timeout(Duration::from_secs(15), provider.list_models()).await;
let error: Option<String> = match res {
Ok(Ok(_)) => None,
Ok(Err(e)) => Some(e.to_string().chars().take(300).collect()),
Err(_) => Some("timed out".to_string()),
let (error, count): (Option<String>, usize) = match res {
Ok(Ok(m)) => (None, m.len()),
Ok(Err(e)) => (Some(e.to_string().chars().take(300).collect()), 0),
Err(_) => (Some("timed out".to_string()), 0),
};
// The host answered but has no model list: reachable.
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405")));
@@ -54,8 +49,16 @@ pub async fn check_all(app: &App) {
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
Some(_) => "down",
};
(name, ProviderStatus { up: error.is_none(), state, error, latency_ms: start.elapsed().as_millis() as u64, checked_at: chrono_now() })
}
(ProviderStatus { up: error.is_none(), state, error, latency_ms: start.elapsed().as_millis() as u64, checked_at: chrono_now() }, count)
}
pub async fn check_all(app: &App) {
let registry = app.registry();
let mut found = BTreeMap::new();
let checks = registry.providers.iter().map(|(name, entry)| {
let provider = entry.provider.clone();
let name = name.clone();
async move { (name, probe(provider).await.0) }
});
for (name, st) in futures::future::join_all(checks).await {
if !st.up {
+179 -17
View File
@@ -34,6 +34,8 @@ pub struct GenerateRequest {
pub dir: PathBuf,
pub user_id: Option<String>,
pub session_id: Option<String>,
/// Images to refine (sent to models with a `reference_param`).
pub references: Vec<PathBuf>,
}
fn slug(s: &str) -> String {
@@ -47,14 +49,90 @@ fn slug(s: &str) -> String {
out.trim_matches('-').chars().take(40).collect::<String>().trim_end_matches('-').to_string()
}
/// Purposes the agent can ask for; models list theirs in `good_for`.
pub const PURPOSES: &[&str] = &["photo", "illustration", "text", "design"];
/// Defaults for well-known fal.ai models: (id, stage, good_for, reference_param, size_param, price).
const KNOWN: &[(&str, &str, &[&str], Option<&str>, Option<&str>, f64)] = &[
("fal-ai/flux/schnell", "preview", &["photo", "illustration"], None, None, 0.003),
("fal-ai/nano-banana-pro", "final", &["photo", "illustration", "text"], None, Some("aspect_ratio"), 0.15),
("fal-ai/nano-banana-pro/edit", "final", &["photo", "illustration", "text", "design"], Some("image_urls"), Some("aspect_ratio"), 0.15),
("fal-ai/flux-2-pro", "final", &["photo"], None, None, 0.03),
("fal-ai/ideogram/v3", "final", &["text", "design"], None, Some("aspect_ratio"), 0.06),
("fal-ai/recraft/v4/pro/text-to-image", "final", &["design", "illustration"], None, None, 0.05),
];
/// A configured model with the known defaults filled in where the config leaves them out.
fn with_defaults(mut m: ImageModel) -> ImageModel {
if let Some((_, stage, good_for, reference, size, price)) = KNOWN.iter().find(|k| k.0 == m.id) {
m.stage.get_or_insert_with(|| stage.to_string());
if m.good_for.is_empty() {
m.good_for = good_for.iter().map(|s| s.to_string()).collect();
}
if m.reference_param.is_none() {
m.reference_param = reference.map(String::from);
}
if m.size_param.is_none() {
m.size_param = size.map(String::from);
}
m.price.get_or_insert(*price);
}
m
}
fn models(app: &App) -> Vec<ImageModel> {
app.config().images.models.iter().cloned().map(with_defaults).collect()
}
fn model_config(app: &App, id: &str) -> ImageModel {
app.config()
.images
.models
.iter()
.find(|m| m.id == id)
.cloned()
.unwrap_or_else(|| ImageModel { id: id.to_string(), ..Default::default() })
models(app).into_iter().find(|m| m.id == id).unwrap_or_else(|| with_defaults(ImageModel { id: id.to_string(), ..Default::default() }))
}
/// Pick the model for a request: an explicit model wins; previews use the first preview model
/// (else the default); final images use the best-matching final model, preferring one that can
/// take the reference image when there is one.
pub fn choose_model(app: &App, stage: &str, purpose: Option<&str>, with_reference: bool) -> ImageModel {
let default = app.config().images.default_model.clone();
pick(&models(app), stage, purpose, with_reference).unwrap_or_else(|| model_config(app, &default))
}
fn pick(all: &[ImageModel], stage: &str, purpose: Option<&str>, with_reference: bool) -> Option<ImageModel> {
if stage == "preview" && !with_reference {
return all.iter().find(|m| m.stage.as_deref() == Some("preview")).cloned();
}
let score = |m: &ImageModel| {
let mut s = 0;
if m.stage.as_deref() == Some("final") {
s += 4;
}
match (with_reference, m.reference_param.is_some()) {
(true, true) => s += 8,
// Without a reference, plain text-to-image endpoints come first.
(false, true) => s -= 1,
_ => {}
}
if purpose.is_some_and(|p| m.good_for.iter().any(|g| g == p)) {
s += 2;
}
s
};
// max_by_key keeps the last of equal scores; reverse so the config order wins ties.
all.iter().rev().max_by_key(|m| score(m)).cloned()
}
/// Read local reference images as data URIs (fal.ai accepts them in place of URLs).
async fn reference_uris(paths: &[PathBuf]) -> Result<Vec<String>> {
let mut out = Vec::new();
for p in paths {
let bytes = tokio::fs::read(p).await.with_context(|| format!("reference image {}", p.display()))?;
let mime = match p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref() {
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
_ => "image/png",
};
out.push(format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes)));
}
Ok(out)
}
async fn download(url: &str) -> Result<(Vec<u8>, String)> {
@@ -82,15 +160,22 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
crate::budget::check(app, "fal", req.user_id.as_deref())?;
let model_id = req.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| ic.default_model.clone());
let model = model_config(app, &model_id);
let size = req.size.clone().filter(|s| SIZES.contains(&s.as_str())).unwrap_or_else(|| "landscape_4_3".into());
let explicit_size = req.size.clone().filter(|s| SIZES.contains(&s.as_str()));
let size = explicit_size.clone().unwrap_or_else(|| "landscape_4_3".into());
let count = req.count.clamp(1, 4);
let mut body = json!({"prompt": req.prompt, "num_images": count, "output_format": "png"});
let refining = !req.references.is_empty() && model.reference_param.is_some();
match model.size_param.as_deref() {
// When refining without an explicit size, keep the reference's aspect ratio.
Some("aspect_ratio") if refining && explicit_size.is_none() => body["aspect_ratio"] = json!("auto"),
Some("aspect_ratio") => body["aspect_ratio"] = json!(aspect_ratio(&size)),
Some("none") => {}
_ => body["image_size"] = json!(size),
}
if refining && let Some(param) = &model.reference_param {
body[param.as_str()] = json!(reference_uris(&req.references).await?);
}
for (k, v) in &model.options {
body[k] = v.clone();
}
@@ -162,6 +247,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
cost,
error: false,
user_id: None,
allowance: None,
});
Ok(records)
}
@@ -184,9 +270,13 @@ impl Tool for GenerateImage {
}
fn description(&self) -> String {
"Generate images from a text prompt (fal.ai) and save them in the project. Use for \
illustrations, icons, backgrounds or placeholder photos the user needs. Write a detailed \
visual prompt in English (subject, style, composition, colours, lighting). Each image \
costs money – generate only what is needed. Returns the file paths and a preview."
illustrations, icons, backgrounds or photos the user needs. Write a detailed visual prompt \
in English (subject, style, composition, colours, lighting). The server picks the model. \
Work in two steps: first stage \"preview\" (cheap drafts, count 2-4); show them and ask the \
user with ask_user which one to refine – include the final model and price the preview \
result names. Then call again with stage \"final\", the chosen file in \"reference\" and \
the same or an improved prompt. Skip the preview only if the user asks for a final image \
directly. Each image costs money – generate only what is needed."
.into()
}
fn schema(&self) -> Value {
@@ -194,37 +284,55 @@ impl Tool for GenerateImage {
"type": "object",
"properties": {
"prompt": {"type": "string"},
"size": {"type": "string", "enum": SIZES},
"stage": {"type": "string", "enum": ["preview", "final"], "description": "preview = cheap drafts (default), final = best model for the purpose"},
"purpose": {"type": "string", "enum": PURPOSES, "description": "What the image is: photo (realistic), illustration, text (lettering, posters, signs), design (logos, icons, UI, vector style)"},
"reference": {"type": "array", "items": {"type": "string"}, "maxItems": 4, "description": "Image files (relative paths) to refine, e.g. the preview the user chose"},
"size": {"type": "string", "enum": SIZES, "description": "Default: landscape_4_3; when refining, the reference's aspect ratio"},
"count": {"type": "integer", "minimum": 1, "maximum": 4},
"directory": {"type": "string", "description": "Target directory relative to the working directory (default: images.output_dir)"},
"model": {"type": "string", "description": "fal.ai model id (optional, default from the settings)"}
"model": {"type": "string", "description": "fal.ai model id – only if the user asks for a specific model"}
},
"required": ["prompt"]
})
}
fn title(&self, input: &Value) -> String {
format!("Generate image: {}", input["prompt"].as_str().unwrap_or("").chars().take(80).collect::<String>())
let stage = if input["stage"].as_str() == Some("final") { "Generate final image" } else { "Generate image" };
format!("{stage}: {}", input["prompt"].as_str().unwrap_or("").chars().take(80).collect::<String>())
}
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let prompt = arg_str(&input, "prompt")?.to_string();
let stage = if input["stage"].as_str() == Some("final") { "final" } else { "preview" };
let purpose = input["purpose"].as_str().filter(|p| PURPOSES.contains(p));
let dir_arg = input["directory"].as_str().map(String::from).unwrap_or_else(|| ctx.app.config().images.output_dir.clone());
let dir = ctx.resolve(&dir_arg)?;
let mut references = Vec::new();
for r in input["reference"].as_array().into_iter().flatten().filter_map(|v| v.as_str()).take(4) {
references.push(ctx.resolve(r)?);
}
let model = match input["model"].as_str().filter(|m| !m.is_empty()) {
Some(m) => model_config(&ctx.app, m),
None => choose_model(&ctx.app, stage, purpose, !references.is_empty()),
};
let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id);
let refined = !references.is_empty() && model.reference_param.is_some();
let records = generate(
&ctx.app,
GenerateRequest {
prompt,
model: input["model"].as_str().map(String::from),
model: Some(model.id.clone()),
size: input["size"].as_str().map(String::from),
count: input["count"].as_u64().unwrap_or(1) as u32,
count: input["count"].as_u64().unwrap_or(if stage == "preview" { 2 } else { 1 }) as u32,
dir,
user_id: owner,
session_id: Some(ctx.session_id.clone()),
references,
},
)
.await
.map_err(|e| anyhow!("{e:#}"))?;
let mut text = String::from("Generated:\n");
let price = |m: &ImageModel| m.price.map(|p| format!(" (${p:.3} per image)")).unwrap_or_default();
let label = |m: &ImageModel| m.name.clone().unwrap_or_else(|| m.id.clone());
let mut text = format!("Model: {}{}\nGenerated:\n", label(&model), price(&model));
let mut images = Vec::new();
for r in &records {
let rel = Path::new(&r.path).strip_prefix(&ctx.cwd).map(|p| p.display().to_string()).unwrap_or_else(|_| r.path.clone());
@@ -233,7 +341,61 @@ impl Tool for GenerateImage {
images.push(p);
}
}
if stage == "preview" && !refined {
let fin = choose_model(&ctx.app, "final", purpose, true);
if fin.id != model.id {
let how = if fin.reference_param.is_some() { "refines the chosen preview" } else { "generates it again from the prompt" };
text.push_str(&format!(
"\nThese are previews. Ask the user (ask_user) which one to refine. The final image would use {}{} and {how}.",
label(&fin),
price(&fin)
));
}
} else if !input["reference"].as_array().is_none_or(|a| a.is_empty()) && !refined {
text.push_str("\nNote: this model takes no reference image, so it was generated from the prompt only.");
}
let ids: Vec<&str> = records.iter().map(|r| r.id.as_str()).collect();
Ok(ToolOutput { content: text, images, meta: Some(json!({"image_ids": ids})), ..Default::default() })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn m(id: &str) -> ImageModel {
with_defaults(ImageModel { id: id.into(), ..Default::default() })
}
#[test]
fn picks_by_stage_purpose_and_reference() {
let all = vec![
m("fal-ai/flux/schnell"),
m("fal-ai/nano-banana-pro/edit"),
m("fal-ai/nano-banana-pro"),
m("fal-ai/flux-2-pro"),
m("fal-ai/ideogram/v3"),
m("fal-ai/recraft/v4/pro/text-to-image"),
];
let id = |stage, purpose, r| pick(&all, stage, purpose, r).unwrap().id;
assert_eq!(id("preview", Some("photo"), false), "fal-ai/flux/schnell");
// Refining a preview: the edit model, whatever the purpose.
assert_eq!(id("final", Some("photo"), true), "fal-ai/nano-banana-pro/edit");
assert_eq!(id("final", Some("design"), true), "fal-ai/nano-banana-pro/edit");
// From the prompt only: the first fitting text-to-image model (config order).
assert_eq!(id("final", Some("photo"), false), "fal-ai/nano-banana-pro");
assert_eq!(id("final", Some("text"), false), "fal-ai/nano-banana-pro");
assert_eq!(id("final", Some("design"), false), "fal-ai/ideogram/v3");
let no_edit: Vec<_> = all.iter().filter(|m| m.reference_param.is_none()).cloned().collect();
assert_eq!(pick(&no_edit, "final", Some("design"), true).unwrap().id, "fal-ai/ideogram/v3");
assert_eq!(pick(&no_edit, "final", Some("photo"), false).unwrap().id, "fal-ai/nano-banana-pro");
}
#[test]
fn config_overrides_known_defaults() {
let custom = with_defaults(ImageModel { id: "fal-ai/flux/schnell".into(), stage: Some("final".into()), price: Some(0.01), ..Default::default() });
assert_eq!(custom.stage.as_deref(), Some("final"));
assert_eq!(custom.price, Some(0.01));
assert!(m("some/unknown-model").stage.is_none());
}
}
+6 -1
View File
@@ -54,7 +54,12 @@ async fn main() -> Result<()> {
}
let path = config::config_path();
let (cfg, _) = config::load_or_init(&path)?;
let (mut cfg, _) = config::load_or_init(&path)?;
// A backup restored from the settings is applied here, before the database is opened.
if backup::apply_pending_restore(&cfg.data_dir(), &path)? {
tracing::warn!("restored database and config from a backup");
cfg = config::load_or_init(&path)?.0;
}
tracing::info!("config: {}", path.display());
tracing::info!("workspace: {}", cfg.workspace().display());
let _ = sandbox::probe().await;
+6 -1
View File
@@ -160,7 +160,7 @@ impl McpServer {
json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {"elicitation": {}, "roots": {"listChanged": false}},
"clientInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")}
"clientInfo": {"name": "codecraft", "version": codecraft_core::version::VERSION}
}),
)
.await?;
@@ -452,6 +452,11 @@ pub struct McpManager {
}
impl McpManager {
/// Connection state per server (for the status overlay).
pub fn states(&self) -> BTreeMap<String, McpState> {
self.states.lock().unwrap().clone()
}
pub fn status_json(&self) -> Value {
let states = self.states.lock().unwrap();
let servers = self.servers.lock().unwrap();
+7 -1
View File
@@ -298,6 +298,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
tools: tools.iter().map(|t| t.def()).collect(),
max_tokens: model.max_output.unwrap_or(16_000),
reasoning: true,
session_id: Some(session_id.to_string()),
model: model.clone(),
};
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
@@ -881,6 +882,7 @@ async fn stream_step(
cost: req.model.cost(usage),
error,
user_id: None,
allowance: usage.allowance,
};
if let Err(e) = result {
@@ -966,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,
});
}
@@ -985,6 +988,7 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
tools: vec![],
max_tokens: 2000,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
match complete_text(&app, &spec, provider, req, CancellationToken::new()).await {
@@ -1032,6 +1036,7 @@ pub async fn suggest_title(app: Arc<App>, session_id: &str, model_spec: Option<S
tools: vec![],
max_tokens: 2000,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
let (text, usage) = complete_text(&app, &spec, provider, req, CancellationToken::new()).await?;
@@ -1159,7 +1164,7 @@ async fn review_turn(app: &Arc<App>, session: &Session, agent: &prompts::Agent,
agent.name,
if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() }
);
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone() };
let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone(), session_id: Some(session.id.clone()) };
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
record_side_usage(app, session, &spec, &model, &usage, "review");
let text = strip_think(&text);
@@ -1254,6 +1259,7 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
tools: vec![],
max_tokens: 16_000,
reasoning: false,
session_id: Some(session.id.clone()),
model: model.clone(),
};
let (text, usage) = complete_text(&app, &spec, provider.clone(), req, cancel.clone()).await?;
+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")?;
+3 -14
View File
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use std::time::Duration;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub use codecraft_core::version::VERSION;
/// Git commit the image was built from (Docker build arg GIT_COMMIT).
pub fn commit() -> &'static str {
@@ -17,10 +17,7 @@ pub struct UpdateState {
pub latest: Mutex<Option<Value>>,
}
fn newer(a: &str, b: &str) -> bool {
let p = |v: &str| v.trim_start_matches('v').split(['.', '-']).take(3).map(|x| x.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
p(a) > p(b)
}
use codecraft_core::version::newer;
async fn check(app: &App) -> Option<Value> {
let url = app.config().server.update_url.clone();
@@ -46,12 +43,4 @@ pub fn spawn_checker(app: Arc<App>) {
});
}
#[cfg(test)]
mod tests {
#[test]
fn version_compare() {
assert!(super::newer("v0.2.0", "0.1.9"));
assert!(super::newer("1.0.0", "0.9.9"));
assert!(!super::newer("v0.1.0", "0.1.0"));
}
}
+35 -1
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,7 +86,8 @@ export function BudgetPanel() {
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.provider}>
<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>}
@@ -76,6 +101,15 @@ export function BudgetPanel() {
</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>
+14
View File
@@ -9,6 +9,7 @@ import { Markdown } from "./Markdown";
import { GitPanel, type GitStatus } from "./GitPanel";
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
import { confirmDialog, promptDialog, toast } from "../dialogs";
import { navigate } from "../router";
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
@@ -186,6 +187,16 @@ export function Files({ onMenu }: { onMenu: () => void }) {
});
};
/** New chat with the folder as working directory (default model and mode). */
const chatHere = async (e: Entry) => {
try {
const s = await post<{ id: string }>("/sessions", { cwd: e.path });
navigate(`/s/${s.id}`);
} catch (err) {
toast(err);
}
};
const rename = async (e: Entry) => {
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
@@ -324,6 +335,9 @@ export function Files({ onMenu }: { onMenu: () => void }) {
<details className="entry-menu">
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
<div className="menu">
{e.is_dir && (
<button onClick={() => void chatHere(e)}>{t("files.chatHere")}</button>
)}
<button onClick={() => rename(e)}>{t("files.rename")}</button>
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
{!e.is_dir && (
+88 -12
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 });
@@ -461,6 +489,33 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
return savePatches(patches);
});
// Check URL and key as entered, without saving.
const [testResult, setTestResult] = useState<{ ok: boolean; text: string }>();
const [testing, setTesting] = useState(false);
const test = async () => {
setTesting(true);
setTestResult(undefined);
try {
const r = await post<{ status: ProviderStatus; models: number }>("/providers/test", {
name,
kind: form.kind,
base_url: form.base_url,
api_key: form.api_key,
auth_prefix: form.auth_prefix ?? "",
});
const st = r.status;
setTestResult(
st.up
? { ok: true, text: t("settings.testOk", { ms: st.latency_ms, n: r.models }) }
: { ok: false, text: `${st.state === "auth" ? t("settings.providerAuth") : t("settings.providerDown")}: ${st.error ?? ""}` },
);
} catch (e: any) {
setTestResult({ ok: false, text: e.message });
} finally {
setTesting(false);
}
};
const remove = async () => {
if (!(await confirmDialog({ message: t("settings.deleteProviderConfirm", { name }), danger: true, confirmLabel: t("common.delete") }))) return;
void run(() => savePatches([{ path: ["providers", name], value: null }]));
@@ -506,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>
@@ -514,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>
</>
)}
@@ -528,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>
@@ -594,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>
@@ -614,7 +681,15 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
</div>
{models.length === 0 && <div className="hint" style={{ color: "var(--muted)", fontSize: 12 }}>{t("settings.modelsEmptyHint")}</div>}
{view}
{testResult && (
<div className={`notice ${testResult.ok ? "ok" : "err"}`} style={{ marginTop: 12 }}>
{testResult.text}
</div>
)}
<div className="row" style={{ marginTop: 12 }}>
<button onClick={test} disabled={testing || !form.base_url}>
<Icon name="plug" /> {testing ? t("settings.testing") : t("settings.testConnection")}
</button>
<button className="primary" onClick={save}>
{t("common.save")}
</button>
@@ -637,9 +712,10 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
const providers = cfg.config.providers ?? {};
const [status, setStatus] = useState<Record<string, ProviderStatus>>({});
const [checking, setChecking] = useState(false);
// Saving a provider re-checks it on the server, so refresh whenever the config changes.
useEffect(() => {
get<Record<string, ProviderStatus>>("/providers/status").then(setStatus).catch(() => {});
}, []);
}, [cfg]);
const recheck = () => {
setChecking(true);
post<Record<string, ProviderStatus>>("/providers/status")
+2 -2
View File
@@ -1,4 +1,5 @@
import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { get, patch, post } from "../api";
@@ -102,7 +103,6 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
const t = useT();
const sessions = useStore((s) => s.sessions);
const busy = useStore((s) => s.busy);
const connected = useStore((s) => s.connected);
const permissions = useStore((s) => s.permissions);
const questions = useStore((s) => s.questions);
const me = useStore((s) => s.me);
@@ -187,7 +187,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
<img src="/icon.svg" alt="" />
CodeCraft
</div>
<span className={`conn-dot ${connected ? "on" : ""}`} title={connected ? t("sidebar.connected") : t("sidebar.disconnected")} />
<StatusLeds />
</div>
<div style={{ padding: "0 12px 8px" }}>
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
+214
View File
@@ -0,0 +1,214 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { get, post } from "../api";
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";
interface ProviderState {
state: "ok" | "auth" | "down" | "unknown";
latency_ms?: number;
checked_at?: number;
error?: string | null;
models: number;
plan?: Plan | null;
}
interface McpEntry {
state: "connected" | "connecting" | "failed";
tools?: number;
error?: string | null;
}
interface Overview {
version: string;
providers: Record<string, ProviderState>;
mcp: Record<string, McpEntry>;
}
const POLL_MS = 30_000;
/** A dropped connection shows yellow (reconnecting) for this long, then red. */
const RECONNECT_GRACE_MS = 10_000;
/** Combined level of a group: all fine → ok, some broken → warn, none working → err. */
function combine(levels: Level[]): Level {
const active = levels.filter((l) => l !== "off");
if (active.length === 0) return "off";
if (active.every((l) => l === "ok")) return "ok";
if (active.some((l) => l === "ok" || l === "warn")) return "warn";
return "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 }) {
return <span className={`led led-${level}`} title={title} />;
}
/** Three status LEDs (server, models, MCP) in the sidebar; a click opens the details. */
export function StatusLeds() {
const t = useT();
const connected = useStore((s) => s.connected);
const admin = useStore((s) => s.me?.role === "admin");
const [data, setData] = useState<Overview>();
const [open, setOpen] = useState(false);
const [checking, setChecking] = useState(false);
const [lostAt, setLostAt] = useState<number>();
const [pos, setPos] = useState<{ left: number; top: number }>();
const [, tick] = useState(0);
const root = useRef<HTMLDivElement>(null);
const load = useCallback(() => get<Overview>("/status").then(setData).catch(() => {}), []);
useEffect(() => {
void load();
const id = setInterval(load, POLL_MS);
return () => clearInterval(id);
}, [load]);
// Track when the event connection dropped (yellow while reconnecting, red after a while).
useEffect(() => {
if (connected) {
setLostAt(undefined);
void load();
return;
}
setLostAt((v) => v ?? Date.now());
const id = setTimeout(() => tick((n) => n + 1), RECONNECT_GRACE_MS + 100);
return () => clearTimeout(id);
}, [connected, load]);
// Close on outside click and Escape.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => !root.current?.contains(e.target as Node) && setOpen(false);
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const server: Level = connected ? "ok" : lostAt && Date.now() - lostAt > RECONNECT_GRACE_MS ? "err" : "warn";
const providers = Object.entries(data?.providers ?? {});
const mcp = Object.entries(data?.mcp ?? {});
const models: Level = data ? (providers.length ? combine(providers.map(([, p]) => providerLevel(p))) : "err") : "off";
const tools: Level = combine(mcp.map(([, m]) => mcpLevel(m)));
const label = (l: Level) => t(`status.level.${l}` as Key);
const summary = `${t("status.server")}: ${label(server)} · ${t("status.models")}: ${label(models)} · ${t("status.mcp")}: ${label(tools)}`;
const recheck = async () => {
setChecking(true);
try {
await post("/providers/status");
await load();
} finally {
setChecking(false);
}
};
const go = (tab: string) => {
setOpen(false);
navigate(`/settings/${tab}`);
};
const providerText = (p: ProviderState) =>
p.state === "ok"
? t("status.providerOk", { n: p.models, ms: p.latency_ms ?? 0 })
: p.state === "auth"
? t("settings.providerAuth")
: p.state === "down"
? t("settings.providerDown")
: t("status.notChecked");
return (
<div className="status-leds" ref={root}>
<button
className="ghost status-leds-btn"
onClick={(e) => {
// Fixed position below the LEDs, kept inside the window.
const r = e.currentTarget.getBoundingClientRect();
setPos({ left: Math.max(8, Math.min(r.left, window.innerWidth - 328)), top: r.bottom + 6 });
setOpen(!open);
}} title={summary} aria-label={summary} aria-expanded={open}>
<Led level={server} />
<Led level={models} />
<Led level={tools} />
</button>
{open && (
<div className="status-pop" role="dialog" aria-label={t("status.title")} style={pos}>
<div className="status-section">
<div className="status-head">
<Led level={server} /> {t("status.server")}
</div>
<div className="status-row">
<span className="grow">{connected ? t("sidebar.connected") : t("sidebar.disconnected")}</span>
{data && <span className="hint">v{data.version}</span>}
</div>
</div>
<div className="status-section">
<div className="status-head">
<Led level={models} /> {t("status.models")}
<span className="spacer" />
{admin && (
<button className="small ghost" onClick={recheck} disabled={checking} title={t("status.recheck")}>
<Icon name="refresh" size={13} />
</button>
)}
</div>
{providers.length === 0 && <div className="status-row hint">{t("status.noProviders")}</div>}
{providers.map(([name, p]) => (
<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)}
{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 className="status-section">
<div className="status-head">
<Led level={tools} /> {t("status.mcp")}
</div>
{mcp.length === 0 && <div className="status-row hint">{t("status.noMcp")}</div>}
{mcp.map(([name, m]) => (
<div key={name} className="status-row" title={m.error ?? undefined}>
<Led level={mcpLevel(m)} />
<span className="grow mono">{name}</span>
<span className="hint">{m.state === "connected" ? t("mcp.tools", { n: m.tools ?? 0 }) : t(`mcp.state.${m.state}` as Key)}</span>
</div>
))}
</div>
{admin && (
<div className="status-foot">
<button className="small ghost" onClick={() => go("providers")}>
<Icon name="brain" size={13} /> {t("status.providerSettings")}
</button>
<button className="small ghost" onClick={() => go("mcp")}>
<Icon name="plug" size={13} /> {t("status.mcpSettings")}
</button>
</div>
)}
</div>
)}
</div>
);
}
+49 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { get, patch, post } from "../../api";
import { toast } from "../../dialogs";
import { useEffect, useRef, useState } from "react";
import { api, get, patch, post } from "../../api";
import { confirmDialog, toast } from "../../dialogs";
import { useFetch } from "../../hooks";
import { fmtDate, useT } from "../../i18n";
import { formatSize } from "../FileFigure";
@@ -90,6 +90,32 @@ export function Backups() {
}
};
// Restore: the server stages the backup, restarts and applies it before opening the database.
const fileInput = useRef<HTMLInputElement>(null);
const [restoring, setRestoring] = useState(false);
const restore = async (source: { name: string } | { file: File }) => {
const label = "name" in source ? source.name : source.file.name;
const ok = await confirmDialog({ title: t("backup.restoreTitle"), message: t("backup.restoreConfirm", { file: label }), confirmLabel: t("backup.restore"), danger: true });
if (!ok) return;
setRestoring(true);
try {
if ("name" in source) await post(`/backups/${encodeURIComponent(source.name)}/restore`);
else await api("/backups/restore", { method: "POST", body: source.file, headers: { "Content-Type": "application/zip" } });
toast(t("backup.restarting"), "info");
// Wait until the server is gone and back, then reload with the restored data.
await new Promise((r) => setTimeout(r, 2500));
for (let i = 0; i < 120; i++) {
if (await fetch("/api/health").then((r) => r.ok).catch(() => false)) return location.reload();
await new Promise((r) => setTimeout(r, 1000));
}
toast(t("backup.notBack"));
} catch (e: any) {
toast(e.message);
} finally {
setRestoring(false);
}
};
const s3 = (k: keyof S3, label: string, placeholder = "", type = "text") =>
form && (
<label className="field">
@@ -111,6 +137,20 @@ export function Backups() {
<a className="button small" href="/api/backup">
<Icon name="download" /> {t("audit.download")}
</a>
<button className="small" onClick={() => fileInput.current?.click()} disabled={restoring}>
<Icon name="upload" /> {restoring ? t("backup.restoring") : t("backup.restoreFile")}
</button>
<input
ref={fileInput}
type="file"
accept=".zip,application/zip"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) void restore({ file });
}}
/>
<button className="small primary" onClick={runNow} disabled={busy}>
{busy ? t("backup.running") : t("backup.now")}
</button>
@@ -176,12 +216,17 @@ export function Backups() {
{data && data.files.length > 0 && (
<div className="backup-list">
{data.files.map((f) => (
<a key={f.name} href={`/api/backups/${encodeURIComponent(f.name)}`} className="backup-file">
<div key={f.name} className="backup-file">
<Icon name="file" />
<span className="grow">{f.name}</span>
<span className="hint">{formatSize(f.size)}</span>
<button className="small" title={t("backup.restore")} onClick={() => void restore({ name: f.name })} disabled={restoring}>
<Icon name="undo" />
</button>
<a className="button small" title={t("audit.download")} href={`/api/backups/${encodeURIComponent(f.name)}`}>
<Icon name="download" />
</a>
</div>
))}
</div>
)}
+34 -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",
@@ -78,6 +85,7 @@ export const de: Messages = {
"files.newFolderName": "Ordnername:",
"files.renameTo": "Neuer Name:",
"files.copyTo": "Name der Kopie:",
"files.chatHere": "Chat hier öffnen",
"files.rename": "Umbenennen",
"files.duplicate": "Duplizieren",
"files.download": "Herunterladen",
@@ -204,6 +212,21 @@ export const de: Messages = {
"sidebar.yesterday": "Gestern",
"sidebar.week": "Letzte 7 Tage",
"sidebar.older": "Älter",
"status.title": "Status",
"status.server": "Server",
"status.models": "Modelle",
"status.mcp": "MCP",
"status.level.ok": "alles in Ordnung",
"status.level.warn": "teilweise nicht verfügbar",
"status.level.err": "nicht verfügbar",
"status.level.off": "keine konfiguriert",
"status.providerOk": "{n} Modelle · {ms} ms",
"status.notChecked": "noch nicht geprüft",
"status.recheck": "Erneut prüfen",
"status.noProviders": "Keine Provider aktiv.",
"status.noMcp": "Keine MCP-Server konfiguriert.",
"status.providerSettings": "Provider",
"status.mcpSettings": "MCP-Server",
"sidebar.connected": "Verbunden",
"sidebar.disconnected": "Getrennt – verbinde neu…",
"sidebar.needsInput": "Wartet auf deine Eingabe",
@@ -464,6 +487,13 @@ export const de: Messages = {
"sidebar.moveHintExisting": "Vorhandene Ordner: {folders}. Gib einen davon oder einen neuen Namen ein.",
"sidebar.folderName": "Ordnername",
"sidebar.move": "Verschieben",
"backup.restore": "Wiederherstellen",
"backup.restoreFile": "Aus Datei wiederherstellen…",
"backup.restoring": "Wird wiederhergestellt…",
"backup.restoreTitle": "Backup wiederherstellen?",
"backup.restoreConfirm": "Datenbank und Konfiguration durch {file} ersetzen? Alle Sitzungen, Benutzer, Statistiken und Einstellungen werden durch den Stand im Backup ersetzt; Dateien im Workspace bleiben unverändert. Der aktuelle Stand wird vorher gesichert. CodeCraft startet neu – laufende Agents werden beendet.",
"backup.restarting": "Backup übernommen – CodeCraft startet neu…",
"backup.notBack": "Der Server ist nicht zurückgekommen. Er braucht eine Restart-Policy (Docker: restart: unless-stopped) – bitte von Hand starten.",
"backup.now": "Jetzt sichern",
"backup.running": "Sichere…",
"backup.created": "Backup {file} erstellt.",
@@ -625,7 +655,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",
@@ -647,6 +677,9 @@ export const de: Messages = {
"settings.providerName": "Name (optional)",
"settings.providerUp": "erreichbar · {ms} ms",
"settings.providerDown": "nicht erreichbar",
"settings.testConnection": "Verbindung testen",
"settings.testing": "Teste…",
"settings.testOk": "Verbindung klappt: {n} Modelle, {ms} ms. Noch nicht gespeichert.",
"settings.providerAuth": "API-Key fehlt oder ungültig",
"settings.providerCheck": "Provider prüfen",
"settings.providerChecking": "Prüfe…",
+34 -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",
@@ -77,6 +84,7 @@ export const en = {
"files.newFolderName": "Folder name:",
"files.renameTo": "New name:",
"files.copyTo": "Name of the copy:",
"files.chatHere": "Open chat here",
"files.rename": "Rename",
"files.duplicate": "Duplicate",
"files.download": "Download",
@@ -203,6 +211,21 @@ export const en = {
"sidebar.yesterday": "Yesterday",
"sidebar.week": "Last 7 days",
"sidebar.older": "Older",
"status.title": "Status",
"status.server": "Server",
"status.models": "Models",
"status.mcp": "MCP",
"status.level.ok": "all fine",
"status.level.warn": "partly unavailable",
"status.level.err": "unavailable",
"status.level.off": "none configured",
"status.providerOk": "{n} models · {ms} ms",
"status.notChecked": "not checked yet",
"status.recheck": "Check again",
"status.noProviders": "No providers enabled.",
"status.noMcp": "No MCP servers configured.",
"status.providerSettings": "Providers",
"status.mcpSettings": "MCP servers",
"sidebar.connected": "Connected",
"sidebar.disconnected": "Disconnected – reconnecting…",
"sidebar.needsInput": "Waiting for your input",
@@ -463,6 +486,13 @@ export const en = {
"sidebar.moveHintExisting": "Existing folders: {folders}. Type one of them or a new name.",
"sidebar.folderName": "Folder name",
"sidebar.move": "Move",
"backup.restore": "Restore",
"backup.restoreFile": "Restore from file…",
"backup.restoring": "Restoring…",
"backup.restoreTitle": "Restore backup?",
"backup.restoreConfirm": "Replace the database and the configuration with {file}? All sessions, users, statistics and settings are replaced by the state in the backup; files in the workspace stay as they are. The current state is backed up first. CodeCraft restarts – running agents are stopped.",
"backup.restarting": "Backup accepted – CodeCraft is restarting…",
"backup.notBack": "The server did not come back. It needs a restart policy (Docker: restart: unless-stopped) – start it again by hand.",
"backup.now": "Back up now",
"backup.running": "Backing up…",
"backup.created": "Backup {file} created.",
@@ -624,7 +654,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",
@@ -646,6 +676,9 @@ export const en = {
"settings.providerName": "Name (optional)",
"settings.providerUp": "reachable · {ms} ms",
"settings.providerDown": "unreachable",
"settings.testConnection": "Test connection",
"settings.testing": "Testing…",
"settings.testOk": "Connection works: {n} models, {ms} ms. Not saved yet.",
"settings.providerAuth": "API key missing or rejected",
"settings.providerCheck": "Check providers",
"settings.providerChecking": "Checking…",
+151 -7
View File
@@ -132,14 +132,120 @@ textarea:focus {
height: 22px;
}
.conn-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--red);
/* --- status LEDs (sidebar) ------------------------------------------------------ */
/* Theme hues, pushed to a vivid chroma and a readable lightness so the state is
obvious in every theme; warn takes the red's lightness/chroma at an amber hue. */
:root {
--led-ok: #22c55e;
--led-warn: #f5a700;
--led-err: #ef4444;
}
.conn-dot.on {
background: var(--green);
@supports (color: oklch(from red l c h)) {
:root {
--led-ok: oklch(from var(--green) clamp(0.7, l, 0.8) max(c, 0.2) clamp(138, h, 158));
--led-warn: oklch(from var(--red) clamp(0.78, l, 0.85) max(c, 0.17) 75);
--led-err: oklch(from var(--red) clamp(0.6, l, 0.7) max(c, 0.21) h);
}
}
.status-leds {
position: relative;
}
.status-leds-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 7px;
border-radius: 999px;
background: var(--bg2);
}
.led {
width: 9px;
height: 9px;
border-radius: 50%;
flex: none;
display: inline-block;
}
.led-ok {
background: var(--led-ok);
box-shadow: 0 0 6px color-mix(in srgb, var(--led-ok) 70%, transparent);
}
.led-warn {
background: var(--led-warn);
box-shadow: 0 0 6px color-mix(in srgb, var(--led-warn) 70%, transparent);
}
.led-err {
background: var(--led-err);
box-shadow: 0 0 7px color-mix(in srgb, var(--led-err) 80%, transparent);
}
.status-leds-btn .led-err {
animation: led-blink 1.6s ease-in-out infinite;
}
.led-off {
background: transparent;
box-shadow: inset 0 0 0 1.5px var(--muted);
}
@keyframes led-blink {
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
.status-leds-btn .led-err {
animation: none;
}
}
.status-pop {
position: fixed;
z-index: 40;
width: min(320px, calc(100vw - 24px));
background: var(--bg1);
border: 1px solid var(--bg3);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 6px;
font-size: 13px;
}
.status-section + .status-section {
border-top: 1px solid var(--bg3);
margin-top: 4px;
padding-top: 4px;
}
.status-head {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 4px;
font-weight: 600;
color: var(--fg0);
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px 4px 25px;
color: var(--fg1);
}
.status-row .led {
width: 7px;
height: 7px;
margin-left: -15px;
}
.status-row .grow {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-row .hint {
white-space: nowrap;
}
.status-foot {
display: flex;
gap: 6px;
border-top: 1px solid var(--bg3);
margin-top: 4px;
padding: 6px 4px 2px;
}
.session-list {
@@ -3413,6 +3519,10 @@ button.danger-solid {
.backup-file .grow {
flex: 1;
}
.backup-file button,
.backup-file .button {
padding: 2px 6px;
}
/* --- automation templates ------------------------------------------------------ */
.job-tpl {
@@ -3459,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;
}
+36 -1
View File
@@ -1,5 +1,40 @@
/** "What's new" entries per version (shown once after an update). */
/**
* "What's new" entries per version (shown once after an update). Add new entries
* under "unreleased"; scripts/release.sh renames the key to the release version.
*/
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
unreleased: {
en: ["File manager: “Open chat here” in a folder's menu"],
de: ["Dateimanager: „Chat hier öffnen“ im Ordner-Menü"],
},
"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",
"Images: cheap previews first, then the final image with the best model (Nano Banana Pro refines the chosen preview)",
"Settings → Audit: restore a backup (stored or uploaded)",
"Provider form: test the connection before saving; saving checks the status right away",
"opencode Go works again (session header)",
],
de: [
"Status-LEDs in der Seitenleiste für Server, Modelle und MCP – ein Klick zeigt die Details",
"Bilder: erst günstige Vorschauen, dann das Endbild mit dem besten Modell (Nano Banana Pro verfeinert die gewählte Vorschau)",
"Einstellungen → Audit: Backup wiederherstellen (gespeichert oder hochgeladen)",
"Provider-Formular: Verbindung vor dem Speichern testen; Speichern prüft den Status sofort",
"opencode Go funktioniert wieder (Session-Header)",
],
},
"0.1.0": {
en: [
"Terminal client `codecraft`: work locally like with Claude Code – Settings → Account → Terminal client",