release: v0.2.37 — zero-argument startup, first-run wizard, real download progress
Startup no longer needs arguments: the config is created on first launch, a busy port is swapped for a free one, the console is open by default, and the browser lands on a setup wizard that downloads a ready-made .cmf model and wires it into routing (managed server + routing.default) when you press Finish. Download progress was reported from curl's own meter, which prints a full 0→100% bar for the -L redirect hop before the transfer starts — so every job showed 100% and then hung. Progress now comes from bytes on disk against the size from the HuggingFace tree (or a HEAD request). The UI and the gateway also disagreed about the output file name, so a finished download never marked its card as downloaded; the gateway now returns the exact stem and the UI sends it back verbatim for download, progress and delete. Featured models are read live from the infosave HF account in both the wizard and Import, and all 432 UI strings now exist in every one of the 7 languages.
This commit is contained in:
+539
-68
@@ -20,7 +20,7 @@ use axum::{
|
||||
http::{header, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -139,10 +139,11 @@ fn parse_routing(model: &str) -> RoutingDirective {
|
||||
|
||||
// ───────────────────────────── router ───────────────────────────────────────
|
||||
|
||||
/// Admin API routes (token-protected). Mounted into the main router.
|
||||
/// Admin API routes. If `admin_token` is empty, the panel is open (no auth);
|
||||
/// otherwise every `/admin/api/*` request needs `Authorization: Bearer <token>`.
|
||||
/// The token is captured in a middleware closure so it does not depend on State.
|
||||
pub fn api_routes(admin_token: String) -> Router<SharedState> {
|
||||
Router::new()
|
||||
let router = Router::new()
|
||||
.route("/admin/api/health", get(health))
|
||||
.route("/admin/api/router/probe", post(router_probe))
|
||||
.route("/admin/api/meta", get(meta))
|
||||
@@ -183,10 +184,18 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
|
||||
.route("/admin/api/requests", get(get_requests))
|
||||
.route("/admin/api/test", post(run_test))
|
||||
.route("/admin/api/test/stream", post(run_test_stream))
|
||||
.route_layer(middleware::from_fn(move |req: Request, next: Next| {
|
||||
.route("/admin/api/setup/status", get(setup_status))
|
||||
.route("/admin/api/setup/complete", post(setup_complete))
|
||||
.route("/admin/api/setup/featured", get(setup_featured))
|
||||
.route("/admin/api/setup/file", delete(delete_setup_file));
|
||||
if admin_token.is_empty() {
|
||||
router
|
||||
} else {
|
||||
router.route_layer(middleware::from_fn(move |req: Request, next: Next| {
|
||||
let expected = admin_token.clone();
|
||||
async move { auth(expected, req, next).await }
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract Bearer admin token from the `Authorization` header.
|
||||
@@ -1344,6 +1353,450 @@ async fn hf_search(State(state): State<SharedState>, Query(q): Query<HfQuery>) -
|
||||
ok(json!({ "models": models }))
|
||||
}
|
||||
|
||||
// ── first-run onboarding ──
|
||||
|
||||
fn setup_marker(state: &SharedState) -> std::path::PathBuf {
|
||||
std::path::Path::new(&state.config_path)
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join(".setup_done")
|
||||
}
|
||||
|
||||
async fn setup_status(State(state): State<SharedState>) -> ApiResult {
|
||||
let live = state.live();
|
||||
let marker = setup_marker(&state);
|
||||
let done = marker.exists();
|
||||
let has_models = !live.cfg.models.is_empty() || !live.cfg.cmf.effective_servers().is_empty();
|
||||
let has_imports = !state.imports.list().is_empty();
|
||||
ok(json!({
|
||||
"setup_done": done,
|
||||
"has_models": has_models,
|
||||
"has_imports": has_imports,
|
||||
"needs_setup": !done && !has_models,
|
||||
"listen": live.cfg.listen,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetupCompleteBody {
|
||||
#[serde(default)]
|
||||
lang: Option<String>,
|
||||
/// Connect finished downloads as local models (default: yes).
|
||||
#[serde(default)]
|
||||
register: Option<bool>,
|
||||
}
|
||||
|
||||
/// Finish the wizard. Everything that finished downloading is connected as a
|
||||
/// local model here — the wizard promises exactly that, and leaving it to a
|
||||
/// separate button is how a first run ends with an empty model pool.
|
||||
async fn setup_complete(
|
||||
State(state): State<SharedState>,
|
||||
Json(b): Json<SetupCompleteBody>,
|
||||
) -> ApiResult {
|
||||
let marker = setup_marker(&state);
|
||||
if let Some(dir) = marker.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
let lang = b.lang.unwrap_or_else(|| "en".into());
|
||||
let _ = std::fs::write(&marker, format!("done lang={lang} ts={}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs()));
|
||||
|
||||
let mut registered: Vec<String> = Vec::new();
|
||||
if b.register.unwrap_or(true) {
|
||||
let finished: Vec<String> = state
|
||||
.imports
|
||||
.list()
|
||||
.into_iter()
|
||||
.filter(|j| j.state == "done")
|
||||
.map(|j| j.output)
|
||||
.collect();
|
||||
if !finished.is_empty() {
|
||||
let mut cfg = current_cfg(&state);
|
||||
for output in &finished {
|
||||
if let Some(id) = register_cmf_output(&mut cfg, output) {
|
||||
registered.push(id);
|
||||
}
|
||||
}
|
||||
if cfg.routing.default.is_empty() {
|
||||
if let Some(first) = registered.first() {
|
||||
cfg.routing.default = first.clone();
|
||||
}
|
||||
}
|
||||
if !registered.is_empty() {
|
||||
state.reload(cfg)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let runtime = crate::cmf_runtime::installed_version(&state.live().cfg.cmf.cortiq_bin);
|
||||
ok(json!({
|
||||
"ok": true,
|
||||
"registered": registered,
|
||||
"runtime_installed": runtime.is_some(),
|
||||
"runtime_version": runtime,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Curated ready-to-run .cmf models from infosave (HF author) — proxied server-side.
|
||||
async fn setup_featured(State(state): State<SharedState>) -> ApiResult {
|
||||
let live = state.live();
|
||||
let token = if live.cfg.cmf.hf_token_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
state.secrets.resolve(&live.cfg.cmf.hf_token_env)
|
||||
};
|
||||
// Try HF author search; on failure return curated fallback
|
||||
let hf_result = crate::import::hf_search_author("infosave", 30, token.as_deref()).await;
|
||||
let models = match hf_result {
|
||||
Ok(v) => v,
|
||||
Err(_) => serde_json::json!([]),
|
||||
};
|
||||
// curated fallback with real sizes (usedStorage from HF)
|
||||
let fallback = json!([
|
||||
{"id": "infosave/Bonsai-1.7Bcmf", "label": "Bonsai 1.7B", "badge": "1.7B · Q1", "desc": "Small, fast local model — fits 4GB RAM", "size": "334 MB", "size_bytes": 334185278},
|
||||
{"id": "infosave/Bonsai-8B_2bit_cmf", "label": "Bonsai 8B 2-bit", "badge": "8B · Q1T", "desc": "Balanced quality/size — 2-bit ternary", "size": "2.3 GB", "size_bytes": 2322861074_u64},
|
||||
{"id": "infosave/Bonsai-27Bcmf", "label": "Bonsai 27B", "badge": "27B · Q1", "desc": "High quality — needs 16GB RAM", "size": "5.1 GB", "size_bytes": 5103462190_u64},
|
||||
{"id": "infosave/Qwen3.6-27Bcmf", "label": "Qwen 3.6 27B CMF", "badge": "27B · Q4 · MoE", "desc": "MoE model, strong reasoning", "size": "14.3 GB", "size_bytes": 14264207150_u64},
|
||||
{"id": "infosave/Nanbeige4.2-3Bcmf", "label": "Nanbeige 4.2 3B", "badge": "3B · looped", "desc": "Looped transformer, compact", "size": "2.4 GB", "size_bytes": 2364287523_u64},
|
||||
{"id": "infosave/KAT-Coder-V2.5-CMF", "label": "KAT Coder 2.5", "badge": "code · MoE", "desc": "Code specialist", "size": "12.7 GB", "size_bytes": 12653975221_u64},
|
||||
]);
|
||||
let arr = if models.as_array().map(|a| a.is_empty()).unwrap_or(true) {
|
||||
fallback
|
||||
} else {
|
||||
// enrich HF data with label/badge/size for UI — fetch usedStorage concurrently
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
if let Some(arr) = models.as_array() {
|
||||
for m in arr {
|
||||
if let Some(id) = m["id"].as_str() {
|
||||
if id.to_ascii_lowercase().contains("cmf") {
|
||||
ids.push(id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ids.is_empty() { return ok(json!({ "models": fallback })); }
|
||||
// fetch per-variant sizes in parallel (tree API, 10s each)
|
||||
let token_owned = token.map(|s| s.to_string());
|
||||
let variant_lists: Vec<(String, Vec<(String, u64)>)> =
|
||||
futures::future::join_all(ids.iter().map(|id| {
|
||||
let tid = token_owned.clone();
|
||||
let iid = id.clone();
|
||||
async move {
|
||||
let vars = crate::import::hf_repo_variants(&iid, tid.as_deref()).await;
|
||||
(iid, vars)
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let mut variant_map: std::collections::HashMap<String, Vec<(String, u64)>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut fallback_map: std::collections::HashMap<String, (String, u64)> =
|
||||
std::collections::HashMap::new();
|
||||
let mut fallback_badge: std::collections::HashMap<String, String> =
|
||||
std::collections::HashMap::new();
|
||||
// also keep fallback sizes/badges for when tree is unreachable
|
||||
for v in fallback.as_array().unwrap_or(&vec![]) {
|
||||
if let (Some(id), Some(sz)) = (v["id"].as_str(), v["size_bytes"].as_u64()) {
|
||||
if let Some(s) = v["size"].as_str() {
|
||||
fallback_map.insert(id.to_string(), (s.to_string(), sz));
|
||||
}
|
||||
}
|
||||
if let (Some(id), Some(b)) = (v["id"].as_str(), v["badge"].as_str()) {
|
||||
fallback_badge.insert(id.to_string(), b.to_string());
|
||||
}
|
||||
}
|
||||
for (id, vars) in variant_lists {
|
||||
if !vars.is_empty() {
|
||||
variant_map.insert(id, vars);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if let Some(arr) = models.as_array() {
|
||||
for m in arr {
|
||||
let id = m["id"].as_str().unwrap_or("").to_string();
|
||||
if !id.to_ascii_lowercase().contains("cmf") { continue; }
|
||||
let likes = m["likes"].as_u64().unwrap_or(0);
|
||||
let vars = variant_map.get(&id);
|
||||
// extract size token from id
|
||||
let lower = id.to_ascii_lowercase();
|
||||
let size_token = ["1.7b", "48b", "35b", "27b", "12b", "8b", "7b", "3b", "4b"]
|
||||
.iter()
|
||||
.find(|c| lower.contains(*c))
|
||||
.map(|c| match *c {
|
||||
"1.7b" => "1.7B",
|
||||
"48b" => "48B",
|
||||
"35b" => "35B",
|
||||
"27b" => "27B",
|
||||
"12b" => "12B",
|
||||
"8b" => "8B",
|
||||
"7b" => "7B",
|
||||
"3b" => "3B",
|
||||
"4b" => "4B",
|
||||
_ => "CMF",
|
||||
})
|
||||
.unwrap_or("CMF");
|
||||
// quant from variant name if available (more accurate for Bonsai q1), else id
|
||||
let quant_src = vars
|
||||
.and_then(|vs| vs.first().map(|(n, _)| n.as_str()))
|
||||
.unwrap_or(&id)
|
||||
.to_ascii_lowercase();
|
||||
let quant = if quant_src.contains("q1t") || quant_src.contains("2bit") || lower.contains("q1t") || lower.contains("2bit") {
|
||||
"Q1T"
|
||||
} else if quant_src.contains("q2tp") || lower.contains("q2tp") {
|
||||
"Q2TP"
|
||||
} else if quant_src.contains("q4tp") || lower.contains("q4tp") {
|
||||
"Q4TP"
|
||||
} else if quant_src.contains("q1") || lower.contains("q1") {
|
||||
"Q1"
|
||||
} else if quant_src.contains("q4") || lower.contains("q4") {
|
||||
"Q4"
|
||||
} else if quant_src.contains("q8") || lower.contains("q8") {
|
||||
"Q8"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let mut badge = if quant.is_empty() || size_token == "CMF" {
|
||||
if size_token == "CMF" { "CMF · ready".to_string() } else { size_token.to_string() }
|
||||
} else {
|
||||
format!("{size_token} · {quant}")
|
||||
};
|
||||
// fallback has more accurate quant (e.g. Bonsai 1.7B Q1) when tree fetch failed
|
||||
if let Some(fb) = fallback_badge.get(&id) {
|
||||
if badge == size_token && fb.contains('·') {
|
||||
badge = fb.clone();
|
||||
}
|
||||
}
|
||||
let (size, size_bytes, variants_json) = if let Some(vs) = vars {
|
||||
if vs.len() == 1 {
|
||||
let (name, sz) = &vs[0];
|
||||
let s = crate::import::format_bytes(*sz);
|
||||
(Some(s.clone()), Some(*sz), json!([{"name": name, "size": s, "size_bytes": sz}]))
|
||||
} else {
|
||||
// dedupe same-size variants (e.g. q2tp v2/v3 both 112GB → keep latest v3)
|
||||
let mut seen_sz = std::collections::HashSet::new();
|
||||
let mut uniq_vs: Vec<(String, u64)> = Vec::new();
|
||||
// iterate reversed to keep latest (v3) over v2 when same size
|
||||
for (n, sz) in vs.iter().rev() {
|
||||
let s = crate::import::format_bytes(*sz);
|
||||
if seen_sz.insert(s) { uniq_vs.push((n.clone(), *sz)); }
|
||||
}
|
||||
uniq_vs.reverse();
|
||||
let parts: Vec<String> = uniq_vs.iter().map(|(_, sz)| crate::import::format_bytes(*sz)).collect();
|
||||
let max_sz = uniq_vs.iter().map(|(_, sz)| *sz).max();
|
||||
let vars_json: Vec<Value> = uniq_vs.iter().map(|(n, sz)| json!({"name": n, "size": crate::import::format_bytes(*sz), "size_bytes": sz})).collect();
|
||||
(Some(parts.join(" / ")), max_sz, json!(vars_json))
|
||||
}
|
||||
} else if let Some((s, sz)) = fallback_map.get(&id) {
|
||||
(Some(s.clone()), Some(*sz), json!([{"name": id.split('/').last().unwrap_or(&id), "size": s, "size_bytes": sz}]))
|
||||
} else {
|
||||
(None::<String>, None::<u64>, Value::Null)
|
||||
};
|
||||
// check if already downloaded locally (case-insensitive, handles sharded dirs)
|
||||
let models_dir = live.cfg.cmf.models_dir.clone();
|
||||
let base = crate::import::sanitize_for_id(id.split('/').last().unwrap_or(&id));
|
||||
let mut downloaded = false;
|
||||
let mut downloaded_variants: Vec<Value> = Vec::new();
|
||||
if let Some(vs) = variants_json.as_array() {
|
||||
for v in vs {
|
||||
let vname = v["name"].as_str().unwrap_or("");
|
||||
let vbase = if vs.len() == 1 {
|
||||
base.clone()
|
||||
} else {
|
||||
format!("{}-{}", base, crate::import::sanitize_for_id(vname))
|
||||
};
|
||||
let expected = format!("{vbase}.cmf").to_lowercase();
|
||||
// case-insensitive search via read_dir (handles /tmp symlink and case)
|
||||
let mut exists = false;
|
||||
let mut local_sz: u64 = 0;
|
||||
if let Ok(rd) = std::fs::read_dir(&models_dir) {
|
||||
for entry in rd.filter_map(|e| e.ok()) {
|
||||
let name = entry.file_name().to_string_lossy().to_lowercase();
|
||||
if name == expected {
|
||||
exists = true;
|
||||
local_sz = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
if local_sz == 0 && entry.path().is_dir() {
|
||||
local_sz = std::fs::read_dir(entry.path())
|
||||
.ok()
|
||||
.map(|r| r.filter_map(|e| e.ok()).filter_map(|e| e.metadata().ok()).map(|m| m.len()).sum())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback direct path check (for non-listed or still being written)
|
||||
if !exists {
|
||||
let p = std::path::Path::new(&models_dir).join(format!("{vbase}.cmf"));
|
||||
if p.exists() || p.is_dir() {
|
||||
exists = true;
|
||||
local_sz = std::fs::metadata(&p)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or_else(|_| {
|
||||
std::fs::read_dir(&p)
|
||||
.ok()
|
||||
.map(|r| r.filter_map(|e| e.ok()).filter_map(|e| e.metadata().ok()).map(|m| m.len()).sum())
|
||||
.unwrap_or(0)
|
||||
});
|
||||
}
|
||||
}
|
||||
let remote_sz = v["size_bytes"].as_u64().unwrap_or(0);
|
||||
let is_complete = exists && local_sz != 0 && remote_sz != 0 && local_sz == remote_sz;
|
||||
// downloaded = only when complete (not partial)
|
||||
downloaded = downloaded || is_complete;
|
||||
let mut vv = v.clone();
|
||||
vv["downloaded"] = json!(is_complete);
|
||||
vv["local_size"] = json!(local_sz);
|
||||
// the exact output stem the import will produce, and the id of
|
||||
// the job that produces it — the UI passes both back verbatim so
|
||||
// download / progress / delete can never disagree on the file.
|
||||
vv["local_name"] = json!(vbase);
|
||||
vv["job"] =
|
||||
json!(crate::import::gen_id(&format!("{id}::{vname}")));
|
||||
if exists && !is_complete && local_sz != 0 {
|
||||
// partial or outdated — show as update available if not currently downloading
|
||||
// we don't have job state here, so just mark update if sizes differ
|
||||
vv["update_available"] = json!(true);
|
||||
} else if is_complete && remote_sz != 0 && local_sz != remote_sz {
|
||||
vv["update_available"] = json!(true);
|
||||
}
|
||||
downloaded_variants.push(vv);
|
||||
}
|
||||
}
|
||||
let final_variants = if downloaded_variants.is_empty() { variants_json } else { json!(downloaded_variants) };
|
||||
let any_update = downloaded_variants.iter().any(|v| v["update_available"].as_bool().unwrap_or(false));
|
||||
out.push(json!({
|
||||
"id": id,
|
||||
"label": id.split('/').last().unwrap_or(&id),
|
||||
"local_name": base,
|
||||
"badge": badge,
|
||||
"desc": m["pipeline_tag"].as_str().unwrap_or("text-generation"),
|
||||
"likes": likes,
|
||||
"size": size,
|
||||
"size_bytes": size_bytes,
|
||||
"variants": final_variants,
|
||||
"downloaded": downloaded,
|
||||
"update_available": any_update,
|
||||
"raw": m,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if out.is_empty() { fallback } else { json!(out) }
|
||||
};
|
||||
ok(json!({ "models": arr }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SetupFileQuery {
|
||||
repo: String,
|
||||
#[serde(default)]
|
||||
variant: Option<String>,
|
||||
/// Exact output stem (`local_name` from `setup/featured`). Preferred over the
|
||||
/// repo/variant guess, which cannot know how many variants a repo has.
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// Drop a downloaded `.cmf` from the config: any managed server or pool model
|
||||
/// backed by that stem. Returns the ids that were unregistered.
|
||||
fn unregister_cmf_stem(state: &SharedState, stem: &str) -> Result<Vec<String>, ApiError> {
|
||||
let mut cfg = current_cfg(state);
|
||||
let stem_of = |path: &str| {
|
||||
std::path::Path::new(path)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_lowercase())
|
||||
};
|
||||
let mut ids: Vec<String> = cfg
|
||||
.cmf
|
||||
.servers
|
||||
.iter()
|
||||
.filter(|s| stem_of(&s.model).as_deref() == Some(stem))
|
||||
.map(|s| s.id.clone())
|
||||
.collect();
|
||||
for m in &cfg.models {
|
||||
if cmf_file_for_model(&cfg.cmf, m)
|
||||
.and_then(|p| stem_of(&p.to_string_lossy()))
|
||||
.as_deref()
|
||||
== Some(stem)
|
||||
{
|
||||
ids.push(m.id.clone());
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
cfg.cmf
|
||||
.servers
|
||||
.retain(|s| stem_of(&s.model).as_deref() != Some(stem));
|
||||
if stem_of(&cfg.cmf.local_model).as_deref() == Some(stem) {
|
||||
cfg.cmf.local_model = String::new();
|
||||
cfg.cmf.manage_server = false;
|
||||
}
|
||||
cfg.models.retain(|m| !ids.contains(&m.id));
|
||||
for id in &ids {
|
||||
strip_id_from_routing(&mut cfg.routing, id);
|
||||
}
|
||||
state.reload(cfg)?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
async fn delete_setup_file(
|
||||
State(state): State<SharedState>,
|
||||
Query(q): Query<SetupFileQuery>,
|
||||
) -> ApiResult {
|
||||
let repo = q.repo.trim();
|
||||
if repo.is_empty() {
|
||||
return Err(ApiError::bad("repo is required"));
|
||||
}
|
||||
let live = state.live();
|
||||
let models_dir = live.cfg.cmf.models_dir.clone();
|
||||
let base = crate::import::sanitize_for_id(repo.split('/').last().unwrap_or(repo));
|
||||
let variant = q.variant.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty());
|
||||
// Exact stem wins; sanitizing it also keeps the delete inside models_dir.
|
||||
let vbase = match q.name.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(n) => crate::import::sanitize_for_id(n),
|
||||
// for single-file the file is {base}.cmf, for sharded {base}-{variant}.cmf
|
||||
None => match variant {
|
||||
// only sharded variants need suffix; single file ignores
|
||||
Some(v) if v.starts_with("parts-") => {
|
||||
format!("{}-{}", base, crate::import::sanitize_for_id(v))
|
||||
}
|
||||
_ => base.clone(),
|
||||
},
|
||||
};
|
||||
if vbase.is_empty() {
|
||||
return Err(ApiError::bad("cannot derive a file name to delete"));
|
||||
}
|
||||
// Stop serving it before the file goes away, so a managed server doesn't keep
|
||||
// a deleted .cmf open (and the pool doesn't advertise a model that is gone).
|
||||
let unregistered = unregister_cmf_stem(&state, &vbase)?;
|
||||
for id in &unregistered {
|
||||
state.cmf.stop_one(id).await;
|
||||
}
|
||||
let p = std::path::Path::new(&models_dir).join(format!("{vbase}.cmf"));
|
||||
let mut removed = false;
|
||||
if p.exists() || p.is_dir() {
|
||||
removed = remove_cmf_path(&p);
|
||||
} else {
|
||||
// try case-insensitive search (macOS /tmp symlink, etc.)
|
||||
if let Ok(rd) = std::fs::read_dir(&models_dir) {
|
||||
let expected = format!("{vbase}.cmf").to_lowercase();
|
||||
for entry in rd.filter_map(|e| e.ok()) {
|
||||
if entry.file_name().to_string_lossy().to_lowercase() == expected {
|
||||
removed = remove_cmf_path(&entry.path());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// also remove any job with same repo+variant id (so list doesn't keep stale "done")
|
||||
let jid = crate::import::gen_id(&format!("{}::{}", repo, variant.unwrap_or("")));
|
||||
let _ = state.imports.delete(&jid);
|
||||
// also try to delete by repo alone (old non-variant ids)
|
||||
if variant.is_none() {
|
||||
let jid2 = crate::import::gen_id(repo);
|
||||
let _ = state.imports.delete(&jid2);
|
||||
}
|
||||
ok(json!({ "ok": true, "removed": removed, "unregistered": unregistered }))
|
||||
}
|
||||
|
||||
async fn list_imports(State(state): State<SharedState>) -> ApiResult {
|
||||
ok(json!({ "jobs": state.imports.list() }))
|
||||
}
|
||||
@@ -1377,11 +1830,80 @@ async fn cancel_import(State(state): State<SharedState>, Path(job): Path<String>
|
||||
|
||||
/// Delete a finished conversion and its converted `.cmf` file(s) from disk.
|
||||
async fn delete_import(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
|
||||
if state.imports.get(&job).is_none() {
|
||||
let Some(j) = state.imports.get(&job) else {
|
||||
return Err(ApiError::not_found(format!("job '{job}' not found")));
|
||||
};
|
||||
// If it was already registered, drop it from the config first — otherwise the
|
||||
// pool keeps serving a model whose .cmf is about to disappear.
|
||||
let stem = std::path::Path::new(&j.output)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
let unregistered = if stem.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
unregister_cmf_stem(&state, &stem)?
|
||||
};
|
||||
for id in &unregistered {
|
||||
state.cmf.stop_one(id).await;
|
||||
}
|
||||
let removed = state.imports.delete(&job).map_err(ApiError::bad)?;
|
||||
ok(json!({ "ok": removed }))
|
||||
ok(json!({ "ok": removed, "unregistered": unregistered }))
|
||||
}
|
||||
|
||||
/// Add a finished `.cmf` to the config as a *managed* local model: the gateway
|
||||
/// spawns and supervises its own `cortiq serve` for it. Registering it into the
|
||||
/// static pool instead would point the model at `cortiq_server_url` with nothing
|
||||
/// listening there, which is an upstream error the moment anyone calls it.
|
||||
///
|
||||
/// A stale static-pool entry for the same file is migrated rather than rejected,
|
||||
/// so pressing Register again repairs a model left dead by an earlier version.
|
||||
/// `None` = already registered and served.
|
||||
fn register_cmf_output(cfg: &mut crate::config::Config, output: &str) -> Option<String> {
|
||||
let base = std::path::Path::new(output)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "cmf-local".into());
|
||||
let id = format!("cmf-{base}");
|
||||
let served = cfg.cmf.manage_server
|
||||
&& cfg
|
||||
.cmf
|
||||
.servers
|
||||
.iter()
|
||||
.any(|s| s.id == id || s.model == output);
|
||||
if served {
|
||||
return None;
|
||||
}
|
||||
// Same id sitting in the static pool → it is the dead form of this model.
|
||||
// Routing keeps referencing the id, so nothing needs re-pointing.
|
||||
cfg.models.retain(|m| m.id != id);
|
||||
cfg.cmf.servers.retain(|s| s.id != id && s.model != output);
|
||||
cfg.cmf.manage_server = true;
|
||||
let host = if cfg.cmf.local_host.trim().is_empty() {
|
||||
"127.0.0.1"
|
||||
} else {
|
||||
&cfg.cmf.local_host
|
||||
};
|
||||
let base_port = cfg.cmf.servers.iter().map(|s| s.port).max().unwrap_or(8089);
|
||||
let port = next_free_port(host, base_port).unwrap_or_else(|| base_port + 1);
|
||||
cfg.cmf.servers.push(crate::config::CmfServer {
|
||||
id: id.clone(),
|
||||
model: output.to_string(),
|
||||
port,
|
||||
threads: cfg.cmf.threads,
|
||||
gpu: cfg.cmf.gpu,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
max_tokens: None,
|
||||
think_budget: None,
|
||||
system_prompt: None,
|
||||
o1: None,
|
||||
o1_m: None,
|
||||
o1_window: None,
|
||||
o1_sink: None,
|
||||
skip_mtp: false,
|
||||
});
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Register a finished `.cmf` as a local model (OpenAI provider → cortiq-server).
|
||||
@@ -1393,69 +1915,18 @@ async fn register_import(State(state): State<SharedState>, Path(job): Path<Strin
|
||||
if j.state != "done" {
|
||||
return Err(ApiError::bad("conversion not finished"));
|
||||
}
|
||||
let base = std::path::Path::new(&j.output)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "cmf-local".into());
|
||||
let id = format!("cmf-{base}");
|
||||
let mut cfg = current_cfg(&state);
|
||||
|
||||
let is_in_models = cfg.models.iter().any(|m| m.id == id);
|
||||
let is_in_servers = cfg
|
||||
.cmf
|
||||
.servers
|
||||
.iter()
|
||||
.any(|s| s.id == id || s.model == j.output);
|
||||
|
||||
if is_in_models || is_in_servers {
|
||||
return Err(ApiError::bad(format!("model '{id}' is already registered")));
|
||||
}
|
||||
|
||||
if cfg.cmf.manage_server {
|
||||
let host = if cfg.cmf.local_host.trim().is_empty() {
|
||||
"127.0.0.1"
|
||||
} else {
|
||||
&cfg.cmf.local_host
|
||||
};
|
||||
let base_port = cfg.cmf.servers.iter().map(|s| s.port).max().unwrap_or(8089);
|
||||
let port = next_free_port(host, base_port).unwrap_or_else(|| base_port + 1);
|
||||
cfg.cmf.servers.push(crate::config::CmfServer {
|
||||
id: id.clone(),
|
||||
model: j.output.clone(),
|
||||
port,
|
||||
threads: cfg.cmf.threads,
|
||||
gpu: cfg.cmf.gpu,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
max_tokens: None,
|
||||
think_budget: None,
|
||||
system_prompt: None,
|
||||
o1: None,
|
||||
o1_m: None,
|
||||
o1_window: None,
|
||||
o1_sink: None,
|
||||
skip_mtp: false,
|
||||
});
|
||||
} else {
|
||||
cfg.models.push(crate::config::ModelCfg {
|
||||
id: id.clone(),
|
||||
provider: "openai".into(),
|
||||
base_url: cfg.cmf.cortiq_server_url.clone(),
|
||||
model: base,
|
||||
cost_tier: "local".into(),
|
||||
price_in: 0.0,
|
||||
price_out: 0.0,
|
||||
kind: "chat".into(),
|
||||
api_key_env: None,
|
||||
caps: Vec::new(),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
max_tokens: None,
|
||||
think_budget: None,
|
||||
system_prompt: None,
|
||||
o1: None,
|
||||
skip_mtp: false,
|
||||
});
|
||||
let id = register_cmf_output(&mut cfg, &j.output).ok_or_else(|| {
|
||||
ApiError::bad(format!(
|
||||
"model 'cmf-{}' is already registered and running",
|
||||
std::path::Path::new(&j.output)
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default()
|
||||
))
|
||||
})?;
|
||||
if cfg.routing.default.is_empty() {
|
||||
cfg.routing.default = id.clone();
|
||||
}
|
||||
state.reload(cfg)?;
|
||||
ok(json!({ "ok": true, "model_id": id }))
|
||||
|
||||
Reference in New Issue
Block a user