Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4220bc27c2 | ||
|
|
6fdbe0e159 |
@@ -3,28 +3,8 @@
|
|||||||
All notable changes to this project are documented here.
|
All notable changes to this project are documented here.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
||||||
|
|
||||||
## [0.2.27] - 2026-07-21
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Automatic CMF Local Server Lifecycle**: Registering an imported `.cmf` model automatically adds it to `[[cmf.servers]]` and reloads the runtime (`cmf_runtime::manage`), automatically spawning `cortiq serve` on port 8090 so chat completion requests route seamlessly without `upstream unavailable` errors.
|
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Multi-GB Ready CMF Download Stream**: Replaced HTTP stream decoder with a streaming process downloader (`curl`) for pre-converted `.cmf` models to support multi-gigabyte Hugging Face LFS repositories without 30s/60s socket timeouts.
|
|
||||||
- **Ready CMF Quantization & UI Labels**: Set correct quantization badges (`Q1`, `Q1T`) for featured ready-to-run `.cmf` models and removed misleading "converting" log messages for direct downloads.
|
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Direct Ready CMF Download**: Importing pre-converted `.cmf` model repositories (e.g. `infosave/Bonsai-8B_2bit_cmf`) directly streams the ready `.cmf` file from Hugging Face instead of attempting a safetensors conversion without `config.json`.
|
|
||||||
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **New Quantization Schemes**: Added support for `Q4_TILED` (q4t), `Q1T` (1.58-bit ternary), `Q1S` (1-bit sparse), `Q1P` (1-bit PTQ error diffusion), and `Q1` (pure binary) in converter backend and web admin UI.
|
|
||||||
- **Featured Ready-to-Run .cmf Models**: Added 1-tap download cards for pre-converted `.cmf` models (`infosave/Bonsai-1.7Bcmf`, `infosave/Bonsai-8B_2bit_cmf`, `infosave/Bonsai-27Bcmf`).
|
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Docker Compose** quickstart (`docker-compose.yml` + `config/gateway.compose.toml`):
|
- **Docker Compose** quickstart (`docker-compose.yml` + `config/gateway.compose.toml`):
|
||||||
gateway + a local Ollama model in one command. GHCR badge in the READMEs.
|
gateway + a local Ollama model in one command. GHCR badge in the READMEs.
|
||||||
|
|||||||
Generated
+1
-1
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cortiq-gateway"
|
name = "cortiq-gateway"
|
||||||
version = "0.2.27"
|
version = "0.2.17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cortiq-gateway"
|
name = "cortiq-gateway"
|
||||||
version = "0.2.27"
|
version = "0.2.17"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+89
-20
@@ -606,7 +606,10 @@ fn cmf_file_for_model(
|
|||||||
cmf: &crate::config::CmfCfg,
|
cmf: &crate::config::CmfCfg,
|
||||||
m: &crate::config::ModelCfg,
|
m: &crate::config::ModelCfg,
|
||||||
) -> Option<std::path::PathBuf> {
|
) -> Option<std::path::PathBuf> {
|
||||||
|
// Any loopback URL counts as local — managed servers may sit on any port.
|
||||||
let local = m.base_url == cmf.cortiq_server_url
|
let local = m.base_url == cmf.cortiq_server_url
|
||||||
|
|| m.base_url.contains("//127.0.0.1:")
|
||||||
|
|| m.base_url.contains("//localhost:")
|
||||||
|| m.base_url
|
|| m.base_url
|
||||||
.contains(&format!("{}:{}", cmf.local_host, cmf.local_port));
|
.contains(&format!("{}:{}", cmf.local_host, cmf.local_port));
|
||||||
let stem = m.model.trim();
|
let stem = m.model.trim();
|
||||||
@@ -646,12 +649,24 @@ fn cmf_path_referenced(cfg: &crate::config::Config, path: &std::path::Path) -> b
|
|||||||
.any(|m| cmf_file_for_model(&cfg.cmf, m).as_deref() == Some(path))
|
.any(|m| cmf_file_for_model(&cfg.cmf, m).as_deref() == Some(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_model(State(state): State<SharedState>, Path(id): Path<String>) -> ApiResult {
|
#[derive(Deserialize)]
|
||||||
|
struct DeleteModelQuery {
|
||||||
|
/// Also remove the local `.cmf` from disk. Defaults to true (the historical
|
||||||
|
/// behaviour); the UI asks the user and passes it explicitly.
|
||||||
|
delete_file: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_model(
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Query(q): Query<DeleteModelQuery>,
|
||||||
|
) -> ApiResult {
|
||||||
let mut cfg = current_cfg(&state);
|
let mut cfg = current_cfg(&state);
|
||||||
|
let delete_file = q.delete_file.unwrap_or(true);
|
||||||
|
|
||||||
// Managed local models live in [cmf], not [[models]]. Deleting one stops its
|
// Managed local models live in [cmf], not [[models]]. Deleting one stops its
|
||||||
// server, removes it from the managed config, and deletes its file — unless
|
// server, removes it from the managed config, and (when asked) deletes its
|
||||||
// another model still points at the same file.
|
// file — unless another model still points at the same file.
|
||||||
let managed = cfg.cmf.effective_servers().into_iter().find(|s| s.id == id);
|
let managed = cfg.cmf.effective_servers().into_iter().find(|s| s.id == id);
|
||||||
if let Some(server) = managed {
|
if let Some(server) = managed {
|
||||||
if !cfg.models.iter().any(|x| x.id == id) {
|
if !cfg.models.iter().any(|x| x.id == id) {
|
||||||
@@ -666,10 +681,10 @@ async fn delete_model(State(state): State<SharedState>, Path(id): Path<String>)
|
|||||||
let shared = cmf_path_referenced(&cfg, &path);
|
let shared = cmf_path_referenced(&cfg, &path);
|
||||||
state.cmf.stop_one(&id).await;
|
state.cmf.stop_one(&id).await;
|
||||||
state.reload(cfg)?;
|
state.reload(cfg)?;
|
||||||
let file_removed = if shared {
|
let file_removed = if delete_file && !shared {
|
||||||
false
|
|
||||||
} else {
|
|
||||||
remove_cmf_path(&path)
|
remove_cmf_path(&path)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
};
|
};
|
||||||
return ok(json!({ "ok": true, "file_removed": file_removed }));
|
return ok(json!({ "ok": true, "file_removed": file_removed }));
|
||||||
}
|
}
|
||||||
@@ -685,8 +700,8 @@ async fn delete_model(State(state): State<SharedState>, Path(id): Path<String>)
|
|||||||
let file = victim
|
let file = victim
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|m| cmf_file_for_model(&cfg.cmf, m));
|
.and_then(|m| cmf_file_for_model(&cfg.cmf, m));
|
||||||
// Only remove the file if no remaining model still references it.
|
// Only remove the file if asked to AND no remaining model still references it.
|
||||||
let file = file.filter(|p| !cmf_path_referenced(&cfg, p));
|
let file = file.filter(|p| delete_file && !cmf_path_referenced(&cfg, p));
|
||||||
state.reload(cfg)?;
|
state.reload(cfg)?;
|
||||||
let file_removed = file.map(|p| remove_cmf_path(&p)).unwrap_or(false);
|
let file_removed = file.map(|p| remove_cmf_path(&p)).unwrap_or(false);
|
||||||
ok(json!({ "ok": true, "file_removed": file_removed }))
|
ok(json!({ "ok": true, "file_removed": file_removed }))
|
||||||
@@ -905,12 +920,75 @@ async fn put_settings(State(state): State<SharedState>, Json(b): Json<SettingsBo
|
|||||||
{
|
{
|
||||||
needs_restart = true;
|
needs_restart = true;
|
||||||
}
|
}
|
||||||
|
let old_servers = cfg.cmf.effective_servers();
|
||||||
cfg.cmf = v;
|
cfg.cmf = v;
|
||||||
|
// Replacing or renaming a managed model must not brick the save: routing
|
||||||
|
// may still reference the old id. Map a vanished id to its replacement
|
||||||
|
// (the new server on the same port), strip ids that are truly gone, and
|
||||||
|
// repair the default so validate() passes.
|
||||||
|
heal_routing_after_cmf_change(&mut cfg, &old_servers);
|
||||||
}
|
}
|
||||||
state.reload(cfg)?;
|
state.reload(cfg)?;
|
||||||
ok(json!({ "ok": true, "needs_restart": needs_restart }))
|
ok(json!({ "ok": true, "needs_restart": needs_restart }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// After the managed-servers list changed, rewrite [routing] so it only
|
||||||
|
/// references ids that still exist. A removed server whose port is now held by
|
||||||
|
/// a NEW server is treated as a replacement (old id → new id); other vanished
|
||||||
|
/// ids are stripped. An emptied default falls back to the first managed server,
|
||||||
|
/// then the first pool model.
|
||||||
|
fn heal_routing_after_cmf_change(cfg: &mut Config, old_servers: &[crate::config::CmfServer]) {
|
||||||
|
let servers = cfg.cmf.effective_servers();
|
||||||
|
let known: std::collections::HashSet<String> = cfg
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.id.clone())
|
||||||
|
.chain(servers.iter().map(|s| s.id.clone()))
|
||||||
|
.collect();
|
||||||
|
let new_by_port: HashMap<u16, String> =
|
||||||
|
servers.iter().map(|s| (s.port, s.id.clone())).collect();
|
||||||
|
let mut rename: HashMap<String, String> = HashMap::new();
|
||||||
|
for old in old_servers {
|
||||||
|
if !known.contains(&old.id) {
|
||||||
|
if let Some(new_id) = new_by_port.get(&old.port) {
|
||||||
|
rename.insert(old.id.clone(), new_id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fix = |id: &str| -> Option<String> {
|
||||||
|
if known.contains(id) {
|
||||||
|
Some(id.to_string())
|
||||||
|
} else {
|
||||||
|
rename.get(id).cloned()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !cfg.routing.default.is_empty() {
|
||||||
|
cfg.routing.default = fix(&cfg.routing.default).unwrap_or_default();
|
||||||
|
}
|
||||||
|
if cfg.routing.default.is_empty() {
|
||||||
|
cfg.routing.default = servers
|
||||||
|
.first()
|
||||||
|
.map(|s| s.id.clone())
|
||||||
|
.or_else(|| cfg.models.first().map(|m| m.id.clone()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
for tt in cfg.routing.tiers.values_mut() {
|
||||||
|
let list = match tt {
|
||||||
|
TierTargets::List(v) => v.clone(),
|
||||||
|
TierTargets::One(s) => vec![s.clone()],
|
||||||
|
};
|
||||||
|
let mut healed: Vec<String> = Vec::new();
|
||||||
|
for id in &list {
|
||||||
|
if let Some(new_id) = fix(id) {
|
||||||
|
if !healed.contains(&new_id) {
|
||||||
|
healed.push(new_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*tt = TierTargets::List(healed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────────── cmf runtime ─────────────────────────────────
|
// ───────────────────────────── cmf runtime ─────────────────────────────────
|
||||||
/// Status of the `cortiq` CLI (the CMF format runtime): is it installed, which
|
/// Status of the `cortiq` CLI (the CMF format runtime): is it installed, which
|
||||||
/// version, and whether a managed server is running. Drives the "Install cortiq"
|
/// version, and whether a managed server is running. Drives the "Install cortiq"
|
||||||
@@ -1028,6 +1106,9 @@ async fn cmf_port_check(State(state): State<SharedState>, Query(q): Query<PortQu
|
|||||||
ok(json!({
|
ok(json!({
|
||||||
"port": port,
|
"port": port,
|
||||||
"available": available,
|
"available": available,
|
||||||
|
// true = held by this gateway's own managed server (a replaced/edited
|
||||||
|
// row frees it on restart) — the UI shows this as info, not an error
|
||||||
|
"managed": running_here,
|
||||||
"detail": detail,
|
"detail": detail,
|
||||||
"suggested": suggested,
|
"suggested": suggested,
|
||||||
}))
|
}))
|
||||||
@@ -1319,18 +1400,6 @@ async fn register_import(State(state): State<SharedState>, Path(job): Path<Strin
|
|||||||
api_key_env: None,
|
api_key_env: None,
|
||||||
caps: Vec::new(),
|
caps: Vec::new(),
|
||||||
});
|
});
|
||||||
if cfg.cmf.manage_server {
|
|
||||||
let port = cfg.cmf.local_port;
|
|
||||||
if !cfg.cmf.servers.iter().any(|s| s.model == j.output) {
|
|
||||||
cfg.cmf.servers.push(crate::config::CmfServer {
|
|
||||||
id: id.clone(),
|
|
||||||
model: j.output.clone(),
|
|
||||||
port,
|
|
||||||
threads: cfg.cmf.threads,
|
|
||||||
gpu: cfg.cmf.gpu,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.reload(cfg)?;
|
state.reload(cfg)?;
|
||||||
ok(json!({ "ok": true, "model_id": id }))
|
ok(json!({ "ok": true, "model_id": id }))
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-194
@@ -260,151 +260,14 @@ fn sanitize(s: &str) -> String {
|
|||||||
/// Map the UI quant label to the native `cortiq` quant flag.
|
/// Map the UI quant label to the native `cortiq` quant flag.
|
||||||
fn map_quant(q: &str) -> &'static str {
|
fn map_quant(q: &str) -> &'static str {
|
||||||
match q.to_ascii_uppercase().as_str() {
|
match q.to_ascii_uppercase().as_str() {
|
||||||
s if s.starts_with("Q4T") || s == "Q4_TILED" => "q4t",
|
|
||||||
s if s.starts_with("Q4") => "q4",
|
s if s.starts_with("Q4") => "q4",
|
||||||
"Q8_2F" | "Q82F" => "q8_2f",
|
"Q8_2F" => "q8_2f",
|
||||||
"F16" | "FP16" => "f16",
|
"F16" | "FP16" => "f16",
|
||||||
"VBIT" => "vbit",
|
"VBIT" => "vbit",
|
||||||
"Q1" => "q1",
|
|
||||||
"Q1P" | "Q1_PTQ" => "q1p",
|
|
||||||
"Q1S" | "Q1_MASK" => "q1s",
|
|
||||||
"Q1T" | "Q1_TERNARY" => "q1t",
|
|
||||||
_ => "q8",
|
_ => "q8",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_cmf_repo(repo: &str) -> bool {
|
|
||||||
let lower = repo.to_ascii_lowercase();
|
|
||||||
lower.contains("cmf") || lower.ends_with(".cmf")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn download_cmf_repo(
|
|
||||||
repo: String,
|
|
||||||
output_abs: String,
|
|
||||||
hf_token: Option<String>,
|
|
||||||
store: Arc<JobStore>,
|
|
||||||
id: String,
|
|
||||||
cancel: Arc<Notify>,
|
|
||||||
) {
|
|
||||||
store.set_progress(&id, 0.0, "listing files".into());
|
|
||||||
|
|
||||||
let tree_url = format!("https://huggingface.co/api/models/{repo}/tree/main?recursive=true");
|
|
||||||
let client = reqwest::Client::new();
|
|
||||||
let mut req = client.get(&tree_url).header("User-Agent", "cortiq-gateway");
|
|
||||||
if let Some(t) = &hf_token {
|
|
||||||
req = req.bearer_auth(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cmf_rel_path = None;
|
|
||||||
if let Ok(resp) = req.send().await {
|
|
||||||
if resp.status().is_success() {
|
|
||||||
if let Ok(json) = resp.json::<serde_json::Value>().await {
|
|
||||||
if let Some(arr) = json.as_array() {
|
|
||||||
for item in arr {
|
|
||||||
if let Some(path) = item.get("path").and_then(|p| p.as_str()) {
|
|
||||||
if path.to_ascii_lowercase().ends_with(".cmf") {
|
|
||||||
cmf_rel_path = Some(path.to_string());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let cmf_path = match cmf_rel_path {
|
|
||||||
Some(p) => p,
|
|
||||||
None => {
|
|
||||||
let repo_name = repo.split('/').last().unwrap_or(&repo);
|
|
||||||
format!("{repo_name}.cmf")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
store.push_line(&id, format!("→ downloading {cmf_path} from HuggingFace..."));
|
|
||||||
store.set_progress(&id, 0.05, "downloading".into());
|
|
||||||
|
|
||||||
let download_url = format!("https://huggingface.co/{repo}/resolve/main/{cmf_path}");
|
|
||||||
|
|
||||||
let mut cmd = tokio::process::Command::new("curl");
|
|
||||||
cmd.arg("-f").arg("-L").arg("-#").arg("-o").arg(&output_abs);
|
|
||||||
|
|
||||||
if let Some(t) = &hf_token {
|
|
||||||
cmd.arg("-H").arg(format!("Authorization: Bearer {t}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.arg(&download_url);
|
|
||||||
cmd.stderr(std::process::Stdio::piped());
|
|
||||||
cmd.stdout(std::process::Stdio::null());
|
|
||||||
|
|
||||||
let mut child = match cmd.spawn() {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
store.push_line(&id, format!("✗ failed to start curl download: {e}"));
|
|
||||||
store.finish(&id, false);
|
|
||||||
store.drop_cancel(&id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let stderr = child.stderr.take();
|
|
||||||
let mut t_err = None;
|
|
||||||
if let Some(err_stream) = stderr {
|
|
||||||
let store_clone = store.clone();
|
|
||||||
let id_clone = id.clone();
|
|
||||||
t_err = Some(tokio::spawn(async move {
|
|
||||||
use tokio::io::AsyncReadExt;
|
|
||||||
let mut reader = err_stream;
|
|
||||||
let mut buf = [0u8; 256];
|
|
||||||
while let Ok(n) = reader.read(&mut buf).await {
|
|
||||||
if n == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let text = String::from_utf8_lossy(&buf[..n]);
|
|
||||||
for word in text.split_whitespace() {
|
|
||||||
if let Some(num_str) = word.strip_suffix('%') {
|
|
||||||
if let Ok(pct) = num_str.parse::<f32>() {
|
|
||||||
let frac = (pct / 100.0).clamp(0.0, 1.0);
|
|
||||||
store_clone.set_progress(&id_clone, frac, "downloading".into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
status_res = child.wait() => {
|
|
||||||
if let Some(t) = t_err { t.abort(); }
|
|
||||||
match status_res {
|
|
||||||
Ok(status) if status.success() => {
|
|
||||||
store.push_line(&id, "✓ done".into());
|
|
||||||
store.finish(&id, true);
|
|
||||||
}
|
|
||||||
Ok(status) => {
|
|
||||||
cleanup_output(&output_abs);
|
|
||||||
store.push_line(&id, format!("✗ download failed: exit code {:?}", status.code()));
|
|
||||||
store.finish(&id, false);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
cleanup_output(&output_abs);
|
|
||||||
store.push_line(&id, format!("✗ download process error: {e}"));
|
|
||||||
store.finish(&id, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = cancel.notified() => {
|
|
||||||
let _ = child.start_kill();
|
|
||||||
let _ = child.wait().await;
|
|
||||||
if let Some(t) = t_err { t.abort(); }
|
|
||||||
cleanup_output(&output_abs);
|
|
||||||
store.push_line(&id, "✗ cancelled by user — partial output removed".into());
|
|
||||||
store.set_state(&id, "cancelled");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
store.drop_cancel(&id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// into the job log. Fails fast if the converter script is missing.
|
/// into the job log. Fails fast if the converter script is missing.
|
||||||
pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Result<String, String> {
|
pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Result<String, String> {
|
||||||
if p.repo.trim().is_empty() {
|
if p.repo.trim().is_empty() {
|
||||||
@@ -463,30 +326,13 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
|||||||
.unwrap_or_else(|_| output.clone());
|
.unwrap_or_else(|_| output.clone());
|
||||||
|
|
||||||
let id = gen_id(&p.repo);
|
let id = gen_id(&p.repo);
|
||||||
let is_cmf = is_cmf_repo(&p.repo);
|
|
||||||
let quant_display = if p.quant.is_empty() || p.quant == "auto" {
|
|
||||||
if is_cmf {
|
|
||||||
"READY_CMF".to_string()
|
|
||||||
} else {
|
|
||||||
"q8".to_string()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
p.quant.clone()
|
|
||||||
};
|
|
||||||
store.insert(Job {
|
store.insert(Job {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
repo: p.repo.clone(),
|
repo: p.repo.clone(),
|
||||||
quant: quant_display.clone(),
|
quant: p.quant.clone(),
|
||||||
output: output_abs.clone(),
|
output: output_abs.clone(),
|
||||||
state: "running".into(),
|
state: "running".into(),
|
||||||
log: if is_cmf {
|
log: vec![format!("→ converting {} to {} ({})", p.repo, base, p.quant)],
|
||||||
vec![format!(
|
|
||||||
"→ downloading ready .cmf model {} ({})",
|
|
||||||
p.repo, quant_display
|
|
||||||
)]
|
|
||||||
} else {
|
|
||||||
vec![format!("→ converting {} to {} ({})", p.repo, base, p.quant)]
|
|
||||||
},
|
|
||||||
started: now(),
|
started: now(),
|
||||||
finished: None,
|
finished: None,
|
||||||
size_bytes: None,
|
size_bytes: None,
|
||||||
@@ -502,14 +348,6 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
|||||||
std::env::var(&cfg.hf_token_env).ok()
|
std::env::var(&cfg.hf_token_env).ok()
|
||||||
};
|
};
|
||||||
|
|
||||||
if is_cmf && !use_python {
|
|
||||||
let ret_id = id.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
download_cmf_repo(p.repo, output_abs, hf_token, store, id, cancel).await;
|
|
||||||
});
|
|
||||||
return Ok(ret_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Program + args: `cortiq convert` by default, or the Python converter for
|
// Program + args: `cortiq convert` by default, or the Python converter for
|
||||||
// advanced options. Both stream `@PROGRESS <frac>` markers into the log.
|
// advanced options. Both stream `@PROGRESS <frac>` markers into the log.
|
||||||
let (program, args, workdir): (String, Vec<String>, std::path::PathBuf) = if use_python {
|
let (program, args, workdir): (String, Vec<String>, std::path::PathBuf) = if use_python {
|
||||||
@@ -692,32 +530,3 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
|||||||
|
|
||||||
Ok(ret_id)
|
Ok(ret_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_map_quant_variants() {
|
|
||||||
assert_eq!(map_quant("Q8_2F"), "q8_2f");
|
|
||||||
assert_eq!(map_quant("q82f"), "q8_2f");
|
|
||||||
assert_eq!(map_quant("Q8_ROW"), "q8");
|
|
||||||
assert_eq!(map_quant("Q4_BLOCK"), "q4");
|
|
||||||
assert_eq!(map_quant("Q4_TILED"), "q4t");
|
|
||||||
assert_eq!(map_quant("q4t"), "q4t");
|
|
||||||
assert_eq!(map_quant("vbit"), "vbit");
|
|
||||||
assert_eq!(map_quant("q1"), "q1");
|
|
||||||
assert_eq!(map_quant("Q1P"), "q1p");
|
|
||||||
assert_eq!(map_quant("q1s"), "q1s");
|
|
||||||
assert_eq!(map_quant("Q1T"), "q1t");
|
|
||||||
assert_eq!(map_quant("F16"), "f16");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_is_cmf_repo_check() {
|
|
||||||
assert!(is_cmf_repo("infosave/Bonsai-8B_2bit_cmf"));
|
|
||||||
assert!(is_cmf_repo("infosave/Bonsai-1.7Bcmf"));
|
|
||||||
assert!(is_cmf_repo("user/model.cmf"));
|
|
||||||
assert!(!is_cmf_repo("Qwen/Qwen2.5-0.5B-Instruct"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -145,14 +145,6 @@ impl AppState {
|
|||||||
let live = Live::build(new_cfg.clone(), &self.secrets, self.router_status.clone())?;
|
let live = Live::build(new_cfg.clone(), &self.secrets, self.router_status.clone())?;
|
||||||
new_cfg.save(&self.config_path)?;
|
new_cfg.save(&self.config_path)?;
|
||||||
self.live.store(Arc::new(live));
|
self.live.store(Arc::new(live));
|
||||||
|
|
||||||
let cmf_rt = self.cmf.clone();
|
|
||||||
let cmf_cfg = new_cfg.cmf.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
cmf_rt.stop().await;
|
|
||||||
crate::cmf_runtime::manage(cmf_rt, cmf_cfg).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
-1
@@ -60,7 +60,8 @@ export const api = {
|
|||||||
listModels: () => j("GET", "/models"),
|
listModels: () => j("GET", "/models"),
|
||||||
createModel: (b) => j("POST", "/models", b),
|
createModel: (b) => j("POST", "/models", b),
|
||||||
updateModel: (id, b) => j("PUT", "/models/" + encodeURIComponent(id), b),
|
updateModel: (id, b) => j("PUT", "/models/" + encodeURIComponent(id), b),
|
||||||
deleteModel: (id) => j("DELETE", "/models/" + encodeURIComponent(id)),
|
deleteModel: (id, deleteFile = true) =>
|
||||||
|
j("DELETE", "/models/" + encodeURIComponent(id) + "?delete_file=" + (deleteFile ? "true" : "false")),
|
||||||
probeModel: (id) => j("POST", "/models/" + encodeURIComponent(id) + "/probe"),
|
probeModel: (id) => j("POST", "/models/" + encodeURIComponent(id) + "/probe"),
|
||||||
|
|
||||||
// routing
|
// routing
|
||||||
|
|||||||
Vendored
+3
-9
@@ -132,6 +132,8 @@ export default {
|
|||||||
"models.form.secretMissing": "not set",
|
"models.form.secretMissing": "not set",
|
||||||
"models.form.secretPlaceholder": "set or replace key (write-only)",
|
"models.form.secretPlaceholder": "set or replace key (write-only)",
|
||||||
"models.deleteConfirm": "Delete model \"{id}\"?",
|
"models.deleteConfirm": "Delete model \"{id}\"?",
|
||||||
|
"models.deleteFile": "also delete the model file from disk",
|
||||||
|
"models.deletedWithFile": "Deleted (file removed from disk)",
|
||||||
"models.form.idOrModel": "Enter an id or a model name",
|
"models.form.idOrModel": "Enter an id or a model name",
|
||||||
"models.form.apiKey": "API Key",
|
"models.form.apiKey": "API Key",
|
||||||
"models.form.apiKeyPlaceholder": "paste your API key",
|
"models.form.apiKeyPlaceholder": "paste your API key",
|
||||||
@@ -267,6 +269,7 @@ export default {
|
|||||||
"settings.cmf.checkPort": "Check port",
|
"settings.cmf.checkPort": "Check port",
|
||||||
"settings.cmf.portFree": "port is free",
|
"settings.cmf.portFree": "port is free",
|
||||||
"settings.cmf.portBusy": "port is in use",
|
"settings.cmf.portBusy": "port is in use",
|
||||||
|
"settings.cmf.portOwn": "in use by this gateway's current server — freed on restart, replacing is fine",
|
||||||
"settings.cmf.useSuggested": "use {port}",
|
"settings.cmf.useSuggested": "use {port}",
|
||||||
"settings.cmf.serving": "serving {model} on :{port}",
|
"settings.cmf.serving": "serving {model} on :{port}",
|
||||||
"settings.cmf.intro": "Run models locally with the cortiq engine — works even offline or when the router is down.",
|
"settings.cmf.intro": "Run models locally with the cortiq engine — works even offline or when the router is down.",
|
||||||
@@ -384,21 +387,12 @@ export default {
|
|||||||
"import.state.done": "done",
|
"import.state.done": "done",
|
||||||
"import.state.error": "error",
|
"import.state.error": "error",
|
||||||
"import.state.cancelled": "cancelled",
|
"import.state.cancelled": "cancelled",
|
||||||
"import.featuredTitle": "Featured Ready-to-Run .cmf Models",
|
|
||||||
"import.readyCmfBadge": "Ready .cmf",
|
|
||||||
"import.directDownload": "Download",
|
|
||||||
"import.q.q8_2f": "8-bit two-field (𝒲×θ) — recommended: best quality/size.",
|
"import.q.q8_2f": "8-bit two-field (𝒲×θ) — recommended: best quality/size.",
|
||||||
"import.q.q8_row": "8-bit per-row — simple, robust.",
|
"import.q.q8_row": "8-bit per-row — simple, robust.",
|
||||||
"import.q.q4": "4-bit block — smallest, lower quality.",
|
"import.q.q4": "4-bit block — smallest, lower quality.",
|
||||||
"import.q.q4_tiled": "4-bit tiled (Q4_TILED) — 1.66x faster on Metal GPU.",
|
|
||||||
"import.q.vbit": "Variable 3–8 bit — per-expert bit budgets.",
|
"import.q.vbit": "Variable 3–8 bit — per-expert bit budgets.",
|
||||||
"import.q.q1t": "1.58-bit ternary (Q1T) — BitNet b1.58 with sparse outliers.",
|
|
||||||
"import.q.q1s": "1-bit sparse (Q1S) — 1-bit base with holographic outliers.",
|
|
||||||
"import.q.q1p": "1-bit PTQ (Q1P) — 1-bit with error diffusion.",
|
|
||||||
"import.q.q1": "1-bit (Q1) — pure binary format.",
|
|
||||||
"import.q.f16": "16-bit — no quantization.",
|
"import.q.f16": "16-bit — no quantization.",
|
||||||
"import.q.f32": "32-bit — full precision (large file).",
|
"import.q.f32": "32-bit — full precision (large file).",
|
||||||
|
|
||||||
"import.adv.title": "Advanced settings",
|
"import.adv.title": "Advanced settings",
|
||||||
"import.adv.auto": "auto",
|
"import.adv.auto": "auto",
|
||||||
"import.adv.linearCore": "Linear core",
|
"import.adv.linearCore": "Linear core",
|
||||||
|
|||||||
Vendored
+3
-9
@@ -131,6 +131,8 @@ export default {
|
|||||||
"models.form.secretMissing": "не задан",
|
"models.form.secretMissing": "не задан",
|
||||||
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
|
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
|
||||||
"models.deleteConfirm": "Удалить модель \"{id}\"?",
|
"models.deleteConfirm": "Удалить модель \"{id}\"?",
|
||||||
|
"models.deleteFile": "также удалить файл модели с диска",
|
||||||
|
"models.deletedWithFile": "Удалено (файл стёрт с диска)",
|
||||||
"models.form.idOrModel": "Укажите id или имя модели",
|
"models.form.idOrModel": "Укажите id или имя модели",
|
||||||
"models.form.apiKey": "API-ключ",
|
"models.form.apiKey": "API-ключ",
|
||||||
"models.form.apiKeyPlaceholder": "вставьте API-ключ",
|
"models.form.apiKeyPlaceholder": "вставьте API-ключ",
|
||||||
@@ -266,6 +268,7 @@ export default {
|
|||||||
"settings.cmf.checkPort": "Проверить порт",
|
"settings.cmf.checkPort": "Проверить порт",
|
||||||
"settings.cmf.portFree": "порт свободен",
|
"settings.cmf.portFree": "порт свободен",
|
||||||
"settings.cmf.portBusy": "порт занят",
|
"settings.cmf.portBusy": "порт занят",
|
||||||
|
"settings.cmf.portOwn": "занят текущим сервером шлюза — освободится после перезапуска, замена допустима",
|
||||||
"settings.cmf.useSuggested": "занять {port}",
|
"settings.cmf.useSuggested": "занять {port}",
|
||||||
"settings.cmf.serving": "работает: {model} на :{port}",
|
"settings.cmf.serving": "работает: {model} на :{port}",
|
||||||
"settings.cmf.intro": "Запускайте модели локально движком cortiq — работает даже офлайн или когда роутер недоступен.",
|
"settings.cmf.intro": "Запускайте модели локально движком cortiq — работает даже офлайн или когда роутер недоступен.",
|
||||||
@@ -383,21 +386,12 @@ export default {
|
|||||||
"import.state.done": "готово",
|
"import.state.done": "готово",
|
||||||
"import.state.error": "ошибка",
|
"import.state.error": "ошибка",
|
||||||
"import.state.cancelled": "отменено",
|
"import.state.cancelled": "отменено",
|
||||||
"import.featuredTitle": "Рекомендуемые готовые .cmf модели",
|
|
||||||
"import.readyCmfBadge": "Готовая .cmf",
|
|
||||||
"import.directDownload": "Скачать",
|
|
||||||
"import.q.q8_2f": "8-бит двухполевой (𝒲×θ) — рекомендуется: лучшее качество/размер.",
|
"import.q.q8_2f": "8-бит двухполевой (𝒲×θ) — рекомендуется: лучшее качество/размер.",
|
||||||
"import.q.q8_row": "8-бит построчно — просто, надёжно.",
|
"import.q.q8_row": "8-бит построчно — просто, надёжно.",
|
||||||
"import.q.q4": "4-бит блочно — компактнее, ниже качество.",
|
"import.q.q4": "4-бит блочно — компактнее, ниже качество.",
|
||||||
"import.q.q4_tiled": "4-бит тайловый (Q4_TILED) — х1.66 быстрее на Metal GPU.",
|
|
||||||
"import.q.vbit": "Переменная 3–8 бит — per-expert бюджеты.",
|
"import.q.vbit": "Переменная 3–8 бит — per-expert бюджеты.",
|
||||||
"import.q.q1t": "1.58-бит тернарный (Q1T) — BitNet b1.58 с оутлайерами.",
|
|
||||||
"import.q.q1s": "1-бит спарс (Q1S) — 1-бит база с голографическими оутлайерами.",
|
|
||||||
"import.q.q1p": "1-бит PTQ (Q1P) — 1-бит с переносом ошибки (error diffusion).",
|
|
||||||
"import.q.q1": "1-бит (Q1) — чистый бинарный формат.",
|
|
||||||
"import.q.f16": "16-бит — без квантования.",
|
"import.q.f16": "16-бит — без квантования.",
|
||||||
"import.q.f32": "32-бит — полная точность (большой файл).",
|
"import.q.f32": "32-бит — полная точность (большой файл).",
|
||||||
|
|
||||||
"import.adv.title": "Расширенные настройки",
|
"import.adv.title": "Расширенные настройки",
|
||||||
"import.adv.auto": "авто",
|
"import.adv.auto": "авто",
|
||||||
"import.adv.linearCore": "Линейное ядро",
|
"import.adv.linearCore": "Линейное ядро",
|
||||||
|
|||||||
Vendored
-54
@@ -10,23 +10,11 @@ const QUANTS = [
|
|||||||
["Q8_2F", "import.q.q8_2f"],
|
["Q8_2F", "import.q.q8_2f"],
|
||||||
["Q8_ROW", "import.q.q8_row"],
|
["Q8_ROW", "import.q.q8_row"],
|
||||||
["Q4_BLOCK", "import.q.q4"],
|
["Q4_BLOCK", "import.q.q4"],
|
||||||
["Q4_TILED", "import.q.q4_tiled"],
|
|
||||||
["VBIT", "import.q.vbit"],
|
["VBIT", "import.q.vbit"],
|
||||||
["Q1T", "import.q.q1t"],
|
|
||||||
["Q1S", "import.q.q1s"],
|
|
||||||
["Q1P", "import.q.q1p"],
|
|
||||||
["Q1", "import.q.q1"],
|
|
||||||
["F16", "import.q.f16"],
|
["F16", "import.q.f16"],
|
||||||
["F32", "import.q.f32"],
|
["F32", "import.q.f32"],
|
||||||
];
|
];
|
||||||
|
|
||||||
const FEATURED_REPOS = [
|
|
||||||
{ id: "infosave/Bonsai-1.7Bcmf", label: "Bonsai 1.7B CMF", badge: "1.7B · Q1", quant: "Q1" },
|
|
||||||
{ id: "infosave/Bonsai-8B_2bit_cmf", label: "Bonsai 8B 2-bit CMF", badge: "8B · Q1T", quant: "Q1T" },
|
|
||||||
{ id: "infosave/Bonsai-27Bcmf", label: "Bonsai 27B CMF", badge: "27B · Q1", quant: "Q1" },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
function field(label, control, hint) {
|
function field(label, control, hint) {
|
||||||
return h("label", { class: "field" }, h("span", {}, label), control,
|
return h("label", { class: "field" }, h("span", {}, label), control,
|
||||||
hint ? h("div", { class: "hint" }, hint) : null);
|
hint ? h("div", { class: "hint" }, hint) : null);
|
||||||
@@ -370,46 +358,6 @@ export function renderImport() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function featuredCard(item) {
|
|
||||||
const card = h(
|
|
||||||
"div",
|
|
||||||
{ class: "hf-card featured-card" },
|
|
||||||
h("div", { class: "row", style: "align-items:center; justify-content:space-between;" },
|
|
||||||
h("div", {},
|
|
||||||
h("div", { class: "hf-id" }, item.label),
|
|
||||||
h("div", { class: "hf-meta" },
|
|
||||||
h("span", { class: "badge" }, item.badge),
|
|
||||||
h("span", { class: "muted" }, item.id)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
h("button", { class: "btn primary sm" }, "⬇ " + t("import.directDownload"))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
card.querySelector("button").addEventListener("click", async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
try {
|
|
||||||
await api.startImport({
|
|
||||||
repo: item.id,
|
|
||||||
quant: item.quant || "READY_CMF",
|
|
||||||
name: item.id.split("/").pop().toLowerCase(),
|
|
||||||
});
|
|
||||||
toast(t("import.started"), "ok");
|
|
||||||
refreshJobs();
|
|
||||||
jobsHost.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
||||||
} catch (err) {
|
|
||||||
toast(err.message, "err");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
const featuredHost = h(
|
|
||||||
"div",
|
|
||||||
{ class: "card featured-section" },
|
|
||||||
h("div", { class: "card-title" }, "⚡ " + t("import.featuredTitle")),
|
|
||||||
h("div", { class: "hf-grid" }, ...FEATURED_REPOS.map(featuredCard))
|
|
||||||
);
|
|
||||||
|
|
||||||
const root = h(
|
const root = h(
|
||||||
"div",
|
"div",
|
||||||
{},
|
{},
|
||||||
@@ -417,7 +365,6 @@ export function renderImport() {
|
|||||||
h("h2", {}, t("nav.import")),
|
h("h2", {}, t("nav.import")),
|
||||||
h("div", { class: "muted" }, t("import.subtitle"))),
|
h("div", { class: "muted" }, t("import.subtitle"))),
|
||||||
jobsHost, // progress panel on top, above the search
|
jobsHost, // progress panel on top, above the search
|
||||||
featuredHost, // featured ready-to-run models
|
|
||||||
h("div", { class: "card" }, searchIn, results),
|
h("div", { class: "card" }, searchIn, results),
|
||||||
formHost
|
formHost
|
||||||
);
|
);
|
||||||
@@ -425,4 +372,3 @@ export function renderImport() {
|
|||||||
refreshJobs(); // resume: show any running/recent conversions immediately
|
refreshJobs(); // resume: show any running/recent conversions immediately
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+26
-10
@@ -1,5 +1,5 @@
|
|||||||
// Models — pool table with add/edit modal, probe, and per-model secret entry.
|
// Models — pool table with add/edit modal, probe, and per-model secret entry.
|
||||||
import { h, mount, modal, toast, confirmDialog, money } from "../ui.js";
|
import { h, mount, modal, toast, money } from "../ui.js";
|
||||||
import { t } from "../i18n.js";
|
import { t } from "../i18n.js";
|
||||||
import { api } from "../api.js";
|
import { api } from "../api.js";
|
||||||
import { appState } from "../app.js";
|
import { appState } from "../app.js";
|
||||||
@@ -280,15 +280,31 @@ function row(m, meta, reload) {
|
|||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
class: "btn sm danger",
|
class: "btn sm danger",
|
||||||
onClick: async () => {
|
onClick: () => {
|
||||||
if (!(await confirmDialog(t("models.deleteConfirm", { id: m.id })))) return;
|
// local models may have a .cmf on disk — let the user decide its fate
|
||||||
try {
|
const isLocal = m.managed || /127\.0\.0\.1|localhost/.test(m.base_url || "");
|
||||||
await api.deleteModel(m.id);
|
const fileCb = h("input", { type: "checkbox", checked: isLocal ? true : null });
|
||||||
toast(t("toast.deleted"), "good");
|
const body = h(
|
||||||
await reload();
|
"div",
|
||||||
} catch (e) {
|
{},
|
||||||
toast(String(e.message || e), "bad");
|
h("p", {}, t("models.deleteConfirm", { id: m.id })),
|
||||||
}
|
isLocal
|
||||||
|
? h("label", { class: "check" }, fileCb, t("models.deleteFile"))
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
modal(t("common.delete") + ": " + m.id, body, async () => {
|
||||||
|
try {
|
||||||
|
const r = await api.deleteModel(m.id, isLocal && fileCb.checked);
|
||||||
|
toast(
|
||||||
|
r.file_removed ? t("models.deletedWithFile") : t("toast.deleted"),
|
||||||
|
"good"
|
||||||
|
);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
toast(String(e.message || e), "bad");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, t("common.delete"));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
t("common.delete")
|
t("common.delete")
|
||||||
|
|||||||
Vendored
+5
-1
@@ -245,7 +245,11 @@ export async function renderSettings() {
|
|||||||
try {
|
try {
|
||||||
const r = await api.cmfPortCheck(p);
|
const r = await api.cmfPortCheck(p);
|
||||||
if (r.available) mount(st, h("span", { style: "color:var(--good)" }, "✓ " + t("settings.cmf.portFree")));
|
if (r.available) mount(st, h("span", { style: "color:var(--good)" }, "✓ " + t("settings.cmf.portFree")));
|
||||||
else {
|
else if (r.managed) {
|
||||||
|
// held by this gateway's own managed server — replacing the model on
|
||||||
|
// this port is fine, it frees up on restart
|
||||||
|
mount(st, h("span", { style: "color:var(--accent)" }, "ℹ " + t("settings.cmf.portOwn")));
|
||||||
|
} else {
|
||||||
const kids = [h("span", { style: "color:var(--bad)" }, "✗ " + t("settings.cmf.portBusy"))];
|
const kids = [h("span", { style: "color:var(--bad)" }, "✗ " + t("settings.cmf.portBusy"))];
|
||||||
if (r.suggested) { const b = h("button", { class: "btn small", type: "button" }, t("settings.cmf.useSuggested", { port: r.suggested })); b.addEventListener("click", () => { portIn.value = r.suggested; mount(st, ""); }); kids.push(b); }
|
if (r.suggested) { const b = h("button", { class: "btn small", type: "button" }, t("settings.cmf.useSuggested", { port: r.suggested })); b.addEventListener("click", () => { portIn.value = r.suggested; mount(st, ""); }); kids.push(b); }
|
||||||
mount(st, h("span", {}, ...kids));
|
mount(st, h("span", {}, ...kids));
|
||||||
|
|||||||
Reference in New Issue
Block a user