Status LEDs, image model selection, backup restore, provider test, monthly versions

- Sidebar: three status LEDs (server, models, MCP) with an overview popover (/api/status)
- Images: preview stage, purpose-based model choice and reference images
  (Nano Banana Pro edit refines the chosen preview)
- Settings: restore a stored or uploaded backup (staged, applied on restart)
- Provider form: test connection; saving or reloading re-checks the status at once
- opencode Zen/Go: send x-opencode-session with a stable id per conversation
- Releases are named vYYYYMM-NNNNN; scripts/release.sh numbers, tags, builds and publishes
- Fix: backups made in the same second no longer overwrite each other

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 09:31:24 +01:00
1 parent 49d404eec7
commit 1f69d63b09
35 files changed
+1156 -88

No files matched your search

+2 -1
View File
@@ -168,7 +168,8 @@ impl Provider for Anthropic {
for (k, v) in &req.model.options {
body[k] = v.clone();
}
let resp = self.request(reqwest::Method::POST, "/messages").json(&body).send().await?;
let rb = self.request(reqwest::Method::POST, "/messages").json(&body);
let resp = self.ep.with_session(rb, req.session_id.as_deref()).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}
+38 -1
View File
@@ -30,6 +30,9 @@ pub struct ChatRequest {
pub max_tokens: u64,
/// Enable extended thinking / reasoning when the model supports it.
pub reasoning: bool,
/// Stable id of the conversation; sent to gateways that route by session
/// (opencode: `x-opencode-session`) for routing and prompt caching.
pub session_id: Option<String>,
}
#[derive(Debug, Clone)]
@@ -84,6 +87,16 @@ impl Endpoint {
}
}
/// Session header for gateways that need one (opencode Zen/Go reject requests without it).
pub fn with_session(&self, rb: reqwest::RequestBuilder, session_id: Option<&str>) -> reqwest::RequestBuilder {
match session_id {
Some(id) if self.base_url.contains("opencode.ai") && !self.headers.keys().any(|k| k.eq_ignore_ascii_case("x-opencode-session")) => {
rb.header("x-opencode-session", id)
}
_ => rb,
}
}
pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if !self.api_key.is_empty() {
rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key));
@@ -99,7 +112,7 @@ impl Endpoint {
pub fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(20))
.user_agent(concat!("codecraft/", env!("CARGO_PKG_VERSION")))
.user_agent(format!("codecraft/{}", crate::version::VERSION))
.build()
.expect("http client")
}
@@ -128,3 +141,27 @@ pub async fn http_error(resp: reqwest::Response) -> anyhow::Error {
.unwrap_or_else(|| body.chars().take(500).collect());
anyhow!("provider returned HTTP {status}: {detail}")
}
#[cfg(test)]
mod session_header_tests {
use super::*;
fn ep(base: &str) -> Endpoint {
Endpoint { base_url: base.into(), api_key: String::new(), auth_header: "Authorization".into(), auth_prefix: "Bearer ".into(), headers: BTreeMap::new() }
}
fn header(ep: &Endpoint, id: Option<&str>) -> Option<String> {
let rb = ep.with_session(reqwest::Client::new().post(format!("{}/chat/completions", ep.base_url)), id);
rb.build().unwrap().headers().get("x-opencode-session").map(|v| v.to_str().unwrap().to_string())
}
#[test]
fn opencode_gets_the_session_header() {
assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), Some("s1")).as_deref(), Some("s1"));
assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), None), None);
assert_eq!(header(&ep("https://api.openai.com/v1"), Some("s1")), None);
let mut custom = ep("https://opencode.ai/zen/v1");
custom.headers.insert("X-Opencode-Session".into(), "fixed".into());
assert_eq!(header(&custom, Some("s1")), None, "a configured header wins");
}
}
+1 -1
View File
@@ -198,7 +198,7 @@ impl Provider for OpenAi {
body[k] = v.clone();
}
let rb = self.client.post(format!("{}/chat/completions", self.ep.base_url)).json(&body);
let resp = self.ep.apply(rb).send().await?;
let resp = self.ep.with_session(self.ep.apply(rb), req.session_id.as_deref()).send().await?;
if !resp.status().is_success() {
return Err(http_error(resp).await);
}