Compare commits
4
Commits
v0.1.0
..
151a92959d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151a92959d | ||
|
|
bfa1f641d7 | ||
|
|
70367211c1 | ||
|
|
1f69d63b09 |
No files matched your search
+4
-11
@@ -1,11 +1,11 @@
|
|||||||
|
# Deployment settings only. API keys, MCP tokens, SSO secrets and everything else
|
||||||
|
# are set in the web UI (Settings) and stored in the configuration file.
|
||||||
|
|
||||||
# Optional: create the first admin on first start. Otherwise the server logs a
|
# Optional: create the first admin on first start. Otherwise the server logs a
|
||||||
# one-time setup code and you create the admin in the web UI.
|
# one-time setup code and you create the admin in the web UI.
|
||||||
CODECRAFT_ADMIN_USER=
|
CODECRAFT_ADMIN_USER=
|
||||||
CODECRAFT_ADMIN_PASSWORD=
|
CODECRAFT_ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Single sign-on client secret (see [auth.oidc] in config.toml).
|
|
||||||
OIDC_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Directory with your projects, mounted as /workspace.
|
# Directory with your projects, mounted as /workspace.
|
||||||
WORKSPACE=/path/to/your/projects
|
WORKSPACE=/path/to/your/projects
|
||||||
|
|
||||||
@@ -17,17 +17,10 @@ BIND_ADDR=127.0.0.1
|
|||||||
UID=1000
|
UID=1000
|
||||||
GID=1000
|
GID=1000
|
||||||
|
|
||||||
# Provider keys – referenced in data/config.toml as ${NAME}.
|
|
||||||
ANTHROPIC_API_KEY=
|
|
||||||
GEMINI_API_KEY=
|
|
||||||
OPENCODE_API_KEY=
|
|
||||||
FAL_KEY=
|
|
||||||
OPENROUTER_API_KEY=
|
|
||||||
|
|
||||||
# Time zone for automations (cron schedules) and timestamps, e.g. Europe/Berlin
|
# Time zone for automations (cron schedules) and timestamps, e.g. Europe/Berlin
|
||||||
TZ=UTC
|
TZ=UTC
|
||||||
|
|
||||||
# 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
|
CODECRAFT_VERSION=latest
|
||||||
|
|
||||||
# Log format: empty for text, "json" for one JSON object per line
|
# Log format: empty for text, "json" for one JSON object per line
|
||||||
|
|||||||
@@ -9,3 +9,4 @@
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
.env.bak*
|
||||||
@@ -3,6 +3,55 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
|
## [v202611-00002] – 2026-11-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **opencode prices:** *Fetch prices* reads prices, context and output limits, image input and
|
||||||
|
reasoning from opencode's model list (Zen and Go), including long-context tiers and
|
||||||
|
peak-hour or date-based factors. Costs apply them per request (`price_tiers`,
|
||||||
|
`price_multipliers` on a model).
|
||||||
|
- **opencode costs and Go limits:** the cost opencode reports per response is recorded instead
|
||||||
|
of an estimate, and with a *plan limit* on the provider the Go allowance is tracked in the
|
||||||
|
5-hour, 7-day and monthly windows (budget table and status overlay; usage through CodeCraft
|
||||||
|
only).
|
||||||
|
- **Settings → Config file** lists `${NAME}` references and moves the values of set variables
|
||||||
|
into the configuration, so `.env` only needs deployment settings.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Examples and forms no longer suggest environment variables for keys; `.env.example` holds
|
||||||
|
deployment settings only.
|
||||||
|
|
||||||
|
## [v202611-00001] – 2026-11-09
|
||||||
|
|
||||||
|
### 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
|
## [0.1.0] – 2026-11-09
|
||||||
|
|
||||||
First public release.
|
First public release.
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ COPY server/resources-cli ./server/resources-cli
|
|||||||
COPY cli/src ./cli/src
|
COPY cli/src ./cli/src
|
||||||
ARG GIT_COMMIT=""
|
ARG GIT_COMMIT=""
|
||||||
ENV CODECRAFT_COMMIT=${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 \
|
RUN touch core/src/lib.rs server/src/main.rs \
|
||||||
&& cargo zigbuild --release -p codecraft --target "$(cat /target).2.36" \
|
&& cargo zigbuild --release -p codecraft --target "$(cat /target).2.36" \
|
||||||
&& cp "target/$(cat /target)/release/codecraft" /codecraft
|
&& cp "target/$(cat /target)/release/codecraft" /codecraft
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
@@ -74,17 +74,20 @@ curl -fsSLO https://git.familie-zink.org/zink/codecraft/raw/branch/main/docker-c
|
|||||||
curl -fsSL --create-dirs -o docker/seccomp-sandbox.json \
|
curl -fsSL --create-dirs -o docker/seccomp-sandbox.json \
|
||||||
https://git.familie-zink.org/zink/codecraft/raw/branch/main/docker/seccomp-sandbox.json
|
https://git.familie-zink.org/zink/codecraft/raw/branch/main/docker/seccomp-sandbox.json
|
||||||
curl -fsSL -o .env https://git.familie-zink.org/zink/codecraft/raw/branch/main/.env.example
|
curl -fsSL -o .env https://git.familie-zink.org/zink/codecraft/raw/branch/main/.env.example
|
||||||
$EDITOR .env # workspace path, provider keys, time zone
|
$EDITOR .env # workspace path, port, user id, time zone
|
||||||
docker compose -f docker-compose.release.yml up -d
|
docker compose -f docker-compose.release.yml up -d
|
||||||
docker compose -f docker-compose.release.yml logs codecraft # one-time setup code for the first admin
|
docker compose -f docker-compose.release.yml logs codecraft # one-time setup code for the first admin
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:8787`, enter the setup code and create the admin account. Then add a
|
Open `http://localhost:8787`, enter the setup code and create the admin account. Then add a
|
||||||
provider under *Settings → Providers & models* – or put the keys into `.env` and reference
|
provider under *Settings → Providers & models*. `.env` only holds deployment settings; keys,
|
||||||
them in the config as `${NAME}`.
|
tokens and everything else are set in the UI and stored in the configuration file
|
||||||
|
(`${NAME}` references to environment variables still work, and *Settings → Config file* moves
|
||||||
|
their values into the file).
|
||||||
|
|
||||||
**Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`.
|
**Update:** `docker compose -f docker-compose.release.yml pull && docker compose -f docker-compose.release.yml up -d`.
|
||||||
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`,
|
**From source:** `git clone https://git.familie-zink.org/zink/codecraft.git && cd codecraft`,
|
||||||
`cp .env.example .env`, then `docker compose up -d --build`.
|
`cp .env.example .env`, then `docker compose up -d --build`.
|
||||||
@@ -152,8 +155,14 @@ output limit, reasoning effort, extra request options and prices per million tok
|
|||||||
model's fallback.
|
model's fallback.
|
||||||
- **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that
|
- **Concurrency limits:** `max_concurrency` per host or model queues requests for servers that
|
||||||
process one at a time; waiting time is shown and never counted as model latency.
|
process one at a time; waiting time is shown and never counted as model latency.
|
||||||
|
- **Prices:** *Fetch prices* fills in prices for OpenRouter and opencode (Zen and Go), including
|
||||||
|
long-context tiers and peak-hour factors, which are applied per request.
|
||||||
- **Status:** every five minutes each provider's model list is fetched (no tokens used), so
|
- **Status:** every five minutes each provider's model list is fetched (no tokens used), so
|
||||||
you see at a glance which hosts answer and which keys are rejected.
|
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
|
## Terminal client
|
||||||
|
|
||||||
@@ -199,7 +208,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.
|
- **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
|
- **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*
|
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.
|
- **Health:** `GET /api/health` for monitoring, with version and commit.
|
||||||
|
|
||||||
## Users and security
|
## Users and security
|
||||||
@@ -245,6 +256,7 @@ place and keep your comments. Secrets belong in the environment and are referenc
|
|||||||
| `[auth]`, `[auth.oidc]` | sign-in, public URL, single sign-on |
|
| `[auth]`, `[auth.oidc]` | sign-in, public URL, single sign-on |
|
||||||
| `[sandbox]` | shell sandbox for non-admins (`auto`, `always`, `off`) |
|
| `[sandbox]` | shell sandbox for non-admins (`auto`, `always`, `off`) |
|
||||||
| `[backup]`, `[backup.s3]` | daily backups, retention, S3 target |
|
| `[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 |
|
| `[craft]` | headless browser, screen widths, clean room |
|
||||||
|
|
||||||
Compound shell commands (`&&`, `;`, `|`) are only auto-allowed when every part is allowed.
|
Compound shell commands (`&&`, `;`, `|`) are only auto-allowed when every part is allowed.
|
||||||
@@ -329,6 +341,12 @@ The Docker build cross-compiles the server with
|
|||||||
quickly on either architecture:
|
quickly on either architecture:
|
||||||
`docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/codecraft:<version> --push .`
|
`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
|
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.
|
`web/src/i18n/index.ts` – the type checker reports missing keys.
|
||||||
|
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ fn provider_config(preset: &crate::models::Preset, base_url: &str, key: &str) ->
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
max_concurrency: None,
|
max_concurrency: None,
|
||||||
monthly_budget: None,
|
monthly_budget: None,
|
||||||
|
plan_limit: None,
|
||||||
admin_key: String::new(),
|
admin_key: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-6
@@ -29,7 +29,7 @@ use tokio::sync::mpsc;
|
|||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[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 {
|
struct Cli {
|
||||||
/// Start with this prompt
|
/// Start with this prompt
|
||||||
prompt: Option<String>,
|
prompt: Option<String>,
|
||||||
@@ -164,8 +164,7 @@ fn prompt_line(label: &str, secret: bool) -> Result<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn version_newer(server: &str, ours: &str) -> bool {
|
fn version_newer(server: &str, ours: &str) -> bool {
|
||||||
let parse = |v: &str| v.split('.').map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
|
codecraft_core::version::newer(server, ours)
|
||||||
parse(server) > parse(ours)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn target() -> String {
|
fn target() -> String {
|
||||||
@@ -178,8 +177,8 @@ async fn self_update() -> Result<()> {
|
|||||||
let http = server::http();
|
let http = server::http();
|
||||||
let info: Value = http.get(format!("{}/api/info", login.url)).send().await?.json().await?;
|
let info: Value = http.get(format!("{}/api/info", login.url)).send().await?.json().await?;
|
||||||
let latest = info["version"].as_str().unwrap_or("0");
|
let latest = info["version"].as_str().unwrap_or("0");
|
||||||
if !version_newer(latest, env!("CARGO_PKG_VERSION")) {
|
if !version_newer(latest, codecraft_core::version::VERSION) {
|
||||||
println!("codecraft {} is up to date.", env!("CARGO_PKG_VERSION"));
|
println!("codecraft {} is up to date.", codecraft_core::version::VERSION);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let url = format!("{}/cli/download/{}", login.url, target());
|
let url = format!("{}/cli/download/{}", login.url, target());
|
||||||
@@ -365,7 +364,7 @@ async fn main() -> Result<()> {
|
|||||||
// Notice when the server has a newer CLI.
|
// Notice when the server has a newer CLI.
|
||||||
let mut status = String::new();
|
let mut status = String::new();
|
||||||
if let Some(v) = env.server_cfg.as_ref().and_then(|s| s["version"].as_str()) {
|
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`");
|
status = format!("update available: {v} – run `codecraft update`");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -105,7 +105,7 @@ pub async fn stream(
|
|||||||
mc.effort = Some(e);
|
mc.effort = Some(e);
|
||||||
}
|
}
|
||||||
let max_tokens = mc.max_output.unwrap_or(16_000);
|
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
|
provider.stream(req, tx, cancel).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ pub struct Server {
|
|||||||
|
|
||||||
pub fn http() -> reqwest::Client {
|
pub fn http() -> reqwest::Client {
|
||||||
reqwest::Client::builder()
|
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))
|
.connect_timeout(std::time::Duration::from_secs(15))
|
||||||
.build()
|
.build()
|
||||||
.expect("http client")
|
.expect("http client")
|
||||||
|
|||||||
+23
-15
@@ -8,9 +8,10 @@
|
|||||||
#
|
#
|
||||||
# Location: $CODECRAFT_CONFIG, otherwise <data_dir>/config.toml (Docker: /data/config.toml)
|
# Location: $CODECRAFT_CONFIG, otherwise <data_dir>/config.toml (Docker: /data/config.toml)
|
||||||
#
|
#
|
||||||
# Secrets: any string value may reference environment variables with ${NAME},
|
# Keys and tokens are entered in the web UI (Settings) and stored here, readable
|
||||||
# e.g. api_key = "${ANTHROPIC_API_KEY}". Prefer this over literal keys – keys
|
# for admins and included in backups. Any string value may also reference an
|
||||||
# entered in the UI are stored here in plain text.
|
# environment variable as ${NAME}; Settings → Config file moves such values
|
||||||
|
# into this file.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
@@ -47,7 +48,7 @@ currency = "USD"
|
|||||||
# proxy's body limit (e.g. Nginx client_max_body_size) does not apply.
|
# proxy's body limit (e.g. Nginx client_max_body_size) does not apply.
|
||||||
max_upload_mb = 500
|
max_upload_mb = 500
|
||||||
# Bearer token for Prometheus at GET /metrics (empty = /metrics disabled).
|
# Bearer token for Prometheus at GET /metrics (empty = /metrics disabled).
|
||||||
# metrics_token = "${CODECRAFT_METRICS_TOKEN}"
|
# metrics_token = "a-long-random-string"
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -81,7 +82,7 @@ max_upload_mb = 500
|
|||||||
[providers.anthropic]
|
[providers.anthropic]
|
||||||
kind = "anthropic"
|
kind = "anthropic"
|
||||||
base_url = "https://api.anthropic.com/v1"
|
base_url = "https://api.anthropic.com/v1"
|
||||||
api_key = "${ANTHROPIC_API_KEY}"
|
api_key = "" # set in Settings
|
||||||
# Current Claude models think adaptively; "effort" controls depth and cost.
|
# Current Claude models think adaptively; "effort" controls depth and cost.
|
||||||
# Prices: Anthropic list prices in USD per 1M tokens (Nov 2026) – adjust if needed.
|
# Prices: Anthropic list prices in USD per 1M tokens (Nov 2026) – adjust if needed.
|
||||||
models = [
|
models = [
|
||||||
@@ -97,25 +98,25 @@ models = [
|
|||||||
[providers.gemini]
|
[providers.gemini]
|
||||||
kind = "openai"
|
kind = "openai"
|
||||||
base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
|
base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||||
api_key = "${GEMINI_API_KEY}"
|
api_key = "" # set in Settings
|
||||||
models = [] # discovered automatically
|
models = [] # discovered automatically
|
||||||
enabled = false # set to true once GEMINI_API_KEY is available
|
enabled = false # enable once the key is set
|
||||||
|
|
||||||
# opencode Go subscription (OpenAI-compatible endpoint for most models).
|
# opencode Go subscription (OpenAI-compatible endpoint for most models).
|
||||||
# Current model list: https://opencode.ai/inference/go/v1/models
|
# Current model list: https://opencode.ai/inference/go/v1/models
|
||||||
[providers.opencode-go]
|
[providers.opencode-go]
|
||||||
kind = "openai"
|
kind = "openai"
|
||||||
base_url = "https://opencode.ai/inference/go/openai/v1"
|
base_url = "https://opencode.ai/inference/go/openai/v1"
|
||||||
api_key = "${OPENCODE_API_KEY}"
|
api_key = "" # set in Settings
|
||||||
models = []
|
models = []
|
||||||
enabled = false
|
enabled = false
|
||||||
|
|
||||||
# fal.ai – OpenRouter-backed LLM router. fal expects "Authorization: Key <FAL_KEY>".
|
# fal.ai – OpenRouter-backed LLM router. fal expects "Authorization: Key <key>".
|
||||||
# NOTE: verify base_url against the fal docs for your account.
|
# NOTE: verify base_url against the fal docs for your account.
|
||||||
[providers.fal]
|
[providers.fal]
|
||||||
kind = "openai"
|
kind = "openai"
|
||||||
base_url = "https://fal.run/openrouter/router/openai/v1"
|
base_url = "https://fal.run/openrouter/router/openai/v1"
|
||||||
api_key = "${FAL_KEY}"
|
api_key = "" # set in Settings
|
||||||
auth_prefix = "Key "
|
auth_prefix = "Key "
|
||||||
models = [
|
models = [
|
||||||
{ id = "google/gemini-2.5-flash", context = 1000000, max_output = 32000, vision = true },
|
{ id = "google/gemini-2.5-flash", context = 1000000, max_output = 32000, vision = true },
|
||||||
@@ -151,7 +152,7 @@ enabled = false
|
|||||||
# [mcp.trilium]
|
# [mcp.trilium]
|
||||||
# type = "http"
|
# type = "http"
|
||||||
# url = "https://notes.example.org/mcp"
|
# url = "https://notes.example.org/mcp"
|
||||||
# headers = { Authorization = "Bearer ${TRILIUM_TOKEN}" }
|
# headers = { Authorization = "Bearer <token>" }
|
||||||
|
|
||||||
# [mcp.filesystem]
|
# [mcp.filesystem]
|
||||||
# type = "stdio"
|
# type = "stdio"
|
||||||
@@ -243,11 +244,18 @@ generate_image = "ask" # costs money per image
|
|||||||
[images]
|
[images]
|
||||||
enabled = false
|
enabled = false
|
||||||
base_url = "https://fal.run"
|
base_url = "https://fal.run"
|
||||||
api_key = "${FAL_KEY}"
|
api_key = "" # set in Settings
|
||||||
default_model = "fal-ai/flux/schnell"
|
default_model = "fal-ai/flux/schnell"
|
||||||
output_dir = "generated"
|
output_dir = "generated"
|
||||||
|
# The agent drafts with a "preview" model, asks which draft to refine, then
|
||||||
|
# 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 = [
|
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/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/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" },
|
{ id = "fal-ai/recraft/v4/pro/text-to-image", name = "Recraft V4 pro – design" },
|
||||||
@@ -281,7 +289,7 @@ cookie_secure = "auto"
|
|||||||
enabled = false
|
enabled = false
|
||||||
issuer = "https://auth.example.org/application/o/codecraft/"
|
issuer = "https://auth.example.org/application/o/codecraft/"
|
||||||
client_id = "codecraft"
|
client_id = "codecraft"
|
||||||
client_secret = "${OIDC_CLIENT_SECRET}"
|
client_secret = "" # set in Settings
|
||||||
scopes = ["openid", "profile", "email", "groups"]
|
scopes = ["openid", "profile", "email", "groups"]
|
||||||
username_claim = "preferred_username"
|
username_claim = "preferred_username"
|
||||||
groups_claim = "groups"
|
groups_claim = "groups"
|
||||||
@@ -302,7 +310,7 @@ button_label = "Single sign-on"
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
[search]
|
[search]
|
||||||
# "duckduckgo" (no key, may be rate-limited), "searxng" (self-hosted, set url),
|
# "duckduckgo" (no key, may be rate-limited), "searxng" (self-hosted, set url),
|
||||||
# "brave" or "tavily" (api_key, e.g. "${BRAVE_API_KEY}")
|
# "brave" or "tavily" (set api_key in Settings)
|
||||||
provider = "duckduckgo"
|
provider = "duckduckgo"
|
||||||
url = ""
|
url = ""
|
||||||
api_key = ""
|
api_key = ""
|
||||||
|
|||||||
+129
-6
@@ -37,6 +37,10 @@ pub struct ProviderConfig {
|
|||||||
/// Monthly budget in the configured currency (tracked from recorded costs).
|
/// Monthly budget in the configured currency (tracked from recorded costs).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub monthly_budget: Option<f64>,
|
pub monthly_budget: Option<f64>,
|
||||||
|
/// Monthly usage limit of a subscription plan in USD (opencode Go). Usage is tracked
|
||||||
|
/// from the allowance each response reports: 5 hours 20 %, week 50 %, month 100 %.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub plan_limit: Option<f64>,
|
||||||
/// Optional admin key to read the provider's own cost report
|
/// Optional admin key to read the provider's own cost report
|
||||||
/// (Anthropic: sk-ant-admin01-…, organisation-wide).
|
/// (Anthropic: sk-ant-admin01-…, organisation-wide).
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
@@ -76,6 +80,13 @@ pub struct ModelConfig {
|
|||||||
pub cache_read_price: Option<f64>,
|
pub cache_read_price: Option<f64>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub cache_write_price: Option<f64>,
|
pub cache_write_price: Option<f64>,
|
||||||
|
/// Other prices from a context size on (e.g. long-context pricing); the largest
|
||||||
|
/// threshold below the request's context wins, missing prices keep the base price.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub price_tiers: Vec<PriceTier>,
|
||||||
|
/// Time-based price factors (peak hours, promotions); matching factors multiply.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub price_multipliers: Vec<PriceMultiplier>,
|
||||||
/// Max. simultaneous requests to this model (None = unlimited).
|
/// Max. simultaneous requests to this model (None = unlimited).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub max_concurrency: Option<usize>,
|
pub max_concurrency: Option<usize>,
|
||||||
@@ -87,15 +98,127 @@ pub struct ModelConfig {
|
|||||||
pub options: serde_json::Map<String, serde_json::Value>,
|
pub options: serde_json::Map<String, serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prices from a context size on (tokens of the whole prompt, cache included).
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
|
||||||
|
pub struct PriceTier {
|
||||||
|
pub above_context: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub input_price: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub output_price: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cache_read_price: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cache_write_price: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A price factor that applies at certain times (all given conditions must hold, UTC).
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
|
||||||
|
pub struct PriceMultiplier {
|
||||||
|
pub factor: f64,
|
||||||
|
/// Weekdays ("mon" … "sun"); empty = every day.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub days: Vec<String>,
|
||||||
|
/// Time ranges ["HH:MM", "HH:MM") in UTC; empty = all day.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub hours_utc: Vec<(String, String)>,
|
||||||
|
/// First day it applies (YYYY-MM-DD, UTC).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub from: Option<String>,
|
||||||
|
/// First day it no longer applies (YYYY-MM-DD, UTC).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub until: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PriceMultiplier {
|
||||||
|
pub fn applies(&self, at: chrono::DateTime<chrono::Utc>) -> bool {
|
||||||
|
use chrono::{Datelike, Timelike};
|
||||||
|
let day = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"][at.weekday().num_days_from_monday() as usize];
|
||||||
|
if !self.days.is_empty() && !self.days.iter().any(|d| d.eq_ignore_ascii_case(day)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let minutes = |s: &str| s.split_once(':').and_then(|(h, m)| Some(h.trim().parse::<u32>().ok()? * 60 + m.trim().parse::<u32>().ok()?));
|
||||||
|
let now = at.hour() * 60 + at.minute();
|
||||||
|
if !self.hours_utc.is_empty()
|
||||||
|
&& !self.hours_utc.iter().any(|(a, b)| matches!((minutes(a), minutes(b)), (Some(a), Some(b)) if now >= a && now < b))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let date = at.format("%Y-%m-%d").to_string();
|
||||||
|
if self.from.as_deref().is_some_and(|f| date.as_str() < f) || self.until.as_deref().is_some_and(|u| date.as_str() >= u) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ModelConfig {
|
impl ModelConfig {
|
||||||
/// Cost of one request. Cache prices default to the input price.
|
/// Cost of one request now. Cache prices default to the input price.
|
||||||
|
/// The provider's own figure wins when it reports one (opencode).
|
||||||
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
||||||
let inp = self.input_price.unwrap_or(0.0);
|
u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cost of one request at a given time: context tiers and time factors applied.
|
||||||
|
pub fn cost_at(&self, u: &crate::types::Usage, at: chrono::DateTime<chrono::Utc>) -> f64 {
|
||||||
|
let context = u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
|
||||||
|
let tier = self.price_tiers.iter().filter(|t| context > t.above_context).max_by_key(|t| t.above_context);
|
||||||
|
let pick = |base: Option<f64>, tiered: fn(&PriceTier) -> Option<f64>| tier.and_then(tiered).or(base);
|
||||||
|
let inp = pick(self.input_price, |t| t.input_price).unwrap_or(0.0);
|
||||||
|
let out = pick(self.output_price, |t| t.output_price).unwrap_or(0.0);
|
||||||
|
let read = pick(self.cache_read_price, |t| t.cache_read_price).unwrap_or(inp);
|
||||||
|
let write = pick(self.cache_write_price, |t| t.cache_write_price).unwrap_or(inp);
|
||||||
let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0;
|
let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0;
|
||||||
per(u.input_tokens, inp)
|
let factor: f64 = self.price_multipliers.iter().filter(|m| m.applies(at)).map(|m| m.factor).product();
|
||||||
+ per(u.output_tokens, self.output_price.unwrap_or(0.0))
|
(per(u.input_tokens, inp) + per(u.output_tokens, out) + per(u.cache_read_tokens, read) + per(u.cache_write_tokens, write)) * factor
|
||||||
+ per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp))
|
}
|
||||||
+ per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp))
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod price_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::types::Usage;
|
||||||
|
use chrono::TimeZone;
|
||||||
|
|
||||||
|
fn usage(input: u64, output: u64, read: u64) -> Usage {
|
||||||
|
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tiers_and_multipliers() {
|
||||||
|
let m = ModelConfig {
|
||||||
|
input_price: Some(2.0),
|
||||||
|
output_price: Some(6.0),
|
||||||
|
cache_read_price: Some(0.5),
|
||||||
|
price_tiers: vec![PriceTier { above_context: 200_000, input_price: Some(4.0), output_price: Some(12.0), cache_read_price: Some(1.0), ..Default::default() }],
|
||||||
|
price_multipliers: vec![PriceMultiplier {
|
||||||
|
factor: 2.0,
|
||||||
|
days: vec!["mon".into(), "tue".into(), "wed".into(), "thu".into(), "fri".into()],
|
||||||
|
hours_utc: vec![("01:00".into(), "04:00".into()), ("06:00".into(), "10:00".into())],
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let sat = chrono::Utc.with_ymd_and_hms(2026, 11, 7, 7, 0, 0).unwrap(); // Saturday 07:00
|
||||||
|
let mon_peak = chrono::Utc.with_ymd_and_hms(2026, 11, 9, 7, 30, 0).unwrap(); // Monday 07:30
|
||||||
|
let mon_off = chrono::Utc.with_ymd_and_hms(2026, 11, 9, 10, 0, 0).unwrap(); // Monday 10:00 (end excluded)
|
||||||
|
let small = usage(100_000, 1_000_000, 0);
|
||||||
|
assert!((m.cost_at(&small, sat) - (0.2 + 6.0)).abs() < 1e-9);
|
||||||
|
assert!((m.cost_at(&small, mon_peak) - 2.0 * (0.2 + 6.0)).abs() < 1e-9);
|
||||||
|
assert!((m.cost_at(&small, mon_off) - (0.2 + 6.0)).abs() < 1e-9);
|
||||||
|
// 150k fresh + 100k cached = 250k context: long-context tier.
|
||||||
|
let big = usage(150_000, 0, 100_000);
|
||||||
|
assert!((m.cost_at(&big, sat) - (0.6 + 0.1)).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn date_ranges() {
|
||||||
|
let m = PriceMultiplier { factor: 2.0, from: Some("2026-12-01".into()), ..Default::default() };
|
||||||
|
assert!(!m.applies(chrono::Utc.with_ymd_and_hms(2026, 11, 30, 23, 59, 0).unwrap()));
|
||||||
|
assert!(m.applies(chrono::Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap()));
|
||||||
|
let until = PriceMultiplier { factor: 0.5, until: Some("2026-12-01".into()), ..Default::default() };
|
||||||
|
assert!(until.applies(chrono::Utc.with_ymd_and_hms(2026, 11, 30, 12, 0, 0).unwrap()));
|
||||||
|
assert!(!until.applies(chrono::Utc.with_ymd_and_hms(2026, 12, 1, 0, 0, 0).unwrap()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ pub mod permission;
|
|||||||
pub mod prompts;
|
pub mod prompts;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
pub mod version;
|
||||||
@@ -168,7 +168,8 @@ impl Provider for Anthropic {
|
|||||||
for (k, v) in &req.model.options {
|
for (k, v) in &req.model.options {
|
||||||
body[k] = v.clone();
|
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() {
|
if !resp.status().is_success() {
|
||||||
return Err(http_error(resp).await);
|
return Err(http_error(resp).await);
|
||||||
}
|
}
|
||||||
@@ -265,6 +266,7 @@ impl Provider for Anthropic {
|
|||||||
if let Some(o) = data["usage"]["output_tokens"].as_u64() {
|
if let Some(o) = data["usage"]["output_tokens"].as_u64() {
|
||||||
usage.output_tokens = o;
|
usage.output_tokens = o;
|
||||||
}
|
}
|
||||||
|
usage.read_reported_cost(&data["usage"]);
|
||||||
}
|
}
|
||||||
"error" => {
|
"error" => {
|
||||||
let msg = data["error"]["message"].as_str().unwrap_or("unknown error");
|
let msg = data["error"]["message"].as_str().unwrap_or("unknown error");
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ pub struct ChatRequest {
|
|||||||
pub max_tokens: u64,
|
pub max_tokens: u64,
|
||||||
/// Enable extended thinking / reasoning when the model supports it.
|
/// Enable extended thinking / reasoning when the model supports it.
|
||||||
pub reasoning: bool,
|
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)]
|
#[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 {
|
pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||||
if !self.api_key.is_empty() {
|
if !self.api_key.is_empty() {
|
||||||
rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key));
|
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 {
|
pub fn http_client() -> reqwest::Client {
|
||||||
reqwest::Client::builder()
|
reqwest::Client::builder()
|
||||||
.connect_timeout(std::time::Duration::from_secs(20))
|
.connect_timeout(std::time::Duration::from_secs(20))
|
||||||
.user_agent(concat!("codecraft/", env!("CARGO_PKG_VERSION")))
|
.user_agent(format!("codecraft/{}", crate::version::VERSION))
|
||||||
.build()
|
.build()
|
||||||
.expect("http client")
|
.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());
|
.unwrap_or_else(|| body.chars().take(500).collect());
|
||||||
anyhow!("provider returned HTTP {status}: {detail}")
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,7 +198,7 @@ impl Provider for OpenAi {
|
|||||||
body[k] = v.clone();
|
body[k] = v.clone();
|
||||||
}
|
}
|
||||||
let rb = self.client.post(format!("{}/chat/completions", self.ep.base_url)).json(&body);
|
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() {
|
if !resp.status().is_success() {
|
||||||
return Err(http_error(resp).await);
|
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.input_tokens = u["prompt_tokens"].as_u64().unwrap_or(0).saturating_sub(cached);
|
||||||
usage.cache_read_tokens = cached;
|
usage.cache_read_tokens = cached;
|
||||||
usage.output_tokens = u["completion_tokens"].as_u64().unwrap_or(0);
|
usage.output_tokens = u["completion_tokens"].as_u64().unwrap_or(0);
|
||||||
|
usage.read_reported_cost(u);
|
||||||
}
|
}
|
||||||
let Some(choice) = data["choices"].get(0) else { continue };
|
let Some(choice) = data["choices"].get(0) else { continue };
|
||||||
let d = &choice["delta"];
|
let d = &choice["delta"];
|
||||||
|
|||||||
@@ -93,6 +93,24 @@ pub struct Usage {
|
|||||||
pub cache_read_tokens: u64,
|
pub cache_read_tokens: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cache_write_tokens: u64,
|
pub cache_write_tokens: u64,
|
||||||
|
/// Cost the provider reported for the request (USD; opencode: what was charged).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cost: Option<f64>,
|
||||||
|
/// opencode Go: how much the request counted toward the plan's usage limits (USD).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub allowance: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Usage {
|
||||||
|
/// Read the cost fields gateways add to `usage` (opencode: `cost`, `opencode.allowance`).
|
||||||
|
pub fn read_reported_cost(&mut self, u: &serde_json::Value) {
|
||||||
|
if let Some(c) = u["cost"].as_f64() {
|
||||||
|
self.cost = Some(c);
|
||||||
|
}
|
||||||
|
if let Some(a) = u["opencode"]["allowance"].as_f64() {
|
||||||
|
self.allowance = Some(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Usage {
|
impl Usage {
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
# /data/home home directory of the agent (git config, caches, ...)
|
# /data/home home directory of the agent (git config, caches, ...)
|
||||||
services:
|
services:
|
||||||
codecraft:
|
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}
|
image: git.familie-zink.org/zink/codecraft:${CODECRAFT_VERSION:-latest}
|
||||||
container_name: codecraft
|
container_name: codecraft
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
Executable
+122
@@ -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"
|
||||||
@@ -66,6 +66,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/folders/{id}/access", put(set_folder_access))
|
.route("/folders/{id}/access", put(set_folder_access))
|
||||||
.route("/config", get(get_config).patch(patch_config))
|
.route("/config", get(get_config).patch(patch_config))
|
||||||
.route("/config/raw", get(get_config_raw).put(put_config_raw))
|
.route("/config/raw", get(get_config_raw).put(put_config_raw))
|
||||||
|
.route("/config/inline-env", get(inline_env_preview).post(inline_env_apply))
|
||||||
.route("/providers/reload", post(reload_providers))
|
.route("/providers/reload", post(reload_providers))
|
||||||
.route("/models", get(models))
|
.route("/models", get(models))
|
||||||
.route("/agents", get(agents))
|
.route("/agents", get(agents))
|
||||||
@@ -119,11 +120,15 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/backup", get(backup))
|
.route("/backup", get(backup))
|
||||||
.route("/backups", get(list_backups).post(run_backup))
|
.route("/backups", get(list_backups).post(run_backup))
|
||||||
.route("/backups/{name}", get(download_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("/health", get(health))
|
||||||
.route("/tools", get(crate::reverse::status))
|
.route("/tools", get(crate::reverse::status))
|
||||||
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
|
.route("/tools/ghidra/install", post(crate::reverse::install_ghidra))
|
||||||
.route("/providers/{name}/prices", get(provider_prices))
|
.route("/providers/{name}/prices", get(provider_prices))
|
||||||
.route("/providers/status", get(provider_status).post(recheck_providers))
|
.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", get(list_images).post(create_images))
|
||||||
.route("/images/config", get(images_config))
|
.route("/images/config", get(images_config))
|
||||||
.route("/images/{id}", delete(delete_image))
|
.route("/images/{id}", delete(delete_image))
|
||||||
@@ -285,6 +290,24 @@ struct RawBody {
|
|||||||
text: String,
|
text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which ${VAR} references the config still has (and whether they are set).
|
||||||
|
async fn inline_env_preview(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
require_admin(&user)?;
|
||||||
|
let (_, set, unset) = config::inline_env(&app.config_text()?)?;
|
||||||
|
Ok(Json(json!({"set": set, "unset": unset})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the values of set environment variables into the config file.
|
||||||
|
async fn inline_env_apply(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
require_admin(&user)?;
|
||||||
|
let (text, set, unset) = config::inline_env(&app.config_text()?)?;
|
||||||
|
if !set.is_empty() {
|
||||||
|
app.save_config(&text).await?;
|
||||||
|
app.audit(Some(&user), "config.inline_env", set.join(", "), "");
|
||||||
|
}
|
||||||
|
Ok(Json(json!({"set": set, "unset": unset})))
|
||||||
|
}
|
||||||
|
|
||||||
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
async fn put_config_raw(Extension(user): Extension<User>, State(app): State<AppState>, Json(body): Json<RawBody>) -> ApiResult {
|
||||||
require_admin(&user)?;
|
require_admin(&user)?;
|
||||||
app.save_config(&body.text).await?;
|
app.save_config(&body.text).await?;
|
||||||
@@ -1116,6 +1139,33 @@ async fn run_backup(Extension(user): Extension<User>, State(app): State<AppState
|
|||||||
Ok(Json(json!(res)))
|
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> {
|
async fn download_backup(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> Result<Response, ApiError> {
|
||||||
require_admin(&user)?;
|
require_admin(&user)?;
|
||||||
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
|
if !crate::backup::list(&app).iter().any(|b| b.name == name) {
|
||||||
@@ -1138,10 +1188,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()}))
|
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> {
|
async fn provider_status(State(app): State<AppState>) -> Json<Value> {
|
||||||
Json(json!(app.health.all()))
|
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 {
|
async fn recheck_providers(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
require_admin(&user)?;
|
require_admin(&user)?;
|
||||||
crate::health::check_all(&app).await;
|
crate::health::check_all(&app).await;
|
||||||
@@ -1220,6 +1349,7 @@ async fn create_images(Extension(user): Extension<User>, State(app): State<AppSt
|
|||||||
dir,
|
dir,
|
||||||
user_id: Some(user.id.clone()),
|
user_id: Some(user.id.clone()),
|
||||||
session_id: None,
|
session_id: None,
|
||||||
|
references: vec![],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -247,11 +247,16 @@ impl App {
|
|||||||
std::fs::rename(&tmp, &self.config_path)?;
|
std::fs::rename(&tmp, &self.config_path)?;
|
||||||
let registry = Registry::build(&cfg).await;
|
let registry = Registry::build(&cfg).await;
|
||||||
let mcp_changed = serde_json::to_value(&cfg.mcp)? != serde_json::to_value(&self.config().mcp)?;
|
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.config.write().unwrap() = Arc::new(cfg);
|
||||||
*self.registry.write().unwrap() = Arc::new(registry);
|
*self.registry.write().unwrap() = Arc::new(registry);
|
||||||
if mcp_changed {
|
if mcp_changed {
|
||||||
self.clone().start_mcp();
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +268,7 @@ impl App {
|
|||||||
pub async fn reload_providers(&self) {
|
pub async fn reload_providers(&self) {
|
||||||
let registry = Registry::build(&self.config()).await;
|
let registry = Registry::build(&self.config()).await;
|
||||||
*self.registry.write().unwrap() = Arc::new(registry);
|
*self.registry.write().unwrap() = Arc::new(registry);
|
||||||
|
crate::health::check_all(self).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- events ---------------------------------------------------------
|
// --- events ---------------------------------------------------------
|
||||||
|
|||||||
+63
-1
@@ -56,6 +56,56 @@ pub async fn create_zip(app: &App) -> Result<Vec<u8>> {
|
|||||||
.await?
|
.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 {
|
pub fn backup_dir(app: &App) -> PathBuf {
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
if cfg.backup.dir.is_empty() { cfg.data_dir().join("backups") } else { PathBuf::from(expand_env(&cfg.backup.dir)) }
|
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.
|
/// Create a backup now: local file (+ pruning) and S3 upload if configured.
|
||||||
pub async fn run(app: &App) -> Result<LastRun> {
|
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 _guard = app.backup.running.lock().await;
|
||||||
let cfg = app.config().backup.clone();
|
let cfg = app.config().backup.clone();
|
||||||
let dir = backup_dir(app);
|
let dir = backup_dir(app);
|
||||||
tokio::fs::create_dir_all(&dir).await.with_context(|| format!("create {}", dir.display()))?;
|
tokio::fs::create_dir_all(&dir).await.with_context(|| format!("create {}", dir.display()))?;
|
||||||
let bytes = create_zip(app).await?;
|
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);
|
let path = dir.join(&name);
|
||||||
tokio::fs::write(&path, &bytes).await.with_context(|| format!("write {}", path.display()))?;
|
tokio::fs::write(&path, &bytes).await.with_context(|| format!("write {}", path.display()))?;
|
||||||
// Restrictive permissions: the zip contains the config with API keys.
|
// Restrictive permissions: the zip contains the config with API keys.
|
||||||
|
|||||||
+175
-2
@@ -129,6 +129,28 @@ fn entry(name: &str, budget: Option<f64>, spent: f64, warn: f64) -> Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Budget overview for the current month.
|
/// Budget overview for the current month.
|
||||||
|
/// Subscription windows of opencode Go, as share of the plan's monthly limit: rolling
|
||||||
|
/// 5 hours (20 %) and 7 days (50 %), and the calendar month in UTC (100 %). Only usage
|
||||||
|
/// through CodeCraft is counted.
|
||||||
|
pub fn plan_usage(app: &App, provider: &str, limit: f64) -> Result<Value> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let month = chrono::Utc.with_ymd_and_hms(now.year(), now.month(), 1, 0, 0, 0).single().map(|d| d.timestamp_millis()).unwrap_or(0);
|
||||||
|
let ms = now.timestamp_millis();
|
||||||
|
let window = |name: &str, from: i64, share: f64| -> Result<Value> {
|
||||||
|
let used = app.store.allowance_since(provider, from)?;
|
||||||
|
let cap = limit * share;
|
||||||
|
Ok(json!({"window": name, "used": used, "limit": cap, "ratio": if cap > 0.0 { used / cap } else { 0.0 }}))
|
||||||
|
};
|
||||||
|
Ok(json!({
|
||||||
|
"limit": limit,
|
||||||
|
"windows": [
|
||||||
|
window("5h", ms - 5 * 3_600_000, 0.2)?,
|
||||||
|
window("week", ms - 7 * 86_400_000, 0.5)?,
|
||||||
|
window("month", month, 1.0)?,
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn overview(app: &App) -> Result<Value> {
|
pub async fn overview(app: &App) -> Result<Value> {
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let spent = app.store.cost_by_provider(month_start())?;
|
let spent = app.store.cost_by_provider(month_start())?;
|
||||||
@@ -139,7 +161,11 @@ pub async fn overview(app: &App) -> Result<Value> {
|
|||||||
if !p.enabled {
|
if !p.enabled {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
rows.push(entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn));
|
let mut row = entry(name, p.monthly_budget, spent.get(name).copied().unwrap_or(0.0), warn);
|
||||||
|
if let Some(limit) = p.plan_limit.filter(|l| *l > 0.0) {
|
||||||
|
row["plan"] = plan_usage(app, name, limit)?;
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
let p = p.clone();
|
let p = p.clone();
|
||||||
tasks.push(tokio::spawn(async move { remote_balance(&p).await }));
|
tasks.push(tokio::spawn(async move { remote_balance(&p).await }));
|
||||||
}
|
}
|
||||||
@@ -174,13 +200,138 @@ pub async fn overview(app: &App) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Per-token prices from the provider's model list, converted to per 1M tokens.
|
/// Per-token prices from the provider's model list, converted to per 1M tokens.
|
||||||
|
fn is_opencode(p: &ProviderConfig) -> bool {
|
||||||
|
p.base_url.contains("opencode.ai")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_json(url: &str) -> Result<Value> {
|
||||||
|
Ok(crate::provider::http_client().get(url).timeout(Duration::from_secs(30)).send().await?.error_for_status()?.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One opencode condition object → (days, hours, from, until); None if it uses
|
||||||
|
/// something we do not understand.
|
||||||
|
type When = (Vec<String>, Vec<(String, String)>, Option<String>, Option<String>);
|
||||||
|
|
||||||
|
fn opencode_when(w: &Value) -> Option<When> {
|
||||||
|
let obj = w.as_object()?;
|
||||||
|
let (mut days, mut hours, mut from, mut until) = (Vec::new(), Vec::new(), None, None);
|
||||||
|
// A pair of utc_hours bounds, given as {"all": [{"utc_hours": {"gte"}}, {"utc_hours": {"lt"}}]}
|
||||||
|
// or directly as {"utc_hours": {"gte", "lt"}}.
|
||||||
|
let range = |v: &Value| -> Option<(String, String)> {
|
||||||
|
if let Some(h) = v.get("utc_hours") {
|
||||||
|
return Some((h["gte"].as_str()?.to_string(), h["lt"].as_str()?.to_string()));
|
||||||
|
}
|
||||||
|
let all = v.get("all")?.as_array()?;
|
||||||
|
let get = |k: &str| all.iter().find_map(|x| x["utc_hours"][k].as_str()).map(String::from);
|
||||||
|
Some((get("gte")?, get("lt")?))
|
||||||
|
};
|
||||||
|
for (k, v) in obj {
|
||||||
|
match k.as_str() {
|
||||||
|
"utc_days" => days = v["in"].as_array()?.iter().map(|d| d.as_str().map(String::from)).collect::<Option<_>>()?,
|
||||||
|
"any" => hours = v.as_array()?.iter().map(range).collect::<Option<_>>()?,
|
||||||
|
"all" | "utc_hours" => hours = vec![range(w)?],
|
||||||
|
"date" => {
|
||||||
|
from = v["gte"].as_str().map(String::from);
|
||||||
|
until = v["lt"].as_str().map(String::from);
|
||||||
|
if from.is_none() && until.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((days, hours, from, until))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
|
||||||
|
fn opencode_model(m: &Value) -> Value {
|
||||||
|
let cost = &m["cost"]["text"];
|
||||||
|
let (input, output) = (&cost["input"], &cost["output"]);
|
||||||
|
// Context tiers: input and output overrides share their thresholds.
|
||||||
|
let mut tiers: std::collections::BTreeMap<u64, serde_json::Map<String, Value>> = Default::default();
|
||||||
|
for (side, field) in [(input, "input_price"), (output, "output_price")] {
|
||||||
|
for o in side["overrides"].as_array().into_iter().flatten() {
|
||||||
|
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
|
||||||
|
let t = tiers.entry(gt).or_default();
|
||||||
|
t.insert(field.into(), o["price"].clone());
|
||||||
|
if field == "input_price" {
|
||||||
|
if o["cache"]["read"].is_number() {
|
||||||
|
t.insert("cache_read_price".into(), o["cache"]["read"].clone());
|
||||||
|
}
|
||||||
|
if o["cache"]["write"].is_number() {
|
||||||
|
t.insert("cache_write_price".into(), o["cache"]["write"].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let price_tiers: Vec<Value> = tiers.into_iter().map(|(gt, mut t)| {
|
||||||
|
t.insert("above_context".into(), json!(gt));
|
||||||
|
Value::Object(t)
|
||||||
|
}).collect();
|
||||||
|
let mut notes = Vec::new();
|
||||||
|
let price_multipliers: Vec<Value> = m["cost"]["multipliers"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|x| match opencode_when(&x["when"]) {
|
||||||
|
Some((days, hours, from, until)) => Some(json!({"factor": x["factor"], "days": days, "hours_utc": hours, "from": from, "until": until})),
|
||||||
|
None => {
|
||||||
|
notes.push(format!("unsupported price condition: {}", x["when"]));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|mut v| {
|
||||||
|
// Drop empty conditions so the config stays short.
|
||||||
|
v.as_object_mut().unwrap().retain(|_, x| !(x.is_null() || x.as_array().is_some_and(|a| a.is_empty())));
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let images = m["modalities"]["input"].as_array().is_some_and(|a| a.iter().any(|x| x.as_str().is_some_and(|s| s.starts_with("image"))));
|
||||||
|
json!({
|
||||||
|
"id": m["id"],
|
||||||
|
"context": m["limit"]["context"],
|
||||||
|
"max_output": m["limit"]["output"],
|
||||||
|
"vision": images,
|
||||||
|
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
||||||
|
"input_price": input["price"],
|
||||||
|
"output_price": output["price"],
|
||||||
|
"cache_read_price": input["cache"]["read"],
|
||||||
|
"cache_write_price": input["cache"]["write"],
|
||||||
|
"price_tiers": price_tiers,
|
||||||
|
"price_multipliers": price_multipliers,
|
||||||
|
"notes": notes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// opencode (Zen and Go) publishes prices in its model list. The Anthropic-protocol
|
||||||
|
/// endpoint lists models without them, so fall back to the gateway's own /v1/models.
|
||||||
|
async fn opencode_prices(base: &str) -> Result<Value> {
|
||||||
|
let mut urls = vec![format!("{base}/models")];
|
||||||
|
for suffix in ["/openai/v1", "/anthropic/v1"] {
|
||||||
|
if let Some(root) = base.strip_suffix(suffix) {
|
||||||
|
urls.push(format!("{root}/v1/models"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for url in &urls {
|
||||||
|
let Ok(v) = get_json(url).await else { continue };
|
||||||
|
let models: Vec<Value> = v["data"].as_array().into_iter().flatten().filter(|m| m.get("cost").is_some()).map(opencode_model).collect();
|
||||||
|
if !models.is_empty() {
|
||||||
|
return Ok(json!({"currency": "USD", "models": models}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("opencode did not return prices for this endpoint")
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
||||||
let cfg = app.config();
|
let cfg = app.config();
|
||||||
let p = cfg.providers.get(provider).context("unknown provider")?;
|
let p = cfg.providers.get(provider).context("unknown provider")?;
|
||||||
|
let base = expand_env(&p.base_url).trim_end_matches('/').to_string();
|
||||||
|
if is_opencode(p) {
|
||||||
|
return opencode_prices(&base).await;
|
||||||
|
}
|
||||||
if !is_openrouter(p) {
|
if !is_openrouter(p) {
|
||||||
bail!("this provider does not publish prices via its API – enter them manually");
|
bail!("this provider does not publish prices via its API – enter them manually");
|
||||||
}
|
}
|
||||||
let base = expand_env(&p.base_url).trim_end_matches('/').to_string();
|
|
||||||
let v: Value = crate::provider::http_client()
|
let v: Value = crate::provider::http_client()
|
||||||
.get(format!("{base}/models"))
|
.get(format!("{base}/models"))
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
@@ -211,6 +362,28 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn opencode_cost_conversion() {
|
||||||
|
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
||||||
|
"modalities":{"input":["text","image/png"]},"capabilities":{"reasoning":{"supported":true}},
|
||||||
|
"cost":{"text":{"input":{"price":2,"cache":{"read":0.5},"overrides":[{"when":{"context":{"gt":200000}},"price":4,"cache":{"read":1}}]},
|
||||||
|
"output":{"price":6,"overrides":[{"when":{"context":{"gt":200000}},"price":12}]}},
|
||||||
|
"multipliers":[{"when":{"utc_days":{"in":["mon","tue"]},"any":[{"all":[{"utc_hours":{"gte":"01:00"}},{"utc_hours":{"lt":"04:00"}}]}]},"factor":2},
|
||||||
|
{"when":{"date":{"gte":"2026-12-01"}},"factor":2},
|
||||||
|
{"when":{"region":"eu"},"factor":3}]}}"#).unwrap();
|
||||||
|
let r = super::opencode_model(&m);
|
||||||
|
assert_eq!(r["input_price"], 2);
|
||||||
|
assert_eq!(r["cache_read_price"], 0.5);
|
||||||
|
assert_eq!(r["vision"], true);
|
||||||
|
assert_eq!(r["price_tiers"][0], serde_json::json!({"above_context": 200000, "input_price": 4, "cache_read_price": 1, "output_price": 12}));
|
||||||
|
assert_eq!(r["price_multipliers"][0], serde_json::json!({"factor": 2, "days": ["mon", "tue"], "hours_utc": [["01:00", "04:00"]]}));
|
||||||
|
assert_eq!(r["price_multipliers"][1], serde_json::json!({"factor": 2, "from": "2026-12-01"}));
|
||||||
|
assert_eq!(r["notes"].as_array().unwrap().len(), 1, "unknown condition is reported, not guessed");
|
||||||
|
// The result must deserialize into the model config.
|
||||||
|
let mc: crate::config::ModelConfig = serde_json::from_value(serde_json::json!({"id": "grok", "price_tiers": r["price_tiers"], "price_multipliers": r["price_multipliers"]})).unwrap();
|
||||||
|
assert_eq!(mc.price_tiers[0].above_context, 200000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn budget_entry_flags() {
|
fn budget_entry_flags() {
|
||||||
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
let e = super::entry("x", Some(10.0), 8.5, 0.8);
|
||||||
|
|||||||
@@ -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 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()) };
|
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
"version": codecraft_core::version::VERSION,
|
||||||
"user": user,
|
"user": user,
|
||||||
"models": app.registry().models_json(),
|
"models": app.registry().models_json(),
|
||||||
"default_model": default_model,
|
"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();
|
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:#}")))?;
|
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 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 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 agent = format!("cli:{}", b.agent.unwrap_or_else(|| "build".into()));
|
||||||
let spec = b.model.clone();
|
let spec = b.model.clone();
|
||||||
let (out_tx, out_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(256);
|
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),
|
cost: model.cost(&usage),
|
||||||
error,
|
error,
|
||||||
user_id: Some(user.id.clone()),
|
user_id: Some(user.id.clone()),
|
||||||
|
allowance: usage.allowance,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
let stream = futures::stream::unfold(out_rx, |mut rx| async move { rx.recv().await.map(|ev| (ev, rx)) });
|
let stream = futures::stream::unfold(out_rx, |mut rx| async move { rx.recv().await.map(|ev| (ev, rx)) });
|
||||||
|
|||||||
+81
-1
@@ -193,6 +193,15 @@ pub struct ImageModel {
|
|||||||
/// Extra request fields sent as-is.
|
/// Extra request fields sent as-is.
|
||||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||||
pub options: serde_json::Map<String, serde_json::Value>,
|
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 {
|
impl Default for ImagesConfig {
|
||||||
@@ -461,6 +470,57 @@ pub fn load_or_init(path: &Path) -> Result<(Config, String)> {
|
|||||||
Ok((cfg, text))
|
Ok((cfg, text))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace `${NAME}` references in all string values with the environment's values,
|
||||||
|
/// so the settings live in the config only. Comments and formatting stay; references
|
||||||
|
/// to unset variables are kept. Returns (new text, replaced names, unset names).
|
||||||
|
pub fn inline_env(text: &str) -> Result<(String, Vec<String>, Vec<String>)> {
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?;
|
||||||
|
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
|
||||||
|
let (mut done, mut missing) = (BTreeSet::new(), BTreeSet::new());
|
||||||
|
let mut fix = |s: &str| -> Option<String> {
|
||||||
|
if !re.is_match(s) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let out = re.replace_all(s, |c: ®ex::Captures| match std::env::var(&c[1]).ok().filter(|v| !v.is_empty()) {
|
||||||
|
Some(v) => {
|
||||||
|
done.insert(c[1].to_string());
|
||||||
|
v
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
missing.insert(c[1].to_string());
|
||||||
|
c[0].to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(out != s).then(|| out.into_owned())
|
||||||
|
};
|
||||||
|
fn walk_value(v: &mut toml_edit::Value, fix: &mut dyn FnMut(&str) -> Option<String>) {
|
||||||
|
match v {
|
||||||
|
toml_edit::Value::String(f) => {
|
||||||
|
if let Some(new) = fix(f.value()) {
|
||||||
|
let decor = f.decor().clone();
|
||||||
|
let mut nv = toml_edit::Formatted::new(new);
|
||||||
|
*nv.decor_mut() = decor;
|
||||||
|
*f = nv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toml_edit::Value::Array(a) => a.iter_mut().for_each(|x| walk_value(x, fix)),
|
||||||
|
toml_edit::Value::InlineTable(t) => t.iter_mut().for_each(|(_, x)| walk_value(x, fix)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn walk_item(i: &mut toml_edit::Item, fix: &mut dyn FnMut(&str) -> Option<String>) {
|
||||||
|
match i {
|
||||||
|
toml_edit::Item::Value(v) => walk_value(v, fix),
|
||||||
|
toml_edit::Item::Table(t) => t.iter_mut().for_each(|(_, x)| walk_item(x, fix)),
|
||||||
|
toml_edit::Item::ArrayOfTables(a) => a.iter_mut().for_each(|t| t.iter_mut().for_each(|(_, x)| walk_item(x, fix))),
|
||||||
|
toml_edit::Item::None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk_item(doc.as_item_mut(), &mut fix);
|
||||||
|
Ok((doc.to_string(), done.into_iter().collect(), missing.into_iter().collect()))
|
||||||
|
}
|
||||||
|
|
||||||
/// A structured edit coming from the UI. Applied with toml_edit so comments survive.
|
/// A structured edit coming from the UI. Applied with toml_edit so comments survive.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ConfigPatch {
|
pub struct ConfigPatch {
|
||||||
@@ -607,6 +667,26 @@ pub fn redacted_json(cfg: &Config) -> serde_json::Value {
|
|||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod inline_env_tests {
|
||||||
|
#[test]
|
||||||
|
fn replaces_set_variables_and_keeps_comments() {
|
||||||
|
// SAFETY: test-only, the variable names are unique to this test.
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("CC_TEST_INLINE_KEY", "sk-123");
|
||||||
|
std::env::remove_var("CC_TEST_INLINE_UNSET");
|
||||||
|
}
|
||||||
|
let text = "# keys: use ${NAME}\n[providers.a]\napi_key = \"${CC_TEST_INLINE_KEY}\" # stored\nheaders = { Authorization = \"Bearer ${CC_TEST_INLINE_KEY}\" }\nother = \"${CC_TEST_INLINE_UNSET}\"\n";
|
||||||
|
let (out, done, missing) = super::inline_env(text).unwrap();
|
||||||
|
assert!(out.contains("api_key = \"sk-123\" # stored"), "{out}");
|
||||||
|
assert!(out.contains("Bearer sk-123"));
|
||||||
|
assert!(out.contains("# keys: use ${NAME}"), "comments untouched");
|
||||||
|
assert!(out.contains("${CC_TEST_INLINE_UNSET}"));
|
||||||
|
assert_eq!(done, vec!["CC_TEST_INLINE_KEY"]);
|
||||||
|
assert_eq!(missing, vec!["CC_TEST_INLINE_UNSET"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -630,7 +710,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(out.contains("# Google Gemini through its OpenAI-compatible endpoint."));
|
assert!(out.contains("# Google Gemini through its OpenAI-compatible endpoint."));
|
||||||
assert!(out.contains("enabled = true # set to true once GEMINI_API_KEY is available"));
|
assert!(out.contains("enabled = true # enable once the key is set"));
|
||||||
let cfg = Config::parse(&out).unwrap();
|
let cfg = Config::parse(&out).unwrap();
|
||||||
assert!(cfg.providers["gemini"].enabled);
|
assert!(cfg.providers["gemini"].enabled);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-17
@@ -33,29 +33,32 @@ impl Health {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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, 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")));
|
||||||
|
let state = match &error {
|
||||||
|
None => "ok",
|
||||||
|
Some(e) if e.contains("HTTP 401") || e.contains("HTTP 403") => "auth",
|
||||||
|
Some(_) => "down",
|
||||||
|
};
|
||||||
|
(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) {
|
pub async fn check_all(app: &App) {
|
||||||
let registry = app.registry();
|
let registry = app.registry();
|
||||||
let mut found = BTreeMap::new();
|
let mut found = BTreeMap::new();
|
||||||
let checks = registry.providers.iter().map(|(name, entry)| {
|
let checks = registry.providers.iter().map(|(name, entry)| {
|
||||||
let provider = entry.provider.clone();
|
let provider = entry.provider.clone();
|
||||||
let name = name.clone();
|
let name = name.clone();
|
||||||
async move {
|
async move { (name, probe(provider).await.0) }
|
||||||
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()),
|
|
||||||
};
|
|
||||||
// The host answered but has no model list: reachable.
|
|
||||||
let error = error.filter(|e| !(e.contains("HTTP 404") || e.contains("HTTP 405")));
|
|
||||||
let state = match &error {
|
|
||||||
None => "ok",
|
|
||||||
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() })
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
for (name, st) in futures::future::join_all(checks).await {
|
for (name, st) in futures::future::join_all(checks).await {
|
||||||
if !st.up {
|
if !st.up {
|
||||||
|
|||||||
+179
-17
@@ -34,6 +34,8 @@ pub struct GenerateRequest {
|
|||||||
pub dir: PathBuf,
|
pub dir: PathBuf,
|
||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
pub session_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 {
|
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()
|
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 {
|
fn model_config(app: &App, id: &str) -> ImageModel {
|
||||||
app.config()
|
models(app).into_iter().find(|m| m.id == id).unwrap_or_else(|| with_defaults(ImageModel { id: id.to_string(), ..Default::default() }))
|
||||||
.images
|
}
|
||||||
.models
|
|
||||||
.iter()
|
/// Pick the model for a request: an explicit model wins; previews use the first preview model
|
||||||
.find(|m| m.id == id)
|
/// (else the default); final images use the best-matching final model, preferring one that can
|
||||||
.cloned()
|
/// take the reference image when there is one.
|
||||||
.unwrap_or_else(|| ImageModel { id: id.to_string(), ..Default::default() })
|
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)> {
|
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())?;
|
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_id = req.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| ic.default_model.clone());
|
||||||
let model = model_config(app, &model_id);
|
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 count = req.count.clamp(1, 4);
|
||||||
|
|
||||||
let mut body = json!({"prompt": req.prompt, "num_images": count, "output_format": "png"});
|
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() {
|
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("aspect_ratio") => body["aspect_ratio"] = json!(aspect_ratio(&size)),
|
||||||
Some("none") => {}
|
Some("none") => {}
|
||||||
_ => body["image_size"] = json!(size),
|
_ => 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 {
|
for (k, v) in &model.options {
|
||||||
body[k] = v.clone();
|
body[k] = v.clone();
|
||||||
}
|
}
|
||||||
@@ -162,6 +247,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
|||||||
cost,
|
cost,
|
||||||
error: false,
|
error: false,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
|
allowance: None,
|
||||||
});
|
});
|
||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
@@ -184,9 +270,13 @@ impl Tool for GenerateImage {
|
|||||||
}
|
}
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
"Generate images from a text prompt (fal.ai) and save them in the project. Use for \
|
"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 \
|
illustrations, icons, backgrounds or photos the user needs. Write a detailed visual prompt \
|
||||||
visual prompt in English (subject, style, composition, colours, lighting). Each image \
|
in English (subject, style, composition, colours, lighting). The server picks the model. \
|
||||||
costs money – generate only what is needed. Returns the file paths and a preview."
|
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()
|
.into()
|
||||||
}
|
}
|
||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
@@ -194,37 +284,55 @@ impl Tool for GenerateImage {
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"prompt": {"type": "string"},
|
"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},
|
"count": {"type": "integer", "minimum": 1, "maximum": 4},
|
||||||
"directory": {"type": "string", "description": "Target directory relative to the working directory (default: images.output_dir)"},
|
"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"]
|
"required": ["prompt"]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
fn title(&self, input: &Value) -> String {
|
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> {
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||||||
let prompt = arg_str(&input, "prompt")?.to_string();
|
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_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 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 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(
|
let records = generate(
|
||||||
&ctx.app,
|
&ctx.app,
|
||||||
GenerateRequest {
|
GenerateRequest {
|
||||||
prompt,
|
prompt,
|
||||||
model: input["model"].as_str().map(String::from),
|
model: Some(model.id.clone()),
|
||||||
size: input["size"].as_str().map(String::from),
|
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,
|
dir,
|
||||||
user_id: owner,
|
user_id: owner,
|
||||||
session_id: Some(ctx.session_id.clone()),
|
session_id: Some(ctx.session_id.clone()),
|
||||||
|
references,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("{e:#}"))?;
|
.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();
|
let mut images = Vec::new();
|
||||||
for r in &records {
|
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());
|
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);
|
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();
|
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() })
|
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
@@ -54,7 +54,12 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let path = config::config_path();
|
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!("config: {}", path.display());
|
||||||
tracing::info!("workspace: {}", cfg.workspace().display());
|
tracing::info!("workspace: {}", cfg.workspace().display());
|
||||||
let _ = sandbox::probe().await;
|
let _ = sandbox::probe().await;
|
||||||
|
|||||||
+6
-1
@@ -160,7 +160,7 @@ impl McpServer {
|
|||||||
json!({
|
json!({
|
||||||
"protocolVersion": PROTOCOL_VERSION,
|
"protocolVersion": PROTOCOL_VERSION,
|
||||||
"capabilities": {"elicitation": {}, "roots": {"listChanged": false}},
|
"capabilities": {"elicitation": {}, "roots": {"listChanged": false}},
|
||||||
"clientInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")}
|
"clientInfo": {"name": "codecraft", "version": codecraft_core::version::VERSION}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -452,6 +452,11 @@ pub struct McpManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub fn status_json(&self) -> Value {
|
||||||
let states = self.states.lock().unwrap();
|
let states = self.states.lock().unwrap();
|
||||||
let servers = self.servers.lock().unwrap();
|
let servers = self.servers.lock().unwrap();
|
||||||
|
|||||||
@@ -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(),
|
tools: tools.iter().map(|t| t.def()).collect(),
|
||||||
max_tokens: model.max_output.unwrap_or(16_000),
|
max_tokens: model.max_output.unwrap_or(16_000),
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
|
session_id: Some(session_id.to_string()),
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
};
|
};
|
||||||
let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await {
|
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),
|
cost: req.model.cost(usage),
|
||||||
error,
|
error,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
|
allowance: usage.allowance,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
@@ -966,6 +968,7 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
|
|||||||
cost: model.cost(usage),
|
cost: model.cost(usage),
|
||||||
error: false,
|
error: false,
|
||||||
user_id: None,
|
user_id: None,
|
||||||
|
allowance: usage.allowance,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -985,6 +988,7 @@ fn maybe_generate_title(app: Arc<App>, session_id: &str, text: &str) {
|
|||||||
tools: vec![],
|
tools: vec![],
|
||||||
max_tokens: 2000,
|
max_tokens: 2000,
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
|
session_id: Some(session.id.clone()),
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
};
|
};
|
||||||
match complete_text(&app, &spec, provider, req, CancellationToken::new()).await {
|
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![],
|
tools: vec![],
|
||||||
max_tokens: 2000,
|
max_tokens: 2000,
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
|
session_id: Some(session.id.clone()),
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
};
|
};
|
||||||
let (text, usage) = complete_text(&app, &spec, provider, req, CancellationToken::new()).await?;
|
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,
|
agent.name,
|
||||||
if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() }
|
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?;
|
let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?;
|
||||||
record_side_usage(app, session, &spec, &model, &usage, "review");
|
record_side_usage(app, session, &spec, &model, &usage, "review");
|
||||||
let text = strip_think(&text);
|
let text = strip_think(&text);
|
||||||
@@ -1254,6 +1259,7 @@ pub async fn compact(app: Arc<App>, session_id: &str, cancel: CancellationToken)
|
|||||||
tools: vec![],
|
tools: vec![],
|
||||||
max_tokens: 16_000,
|
max_tokens: 16_000,
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
|
session_id: Some(session.id.clone()),
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
};
|
};
|
||||||
let (text, usage) = complete_text(&app, &spec, provider.clone(), req, cancel.clone()).await?;
|
let (text, usage) = complete_text(&app, &spec, provider.clone(), req, cancel.clone()).await?;
|
||||||
|
|||||||
+13
-3
@@ -162,6 +162,7 @@ impl Store {
|
|||||||
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
||||||
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
("users", "monthly_quota", "ALTER TABLE users ADD COLUMN monthly_quota REAL"),
|
||||||
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
|
("jobs", "webhook_secret", "ALTER TABLE jobs ADD COLUMN webhook_secret TEXT"),
|
||||||
|
("usage", "allowance", "ALTER TABLE usage ADD COLUMN allowance REAL"),
|
||||||
] {
|
] {
|
||||||
let exists: bool = conn
|
let exists: bool = conn
|
||||||
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
|
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
|
||||||
@@ -331,6 +332,8 @@ pub struct UsageRecord {
|
|||||||
pub error: bool,
|
pub error: bool,
|
||||||
/// User to bill when there is no session (CLI requests); else the session owner.
|
/// User to bill when there is no session (CLI requests); else the session owner.
|
||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
|
/// opencode Go: share of the plan's usage limits (USD), as reported by the provider.
|
||||||
|
pub allowance: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aggregation SQL shared by all statistics queries.
|
/// Aggregation SQL shared by all statistics queries.
|
||||||
@@ -376,13 +379,14 @@ impl Store {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent,
|
"INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent,
|
||||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||||
ttft_ms, duration_ms, cost, error, user_id)
|
ttft_ms, duration_ms, cost, error, user_id, allowance)
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,
|
||||||
COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)))",
|
COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)), ?16)",
|
||||||
params![
|
params![
|
||||||
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
|
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
|
||||||
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
|
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
|
||||||
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id
|
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id,
|
||||||
|
u.allowance
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -886,6 +890,12 @@ impl Store {
|
|||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
/// Cost per provider since `from` (ms).
|
/// Cost per provider since `from` (ms).
|
||||||
|
/// opencode Go: sum of reported allowance of one provider since `from` (ms).
|
||||||
|
pub fn allowance_since(&self, provider: &str, from: i64) -> Result<f64> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
Ok(conn.query_row("SELECT COALESCE(SUM(allowance), 0) FROM usage WHERE provider = ?1 AND ts >= ?2", params![provider, from], |r| r.get(0))?)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cost_by_provider(&self, from: i64) -> Result<std::collections::BTreeMap<String, f64>> {
|
pub fn cost_by_provider(&self, from: i64) -> Result<std::collections::BTreeMap<String, f64>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare("SELECT provider, COALESCE(SUM(cost), 0) FROM usage WHERE ts >= ?1 GROUP BY provider")?;
|
let mut stmt = conn.prepare("SELECT provider, COALESCE(SUM(cost), 0) FROM usage WHERE ts >= ?1 GROUP BY provider")?;
|
||||||
|
|||||||
+3
-14
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
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).
|
/// Git commit the image was built from (Docker build arg GIT_COMMIT).
|
||||||
pub fn commit() -> &'static str {
|
pub fn commit() -> &'static str {
|
||||||
@@ -17,10 +17,7 @@ pub struct UpdateState {
|
|||||||
pub latest: Mutex<Option<Value>>,
|
pub latest: Mutex<Option<Value>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn newer(a: &str, b: &str) -> bool {
|
use codecraft_core::version::newer;
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check(app: &App) -> Option<Value> {
|
async fn check(app: &App) -> Option<Value> {
|
||||||
let url = app.config().server.update_url.clone();
|
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { useFetch } from "../hooks";
|
import { useFetch } from "../hooks";
|
||||||
import { fmtCost, fmtPercent, useT } from "../i18n";
|
import { fmtCost, fmtPercent, useT } from "../i18n";
|
||||||
@@ -11,9 +12,32 @@ interface BudgetRow {
|
|||||||
warning: boolean;
|
warning: boolean;
|
||||||
exceeded: boolean;
|
exceeded: boolean;
|
||||||
images?: boolean;
|
images?: boolean;
|
||||||
|
plan?: Plan;
|
||||||
remote?: { currency?: string; remaining?: number | null; limit?: number | null; used?: number | null; spent_month?: number; scope?: string; error?: string };
|
remote?: { currency?: string; remaining?: number | null; limit?: number | null; used?: number | null; spent_month?: number; scope?: string; error?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** opencode Go: usage windows against the plan's limits (USD). */
|
||||||
|
export interface Plan {
|
||||||
|
limit: number;
|
||||||
|
windows: { window: "5h" | "week" | "month"; used: number; limit: number; ratio: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Three small meters (5 hours, week, month) for a subscription plan. */
|
||||||
|
export function PlanUsage({ plan }: { plan: Plan }) {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<div className="plan-usage">
|
||||||
|
{plan.windows.map((w) => (
|
||||||
|
<div key={w.window} className="plan-window" title={`${fmtCost(w.used, "USD")} / ${fmtCost(w.limit, "USD")}`}>
|
||||||
|
<span className="plan-label">{t(`budget.window.${w.window}`)}</span>
|
||||||
|
<Bar share={w.ratio} warning={w.ratio >= 0.8} exceeded={w.ratio >= 1} />
|
||||||
|
<span className="plan-pct">{fmtPercent(w.ratio)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface BudgetData {
|
interface BudgetData {
|
||||||
currency: string;
|
currency: string;
|
||||||
total: BudgetRow;
|
total: BudgetRow;
|
||||||
@@ -62,20 +86,30 @@ export function BudgetPanel() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<tr key={r.provider}>
|
<Fragment key={r.provider}>
|
||||||
<td>
|
<tr>
|
||||||
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
|
<td>
|
||||||
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
|
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
|
||||||
</td>
|
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
|
||||||
<td className="num">{fmtCost(r.spent, data.currency)}</td>
|
</td>
|
||||||
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
|
<td className="num">{fmtCost(r.spent, data.currency)}</td>
|
||||||
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
|
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
|
||||||
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
|
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
|
||||||
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
|
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
|
||||||
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
|
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
|
||||||
</td>
|
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
|
||||||
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
|
</td>
|
||||||
</tr>
|
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
|
||||||
|
</tr>
|
||||||
|
{r.plan && (
|
||||||
|
<tr className="plan-row">
|
||||||
|
<td className="muted">{t("budget.plan", { v: fmtCost(r.plan.limit, "USD") })}</td>
|
||||||
|
<td colSpan={5}>
|
||||||
|
<PlanUsage plan={r.plan} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+139
-12
@@ -257,7 +257,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
chromium: c.chromium ?? "chromium",
|
chromium: c.chromium ?? "chromium",
|
||||||
widths: (c.widths ?? []).join(", "),
|
widths: (c.widths ?? []).join(", "),
|
||||||
images_enabled: cfg.config.images?.enabled ?? false,
|
images_enabled: cfg.config.images?.enabled ?? false,
|
||||||
images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? "${FAL_KEY}"),
|
images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? ""),
|
||||||
images_model: cfg.config.images?.default_model ?? "fal-ai/flux/schnell",
|
images_model: cfg.config.images?.default_model ?? "fal-ai/flux/schnell",
|
||||||
images_dir: cfg.config.images?.output_dir ?? "generated",
|
images_dir: cfg.config.images?.output_dir ?? "generated",
|
||||||
});
|
});
|
||||||
@@ -344,7 +344,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
<label>{t("settings.imagesEnabled")}</label>
|
<label>{t("settings.imagesEnabled")}</label>
|
||||||
<input type="checkbox" checked={form.images_enabled} onChange={(e) => setForm({ ...form, images_enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
<input type="checkbox" checked={form.images_enabled} onChange={(e) => setForm({ ...form, images_enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||||
<label>{t("settings.imagesKey")}</label>
|
<label>{t("settings.imagesKey")}</label>
|
||||||
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "${FAL_KEY}"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
|
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "fal.ai API key"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
|
||||||
<label>{t("settings.imagesDefault")}</label>
|
<label>{t("settings.imagesDefault")}</label>
|
||||||
<select value={form.images_model} onChange={(e) => setForm({ ...form, images_model: e.target.value })}>
|
<select value={form.images_model} onChange={(e) => setForm({ ...form, images_model: e.target.value })}>
|
||||||
{(cfg.config.images?.models ?? []).map((m: any) => (
|
{(cfg.config.images?.models ?? []).map((m: any) => (
|
||||||
@@ -385,14 +385,16 @@ interface ModelRow {
|
|||||||
max_concurrency?: number;
|
max_concurrency?: number;
|
||||||
fallback?: string;
|
fallback?: string;
|
||||||
options?: Record<string, unknown>;
|
options?: Record<string, unknown>;
|
||||||
|
price_tiers?: { above_context: number; input_price?: number; output_price?: number; cache_read_price?: number; cache_write_price?: number }[];
|
||||||
|
price_multipliers?: { factor: number; days?: string[]; hours_utc?: [string, string][]; from?: string; until?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
|
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
|
||||||
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "${ANTHROPIC_API_KEY}" },
|
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "" },
|
||||||
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "${GEMINI_API_KEY}" },
|
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "" },
|
||||||
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "${OPENCODE_API_KEY}" },
|
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "" },
|
||||||
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "${FAL_KEY}", auth_prefix: "Key " },
|
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "", auth_prefix: "Key " },
|
||||||
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "${OPENROUTER_API_KEY}" },
|
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "" },
|
||||||
ollama: { kind: "openai", base_url: "http://host.docker.internal:11434/v1", api_key: "" },
|
ollama: { kind: "openai", base_url: "http://host.docker.internal:11434/v1", api_key: "" },
|
||||||
custom: { kind: "openai", base_url: "http://", api_key: "" },
|
custom: { kind: "openai", base_url: "http://", api_key: "" },
|
||||||
};
|
};
|
||||||
@@ -401,11 +403,23 @@ function normalizeModels(models: Json[]): ModelRow[] {
|
|||||||
return (models ?? []).map((m) => (typeof m === "string" ? { id: m } : { ...m }));
|
return (models ?? []).map((m) => (typeof m === "string" ? { id: m } : { ...m }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Readable summary of long-context tiers and time factors (tooltip); empty if there are none. */
|
||||||
|
function priceExtras(m: ModelRow): string {
|
||||||
|
const lines = [
|
||||||
|
...(m.price_tiers ?? []).map((t) => `> ${Math.round(t.above_context / 1000)}k context: ${[t.input_price, t.output_price].map((x) => x ?? "–").join(" / ")}`),
|
||||||
|
...(m.price_multipliers ?? []).map((x) =>
|
||||||
|
[`×${x.factor}`, x.days?.join(","), x.hours_utc?.map(([a, b]) => `${a}–${b} UTC`).join(", "), x.from && `from ${x.from}`, x.until && `until ${x.until}`].filter(Boolean).join(" "),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function cleanModel(m: ModelRow) {
|
function cleanModel(m: ModelRow) {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(m)) {
|
for (const [k, v] of Object.entries(m)) {
|
||||||
if (v === undefined || v === "" || (typeof v === "number" && Number.isNaN(v))) continue;
|
if (v === undefined || v === "" || (typeof v === "number" && Number.isNaN(v))) continue;
|
||||||
if (v === false && (k === "reasoning" || k === "vision")) continue;
|
if (v === false && (k === "reasoning" || k === "vision")) continue;
|
||||||
|
if (Array.isArray(v) && v.length === 0) continue;
|
||||||
out[k] = v;
|
out[k] = v;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -428,7 +442,20 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
|||||||
const f = byId.get(m.id);
|
const f = byId.get(m.id);
|
||||||
if (!f) return m;
|
if (!f) return m;
|
||||||
n++;
|
n++;
|
||||||
return { ...m, input_price: f.input_price ?? m.input_price, output_price: f.output_price ?? m.output_price, cache_read_price: f.cache_read_price ?? m.cache_read_price, cache_write_price: f.cache_write_price ?? m.cache_write_price, context: m.context ?? f.context };
|
return {
|
||||||
|
...m,
|
||||||
|
input_price: f.input_price ?? m.input_price,
|
||||||
|
output_price: f.output_price ?? m.output_price,
|
||||||
|
cache_read_price: f.cache_read_price ?? m.cache_read_price,
|
||||||
|
cache_write_price: f.cache_write_price ?? m.cache_write_price,
|
||||||
|
// Long-context tiers and time factors replace the stored ones when the provider publishes them.
|
||||||
|
price_tiers: f.price_tiers ?? m.price_tiers,
|
||||||
|
price_multipliers: f.price_multipliers ?? m.price_multipliers,
|
||||||
|
context: m.context ?? f.context,
|
||||||
|
max_output: m.max_output ?? f.max_output,
|
||||||
|
vision: m.vision || !!f.vision,
|
||||||
|
reasoning: m.reasoning || !!f.reasoning,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setPriceNote(t("settings.pricesFetched", { n }));
|
setPriceNote(t("settings.pricesFetched", { n }));
|
||||||
@@ -454,6 +481,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
|||||||
{ path: ["providers", name, "auth_prefix"], value: form.auth_prefix ? form.auth_prefix : null },
|
{ path: ["providers", name, "auth_prefix"], value: form.auth_prefix ? form.auth_prefix : null },
|
||||||
{ path: ["providers", name, "max_concurrency"], value: form.max_concurrency ? Number(form.max_concurrency) : null },
|
{ path: ["providers", name, "max_concurrency"], value: form.max_concurrency ? Number(form.max_concurrency) : null },
|
||||||
{ path: ["providers", name, "monthly_budget"], value: form.monthly_budget ? Number(form.monthly_budget) : null },
|
{ path: ["providers", name, "monthly_budget"], value: form.monthly_budget ? Number(form.monthly_budget) : null },
|
||||||
|
{ path: ["providers", name, "plan_limit"], value: form.plan_limit ? Number(form.plan_limit) : null },
|
||||||
];
|
];
|
||||||
// Empty key field = keep the stored (masked) key.
|
// Empty key field = keep the stored (masked) key.
|
||||||
if (form.api_key !== "" || p.api_key !== MASK) patches.push({ path: ["providers", name, "api_key"], value: form.api_key });
|
if (form.api_key !== "" || p.api_key !== MASK) patches.push({ path: ["providers", name, "api_key"], value: form.api_key });
|
||||||
@@ -461,6 +489,33 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
|||||||
return savePatches(patches);
|
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 () => {
|
const remove = async () => {
|
||||||
if (!(await confirmDialog({ message: t("settings.deleteProviderConfirm", { name }), danger: true, confirmLabel: t("common.delete") }))) return;
|
if (!(await confirmDialog({ message: t("settings.deleteProviderConfirm", { name }), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||||
void run(() => savePatches([{ path: ["providers", name], value: null }]));
|
void run(() => savePatches([{ path: ["providers", name], value: null }]));
|
||||||
@@ -506,7 +561,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
|||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={form.api_key}
|
value={form.api_key}
|
||||||
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : "${ENV_VAR}"}
|
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : ""}
|
||||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<div className="hint">{t("settings.apiKeyHint")}</div>
|
<div className="hint">{t("settings.apiKeyHint")}</div>
|
||||||
@@ -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 })} />
|
<input value={form.auth_prefix ?? ""} placeholder={form.kind === "openai" ? "Bearer " : ""} onChange={(e) => setForm({ ...form, auth_prefix: e.target.value })} />
|
||||||
<label>{t("settings.monthlyBudget")}</label>
|
<label>{t("settings.monthlyBudget")}</label>
|
||||||
<input type="number" min={0} step="1" value={form.monthly_budget ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
<input type="number" min={0} step="1" value={form.monthly_budget ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
||||||
|
{/opencode\.ai\/(inference|zen)\/go/.test(form.base_url ?? "") && (
|
||||||
|
<>
|
||||||
|
<label>{t("settings.planLimit")}</label>
|
||||||
|
<input type="number" min={0} step="1" value={form.plan_limit ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, plan_limit: e.target.value })} />
|
||||||
|
<div className="hint">{t("settings.planLimitHint")}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{form.kind === "anthropic" && (
|
{form.kind === "anthropic" && (
|
||||||
<>
|
<>
|
||||||
<label>{t("settings.adminKey")}</label>
|
<label>{t("settings.adminKey")}</label>
|
||||||
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "${ANTHROPIC_ADMIN_KEY}"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
|
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "sk-ant-admin01-…"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
|
||||||
<div className="hint">{t("settings.adminKeyHint")}</div>
|
<div className="hint">{t("settings.adminKeyHint")}</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -528,7 +590,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
|||||||
<h3 style={{ marginTop: 16 }}>
|
<h3 style={{ marginTop: 16 }}>
|
||||||
{t("settings.models")}
|
{t("settings.models")}
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{form.base_url?.includes("openrouter.ai") && (
|
{(form.base_url?.includes("openrouter.ai") || form.base_url?.includes("opencode.ai")) && (
|
||||||
<button className="small" onClick={fetchPrices}>
|
<button className="small" onClick={fetchPrices}>
|
||||||
{t("settings.fetchPrices")}
|
{t("settings.fetchPrices")}
|
||||||
</button>
|
</button>
|
||||||
@@ -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) => (
|
{(["input_price", "output_price", "cache_read_price", "cache_write_price"] as const).map((k) => (
|
||||||
<td key={k}>
|
<td key={k}>
|
||||||
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
|
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
|
||||||
|
{k === "cache_write_price" && priceExtras(m) && (
|
||||||
|
<span className="chip price-extra" title={priceExtras(m)}>
|
||||||
|
+{(m.price_tiers?.length ?? 0) + (m.price_multipliers?.length ?? 0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
<td>
|
<td>
|
||||||
@@ -614,7 +681,15 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
|||||||
</div>
|
</div>
|
||||||
{models.length === 0 && <div className="hint" style={{ color: "var(--muted)", fontSize: 12 }}>{t("settings.modelsEmptyHint")}</div>}
|
{models.length === 0 && <div className="hint" style={{ color: "var(--muted)", fontSize: 12 }}>{t("settings.modelsEmptyHint")}</div>}
|
||||||
{view}
|
{view}
|
||||||
|
{testResult && (
|
||||||
|
<div className={`notice ${testResult.ok ? "ok" : "err"}`} style={{ marginTop: 12 }}>
|
||||||
|
{testResult.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="row" style={{ marginTop: 12 }}>
|
<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}>
|
<button className="primary" onClick={save}>
|
||||||
{t("common.save")}
|
{t("common.save")}
|
||||||
</button>
|
</button>
|
||||||
@@ -637,9 +712,10 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
|
|||||||
const providers = cfg.config.providers ?? {};
|
const providers = cfg.config.providers ?? {};
|
||||||
const [status, setStatus] = useState<Record<string, ProviderStatus>>({});
|
const [status, setStatus] = useState<Record<string, ProviderStatus>>({});
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
|
// Saving a provider re-checks it on the server, so refresh whenever the config changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
get<Record<string, ProviderStatus>>("/providers/status").then(setStatus).catch(() => {});
|
get<Record<string, ProviderStatus>>("/providers/status").then(setStatus).catch(() => {});
|
||||||
}, []);
|
}, [cfg]);
|
||||||
const recheck = () => {
|
const recheck = () => {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
post<Record<string, ProviderStatus>>("/providers/status")
|
post<Record<string, ProviderStatus>>("/providers/status")
|
||||||
@@ -938,6 +1014,56 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void
|
|||||||
|
|
||||||
// --- raw ----------------------------------------------------------------------
|
// --- raw ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** ${VAR} references left in the config: move their values from the environment into it. */
|
||||||
|
function EnvRefs({ onDone }: { onDone: () => void }) {
|
||||||
|
const t = useT();
|
||||||
|
const { data, reload } = useFetch<{ set: string[]; unset: string[] }>("/config/inline-env");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
if (!data || (data.set.length === 0 && data.unset.length === 0)) return null;
|
||||||
|
const apply = async () => {
|
||||||
|
if (!(await confirmDialog({ title: t("settings.envTitle"), message: t("settings.envConfirm", { names: data.set.join(", ") }), confirmLabel: t("settings.envApply") }))) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await post<{ set: string[] }>("/config/inline-env");
|
||||||
|
toast(t("settings.envDone", { n: r.set.length }), "success");
|
||||||
|
reload();
|
||||||
|
onDone();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ marginBottom: 12 }}>
|
||||||
|
<h3>
|
||||||
|
<Icon name="file-text" /> {t("settings.envTitle")}
|
||||||
|
</h3>
|
||||||
|
<div className="hint" style={{ marginBottom: 8 }}>
|
||||||
|
{t("settings.envHint")}
|
||||||
|
</div>
|
||||||
|
{data.set.length > 0 && (
|
||||||
|
<div className="row" style={{ gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
{data.set.map((n) => (
|
||||||
|
<span key={n} className="chip mono">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="primary small" onClick={apply} disabled={busy}>
|
||||||
|
{t("settings.envApply")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.unset.length > 0 && (
|
||||||
|
<div className="hint" style={{ marginTop: 8 }}>
|
||||||
|
{t("settings.envUnset")} <span className="mono">{data.unset.join(", ")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Raw({ reload }: { reload: () => void }) {
|
function Raw({ reload }: { reload: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [text, setText] = useState<string>();
|
const [text, setText] = useState<string>();
|
||||||
@@ -948,6 +1074,7 @@ function Raw({ reload }: { reload: () => void }) {
|
|||||||
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
|
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EnvRefs onDone={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))} />
|
||||||
<div className="notice">{t("settings.rawHint")}</div>
|
<div className="notice">{t("settings.rawHint")}</div>
|
||||||
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
||||||
{view}
|
{view}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
import { StatusLeds } from "./StatusLeds";
|
||||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
import { get, patch, post } from "../api";
|
import { get, patch, post } from "../api";
|
||||||
@@ -102,7 +103,6 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
const t = useT();
|
const t = useT();
|
||||||
const sessions = useStore((s) => s.sessions);
|
const sessions = useStore((s) => s.sessions);
|
||||||
const busy = useStore((s) => s.busy);
|
const busy = useStore((s) => s.busy);
|
||||||
const connected = useStore((s) => s.connected);
|
|
||||||
const permissions = useStore((s) => s.permissions);
|
const permissions = useStore((s) => s.permissions);
|
||||||
const questions = useStore((s) => s.questions);
|
const questions = useStore((s) => s.questions);
|
||||||
const me = useStore((s) => s.me);
|
const me = useStore((s) => s.me);
|
||||||
@@ -187,7 +187,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
|
|||||||
<img src="/icon.svg" alt="" />
|
<img src="/icon.svg" alt="" />
|
||||||
CodeCraft
|
CodeCraft
|
||||||
</div>
|
</div>
|
||||||
<span className={`conn-dot ${connected ? "on" : ""}`} title={connected ? t("sidebar.connected") : t("sidebar.disconnected")} />
|
<StatusLeds />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: "0 12px 8px" }}>
|
<div style={{ padding: "0 12px 8px" }}>
|
||||||
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
|
<button className="primary" style={{ width: "100%" }} onClick={() => navigate("/new")}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { get, patch, post } from "../../api";
|
import { api, get, patch, post } from "../../api";
|
||||||
import { toast } from "../../dialogs";
|
import { confirmDialog, toast } from "../../dialogs";
|
||||||
import { useFetch } from "../../hooks";
|
import { useFetch } from "../../hooks";
|
||||||
import { fmtDate, useT } from "../../i18n";
|
import { fmtDate, useT } from "../../i18n";
|
||||||
import { formatSize } from "../FileFigure";
|
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") =>
|
const s3 = (k: keyof S3, label: string, placeholder = "", type = "text") =>
|
||||||
form && (
|
form && (
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@@ -111,6 +137,20 @@ export function Backups() {
|
|||||||
<a className="button small" href="/api/backup">
|
<a className="button small" href="/api/backup">
|
||||||
<Icon name="download" /> {t("audit.download")}
|
<Icon name="download" /> {t("audit.download")}
|
||||||
</a>
|
</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}>
|
<button className="small primary" onClick={runNow} disabled={busy}>
|
||||||
{busy ? t("backup.running") : t("backup.now")}
|
{busy ? t("backup.running") : t("backup.now")}
|
||||||
</button>
|
</button>
|
||||||
@@ -176,12 +216,17 @@ export function Backups() {
|
|||||||
{data && data.files.length > 0 && (
|
{data && data.files.length > 0 && (
|
||||||
<div className="backup-list">
|
<div className="backup-list">
|
||||||
{data.files.map((f) => (
|
{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" />
|
<Icon name="file" />
|
||||||
<span className="grow">{f.name}</span>
|
<span className="grow">{f.name}</span>
|
||||||
<span className="hint">{formatSize(f.size)}</span>
|
<span className="hint">{formatSize(f.size)}</span>
|
||||||
<Icon name="download" />
|
<button className="small" title={t("backup.restore")} onClick={() => void restore({ name: f.name })} disabled={restoring}>
|
||||||
</a>
|
<Icon name="undo" />
|
||||||
|
</button>
|
||||||
|
<a className="button small" title={t("audit.download")} href={`/api/backups/${encodeURIComponent(f.name)}`}>
|
||||||
|
<Icon name="download" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+39
-1
@@ -40,6 +40,13 @@ export const de: Messages = {
|
|||||||
"settings.imagesDefault": "Standardmodell",
|
"settings.imagesDefault": "Standardmodell",
|
||||||
"settings.imagesDir": "Ordner im Projekt",
|
"settings.imagesDir": "Ordner im Projekt",
|
||||||
"settings.imagesHint": "Modelle und Preise pro Bild werden in der Config-Datei bearbeitet ([images]).",
|
"settings.imagesHint": "Modelle und Preise pro Bild werden in der Config-Datei bearbeitet ([images]).",
|
||||||
|
"budget.plan": "Go-Plan ({v}/Monat)",
|
||||||
|
"budget.window.5h": "5 Stunden",
|
||||||
|
"budget.window.week": "7 Tage",
|
||||||
|
"budget.window.month": "Monat",
|
||||||
|
"settings.planLimit": "Planlimit (USD/Monat)",
|
||||||
|
"settings.planLimitHint": "opencode Go: das monatliche Nutzungslimit deines Plans. CodeCraft zählt den Verbrauch, den jede Antwort meldet (5 Stunden 20 %, Woche 50 %, Monat 100 %) – nur Nutzung über CodeCraft.",
|
||||||
|
"status.plan": "Go {pct} ({window})",
|
||||||
"budget.title": "Budgets in diesem Monat",
|
"budget.title": "Budgets in diesem Monat",
|
||||||
"budget.spent": "Verbraucht",
|
"budget.spent": "Verbraucht",
|
||||||
"budget.budget": "Budget",
|
"budget.budget": "Budget",
|
||||||
@@ -58,6 +65,12 @@ export const de: Messages = {
|
|||||||
"settings.budgetWarning": "Warnen ab",
|
"settings.budgetWarning": "Warnen ab",
|
||||||
"settings.adminKey": "Admin-Key (optional)",
|
"settings.adminKey": "Admin-Key (optional)",
|
||||||
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
|
"settings.adminKeyHint": "Anthropic Admin-API-Key (sk-ant-admin01-…), um die echten, organisationsweiten Monatskosten anzuzeigen.",
|
||||||
|
"settings.envTitle": "Einstellungen aus Umgebungsvariablen",
|
||||||
|
"settings.envHint": "Diese ${NAME}-Verweise holen ihre Werte aus der Server-Umgebung (.env). Übernimm die Werte in die Konfiguration, damit alles hier eingestellt ist – danach können die Variablen aus der .env raus.",
|
||||||
|
"settings.envApply": "In die Konfiguration übernehmen",
|
||||||
|
"settings.envConfirm": "Die aktuellen Werte von {names} in die Konfigurationsdatei schreiben? Die Keys liegen dann in der Config (und in ihren Backups) statt in der Umgebung.",
|
||||||
|
"settings.envDone": "{n} Werte in die Konfiguration übernommen. Du kannst sie jetzt aus der .env entfernen.",
|
||||||
|
"settings.envUnset": "Verwendet, aber nicht gesetzt (bleiben unverändert):",
|
||||||
"settings.fetchPrices": "Preise abrufen",
|
"settings.fetchPrices": "Preise abrufen",
|
||||||
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
|
"settings.pricesFetched": "Preise für {n} Modelle aktualisiert – zum Übernehmen speichern.",
|
||||||
"stats.perMillion": "pro 1 Mio. Tok.",
|
"stats.perMillion": "pro 1 Mio. Tok.",
|
||||||
@@ -204,6 +217,21 @@ export const de: Messages = {
|
|||||||
"sidebar.yesterday": "Gestern",
|
"sidebar.yesterday": "Gestern",
|
||||||
"sidebar.week": "Letzte 7 Tage",
|
"sidebar.week": "Letzte 7 Tage",
|
||||||
"sidebar.older": "Älter",
|
"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.connected": "Verbunden",
|
||||||
"sidebar.disconnected": "Getrennt – verbinde neu…",
|
"sidebar.disconnected": "Getrennt – verbinde neu…",
|
||||||
"sidebar.needsInput": "Wartet auf deine Eingabe",
|
"sidebar.needsInput": "Wartet auf deine Eingabe",
|
||||||
@@ -464,6 +492,13 @@ export const de: Messages = {
|
|||||||
"sidebar.moveHintExisting": "Vorhandene Ordner: {folders}. Gib einen davon oder einen neuen Namen ein.",
|
"sidebar.moveHintExisting": "Vorhandene Ordner: {folders}. Gib einen davon oder einen neuen Namen ein.",
|
||||||
"sidebar.folderName": "Ordnername",
|
"sidebar.folderName": "Ordnername",
|
||||||
"sidebar.move": "Verschieben",
|
"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.now": "Jetzt sichern",
|
||||||
"backup.running": "Sichere…",
|
"backup.running": "Sichere…",
|
||||||
"backup.created": "Backup {file} erstellt.",
|
"backup.created": "Backup {file} erstellt.",
|
||||||
@@ -625,7 +660,7 @@ export const de: Messages = {
|
|||||||
"settings.baseUrl": "Basis-URL",
|
"settings.baseUrl": "Basis-URL",
|
||||||
"settings.apiKey": "API-Key",
|
"settings.apiKey": "API-Key",
|
||||||
"settings.apiKeyStored": "Gespeichert – leer lassen zum Beibehalten",
|
"settings.apiKeyStored": "Gespeichert – leer lassen zum Beibehalten",
|
||||||
"settings.apiKeyHint": "Am besten ${ENV_VAR}-Verweise nutzen; direkte Keys stehen im Klartext in der Config-Datei.",
|
"settings.apiKeyHint": "Wird in der Konfigurationsdatei gespeichert (für Admins lesbar, in Backups enthalten). ${NAME}-Verweise auf Umgebungsvariablen funktionieren weiterhin.",
|
||||||
"settings.authPrefix": "Auth-Präfix",
|
"settings.authPrefix": "Auth-Präfix",
|
||||||
"settings.enabled": "Aktiv",
|
"settings.enabled": "Aktiv",
|
||||||
"settings.modelCount": "{n} Modelle",
|
"settings.modelCount": "{n} Modelle",
|
||||||
@@ -647,6 +682,9 @@ export const de: Messages = {
|
|||||||
"settings.providerName": "Name (optional)",
|
"settings.providerName": "Name (optional)",
|
||||||
"settings.providerUp": "erreichbar · {ms} ms",
|
"settings.providerUp": "erreichbar · {ms} ms",
|
||||||
"settings.providerDown": "nicht erreichbar",
|
"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.providerAuth": "API-Key fehlt oder ungültig",
|
||||||
"settings.providerCheck": "Provider prüfen",
|
"settings.providerCheck": "Provider prüfen",
|
||||||
"settings.providerChecking": "Prüfe…",
|
"settings.providerChecking": "Prüfe…",
|
||||||
|
|||||||
+39
-1
@@ -39,6 +39,13 @@ export const en = {
|
|||||||
"settings.imagesDefault": "Default model",
|
"settings.imagesDefault": "Default model",
|
||||||
"settings.imagesDir": "Folder in the project",
|
"settings.imagesDir": "Folder in the project",
|
||||||
"settings.imagesHint": "Models and prices per image are edited in the config file ([images]).",
|
"settings.imagesHint": "Models and prices per image are edited in the config file ([images]).",
|
||||||
|
"budget.plan": "Go plan ({v}/month)",
|
||||||
|
"budget.window.5h": "5 hours",
|
||||||
|
"budget.window.week": "7 days",
|
||||||
|
"budget.window.month": "Month",
|
||||||
|
"settings.planLimit": "Plan limit (USD/month)",
|
||||||
|
"settings.planLimitHint": "opencode Go: the monthly usage limit of your plan. CodeCraft tracks the allowance each response reports (5 hours 20 %, week 50 %, month 100 %) – only usage through CodeCraft.",
|
||||||
|
"status.plan": "Go {pct} ({window})",
|
||||||
"budget.title": "Budgets this month",
|
"budget.title": "Budgets this month",
|
||||||
"budget.spent": "Spent",
|
"budget.spent": "Spent",
|
||||||
"budget.budget": "Budget",
|
"budget.budget": "Budget",
|
||||||
@@ -57,6 +64,12 @@ export const en = {
|
|||||||
"settings.budgetWarning": "Warn at",
|
"settings.budgetWarning": "Warn at",
|
||||||
"settings.adminKey": "Admin key (optional)",
|
"settings.adminKey": "Admin key (optional)",
|
||||||
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
|
"settings.adminKeyHint": "Anthropic Admin API key (sk-ant-admin01-…) to show the real, organisation-wide monthly cost.",
|
||||||
|
"settings.envTitle": "Settings from environment variables",
|
||||||
|
"settings.envHint": "These ${NAME} references take their values from the server's environment (.env). Move the values into the configuration so everything is set here – then remove the variables from .env.",
|
||||||
|
"settings.envApply": "Move into the configuration",
|
||||||
|
"settings.envConfirm": "Write the current values of {names} into the configuration file? Keys are then stored in the config (and in its backups) instead of the environment.",
|
||||||
|
"settings.envDone": "{n} values moved into the configuration. You can now remove them from .env.",
|
||||||
|
"settings.envUnset": "Referenced but not set (left as they are):",
|
||||||
"settings.fetchPrices": "Fetch prices",
|
"settings.fetchPrices": "Fetch prices",
|
||||||
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
|
"settings.pricesFetched": "Prices updated for {n} models – save to keep them.",
|
||||||
"stats.perMillion": "per 1M tok",
|
"stats.perMillion": "per 1M tok",
|
||||||
@@ -203,6 +216,21 @@ export const en = {
|
|||||||
"sidebar.yesterday": "Yesterday",
|
"sidebar.yesterday": "Yesterday",
|
||||||
"sidebar.week": "Last 7 days",
|
"sidebar.week": "Last 7 days",
|
||||||
"sidebar.older": "Older",
|
"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.connected": "Connected",
|
||||||
"sidebar.disconnected": "Disconnected – reconnecting…",
|
"sidebar.disconnected": "Disconnected – reconnecting…",
|
||||||
"sidebar.needsInput": "Waiting for your input",
|
"sidebar.needsInput": "Waiting for your input",
|
||||||
@@ -463,6 +491,13 @@ export const en = {
|
|||||||
"sidebar.moveHintExisting": "Existing folders: {folders}. Type one of them or a new name.",
|
"sidebar.moveHintExisting": "Existing folders: {folders}. Type one of them or a new name.",
|
||||||
"sidebar.folderName": "Folder name",
|
"sidebar.folderName": "Folder name",
|
||||||
"sidebar.move": "Move",
|
"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.now": "Back up now",
|
||||||
"backup.running": "Backing up…",
|
"backup.running": "Backing up…",
|
||||||
"backup.created": "Backup {file} created.",
|
"backup.created": "Backup {file} created.",
|
||||||
@@ -624,7 +659,7 @@ export const en = {
|
|||||||
"settings.baseUrl": "Base URL",
|
"settings.baseUrl": "Base URL",
|
||||||
"settings.apiKey": "API key",
|
"settings.apiKey": "API key",
|
||||||
"settings.apiKeyStored": "Stored – leave empty to keep",
|
"settings.apiKeyStored": "Stored – leave empty to keep",
|
||||||
"settings.apiKeyHint": "Prefer ${ENV_VAR} references; literal keys are stored in plain text in the config file.",
|
"settings.apiKeyHint": "Stored in the configuration file (readable for admins, included in backups). ${NAME} references to environment variables still work.",
|
||||||
"settings.authPrefix": "Auth prefix",
|
"settings.authPrefix": "Auth prefix",
|
||||||
"settings.enabled": "Enabled",
|
"settings.enabled": "Enabled",
|
||||||
"settings.modelCount": "{n} models",
|
"settings.modelCount": "{n} models",
|
||||||
@@ -646,6 +681,9 @@ export const en = {
|
|||||||
"settings.providerName": "Name (optional)",
|
"settings.providerName": "Name (optional)",
|
||||||
"settings.providerUp": "reachable · {ms} ms",
|
"settings.providerUp": "reachable · {ms} ms",
|
||||||
"settings.providerDown": "unreachable",
|
"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.providerAuth": "API key missing or rejected",
|
||||||
"settings.providerCheck": "Check providers",
|
"settings.providerCheck": "Check providers",
|
||||||
"settings.providerChecking": "Checking…",
|
"settings.providerChecking": "Checking…",
|
||||||
|
|||||||
+151
-7
@@ -132,14 +132,120 @@ textarea:focus {
|
|||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conn-dot {
|
/* --- status LEDs (sidebar) ------------------------------------------------------ */
|
||||||
width: 8px;
|
/* Theme hues, pushed to a vivid chroma and a readable lightness so the state is
|
||||||
height: 8px;
|
obvious in every theme; warn takes the red's lightness/chroma at an amber hue. */
|
||||||
border-radius: 50%;
|
:root {
|
||||||
background: var(--red);
|
--led-ok: #22c55e;
|
||||||
|
--led-warn: #f5a700;
|
||||||
|
--led-err: #ef4444;
|
||||||
}
|
}
|
||||||
.conn-dot.on {
|
@supports (color: oklch(from red l c h)) {
|
||||||
background: var(--green);
|
: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 {
|
.session-list {
|
||||||
@@ -3413,6 +3519,10 @@ button.danger-solid {
|
|||||||
.backup-file .grow {
|
.backup-file .grow {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
.backup-file button,
|
||||||
|
.backup-file .button {
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- automation templates ------------------------------------------------------ */
|
/* --- automation templates ------------------------------------------------------ */
|
||||||
.job-tpl {
|
.job-tpl {
|
||||||
@@ -3459,3 +3569,37 @@ button.danger-solid {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chip.price-extra {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-row td {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.plan-usage {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.plan-window {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 170px;
|
||||||
|
}
|
||||||
|
.plan-window .budget-bar {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
.plan-label {
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.plan-pct {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
+32
-1
@@ -1,5 +1,36 @@
|
|||||||
/** "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[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
"202611-00002": {
|
||||||
|
en: [
|
||||||
|
"opencode: “Fetch prices” fills in prices including long-context and peak-hour pricing",
|
||||||
|
"Settings → Config file: move keys from environment variables into the configuration",
|
||||||
|
"opencode: real costs per request; Go plan usage (5 hours, week, month) in budgets and status",
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
"opencode: „Preise holen“ übernimmt Preise inklusive Long-Context- und Spitzenzeit-Preisen",
|
||||||
|
"Einstellungen → Config-Datei: Keys aus Umgebungsvariablen in die Konfiguration übernehmen",
|
||||||
|
"opencode: echte Kosten pro Anfrage; Go-Plan-Verbrauch (5 Stunden, Woche, Monat) in Budget und Status",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"202611-00001": {
|
||||||
|
en: [
|
||||||
|
"Status LEDs in the sidebar for server, models and MCP – click them for details",
|
||||||
|
"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": {
|
"0.1.0": {
|
||||||
en: [
|
en: [
|
||||||
"Terminal client `codecraft`: work locally like with Claude Code – Settings → Account → Terminal client",
|
"Terminal client `codecraft`: work locally like with Claude Code – Settings → Account → Terminal client",
|
||||||
|
|||||||
Reference in new issue
Block a user