Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fa1b8ea22 | ||
|
|
357441e0da | ||
|
|
9d2181e82b | ||
|
|
30378f7b6a | ||
|
|
b4f78bd192 |
Generated
+1
-1
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cortiq-gateway"
|
name = "cortiq-gateway"
|
||||||
version = "0.2.6"
|
version = "0.2.9"
|
||||||
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.6"
|
version = "0.2.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+188
-29
@@ -169,12 +169,16 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
|
|||||||
.route("/admin/api/shadow", get(get_shadow))
|
.route("/admin/api/shadow", get(get_shadow))
|
||||||
.route("/admin/api/hf/search", get(hf_search))
|
.route("/admin/api/hf/search", get(hf_search))
|
||||||
.route("/admin/api/import", get(list_imports).post(start_import))
|
.route("/admin/api/import", get(list_imports).post(start_import))
|
||||||
.route("/admin/api/import/:job", get(import_status).delete(delete_import))
|
.route(
|
||||||
|
"/admin/api/import/:job",
|
||||||
|
get(import_status).delete(delete_import),
|
||||||
|
)
|
||||||
.route("/admin/api/import/:job/cancel", post(cancel_import))
|
.route("/admin/api/import/:job/cancel", post(cancel_import))
|
||||||
.route("/admin/api/import/:job/register", post(register_import))
|
.route("/admin/api/import/:job/register", post(register_import))
|
||||||
.route("/admin/api/cmf", get(cmf_status))
|
.route("/admin/api/cmf", get(cmf_status))
|
||||||
.route("/admin/api/cmf/install", post(cmf_install))
|
.route("/admin/api/cmf/install", post(cmf_install))
|
||||||
.route("/admin/api/cmf/port", get(cmf_port_check))
|
.route("/admin/api/cmf/port", get(cmf_port_check))
|
||||||
|
.route("/admin/api/cmf/files", get(cmf_files))
|
||||||
.route("/admin/api/requests", get(get_requests))
|
.route("/admin/api/requests", get(get_requests))
|
||||||
.route("/admin/api/test", post(run_test))
|
.route("/admin/api/test", post(run_test))
|
||||||
.route("/admin/api/test/stream", post(run_test_stream))
|
.route("/admin/api/test/stream", post(run_test_stream))
|
||||||
@@ -423,7 +427,7 @@ async fn put_config(State(state): State<SharedState>, Json(cfg): Json<Config>) -
|
|||||||
|
|
||||||
async fn list_models(State(state): State<SharedState>) -> ApiResult {
|
async fn list_models(State(state): State<SharedState>) -> ApiResult {
|
||||||
let live = state.live();
|
let live = state.live();
|
||||||
let models: Vec<Value> = live
|
let mut models: Vec<Value> = live
|
||||||
.cfg
|
.cfg
|
||||||
.models
|
.models
|
||||||
.iter()
|
.iter()
|
||||||
@@ -437,6 +441,31 @@ async fn list_models(State(state): State<SharedState>) -> ApiResult {
|
|||||||
v
|
v
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Managed local CMF servers are registered in the routing registry from the
|
||||||
|
// [cmf] config, not from [[models]]. Surface each here (marked managed, so
|
||||||
|
// the UI shows it read-only) so the real running models are visible and
|
||||||
|
// pinnable without a hand-maintained static pool entry. A fresh install with
|
||||||
|
// no managed model still returns an empty list.
|
||||||
|
let cmf = &live.cfg.cmf;
|
||||||
|
for s in cmf.effective_servers() {
|
||||||
|
if models.iter().any(|m| m["id"] == json!(s.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
models.push(json!({
|
||||||
|
"id": s.id,
|
||||||
|
"provider": "openai",
|
||||||
|
"base_url": format!("http://{}:{}/v1", cmf.local_host, s.port),
|
||||||
|
"model": "cortiq",
|
||||||
|
"cost_tier": "local",
|
||||||
|
"price_in": 0.0,
|
||||||
|
"price_out": 0.0,
|
||||||
|
"kind": "chat",
|
||||||
|
"caps": [],
|
||||||
|
"key_source": "none",
|
||||||
|
"managed": true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
ok(json!({ "models": models }))
|
ok(json!({ "models": models }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,15 +499,109 @@ async fn update_model(
|
|||||||
ok(json!({ "ok": true }))
|
ok(json!({ "ok": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove a `.cmf` path — it may be a plain file or a tensor-dir. Returns whether
|
||||||
|
/// something was actually removed.
|
||||||
|
fn remove_cmf_path(path: &std::path::Path) -> bool {
|
||||||
|
if path.is_dir() {
|
||||||
|
std::fs::remove_dir_all(path).is_ok()
|
||||||
|
} else if path.is_file() {
|
||||||
|
std::fs::remove_file(path).is_ok()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local `.cmf` backing a pool model, if it is a local CMF model whose file
|
||||||
|
/// lives under `models_dir`. Cloud models (real remote base_url) return None.
|
||||||
|
/// Registered imports store `model` = the file stem under models_dir.
|
||||||
|
fn cmf_file_for_model(
|
||||||
|
cmf: &crate::config::CmfCfg,
|
||||||
|
m: &crate::config::ModelCfg,
|
||||||
|
) -> Option<std::path::PathBuf> {
|
||||||
|
let local = m.base_url == cmf.cortiq_server_url
|
||||||
|
|| m.base_url
|
||||||
|
.contains(&format!("{}:{}", cmf.local_host, cmf.local_port));
|
||||||
|
let stem = m.model.trim();
|
||||||
|
if !local || stem.is_empty() || stem.contains('/') || stem.contains("..") {
|
||||||
|
return None; // not local, or an unsafe/underivable path
|
||||||
|
}
|
||||||
|
Some(std::path::Path::new(&cmf.models_dir).join(format!("{stem}.cmf")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a model id from routing (default + every tier) so `validate()` still
|
||||||
|
/// passes after the model is gone.
|
||||||
|
fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) {
|
||||||
|
if routing.default == id {
|
||||||
|
routing.default = String::new();
|
||||||
|
}
|
||||||
|
for tt in routing.tiers.values_mut() {
|
||||||
|
match tt {
|
||||||
|
crate::config::TierTargets::List(v) => v.retain(|x| x != id),
|
||||||
|
crate::config::TierTargets::One(s) if s == id => {
|
||||||
|
*tt = crate::config::TierTargets::List(Vec::new());
|
||||||
|
}
|
||||||
|
crate::config::TierTargets::One(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any managed server or static model in `cfg` still uses `path` as its
|
||||||
|
/// local `.cmf`. Guards against deleting a file that another model shares.
|
||||||
|
fn cmf_path_referenced(cfg: &crate::config::Config, path: &std::path::Path) -> bool {
|
||||||
|
cfg.cmf
|
||||||
|
.effective_servers()
|
||||||
|
.iter()
|
||||||
|
.any(|s| std::path::Path::new(&s.model) == path)
|
||||||
|
|| cfg
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.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 {
|
async fn delete_model(State(state): State<SharedState>, Path(id): Path<String>) -> ApiResult {
|
||||||
let mut cfg = current_cfg(&state);
|
let mut cfg = current_cfg(&state);
|
||||||
|
|
||||||
|
// Managed local models live in [cmf], not [[models]]. Deleting one stops its
|
||||||
|
// server, removes it from the managed config, and deletes its file — unless
|
||||||
|
// another model still points at the same file.
|
||||||
|
let managed = cfg.cmf.effective_servers().into_iter().find(|s| s.id == id);
|
||||||
|
if let Some(server) = managed {
|
||||||
|
if !cfg.models.iter().any(|x| x.id == id) {
|
||||||
|
if cfg.cmf.servers.iter().any(|s| s.id == id) {
|
||||||
|
cfg.cmf.servers.retain(|s| s.id != id); // multi-model list entry
|
||||||
|
} else {
|
||||||
|
cfg.cmf.local_model = String::new(); // legacy single model
|
||||||
|
cfg.cmf.manage_server = false;
|
||||||
|
}
|
||||||
|
strip_id_from_routing(&mut cfg.routing, &id);
|
||||||
|
let path = std::path::PathBuf::from(&server.model);
|
||||||
|
let shared = cmf_path_referenced(&cfg, &path);
|
||||||
|
state.cmf.stop_one(&id).await;
|
||||||
|
state.reload(cfg)?;
|
||||||
|
let file_removed = if shared {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
remove_cmf_path(&path)
|
||||||
|
};
|
||||||
|
return ok(json!({ "ok": true, "file_removed": file_removed }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let victim = cfg.models.iter().find(|x| x.id == id).cloned();
|
||||||
let before = cfg.models.len();
|
let before = cfg.models.len();
|
||||||
cfg.models.retain(|x| x.id != id);
|
cfg.models.retain(|x| x.id != id);
|
||||||
if cfg.models.len() == before {
|
if cfg.models.len() == before {
|
||||||
return Err(ApiError::not_found(format!("model '{id}' not found")));
|
return Err(ApiError::not_found(format!("model '{id}' not found")));
|
||||||
}
|
}
|
||||||
|
strip_id_from_routing(&mut cfg.routing, &id);
|
||||||
|
let file = victim
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| cmf_file_for_model(&cfg.cmf, m));
|
||||||
|
// Only remove the file if no remaining model still references it.
|
||||||
|
let file = file.filter(|p| !cmf_path_referenced(&cfg, p));
|
||||||
state.reload(cfg)?;
|
state.reload(cfg)?;
|
||||||
ok(json!({ "ok": true }))
|
let file_removed = file.map(|p| remove_cmf_path(&p)).unwrap_or(false);
|
||||||
|
ok(json!({ "ok": true, "file_removed": file_removed }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn probe_model(State(state): State<SharedState>, Path(id): Path<String>) -> ApiResult {
|
async fn probe_model(State(state): State<SharedState>, Path(id): Path<String>) -> ApiResult {
|
||||||
@@ -666,6 +789,7 @@ async fn put_settings(State(state): State<SharedState>, Json(b): Json<SettingsBo
|
|||||||
|| v.local_model != cfg.cmf.local_model
|
|| v.local_model != cfg.cmf.local_model
|
||||||
|| v.threads != cfg.cmf.threads
|
|| v.threads != cfg.cmf.threads
|
||||||
|| v.gpu != cfg.cmf.gpu
|
|| v.gpu != cfg.cmf.gpu
|
||||||
|
|| v.servers != cfg.cmf.servers
|
||||||
{
|
{
|
||||||
needs_restart = true;
|
needs_restart = true;
|
||||||
}
|
}
|
||||||
@@ -683,19 +807,32 @@ async fn cmf_status(State(state): State<SharedState>) -> ApiResult {
|
|||||||
let cmf = state.live().cfg.cmf.clone();
|
let cmf = state.live().cfg.cmf.clone();
|
||||||
let ver = crate::cmf_runtime::installed_version(&cmf.cortiq_bin);
|
let ver = crate::cmf_runtime::installed_version(&cmf.cortiq_bin);
|
||||||
let st = state.cmf.status();
|
let st = state.cmf.status();
|
||||||
|
// Each configured managed server, merged with its live runtime status.
|
||||||
|
let servers: Vec<Value> = cmf
|
||||||
|
.effective_servers()
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let live = st.servers.iter().find(|x| x.id == s.id);
|
||||||
|
json!({
|
||||||
|
"id": s.id,
|
||||||
|
"model": s.model,
|
||||||
|
"port": s.port,
|
||||||
|
"threads": s.threads,
|
||||||
|
"gpu": s.gpu,
|
||||||
|
"running": live.map(|l| l.running).unwrap_or(false),
|
||||||
|
"healthy": live.map(|l| l.healthy).unwrap_or(false),
|
||||||
|
"last_error": live.and_then(|l| l.last_error.clone()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
ok(json!({
|
ok(json!({
|
||||||
"installed": ver.is_some(),
|
"installed": ver.is_some(),
|
||||||
"version": ver,
|
"version": ver,
|
||||||
"running": st.running,
|
"latest": st.latest_version,
|
||||||
"healthy": st.healthy,
|
|
||||||
"last_error": st.last_error,
|
|
||||||
"log": st.log,
|
|
||||||
// serving info so the card can show "serving <model> on :<port>"
|
|
||||||
"manage_server": cmf.manage_server,
|
"manage_server": cmf.manage_server,
|
||||||
"local_model": cmf.local_model,
|
|
||||||
"model_id": cmf.model_id,
|
|
||||||
"local_host": cmf.local_host,
|
"local_host": cmf.local_host,
|
||||||
"local_port": cmf.local_port,
|
"servers": servers,
|
||||||
|
"log": st.log,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,6 +844,36 @@ async fn cmf_install(State(state): State<SharedState>) -> ApiResult {
|
|||||||
ok(json!({ "started": true }))
|
ok(json!({ "started": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List the `.cmf` model files available under `models_dir` so the UI can offer
|
||||||
|
/// them as a dropdown instead of a hand-typed path. Includes tensor-dir `.cmf`.
|
||||||
|
async fn cmf_files(State(state): State<SharedState>) -> ApiResult {
|
||||||
|
let dir = state.live().cfg.cmf.models_dir.clone();
|
||||||
|
let mut entries: Vec<(String, String, Option<u64>, bool)> = Vec::new();
|
||||||
|
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||||
|
for e in rd.flatten() {
|
||||||
|
let name = e.file_name().to_string_lossy().to_string();
|
||||||
|
if !name.ends_with(".cmf") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let p = e.path();
|
||||||
|
let is_dir = p.is_dir();
|
||||||
|
let size = if is_dir {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
std::fs::metadata(&p).ok().map(|m| m.len())
|
||||||
|
};
|
||||||
|
let path = format!("{}/{}", dir.trim_end_matches('/'), name);
|
||||||
|
entries.push((name, path, size, is_dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
let files: Vec<Value> = entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, path, size, is_dir)| json!({ "name": name, "path": path, "size": size, "is_dir": is_dir }))
|
||||||
|
.collect();
|
||||||
|
ok(json!({ "dir": dir, "files": files }))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct PortQuery {
|
struct PortQuery {
|
||||||
port: u16,
|
port: u16,
|
||||||
@@ -726,7 +893,7 @@ async fn cmf_port_check(State(state): State<SharedState>, Query(q): Query<PortQu
|
|||||||
};
|
};
|
||||||
let st = state.cmf.status();
|
let st = state.cmf.status();
|
||||||
let port = q.port;
|
let port = q.port;
|
||||||
let running_here = port == cmf.local_port && st.running;
|
let running_here = st.servers.iter().any(|s| s.port == port && s.running);
|
||||||
|
|
||||||
// The bind+connect probe is blocking std::net; run it off the async runtime.
|
// The bind+connect probe is blocking std::net; run it off the async runtime.
|
||||||
let (available, detail, suggested) = tokio::task::spawn_blocking(move || {
|
let (available, detail, suggested) = tokio::task::spawn_blocking(move || {
|
||||||
@@ -736,7 +903,11 @@ async fn cmf_port_check(State(state): State<SharedState>, Query(q): Query<PortQu
|
|||||||
if port_is_free(&host, port) {
|
if port_is_free(&host, port) {
|
||||||
(true, "free", None)
|
(true, "free", None)
|
||||||
} else {
|
} else {
|
||||||
(false, "occupied by another process", next_free_port(&host, port))
|
(
|
||||||
|
false,
|
||||||
|
"occupied by another process",
|
||||||
|
next_free_port(&host, port),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -974,10 +1145,7 @@ async fn start_import(
|
|||||||
ok(json!({ "job": id }))
|
ok(json!({ "job": id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn import_status(
|
async fn import_status(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
|
||||||
State(state): State<SharedState>,
|
|
||||||
Path(job): Path<String>,
|
|
||||||
) -> ApiResult {
|
|
||||||
match state.imports.get(&job) {
|
match state.imports.get(&job) {
|
||||||
Some(j) => ok(serde_json::to_value(j).unwrap_or_else(|_| json!({}))),
|
Some(j) => ok(serde_json::to_value(j).unwrap_or_else(|_| json!({}))),
|
||||||
None => Err(ApiError::not_found(format!("job '{job}' not found"))),
|
None => Err(ApiError::not_found(format!("job '{job}' not found"))),
|
||||||
@@ -985,10 +1153,7 @@ async fn import_status(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel a running conversion: kills the converter and removes partial output.
|
/// Cancel a running conversion: kills the converter and removes partial output.
|
||||||
async fn cancel_import(
|
async fn cancel_import(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
|
||||||
State(state): State<SharedState>,
|
|
||||||
Path(job): Path<String>,
|
|
||||||
) -> ApiResult {
|
|
||||||
if state.imports.get(&job).is_none() {
|
if state.imports.get(&job).is_none() {
|
||||||
return Err(ApiError::not_found(format!("job '{job}' not found")));
|
return Err(ApiError::not_found(format!("job '{job}' not found")));
|
||||||
}
|
}
|
||||||
@@ -997,10 +1162,7 @@ async fn cancel_import(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a finished conversion and its converted `.cmf` file(s) from disk.
|
/// Delete a finished conversion and its converted `.cmf` file(s) from disk.
|
||||||
async fn delete_import(
|
async fn delete_import(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
|
||||||
State(state): State<SharedState>,
|
|
||||||
Path(job): Path<String>,
|
|
||||||
) -> ApiResult {
|
|
||||||
if state.imports.get(&job).is_none() {
|
if state.imports.get(&job).is_none() {
|
||||||
return Err(ApiError::not_found(format!("job '{job}' not found")));
|
return Err(ApiError::not_found(format!("job '{job}' not found")));
|
||||||
}
|
}
|
||||||
@@ -1009,10 +1171,7 @@ async fn delete_import(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Register a finished `.cmf` as a local model (OpenAI provider → cortiq-server).
|
/// Register a finished `.cmf` as a local model (OpenAI provider → cortiq-server).
|
||||||
async fn register_import(
|
async fn register_import(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
|
||||||
State(state): State<SharedState>,
|
|
||||||
Path(job): Path<String>,
|
|
||||||
) -> ApiResult {
|
|
||||||
let j = state
|
let j = state
|
||||||
.imports
|
.imports
|
||||||
.get(&job)
|
.get(&job)
|
||||||
|
|||||||
+205
-84
@@ -1,51 +1,68 @@
|
|||||||
//! Local CMF model server lifecycle.
|
//! Local CMF model server lifecycle.
|
||||||
//!
|
//!
|
||||||
//! Closes the loop with <https://github.com/infosave2007/cmf>: install the
|
//! Closes the loop with <https://github.com/infosave2007/cmf>: install the
|
||||||
//! `cortiq` CLI from crates.io, keep it up to date, and run `cortiq serve` as a
|
//! `cortiq` CLI from crates.io, keep it up to date, and run one `cortiq serve`
|
||||||
//! managed child process. The served model exposes an OpenAI-compatible API, so
|
//! per configured local model as a managed child process. Each served model
|
||||||
//! the registry registers it as an ordinary provider and the gateway routes to
|
//! exposes an OpenAI-compatible API, so the registry registers it as an ordinary
|
||||||
//! it like any other backend.
|
//! provider and the gateway routes to it like any other backend.
|
||||||
|
|
||||||
use crate::config::CmfCfg;
|
use crate::config::{CmfCfg, CmfServer};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
|
|
||||||
/// Observable state of the managed local CMF server (surfaced to the admin API).
|
/// Observable state of one managed local model server.
|
||||||
|
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||||
|
pub struct CmfServerStatus {
|
||||||
|
/// Pool id it is registered under.
|
||||||
|
pub id: String,
|
||||||
|
/// Path of the `.cmf` being served.
|
||||||
|
pub model: String,
|
||||||
|
/// Port the server binds to.
|
||||||
|
pub port: u16,
|
||||||
|
/// OpenAI base URL the local server is registered under.
|
||||||
|
pub base_url: String,
|
||||||
|
/// The child `cortiq serve` process has been spawned.
|
||||||
|
pub running: bool,
|
||||||
|
/// `/healthz` on the local server answered 200.
|
||||||
|
pub healthy: bool,
|
||||||
|
/// Last fatal error for this server, if any.
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observable state of the managed local CMF layer (surfaced to the admin API).
|
||||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||||
pub struct CmfStatus {
|
pub struct CmfStatus {
|
||||||
/// `manage_server` is on and a `local_model` is configured.
|
/// `manage_server` is on and at least one local model is configured.
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Version reported by `cortiq --version` (None = not installed).
|
/// Version reported by `cortiq --version` (None = not installed).
|
||||||
pub installed_version: Option<String>,
|
pub installed_version: Option<String>,
|
||||||
/// Newest `cortiq-cli` on crates.io (populated when `auto_update` runs).
|
/// Newest `cortiq-cli` on crates.io (populated when `auto_update` runs).
|
||||||
pub latest_version: Option<String>,
|
pub latest_version: Option<String>,
|
||||||
/// The child `cortiq serve` process has been spawned.
|
/// Per-model server status.
|
||||||
pub running: bool,
|
pub servers: Vec<CmfServerStatus>,
|
||||||
/// `/healthz` on the local server answered 200.
|
|
||||||
pub healthy: bool,
|
|
||||||
/// OpenAI base URL the local server is registered under.
|
|
||||||
pub base_url: String,
|
|
||||||
/// Path of the `.cmf` being served.
|
|
||||||
pub model: String,
|
|
||||||
/// Last fatal error, if any.
|
|
||||||
pub last_error: Option<String>,
|
|
||||||
/// Recent lifecycle log lines (bounded).
|
/// Recent lifecycle log lines (bounded).
|
||||||
pub log: Vec<String>,
|
pub log: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Holds the managed child process and its live status.
|
/// A spawned server: its pool id and the child process handle.
|
||||||
|
struct Managed {
|
||||||
|
id: String,
|
||||||
|
child: Child,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the managed child processes and their live status.
|
||||||
pub struct CmfRuntime {
|
pub struct CmfRuntime {
|
||||||
status: Mutex<CmfStatus>,
|
status: Mutex<CmfStatus>,
|
||||||
child: Mutex<Option<Child>>,
|
children: Mutex<Vec<Managed>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CmfRuntime {
|
impl CmfRuntime {
|
||||||
pub fn new() -> Arc<Self> {
|
pub fn new() -> Arc<Self> {
|
||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
status: Mutex::new(CmfStatus::default()),
|
status: Mutex::new(CmfStatus::default()),
|
||||||
child: Mutex::new(None),
|
children: Mutex::new(Vec::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,32 +76,115 @@ impl CmfRuntime {
|
|||||||
let mut s = self.status.lock().unwrap();
|
let mut s = self.status.lock().unwrap();
|
||||||
s.log.push(msg);
|
s.log.push(msg);
|
||||||
let n = s.log.len();
|
let n = s.log.len();
|
||||||
if n > 50 {
|
if n > 80 {
|
||||||
s.log.drain(0..n - 50);
|
s.log.drain(0..n - 80);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A global (install/update) failure — not tied to a single server.
|
||||||
fn fail(&self, msg: impl Into<String>) {
|
fn fail(&self, msg: impl Into<String>) {
|
||||||
let msg = msg.into();
|
let msg = msg.into();
|
||||||
tracing::warn!(target: "cmf", "{msg}");
|
tracing::warn!(target: "cmf", "{msg}");
|
||||||
|
self.status.lock().unwrap().log.push(format!("✗ {msg}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a fatal error against a specific server (and the log).
|
||||||
|
fn fail_server(&self, id: &str, msg: impl Into<String>) {
|
||||||
|
let msg = msg.into();
|
||||||
|
tracing::warn!(target: "cmf", "[{id}] {msg}");
|
||||||
let mut s = self.status.lock().unwrap();
|
let mut s = self.status.lock().unwrap();
|
||||||
s.last_error = Some(msg.clone());
|
if let Some(sv) = s.servers.iter_mut().find(|x| x.id == id) {
|
||||||
s.log.push(format!("✗ {msg}"));
|
sv.last_error = Some(msg.clone());
|
||||||
|
}
|
||||||
|
s.log.push(format!("✗ [{id}] {msg}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_child(&self, child: Child) {
|
/// Seed the per-server status slots from the effective server list.
|
||||||
*self.child.lock().unwrap() = Some(child);
|
fn init_servers(&self, servers: &[CmfServer], host: &str) {
|
||||||
self.status.lock().unwrap().running = true;
|
let mut s = self.status.lock().unwrap();
|
||||||
|
s.enabled = true;
|
||||||
|
s.servers = servers
|
||||||
|
.iter()
|
||||||
|
.map(|sv| CmfServerStatus {
|
||||||
|
id: sv.id.clone(),
|
||||||
|
model: sv.model.clone(),
|
||||||
|
port: sv.port,
|
||||||
|
base_url: format!("http://{host}:{}/v1", sv.port),
|
||||||
|
running: false,
|
||||||
|
healthy: false,
|
||||||
|
last_error: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the managed server (used on shutdown / reconfigure).
|
fn set_running(&self, id: &str, running: bool) {
|
||||||
|
if let Some(sv) = self
|
||||||
|
.status
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.servers
|
||||||
|
.iter_mut()
|
||||||
|
.find(|x| x.id == id)
|
||||||
|
{
|
||||||
|
sv.running = running;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_healthy(&self, id: &str, healthy: bool) {
|
||||||
|
if let Some(sv) = self
|
||||||
|
.status
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.servers
|
||||||
|
.iter_mut()
|
||||||
|
.find(|x| x.id == id)
|
||||||
|
{
|
||||||
|
sv.healthy = healthy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_child(&self, id: String, child: Child) {
|
||||||
|
self.children.lock().unwrap().push(Managed {
|
||||||
|
id: id.clone(),
|
||||||
|
child,
|
||||||
|
});
|
||||||
|
self.set_running(&id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_running(&self, id: &str) -> bool {
|
||||||
|
self.status
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.servers
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.id == id && s.running)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop all managed servers (used on shutdown / reconfigure).
|
||||||
pub async fn stop(&self) {
|
pub async fn stop(&self) {
|
||||||
if let Some(mut child) = self.child.lock().unwrap().take() {
|
let mut kids = std::mem::take(&mut *self.children.lock().unwrap());
|
||||||
let _ = child.start_kill();
|
for m in kids.iter_mut() {
|
||||||
|
let _ = m.child.start_kill();
|
||||||
}
|
}
|
||||||
let mut s = self.status.lock().unwrap();
|
let mut s = self.status.lock().unwrap();
|
||||||
s.running = false;
|
for sv in s.servers.iter_mut() {
|
||||||
s.healthy = false;
|
sv.running = false;
|
||||||
|
sv.healthy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop and forget a single managed server (used when a model is deleted).
|
||||||
|
pub async fn stop_one(&self, id: &str) {
|
||||||
|
let removed = {
|
||||||
|
let mut kids = self.children.lock().unwrap();
|
||||||
|
kids.iter()
|
||||||
|
.position(|m| m.id == id)
|
||||||
|
.map(|pos| kids.remove(pos))
|
||||||
|
};
|
||||||
|
if let Some(mut m) = removed {
|
||||||
|
let _ = m.child.start_kill();
|
||||||
|
}
|
||||||
|
self.status.lock().unwrap().servers.retain(|sv| sv.id != id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +192,7 @@ impl Default for CmfRuntime {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
status: Mutex::new(CmfStatus::default()),
|
status: Mutex::new(CmfStatus::default()),
|
||||||
child: Mutex::new(None),
|
children: Mutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,14 +213,15 @@ async fn latest_crates_version() -> Option<String> {
|
|||||||
.await
|
.await
|
||||||
.ok()?;
|
.ok()?;
|
||||||
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
|
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
|
||||||
v["crate"]["newest_version"]
|
v["crate"]["newest_version"].as_str().map(|s| s.to_string())
|
||||||
.as_str()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `<bin> --version` → the trailing version token (e.g. "cortiq 0.1.2" → "0.1.2").
|
/// `<bin> --version` → the trailing version token (e.g. "cortiq 0.1.2" → "0.1.2").
|
||||||
pub(crate) fn installed_version(bin: &str) -> Option<String> {
|
pub(crate) fn installed_version(bin: &str) -> Option<String> {
|
||||||
let out = std::process::Command::new(bin).arg("--version").output().ok()?;
|
let out = std::process::Command::new(bin)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -130,7 +231,11 @@ pub(crate) fn installed_version(bin: &str) -> Option<String> {
|
|||||||
|
|
||||||
fn parse_ver(s: &str) -> (u32, u32, u32) {
|
fn parse_ver(s: &str) -> (u32, u32, u32) {
|
||||||
let mut it = s.split('.').map(|x| x.trim().parse::<u32>().unwrap_or(0));
|
let mut it = s.split('.').map(|x| x.trim().parse::<u32>().unwrap_or(0));
|
||||||
(it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))
|
(
|
||||||
|
it.next().unwrap_or(0),
|
||||||
|
it.next().unwrap_or(0),
|
||||||
|
it.next().unwrap_or(0),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn version_lt(a: &str, b: &str) -> bool {
|
fn version_lt(a: &str, b: &str) -> bool {
|
||||||
@@ -161,30 +266,33 @@ pub async fn install_now(rt: Arc<CmfRuntime>, bin: String) {
|
|||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let v = installed_version(&bin);
|
let v = installed_version(&bin);
|
||||||
rt.status.lock().unwrap().installed_version = v.clone();
|
rt.status.lock().unwrap().installed_version = v.clone();
|
||||||
rt.log(format!("installed cortiq-cli {}", v.as_deref().unwrap_or("?")));
|
rt.log(format!(
|
||||||
|
"installed cortiq-cli {}",
|
||||||
|
v.as_deref().unwrap_or("?")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Err(e) => rt.fail(format!("cargo install cortiq-cli failed: {e}")),
|
Err(e) => rt.fail(format!("cargo install cortiq-cli failed: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn `cortiq serve <model> --host <host> --port <port>`; the child is killed
|
/// Spawn `cortiq serve <model> --host <host> --port <port>` for one server; the
|
||||||
/// when its handle is dropped (i.e. when the gateway shuts down).
|
/// child is killed when its handle is dropped (i.e. when the gateway shuts down).
|
||||||
fn spawn_serve(cfg: &CmfCfg) -> Result<Child, String> {
|
fn spawn_serve(server: &CmfServer, cfg: &CmfCfg) -> Result<Child, String> {
|
||||||
let mut cmd = Command::new(&cfg.cortiq_bin);
|
let mut cmd = Command::new(&cfg.cortiq_bin);
|
||||||
cmd.arg("serve")
|
cmd.arg("serve")
|
||||||
.arg(&cfg.local_model)
|
.arg(&server.model)
|
||||||
.arg("--host")
|
.arg("--host")
|
||||||
.arg(&cfg.local_host)
|
.arg(&cfg.local_host)
|
||||||
.arg("--port")
|
.arg("--port")
|
||||||
.arg(cfg.local_port.to_string());
|
.arg(server.port.to_string());
|
||||||
// Parallelise decode matvecs across cores (CMF_THREADS). 0 = leave the
|
// Parallelise decode matvecs across cores (CMF_THREADS). 0/1 = leave the
|
||||||
// runtime's own default; N>1 gave ~2.8x on a 15B CPU model.
|
// runtime's own default; N>1 gave ~2.8x on a 15B CPU model.
|
||||||
if cfg.threads > 1 {
|
if server.threads > 1 {
|
||||||
cmd.env("CMF_THREADS", cfg.threads.to_string());
|
cmd.env("CMF_THREADS", server.threads.to_string());
|
||||||
}
|
}
|
||||||
// Offload large matvecs to the GPU (Metal on macOS). Memory-bandwidth-bound
|
// Offload large matvecs to the GPU (Metal on macOS). Memory-bandwidth-bound
|
||||||
// on unified memory, so mostly a prefill win; opt-in.
|
// on unified memory, so mostly a prefill win; opt-in.
|
||||||
if cfg.gpu {
|
if server.gpu {
|
||||||
cmd.env("CMF_GPU", "1");
|
cmd.env("CMF_GPU", "1");
|
||||||
}
|
}
|
||||||
cmd.stdout(Stdio::null())
|
cmd.stdout(Stdio::null())
|
||||||
@@ -214,24 +322,17 @@ async fn wait_healthy(host: &str, port: u16, tries: u32) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Orchestrate the whole lifecycle: ensure installed → maybe update → spawn →
|
/// Orchestrate the whole lifecycle: ensure installed → maybe update → spawn each
|
||||||
/// wait healthy. Safe to call in a background task; it never panics.
|
/// configured local model → wait healthy. Safe to call in a background task; it
|
||||||
|
/// never panics.
|
||||||
pub async fn manage(rt: Arc<CmfRuntime>, cfg: CmfCfg) {
|
pub async fn manage(rt: Arc<CmfRuntime>, cfg: CmfCfg) {
|
||||||
if !cfg.manage_server || cfg.local_model.trim().is_empty() {
|
let servers = cfg.effective_servers();
|
||||||
return;
|
if servers.is_empty() {
|
||||||
}
|
|
||||||
{
|
|
||||||
let mut s = rt.status.lock().unwrap();
|
|
||||||
s.enabled = true;
|
|
||||||
s.base_url = format!("http://{}:{}/v1", cfg.local_host, cfg.local_port);
|
|
||||||
s.model = cfg.local_model.clone();
|
|
||||||
}
|
|
||||||
if !std::path::Path::new(&cfg.local_model).exists() {
|
|
||||||
rt.fail(format!("local_model not found: {}", cfg.local_model));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
rt.init_servers(&servers, &cfg.local_host);
|
||||||
|
|
||||||
// 1. Ensure the `cortiq` binary is available.
|
// 1. Ensure the `cortiq` binary is available (once for all servers).
|
||||||
let mut ver = installed_version(&cfg.cortiq_bin);
|
let mut ver = installed_version(&cfg.cortiq_bin);
|
||||||
if ver.is_none() {
|
if ver.is_none() {
|
||||||
if cfg.auto_install {
|
if cfg.auto_install {
|
||||||
@@ -245,7 +346,9 @@ pub async fn manage(rt: Arc<CmfRuntime>, cfg: CmfCfg) {
|
|||||||
rt.log("installed cortiq-cli");
|
rt.log("installed cortiq-cli");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
rt.fail("cortiq binary not found (set [cmf].auto_install = true, or install cortiq-cli)");
|
rt.fail(
|
||||||
|
"cortiq binary not found (set [cmf].auto_install = true, or install cortiq-cli)",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,8 +358,15 @@ pub async fn manage(rt: Arc<CmfRuntime>, cfg: CmfCfg) {
|
|||||||
if cfg.auto_update {
|
if cfg.auto_update {
|
||||||
if let Some(latest) = latest_crates_version().await {
|
if let Some(latest) = latest_crates_version().await {
|
||||||
rt.status.lock().unwrap().latest_version = Some(latest.clone());
|
rt.status.lock().unwrap().latest_version = Some(latest.clone());
|
||||||
if ver.as_deref().map(|c| version_lt(c, &latest)).unwrap_or(false) {
|
if ver
|
||||||
rt.log(format!("updating cortiq-cli {} → {latest}…", ver.as_deref().unwrap_or("?")));
|
.as_deref()
|
||||||
|
.map(|c| version_lt(c, &latest))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
rt.log(format!(
|
||||||
|
"updating cortiq-cli {} → {latest}…",
|
||||||
|
ver.as_deref().unwrap_or("?")
|
||||||
|
));
|
||||||
if let Err(e) = cargo_install(true).await {
|
if let Err(e) = cargo_install(true).await {
|
||||||
rt.log(format!("update failed (keeping current): {e}"));
|
rt.log(format!("update failed (keeping current): {e}"));
|
||||||
} else {
|
} else {
|
||||||
@@ -268,27 +378,38 @@ pub async fn manage(rt: Arc<CmfRuntime>, cfg: CmfCfg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Spawn the local server.
|
// 3. Spawn each server (fast).
|
||||||
rt.log(format!(
|
for server in &servers {
|
||||||
"starting: cortiq serve {} --host {} --port {}",
|
if !std::path::Path::new(&server.model).exists() {
|
||||||
cfg.local_model, cfg.local_host, cfg.local_port
|
rt.fail_server(
|
||||||
));
|
&server.id,
|
||||||
match spawn_serve(&cfg) {
|
format!("model file not found: {}", server.model),
|
||||||
Ok(child) => rt.set_child(child),
|
);
|
||||||
Err(e) => {
|
continue;
|
||||||
rt.fail(format!("failed to spawn cortiq serve: {e}"));
|
}
|
||||||
return;
|
rt.log(format!(
|
||||||
|
"starting: cortiq serve {} --host {} --port {}",
|
||||||
|
server.model, cfg.local_host, server.port
|
||||||
|
));
|
||||||
|
match spawn_serve(server, &cfg) {
|
||||||
|
Ok(child) => rt.add_child(server.id.clone(), child),
|
||||||
|
Err(e) => rt.fail_server(&server.id, format!("failed to spawn: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Wait until it is serving (model load can take a while for big models).
|
// 4. Wait until each spawned server is serving (model load can be slow).
|
||||||
if wait_healthy(&cfg.local_host, cfg.local_port, 240).await {
|
for server in &servers {
|
||||||
rt.status.lock().unwrap().healthy = true;
|
if !rt.is_running(&server.id) {
|
||||||
rt.log(format!(
|
continue; // failed to spawn / file missing
|
||||||
"local CMF server ready — registered as model '{}' at http://{}:{}/v1",
|
}
|
||||||
cfg.model_id, cfg.local_host, cfg.local_port
|
if wait_healthy(&cfg.local_host, server.port, 240).await {
|
||||||
));
|
rt.set_healthy(&server.id, true);
|
||||||
} else {
|
rt.log(format!(
|
||||||
rt.fail("local CMF server did not become healthy in time");
|
"local CMF server ready — '{}' at http://{}:{}/v1",
|
||||||
|
server.id, cfg.local_host, server.port
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
rt.fail_server(&server.id, "did not become healthy in time");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-5
@@ -10,10 +10,18 @@ use std::path::PathBuf;
|
|||||||
/// `~/.cortiq-gateway` — so defaults work regardless of the process CWD
|
/// `~/.cortiq-gateway` — so defaults work regardless of the process CWD
|
||||||
/// (important once the gateway is installed rather than run from its repo).
|
/// (important once the gateway is installed rather than run from its repo).
|
||||||
pub fn data_dir() -> PathBuf {
|
pub fn data_dir() -> PathBuf {
|
||||||
for (var, sub) in [("CORTIQ_GATEWAY_HOME", ""), ("XDG_DATA_HOME", "cortiq-gateway"), ("HOME", ".cortiq-gateway")] {
|
for (var, sub) in [
|
||||||
|
("CORTIQ_GATEWAY_HOME", ""),
|
||||||
|
("XDG_DATA_HOME", "cortiq-gateway"),
|
||||||
|
("HOME", ".cortiq-gateway"),
|
||||||
|
] {
|
||||||
if let Ok(v) = std::env::var(var) {
|
if let Ok(v) = std::env::var(var) {
|
||||||
if !v.is_empty() {
|
if !v.is_empty() {
|
||||||
return if sub.is_empty() { PathBuf::from(v) } else { PathBuf::from(v).join(sub) };
|
return if sub.is_empty() {
|
||||||
|
PathBuf::from(v)
|
||||||
|
} else {
|
||||||
|
PathBuf::from(v).join(sub)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +67,24 @@ pub struct Config {
|
|||||||
pub cmf: CmfCfg,
|
pub cmf: CmfCfg,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One managed local model: the gateway runs a dedicated `cortiq serve` for it
|
||||||
|
/// on its own port and registers it in the pool under `id`.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CmfServer {
|
||||||
|
/// Pool id it is registered under (e.g. "cmf-qwen3-5-2b").
|
||||||
|
pub id: String,
|
||||||
|
/// Path to the `.cmf` to serve.
|
||||||
|
pub model: String,
|
||||||
|
/// TCP port the dedicated `cortiq serve` binds to (must be unique).
|
||||||
|
pub port: u16,
|
||||||
|
/// Worker threads (CMF_THREADS). 0 = runtime default.
|
||||||
|
#[serde(default = "default_cmf_threads")]
|
||||||
|
pub threads: usize,
|
||||||
|
/// Offload matvecs to the GPU (CMF_GPU=1).
|
||||||
|
#[serde(default)]
|
||||||
|
pub gpu: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// CMF-format model factory: import a HuggingFace model, convert it to a
|
/// CMF-format model factory: import a HuggingFace model, convert it to a
|
||||||
/// local `.cmf` (quantized), and serve it via cortiq-server. Points the
|
/// local `.cmf` (quantized), and serve it via cortiq-server. Points the
|
||||||
/// gateway at the Python converter + runtime on the host.
|
/// gateway at the Python converter + runtime on the host.
|
||||||
@@ -121,6 +147,35 @@ pub struct CmfCfg {
|
|||||||
/// (helps prefill of long prompts). Off by default.
|
/// (helps prefill of long prompts). Off by default.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gpu: bool,
|
pub gpu: bool,
|
||||||
|
/// Managed local models. When non-empty (and `manage_server` is on), the
|
||||||
|
/// gateway spawns and supervises one `cortiq serve` per entry — each on its
|
||||||
|
/// own port — and registers each in the pool. Empty = fall back to the single
|
||||||
|
/// legacy `local_model` / `local_port` / `model_id` below (back-compat).
|
||||||
|
#[serde(default)]
|
||||||
|
pub servers: Vec<CmfServer>,
|
||||||
|
}
|
||||||
|
impl CmfCfg {
|
||||||
|
/// The managed local models actually in effect: the `servers` list when set,
|
||||||
|
/// otherwise the single legacy `local_model` (as a one-element list). Empty
|
||||||
|
/// when `manage_server` is off or nothing is configured.
|
||||||
|
pub fn effective_servers(&self) -> Vec<CmfServer> {
|
||||||
|
if !self.manage_server {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if !self.servers.is_empty() {
|
||||||
|
return self.servers.clone();
|
||||||
|
}
|
||||||
|
if !self.local_model.trim().is_empty() {
|
||||||
|
return vec![CmfServer {
|
||||||
|
id: self.model_id.clone(),
|
||||||
|
model: self.local_model.clone(),
|
||||||
|
port: self.local_port,
|
||||||
|
threads: self.threads,
|
||||||
|
gpu: self.gpu,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
impl Default for CmfCfg {
|
impl Default for CmfCfg {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -141,6 +196,7 @@ impl Default for CmfCfg {
|
|||||||
local_only: false,
|
local_only: false,
|
||||||
threads: default_cmf_threads(),
|
threads: default_cmf_threads(),
|
||||||
gpu: false,
|
gpu: false,
|
||||||
|
servers: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,7 +653,8 @@ impl Config {
|
|||||||
// A managed local CMF model counts as an available model even though it
|
// A managed local CMF model counts as an available model even though it
|
||||||
// is registered dynamically rather than via a [[models]] entry — this
|
// is registered dynamically rather than via a [[models]] entry — this
|
||||||
// is what lets the gateway run on local models alone (no cloud, no router).
|
// is what lets the gateway run on local models alone (no cloud, no router).
|
||||||
let has_local = self.cmf.manage_server && !self.cmf.local_model.trim().is_empty();
|
let servers = self.cmf.effective_servers();
|
||||||
|
let has_local = !servers.is_empty();
|
||||||
if self.models.is_empty() && !has_local {
|
if self.models.is_empty() && !has_local {
|
||||||
// Fresh install: start with an EMPTY model pool so the admin console
|
// Fresh install: start with an EMPTY model pool so the admin console
|
||||||
// comes up — the user adds models in /admin → Models (or points [cmf]
|
// comes up — the user adds models in /admin → Models (or points [cmf]
|
||||||
@@ -616,8 +673,15 @@ impl Config {
|
|||||||
// every target in [routing] must exist in the model pool
|
// every target in [routing] must exist in the model pool
|
||||||
let mut ids: std::collections::HashSet<&str> =
|
let mut ids: std::collections::HashSet<&str> =
|
||||||
self.models.iter().map(|m| m.id.as_str()).collect();
|
self.models.iter().map(|m| m.id.as_str()).collect();
|
||||||
if has_local {
|
// managed local servers are known ids too; also enforce unique ports/ids
|
||||||
ids.insert(self.cmf.model_id.as_str());
|
let mut ports = std::collections::HashSet::new();
|
||||||
|
for s in &servers {
|
||||||
|
if !ids.insert(s.id.as_str()) {
|
||||||
|
anyhow::bail!("config: duplicate managed model id '{}'", s.id);
|
||||||
|
}
|
||||||
|
if !ports.insert(s.port) {
|
||||||
|
anyhow::bail!("config: two managed models share port {}", s.port);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !self.routing.default.is_empty() && !ids.contains(self.routing.default.as_str()) {
|
if !self.routing.default.is_empty() && !ids.contains(self.routing.default.as_str()) {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
|
|||||||
+96
-29
@@ -31,13 +31,19 @@ fn cleanup_output(path: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn now() -> u64 {
|
fn now() -> u64 {
|
||||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Job id from a hash of (repo, nanos) — unique without a uuid dependency.
|
/// Job id from a hash of (repo, nanos) — unique without a uuid dependency.
|
||||||
fn gen_id(repo: &str) -> String {
|
fn gen_id(repo: &str) -> String {
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos();
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos();
|
||||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
repo.hash(&mut h);
|
repo.hash(&mut h);
|
||||||
nanos.hash(&mut h);
|
nanos.hash(&mut h);
|
||||||
@@ -82,8 +88,8 @@ pub struct Job {
|
|||||||
pub started: u64,
|
pub started: u64,
|
||||||
pub finished: Option<u64>,
|
pub finished: Option<u64>,
|
||||||
pub size_bytes: Option<u64>,
|
pub size_bytes: Option<u64>,
|
||||||
pub progress: Option<f32>, // 0..1, parsed from converter @PROGRESS
|
pub progress: Option<f32>, // 0..1, parsed from converter @PROGRESS
|
||||||
pub phase: Option<String>, // current phase label
|
pub phase: Option<String>, // current phase label
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -178,16 +184,24 @@ impl JobStore {
|
|||||||
}
|
}
|
||||||
pub fn list(&self) -> Vec<Job> {
|
pub fn list(&self) -> Vec<Job> {
|
||||||
let mut v: Vec<Job> = self.jobs.lock().unwrap().values().cloned().collect();
|
let mut v: Vec<Job> = self.jobs.lock().unwrap().values().cloned().collect();
|
||||||
v.sort_by(|a, b| b.started.cmp(&a.started));
|
v.sort_by_key(|x| std::cmp::Reverse(x.started));
|
||||||
v.truncate(20);
|
v.truncate(20);
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Proxy HuggingFace model search (avoids browser CORS + adds our token).
|
/// Proxy HuggingFace model search (avoids browser CORS + adds our token).
|
||||||
pub async fn hf_search(query: &str, limit: usize, token: Option<&str>) -> Result<serde_json::Value, String> {
|
pub async fn hf_search(
|
||||||
|
query: &str,
|
||||||
|
limit: usize,
|
||||||
|
token: Option<&str>,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
let q: String = url_escape(query);
|
let q: String = url_escape(query);
|
||||||
let sort = if query.trim().is_empty() { "trendingScore" } else { "downloads" };
|
let sort = if query.trim().is_empty() {
|
||||||
|
"trendingScore"
|
||||||
|
} else {
|
||||||
|
"downloads"
|
||||||
|
};
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://huggingface.co/api/models?search={q}&sort={sort}&direction=-1&limit={limit}&full=false"
|
"https://huggingface.co/api/models?search={q}&sort={sort}&direction=-1&limit={limit}&full=false"
|
||||||
);
|
);
|
||||||
@@ -218,7 +232,13 @@ fn url_escape(s: &str) -> String {
|
|||||||
|
|
||||||
fn sanitize(s: &str) -> String {
|
fn sanitize(s: &str) -> String {
|
||||||
s.chars()
|
s.chars()
|
||||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +264,10 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
|||||||
// doesn't support yet (linear-attention folding, v-bit shaping).
|
// doesn't support yet (linear-attention folding, v-bit shaping).
|
||||||
let use_python = p.linear_core.is_some() || p.vbit_shape.is_some() || p.mean_bits.is_some();
|
let use_python = p.linear_core.is_some() || p.vbit_shape.is_some() || p.mean_bits.is_some();
|
||||||
if use_python && !std::path::Path::new(&cfg.converter).exists() {
|
if use_python && !std::path::Path::new(&cfg.converter).exists() {
|
||||||
return Err(format!("advanced options need the Python converter, not found: {}", cfg.converter));
|
return Err(format!(
|
||||||
|
"advanced options need the Python converter, not found: {}",
|
||||||
|
cfg.converter
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let _ = std::fs::create_dir_all(&cfg.models_dir);
|
let _ = std::fs::create_dir_all(&cfg.models_dir);
|
||||||
let base = if p.name.trim().is_empty() {
|
let base = if p.name.trim().is_empty() {
|
||||||
@@ -288,18 +311,43 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
|||||||
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 {
|
||||||
let conv = std::path::Path::new(&cfg.converter);
|
let conv = std::path::Path::new(&cfg.converter);
|
||||||
let mut a = vec![
|
let mut a = vec![
|
||||||
std::fs::canonicalize(conv).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| cfg.converter.clone()),
|
std::fs::canonicalize(conv)
|
||||||
"--model".into(), p.repo.clone(),
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
"--quant".into(), p.quant.clone(),
|
.unwrap_or_else(|_| cfg.converter.clone()),
|
||||||
"--output".into(), output_abs.clone(),
|
"--model".into(),
|
||||||
|
p.repo.clone(),
|
||||||
|
"--quant".into(),
|
||||||
|
p.quant.clone(),
|
||||||
|
"--output".into(),
|
||||||
|
output_abs.clone(),
|
||||||
];
|
];
|
||||||
if let Some(lc) = &p.linear_core { a.push("--linear-core".into()); a.push(lc.clone()); }
|
if let Some(lc) = &p.linear_core {
|
||||||
if let Some(n) = p.nphase { a.push("--nphase".into()); a.push(n.to_string()); }
|
a.push("--linear-core".into());
|
||||||
if let Some(vs) = &p.vbit_shape { a.push("--vbit-shape".into()); a.push(vs.clone()); }
|
a.push(lc.clone());
|
||||||
if let Some(mb) = p.mean_bits { a.push("--mean-bits".into()); a.push(mb.to_string()); }
|
}
|
||||||
if let Some(g) = p.shard_max_gb { a.push("--shard-max-gb".into()); a.push(g.to_string()); }
|
if let Some(n) = p.nphase {
|
||||||
if p.skip_mtp { a.push("--skip-mtp".into()); }
|
a.push("--nphase".into());
|
||||||
let wd = conv.parent().map(|d| d.to_path_buf()).unwrap_or_else(|| std::path::PathBuf::from("."));
|
a.push(n.to_string());
|
||||||
|
}
|
||||||
|
if let Some(vs) = &p.vbit_shape {
|
||||||
|
a.push("--vbit-shape".into());
|
||||||
|
a.push(vs.clone());
|
||||||
|
}
|
||||||
|
if let Some(mb) = p.mean_bits {
|
||||||
|
a.push("--mean-bits".into());
|
||||||
|
a.push(mb.to_string());
|
||||||
|
}
|
||||||
|
if let Some(g) = p.shard_max_gb {
|
||||||
|
a.push("--shard-max-gb".into());
|
||||||
|
a.push(g.to_string());
|
||||||
|
}
|
||||||
|
if p.skip_mtp {
|
||||||
|
a.push("--skip-mtp".into());
|
||||||
|
}
|
||||||
|
let wd = conv
|
||||||
|
.parent()
|
||||||
|
.map(|d| d.to_path_buf())
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||||
(cfg.python_bin.clone(), a, wd)
|
(cfg.python_bin.clone(), a, wd)
|
||||||
} else if p.repo.to_ascii_lowercase().contains("gguf") {
|
} else if p.repo.to_ascii_lowercase().contains("gguf") {
|
||||||
// GGUF repo → native `cortiq import-gguf` (downloads + dequantizes any
|
// GGUF repo → native `cortiq import-gguf` (downloads + dequantizes any
|
||||||
@@ -307,21 +355,40 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
|||||||
let mut a = vec![
|
let mut a = vec![
|
||||||
"import-gguf".into(),
|
"import-gguf".into(),
|
||||||
p.repo.clone(),
|
p.repo.clone(),
|
||||||
"--quant".into(), map_quant(&p.quant).into(),
|
"--quant".into(),
|
||||||
"--output".into(), output_abs.clone(),
|
map_quant(&p.quant).into(),
|
||||||
|
"--output".into(),
|
||||||
|
output_abs.clone(),
|
||||||
];
|
];
|
||||||
if let Some(t) = &hf_token { a.push("--hf-token".into()); a.push(t.clone()); }
|
if let Some(t) = &hf_token {
|
||||||
(cfg.cortiq_bin.clone(), a, std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")))
|
a.push("--hf-token".into());
|
||||||
|
a.push(t.clone());
|
||||||
|
}
|
||||||
|
(
|
||||||
|
cfg.cortiq_bin.clone(),
|
||||||
|
a,
|
||||||
|
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// safetensors → native `cortiq convert` (dense / MoE / GatedDeltaNet).
|
// safetensors → native `cortiq convert` (dense / MoE / GatedDeltaNet).
|
||||||
let mut a = vec![
|
let mut a = vec![
|
||||||
"convert".into(),
|
"convert".into(),
|
||||||
"--model".into(), p.repo.clone(),
|
"--model".into(),
|
||||||
"--quant".into(), map_quant(&p.quant).into(),
|
p.repo.clone(),
|
||||||
"--output".into(), output_abs.clone(),
|
"--quant".into(),
|
||||||
|
map_quant(&p.quant).into(),
|
||||||
|
"--output".into(),
|
||||||
|
output_abs.clone(),
|
||||||
];
|
];
|
||||||
if let Some(t) = &hf_token { a.push("--hf-token".into()); a.push(t.clone()); }
|
if let Some(t) = &hf_token {
|
||||||
(cfg.cortiq_bin.clone(), a, std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")))
|
a.push("--hf-token".into());
|
||||||
|
a.push(t.clone());
|
||||||
|
}
|
||||||
|
(
|
||||||
|
cfg.cortiq_bin.clone(),
|
||||||
|
a,
|
||||||
|
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
let ret_id = id.clone();
|
let ret_id = id.clone();
|
||||||
|
|||||||
+44
-12
@@ -208,15 +208,25 @@ impl Pipeline {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (decision, route_source, candidates) = if live.cfg.cmf.local_only {
|
let (decision, route_source, candidates) = if live.cfg.cmf.local_only {
|
||||||
(mk("local-only", "local"), "local".to_string(), live.local_candidates())
|
(
|
||||||
|
mk("local-only", "local"),
|
||||||
|
"local".to_string(),
|
||||||
|
live.local_candidates(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
match &req.routing {
|
match &req.routing {
|
||||||
RoutingDirective::Pinned { model_id } => {
|
RoutingDirective::Pinned { model_id } => (
|
||||||
(mk("pinned", "pinned"), "pinned".to_string(), vec![model_id.clone()])
|
mk("pinned", "pinned"),
|
||||||
}
|
"pinned".to_string(),
|
||||||
|
vec![model_id.clone()],
|
||||||
|
),
|
||||||
RoutingDirective::Auto { profile } => {
|
RoutingDirective::Auto { profile } => {
|
||||||
if !live.cfg.router.enabled {
|
if !live.cfg.router.enabled {
|
||||||
(mk("router-disabled", "local"), "local".to_string(), live.local_candidates())
|
(
|
||||||
|
mk("router-disabled", "local"),
|
||||||
|
"local".to_string(),
|
||||||
|
live.local_candidates(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
let prof = profile.as_deref().unwrap_or(&live.cfg.route.profile);
|
let prof = profile.as_deref().unwrap_or(&live.cfg.route.profile);
|
||||||
match live.router.route(&text, prof).await {
|
match live.router.route(&text, prof).await {
|
||||||
@@ -229,7 +239,11 @@ impl Pipeline {
|
|||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Router is unavailable. Gracefully degrading to the local/default model."
|
"Router is unavailable. Gracefully degrading to the local/default model."
|
||||||
);
|
);
|
||||||
(mk("degraded", "fallback"), "fallback".to_string(), live.local_candidates())
|
(
|
||||||
|
mk("degraded", "fallback"),
|
||||||
|
"fallback".to_string(),
|
||||||
|
live.local_candidates(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,9 +267,21 @@ impl Pipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local-first safety net: if routing produced no usable candidate (e.g.
|
// An explicit pin must never silently answer from a different model: if
|
||||||
// an unknown pinned model while the router is off), fall back to the
|
// the pinned id is unknown or was filtered out, fail loudly so the caller
|
||||||
// managed local model so a configured local backend still answers.
|
// knows their choice was not honoured (instead of getting a stray reply
|
||||||
|
// from the managed local model via the safety net below).
|
||||||
|
if final_candidates.is_empty() {
|
||||||
|
if let RoutingDirective::Pinned { model_id } = &req.routing {
|
||||||
|
return Err(GatewayError::UpstreamUnavailable(format!(
|
||||||
|
"pinned model '{model_id}' is not available — it is not in the pool (deleted?) or lacks a required capability such as tools"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local-first safety net: for AUTO/degraded routes only, if routing
|
||||||
|
// produced no usable candidate, fall back to the managed local model so a
|
||||||
|
// configured local backend still answers.
|
||||||
if final_candidates.is_empty() && live.cfg.cmf.manage_server {
|
if final_candidates.is_empty() && live.cfg.cmf.manage_server {
|
||||||
for id in live.local_candidates() {
|
for id in live.local_candidates() {
|
||||||
if let Some(prov) = live.registry.get(&id) {
|
if let Some(prov) = live.registry.get(&id) {
|
||||||
@@ -289,7 +315,10 @@ impl Pipeline {
|
|||||||
&& !sh.local_model_id.is_empty()
|
&& !sh.local_model_id.is_empty()
|
||||||
&& matches!(req.routing, RoutingDirective::Auto { .. })
|
&& matches!(req.routing, RoutingDirective::Auto { .. })
|
||||||
&& state.promotion.serves_local(&decision.task_label)
|
&& state.promotion.serves_local(&decision.task_label)
|
||||||
&& !matches!(decision.complexity_tier.as_str(), "high" | "hard" | "complex")
|
&& !matches!(
|
||||||
|
decision.complexity_tier.as_str(),
|
||||||
|
"high" | "hard" | "complex"
|
||||||
|
)
|
||||||
&& !final_candidates.iter().any(|m| m == &sh.local_model_id)
|
&& !final_candidates.iter().any(|m| m == &sh.local_model_id)
|
||||||
{
|
{
|
||||||
final_candidates.insert(0, sh.local_model_id.clone());
|
final_candidates.insert(0, sh.local_model_id.clone());
|
||||||
@@ -713,7 +742,7 @@ fn parse_judge(text: &str) -> (u8, bool) {
|
|||||||
let score = text
|
let score = text
|
||||||
.split("score")
|
.split("score")
|
||||||
.nth(1)
|
.nth(1)
|
||||||
.and_then(|t| t.chars().skip_while(|c| !c.is_ascii_digit()).next())
|
.and_then(|t| t.chars().find(|c| c.is_ascii_digit()))
|
||||||
.and_then(|c| c.to_digit(10))
|
.and_then(|c| c.to_digit(10))
|
||||||
.map(|d| d.min(4) as u8);
|
.map(|d| d.min(4) as u8);
|
||||||
let hard_fail = text
|
let hard_fail = text
|
||||||
@@ -747,7 +776,10 @@ async fn shadow_capture(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 1. Local answer (free, CPU).
|
// 1. Local answer (free, CPU).
|
||||||
let a_local = match local.chat(shadow_req(&cfg.local_model_id, clip(&prompt, 4000), 512)).await {
|
let a_local = match local
|
||||||
|
.chat(shadow_req(&cfg.local_model_id, clip(&prompt, 4000), 512))
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(r) => r
|
Ok(r) => r
|
||||||
.choices
|
.choices
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
+96
-30
@@ -18,7 +18,10 @@ use std::sync::Mutex;
|
|||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
fn now_secs() -> u64 {
|
fn now_secs() -> u64 {
|
||||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One judgment of a local answer (JSONL line, replayed on startup).
|
/// One judgment of a local answer (JSONL line, replayed on startup).
|
||||||
@@ -62,14 +65,14 @@ pub enum PromotionState {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PromotionCfg {
|
pub struct PromotionCfg {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub window: usize, // rolling judgments per label (W)
|
pub window: usize, // rolling judgments per label (W)
|
||||||
pub n_min: usize, // min judgments before promotion is even considered
|
pub n_min: usize, // min judgments before promotion is even considered
|
||||||
pub promote_lb: f64, // Wilson-95 LB of pass must clear this
|
pub promote_lb: f64, // Wilson-95 LB of pass must clear this
|
||||||
pub demote_pass: f64, // point pass-rate below this → demote a served label
|
pub demote_pass: f64, // point pass-rate below this → demote a served label
|
||||||
pub hardfail_ub_max: f64,// Wilson-95 UPPER bound of hard_fail must stay under this
|
pub hardfail_ub_max: f64, // Wilson-95 UPPER bound of hard_fail must stay under this
|
||||||
pub soak: usize, // extra Canary judgments before LocalServed
|
pub soak: usize, // extra Canary judgments before LocalServed
|
||||||
pub z: f64, // 1.96 = 95%
|
pub z: f64, // 1.96 = 95%
|
||||||
pub file: Option<String>,// judge.jsonl path (append + replay)
|
pub file: Option<String>, // judge.jsonl path (append + replay)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for PromotionCfg {
|
impl Default for PromotionCfg {
|
||||||
@@ -92,7 +95,7 @@ struct LabelState {
|
|||||||
passes: VecDeque<bool>, // rolling window of pass/fail
|
passes: VecDeque<bool>, // rolling window of pass/fail
|
||||||
hard_fails: VecDeque<bool>, // rolling window of hard_fail flags
|
hard_fails: VecDeque<bool>, // rolling window of hard_fail flags
|
||||||
state: PromotionState,
|
state: PromotionState,
|
||||||
soaked: usize, // judgments accumulated while in Canary
|
soaked: usize, // judgments accumulated while in Canary
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LabelState {
|
impl LabelState {
|
||||||
@@ -124,20 +127,29 @@ pub fn wilson_bound(s: usize, n: usize, z: f64, upper: bool) -> f64 {
|
|||||||
let denom = 1.0 + z2 / n;
|
let denom = 1.0 + z2 / n;
|
||||||
let centre = p + z2 / (2.0 * n);
|
let centre = p + z2 / (2.0 * n);
|
||||||
let margin = z * ((p * (1.0 - p) / n) + z2 / (4.0 * n * n)).sqrt();
|
let margin = z * ((p * (1.0 - p) / n) + z2 / (4.0 * n * n)).sqrt();
|
||||||
let v = if upper { centre + margin } else { centre - margin };
|
let v = if upper {
|
||||||
|
centre + margin
|
||||||
|
} else {
|
||||||
|
centre - margin
|
||||||
|
};
|
||||||
(v / denom).clamp(0.0, 1.0)
|
(v / denom).clamp(0.0, 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Promotion {
|
impl Promotion {
|
||||||
pub fn new(cfg: PromotionCfg) -> std::sync::Arc<Self> {
|
pub fn new(cfg: PromotionCfg) -> std::sync::Arc<Self> {
|
||||||
let p = std::sync::Arc::new(Self { cfg, labels: Mutex::new(HashMap::new()) });
|
let p = std::sync::Arc::new(Self {
|
||||||
|
cfg,
|
||||||
|
labels: Mutex::new(HashMap::new()),
|
||||||
|
});
|
||||||
p.replay();
|
p.replay();
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replay(&self) {
|
fn replay(&self) {
|
||||||
let Some(path) = &self.cfg.file else { return };
|
let Some(path) = &self.cfg.file else { return };
|
||||||
let Ok(content) = std::fs::read_to_string(path) else { return };
|
let Ok(content) = std::fs::read_to_string(path) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
for line in content.lines() {
|
for line in content.lines() {
|
||||||
if let Ok(rec) = serde_json::from_str::<JudgeRecord>(line) {
|
if let Ok(rec) = serde_json::from_str::<JudgeRecord>(line) {
|
||||||
self.apply(&rec);
|
self.apply(&rec);
|
||||||
@@ -151,8 +163,10 @@ impl Promotion {
|
|||||||
let st = self.apply(&rec);
|
let st = self.apply(&rec);
|
||||||
if let Some(path) = &self.cfg.file {
|
if let Some(path) = &self.cfg.file {
|
||||||
if let Ok(line) = serde_json::to_string(&rec) {
|
if let Ok(line) = serde_json::to_string(&rec) {
|
||||||
if let Ok(mut f) =
|
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||||
std::fs::OpenOptions::new().create(true).append(true).open(path)
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(path)
|
||||||
{
|
{
|
||||||
let _ = writeln!(f, "{line}");
|
let _ = writeln!(f, "{line}");
|
||||||
}
|
}
|
||||||
@@ -163,7 +177,9 @@ impl Promotion {
|
|||||||
|
|
||||||
fn apply(&self, rec: &JudgeRecord) -> PromotionState {
|
fn apply(&self, rec: &JudgeRecord) -> PromotionState {
|
||||||
let mut map = self.labels.lock().unwrap();
|
let mut map = self.labels.lock().unwrap();
|
||||||
let ls = map.entry(rec.task_label.clone()).or_insert_with(LabelState::new);
|
let ls = map
|
||||||
|
.entry(rec.task_label.clone())
|
||||||
|
.or_insert_with(LabelState::new);
|
||||||
push_cap(&mut ls.passes, rec.pass, self.cfg.window);
|
push_cap(&mut ls.passes, rec.pass, self.cfg.window);
|
||||||
push_cap(&mut ls.hard_fails, rec.hard_fail, self.cfg.window);
|
push_cap(&mut ls.hard_fails, rec.hard_fail, self.cfg.window);
|
||||||
self.advance(ls);
|
self.advance(ls);
|
||||||
@@ -188,7 +204,12 @@ impl Promotion {
|
|||||||
|
|
||||||
ls.state = match ls.state {
|
ls.state = match ls.state {
|
||||||
PromotionState::CloudOnly | PromotionState::ShadowJudged => {
|
PromotionState::CloudOnly | PromotionState::ShadowJudged => {
|
||||||
if bar { ls.soaked = 0; PromotionState::Canary } else { PromotionState::ShadowJudged }
|
if bar {
|
||||||
|
ls.soaked = 0;
|
||||||
|
PromotionState::Canary
|
||||||
|
} else {
|
||||||
|
PromotionState::ShadowJudged
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PromotionState::Canary => {
|
PromotionState::Canary => {
|
||||||
if regressed {
|
if regressed {
|
||||||
@@ -203,7 +224,11 @@ impl Promotion {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
PromotionState::LocalServed => {
|
PromotionState::LocalServed => {
|
||||||
if regressed { PromotionState::Demoted } else { PromotionState::LocalServed }
|
if regressed {
|
||||||
|
PromotionState::Demoted
|
||||||
|
} else {
|
||||||
|
PromotionState::LocalServed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Demoted goes back to SHADOW to re-earn promotion (skill stays in .cmf).
|
// Demoted goes back to SHADOW to re-earn promotion (skill stays in .cmf).
|
||||||
PromotionState::Demoted => PromotionState::ShadowJudged,
|
PromotionState::Demoted => PromotionState::ShadowJudged,
|
||||||
@@ -223,7 +248,10 @@ impl Promotion {
|
|||||||
/// Should a request of this label be SERVED by the local model?
|
/// Should a request of this label be SERVED by the local model?
|
||||||
/// (Canary/LocalServed; caller still applies the per-request veto.)
|
/// (Canary/LocalServed; caller still applies the per-request veto.)
|
||||||
pub fn serves_local(&self, label: &str) -> bool {
|
pub fn serves_local(&self, label: &str) -> bool {
|
||||||
matches!(self.state(label), PromotionState::Canary | PromotionState::LocalServed)
|
matches!(
|
||||||
|
self.state(label),
|
||||||
|
PromotionState::Canary | PromotionState::LocalServed
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot for /admin: (state, n, pass-rate, Wilson-LB) per label.
|
/// Snapshot for /admin: (state, n, pass-rate, Wilson-LB) per label.
|
||||||
@@ -235,10 +263,16 @@ impl Promotion {
|
|||||||
let n = ls.passes.len();
|
let n = ls.passes.len();
|
||||||
let s = ls.passes.iter().filter(|&&p| p).count();
|
let s = ls.passes.iter().filter(|&&p| p).count();
|
||||||
let p = if n > 0 { s as f64 / n as f64 } else { 0.0 };
|
let p = if n > 0 { s as f64 / n as f64 } else { 0.0 };
|
||||||
(k.clone(), ls.state, n, p, wilson_bound(s, n, self.cfg.z, false))
|
(
|
||||||
|
k.clone(),
|
||||||
|
ls.state,
|
||||||
|
n,
|
||||||
|
p,
|
||||||
|
wilson_bound(s, n, self.cfg.z, false),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
out.sort_by(|a, b| b.2.cmp(&a.2));
|
out.sort_by_key(|x| std::cmp::Reverse(x.2));
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,12 +293,24 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn cfg() -> PromotionCfg {
|
fn cfg() -> PromotionCfg {
|
||||||
PromotionCfg { enabled: true, n_min: 200, soak: 0, ..Default::default() }
|
PromotionCfg {
|
||||||
|
enabled: true,
|
||||||
|
n_min: 200,
|
||||||
|
soak: 0,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn rec(label: &str, pass: bool, hard_fail: bool) -> JudgeRecord {
|
fn rec(label: &str, pass: bool, hard_fail: bool) -> JudgeRecord {
|
||||||
JudgeRecord {
|
JudgeRecord {
|
||||||
ts: 0, task_label: label.into(), pass, hard_fail, score: if pass { 4 } else { 0 },
|
ts: 0,
|
||||||
source: "checker".into(), recon_e: None, born_conf: None, judge_tokens: 0,
|
task_label: label.into(),
|
||||||
|
pass,
|
||||||
|
hard_fail,
|
||||||
|
score: if pass { 4 } else { 0 },
|
||||||
|
source: "checker".into(),
|
||||||
|
recon_e: None,
|
||||||
|
born_conf: None,
|
||||||
|
judge_tokens: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +362,10 @@ mod tests {
|
|||||||
match row {
|
match row {
|
||||||
Some((_, st, n, pr, lb)) => println!(
|
Some((_, st, n, pr, lb)) => println!(
|
||||||
" {label:<12} {:<13} n={n:<4} pass={:.0}% LB={:.3} serves_local={}",
|
" {label:<12} {:<13} n={n:<4} pass={:.0}% LB={:.3} serves_local={}",
|
||||||
format!("{st:?}"), pr * 100.0, lb, p.serves_local(label)
|
format!("{st:?}"),
|
||||||
|
pr * 100.0,
|
||||||
|
lb,
|
||||||
|
p.serves_local(label)
|
||||||
),
|
),
|
||||||
None => println!(" {label:<12} (cloud-only, no judgments)"),
|
None => println!(" {label:<12} (cloud-only, no judgments)"),
|
||||||
}
|
}
|
||||||
@@ -324,19 +373,29 @@ mod tests {
|
|||||||
|
|
||||||
println!("\n── self-warming loop, live ──");
|
println!("\n── self-warming loop, live ──");
|
||||||
// A mature skill (e.g. ru-tech): local answers pass the judge ~99%.
|
// A mature skill (e.g. ru-tech): local answers pass the judge ~99%.
|
||||||
println!(" 1) SHADOW→CANARY: судим локаль 'ru_tech' (сильный скилл ~99% pass), 210 судейств:");
|
println!(
|
||||||
|
" 1) SHADOW→CANARY: судим локаль 'ru_tech' (сильный скилл ~99% pass), 210 судейств:"
|
||||||
|
);
|
||||||
for i in 0..210 {
|
for i in 0..210 {
|
||||||
p.record(rec("ru_tech", i % 100 != 0, false));
|
p.record(rec("ru_tech", i % 100 != 0, false));
|
||||||
}
|
}
|
||||||
show("ru_tech");
|
show("ru_tech");
|
||||||
assert_eq!(p.state("ru_tech"), PromotionState::Canary, "n≥200 & LB≥0.95 → CANARY");
|
assert_eq!(
|
||||||
|
p.state("ru_tech"),
|
||||||
|
PromotionState::Canary,
|
||||||
|
"n≥200 & LB≥0.95 → CANARY"
|
||||||
|
);
|
||||||
|
|
||||||
println!(" 2) CANARY soak (ещё 60 судейств держат планку) → LOCAL_SERVED:");
|
println!(" 2) CANARY soak (ещё 60 судейств держат планку) → LOCAL_SERVED:");
|
||||||
for i in 0..60 {
|
for i in 0..60 {
|
||||||
p.record(rec("ru_tech", i % 100 != 0, false));
|
p.record(rec("ru_tech", i % 100 != 0, false));
|
||||||
}
|
}
|
||||||
show("ru_tech");
|
show("ru_tech");
|
||||||
assert_eq!(p.state("ru_tech"), PromotionState::LocalServed, "soak → LOCAL_SERVED");
|
assert_eq!(
|
||||||
|
p.state("ru_tech"),
|
||||||
|
PromotionState::LocalServed,
|
||||||
|
"soak → LOCAL_SERVED"
|
||||||
|
);
|
||||||
assert!(p.serves_local("ru_tech"), "теперь обслуживается локально");
|
assert!(p.serves_local("ru_tech"), "теперь обслуживается локально");
|
||||||
|
|
||||||
// The code dataset: local fails the checker → never promotes.
|
// The code dataset: local fails the checker → never promotes.
|
||||||
@@ -345,7 +404,11 @@ mod tests {
|
|||||||
p.record(rec("code", false, false));
|
p.record(rec("code", false, false));
|
||||||
}
|
}
|
||||||
show("code");
|
show("code");
|
||||||
assert_eq!(p.state("code"), PromotionState::ShadowJudged, "остаётся на облаке — безопасно");
|
assert_eq!(
|
||||||
|
p.state("code"),
|
||||||
|
PromotionState::ShadowJudged,
|
||||||
|
"остаётся на облаке — безопасно"
|
||||||
|
);
|
||||||
assert!(!p.serves_local("code"));
|
assert!(!p.serves_local("code"));
|
||||||
|
|
||||||
// Drift on the promoted label → auto-demote back to cloud.
|
// Drift on the promoted label → auto-demote back to cloud.
|
||||||
@@ -376,6 +439,9 @@ mod tests {
|
|||||||
for _ in 0..300 {
|
for _ in 0..300 {
|
||||||
p2.record(rec("t", false, false)); // quality collapses
|
p2.record(rec("t", false, false)); // quality collapses
|
||||||
}
|
}
|
||||||
assert!(matches!(p2.state("t"), PromotionState::Demoted | PromotionState::ShadowJudged));
|
assert!(matches!(
|
||||||
|
p2.state("t"),
|
||||||
|
PromotionState::Demoted | PromotionState::ShadowJudged
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-9
@@ -28,17 +28,17 @@ impl Registry {
|
|||||||
by_id.insert(m.id.clone(), provider);
|
by_id.insert(m.id.clone(), provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Managed local CMF server: register it as an OpenAI-compatible provider
|
// Managed local CMF servers: register each as an OpenAI-compatible
|
||||||
// so it can be routed to like any other backend — including when the
|
// provider so it can be routed to like any other backend — including when
|
||||||
// router is unavailable or the gateway is in local-only mode.
|
// the router is unavailable or the gateway is in local-only mode.
|
||||||
if cfg.cmf.manage_server
|
for s in cfg.cmf.effective_servers() {
|
||||||
&& !cfg.cmf.local_model.trim().is_empty()
|
if by_id.contains_key(&s.id) {
|
||||||
&& !by_id.contains_key(&cfg.cmf.model_id)
|
continue; // a static [[models]] entry with the same id wins
|
||||||
{
|
}
|
||||||
let m = ModelCfg {
|
let m = ModelCfg {
|
||||||
id: cfg.cmf.model_id.clone(),
|
id: s.id.clone(),
|
||||||
provider: "openai".into(),
|
provider: "openai".into(),
|
||||||
base_url: format!("http://{}:{}/v1", cfg.cmf.local_host, cfg.cmf.local_port),
|
base_url: format!("http://{}:{}/v1", cfg.cmf.local_host, s.port),
|
||||||
model: "cortiq".into(),
|
model: "cortiq".into(),
|
||||||
cost_tier: "local".into(),
|
cost_tier: "local".into(),
|
||||||
price_in: 0.0,
|
price_in: 0.0,
|
||||||
|
|||||||
+7
-3
@@ -49,8 +49,8 @@ impl Live {
|
|||||||
/// (local-only / disabled) or unavailable, so CMF models serve with no router.
|
/// (local-only / disabled) or unavailable, so CMF models serve with no router.
|
||||||
pub fn local_candidates(&self) -> Vec<String> {
|
pub fn local_candidates(&self) -> Vec<String> {
|
||||||
let mut v = Vec::new();
|
let mut v = Vec::new();
|
||||||
if self.cfg.cmf.manage_server && !self.cfg.cmf.local_model.trim().is_empty() {
|
for s in self.cfg.cmf.effective_servers() {
|
||||||
v.push(self.cfg.cmf.model_id.clone());
|
v.push(s.id);
|
||||||
}
|
}
|
||||||
let def = self.routing.default_model().to_string();
|
let def = self.routing.default_model().to_string();
|
||||||
if !def.is_empty() && !v.contains(&def) {
|
if !def.is_empty() && !v.contains(&def) {
|
||||||
@@ -90,7 +90,11 @@ fn promotion_cfg(s: &crate::config::ShadowCfg) -> crate::promotion::PromotionCfg
|
|||||||
n_min: s.n_min,
|
n_min: s.n_min,
|
||||||
promote_lb: s.promote_lb,
|
promote_lb: s.promote_lb,
|
||||||
soak: s.soak,
|
soak: s.soak,
|
||||||
file: if s.file.is_empty() { None } else { Some(s.file.clone()) },
|
file: if s.file.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(s.file.clone())
|
||||||
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -99,6 +99,7 @@ export const api = {
|
|||||||
cmfStatus: () => j("GET", "/cmf"),
|
cmfStatus: () => j("GET", "/cmf"),
|
||||||
cmfInstall: () => j("POST", "/cmf/install"),
|
cmfInstall: () => j("POST", "/cmf/install"),
|
||||||
cmfPortCheck: (port) => j("GET", "/cmf/port?port=" + encodeURIComponent(port)),
|
cmfPortCheck: (port) => j("GET", "/cmf/port?port=" + encodeURIComponent(port)),
|
||||||
|
cmfFiles: () => j("GET", "/cmf/files"),
|
||||||
|
|
||||||
// CMF model factory (HuggingFace → local .cmf)
|
// CMF model factory (HuggingFace → local .cmf)
|
||||||
hfSearch: (q, limit = 24) =>
|
hfSearch: (q, limit = 24) =>
|
||||||
|
|||||||
Vendored
+7
@@ -129,6 +129,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.managed": "managed",
|
||||||
|
"models.managedHint": "Provided by the managed CMF server (Settings → Local models). Configure it there.",
|
||||||
|
|
||||||
// routing
|
// routing
|
||||||
"routing.title": "Routing",
|
"routing.title": "Routing",
|
||||||
@@ -242,6 +244,11 @@ export default {
|
|||||||
"settings.cmf.threads": "Worker threads",
|
"settings.cmf.threads": "Worker threads",
|
||||||
"settings.cmf.gpu": "Use GPU (Metal / wgpu)",
|
"settings.cmf.gpu": "Use GPU (Metal / wgpu)",
|
||||||
"settings.cmf.speedNote": "Threads parallelise the decode matvecs (~3x faster; 0 = runtime default). GPU offload mainly helps prefill of long prompts — decode is memory-bandwidth-bound, so on Apple Silicon it is roughly neutral.",
|
"settings.cmf.speedNote": "Threads parallelise the decode matvecs (~3x faster; 0 = runtime default). GPU offload mainly helps prefill of long prompts — decode is memory-bandwidth-bound, so on Apple Silicon it is roughly neutral.",
|
||||||
|
"settings.cmf.pick": "select a model",
|
||||||
|
"settings.cmf.models": "Local models",
|
||||||
|
"settings.cmf.modelsHelp": "Each row is one model the gateway runs (its own cortiq serve on its own port) and registers in the pool. Add as many as you have .cmf files and free ports.",
|
||||||
|
"settings.cmf.rowHint": "id · .cmf file · port · threads · GPU. Changes apply on restart; deleting a row stops that server.",
|
||||||
|
"settings.cmf.addServer": "+ Add local model",
|
||||||
"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",
|
||||||
|
|||||||
Vendored
+7
@@ -128,6 +128,8 @@ export default {
|
|||||||
"models.form.secretMissing": "не задан",
|
"models.form.secretMissing": "не задан",
|
||||||
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
|
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
|
||||||
"models.deleteConfirm": "Удалить модель \"{id}\"?",
|
"models.deleteConfirm": "Удалить модель \"{id}\"?",
|
||||||
|
"models.managed": "управляемая",
|
||||||
|
"models.managedHint": "Предоставляется CMF-сервером (Настройки → Локальные модели). Настраивается там.",
|
||||||
|
|
||||||
// routing
|
// routing
|
||||||
"routing.title": "Роутинг",
|
"routing.title": "Роутинг",
|
||||||
@@ -241,6 +243,11 @@ export default {
|
|||||||
"settings.cmf.threads": "Рабочие потоки",
|
"settings.cmf.threads": "Рабочие потоки",
|
||||||
"settings.cmf.gpu": "Использовать GPU (Metal / wgpu)",
|
"settings.cmf.gpu": "Использовать GPU (Metal / wgpu)",
|
||||||
"settings.cmf.speedNote": "Потоки распараллеливают декод-матвеки (~×3; 0 = по умолчанию рантайма). GPU-оффлоад помогает в основном на prefill длинных промптов — декод упирается в пропускную способность памяти, поэтому на Apple Silicon почти нейтрален.",
|
"settings.cmf.speedNote": "Потоки распараллеливают декод-матвеки (~×3; 0 = по умолчанию рантайма). GPU-оффлоад помогает в основном на prefill длинных промптов — декод упирается в пропускную способность памяти, поэтому на Apple Silicon почти нейтрален.",
|
||||||
|
"settings.cmf.pick": "выберите модель",
|
||||||
|
"settings.cmf.models": "Локальные модели",
|
||||||
|
"settings.cmf.modelsHelp": "Каждая строка — отдельная модель: шлюз поднимает свой cortiq serve на своём порту и регистрирует её в пуле. Добавляйте столько, сколько есть .cmf-файлов и свободных портов.",
|
||||||
|
"settings.cmf.rowHint": "id · файл .cmf · порт · потоки · GPU. Изменения применяются после перезапуска; удаление строки останавливает сервер.",
|
||||||
|
"settings.cmf.addServer": "+ Добавить модель",
|
||||||
"settings.cmf.checkPort": "Проверить порт",
|
"settings.cmf.checkPort": "Проверить порт",
|
||||||
"settings.cmf.portFree": "порт свободен",
|
"settings.cmf.portFree": "порт свободен",
|
||||||
"settings.cmf.portBusy": "порт занят",
|
"settings.cmf.portBusy": "порт занят",
|
||||||
|
|||||||
Vendored
+3
-2
@@ -168,7 +168,8 @@ function row(m, meta, reload) {
|
|||||||
return h(
|
return h(
|
||||||
"tr",
|
"tr",
|
||||||
{},
|
{},
|
||||||
h("td", { class: "mono" }, m.id),
|
h("td", { class: "mono" }, m.id,
|
||||||
|
m.managed ? h("span", { class: "badge", style: "margin-left:6px", title: t("models.managedHint") }, t("models.managed")) : null),
|
||||||
h("td", {}, m.provider),
|
h("td", {}, m.provider),
|
||||||
h("td", { class: "mono" }, m.model),
|
h("td", { class: "mono" }, m.model),
|
||||||
h("td", {}, h("span", { class: "badge " + (m.cost_tier || "") }, m.cost_tier || "—")),
|
h("td", {}, h("span", { class: "badge " + (m.cost_tier || "") }, m.cost_tier || "—")),
|
||||||
@@ -182,7 +183,7 @@ function row(m, meta, reload) {
|
|||||||
"div",
|
"div",
|
||||||
{ class: "flex", style: "justify-content:flex-end" },
|
{ class: "flex", style: "justify-content:flex-end" },
|
||||||
probeBtn,
|
probeBtn,
|
||||||
h("button", { class: "btn sm ghost", onClick: () => openModal(meta, m, reload) }, t("common.edit")),
|
m.managed ? null : h("button", { class: "btn sm ghost", onClick: () => openModal(meta, m, reload) }, t("common.edit")),
|
||||||
h(
|
h(
|
||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
|
|||||||
Vendored
+82
-63
@@ -207,11 +207,63 @@ export async function renderSettings() {
|
|||||||
const cmfManage = check(t("settings.cmf.manage"), s.cmf?.manage_server, t("settings.cmf.manageHelp"));
|
const cmfManage = check(t("settings.cmf.manage"), s.cmf?.manage_server, t("settings.cmf.manageHelp"));
|
||||||
const cmfAutoInstall = check(t("settings.cmf.autoInstall"), s.cmf?.auto_install, t("settings.cmf.autoInstallHelp"));
|
const cmfAutoInstall = check(t("settings.cmf.autoInstall"), s.cmf?.auto_install, t("settings.cmf.autoInstallHelp"));
|
||||||
const cmfAutoUpdate = check(t("settings.cmf.autoUpdate"), s.cmf?.auto_update, t("settings.cmf.autoUpdateHelp"));
|
const cmfAutoUpdate = check(t("settings.cmf.autoUpdate"), s.cmf?.auto_update, t("settings.cmf.autoUpdateHelp"));
|
||||||
const cmfModel = h("input", { value: s.cmf?.local_model || "", placeholder: "models/my-model.cmf" });
|
// ── managed local models: one supervised `cortiq serve` per row ──
|
||||||
const cmfPort = h("input", { type: "number", value: s.cmf?.local_port ?? 8081 });
|
const serverRows = [];
|
||||||
const cmfThreads = h("input", { type: "number", min: 0, value: s.cmf?.threads ?? 8 });
|
const serversWrap = h("div", {});
|
||||||
const cmfGpu = check(t("settings.cmf.gpu"), s.cmf?.gpu, t("settings.cmf.gpuHelp"));
|
const legacyServers = s.cmf?.local_model
|
||||||
const cmfModelIdVal = s.cmf?.model_id || "cmf-local"; // auto — no manual "model id" field
|
? [{ id: s.cmf.model_id || "cmf-local", model: s.cmf.local_model, port: s.cmf.local_port ?? 8081, threads: s.cmf.threads ?? 8, gpu: !!s.cmf.gpu }]
|
||||||
|
: [];
|
||||||
|
const initServers = (s.cmf?.servers && s.cmf.servers.length) ? s.cmf.servers : legacyServers;
|
||||||
|
// available .cmf files under models_dir → offer as a dropdown, not a typed path
|
||||||
|
const cmfFiles = ((await api.cmfFiles().catch(() => ({ files: [] }))).files) || [];
|
||||||
|
const fileLabel = (f) => f.name + (f.size ? ` (${(f.size / 1073741824).toFixed(f.size >= 1073741824 ? 1 : 2)} GB)` : f.is_dir ? " (dir)" : "");
|
||||||
|
function autoId(path) {
|
||||||
|
const base = (path || "").split("/").pop().replace(/\.cmf$/i, "");
|
||||||
|
return base ? "cmf-" + base.replace(/[^a-zA-Z0-9_-]/g, "-") : "";
|
||||||
|
}
|
||||||
|
function makeServerRow(sv) {
|
||||||
|
sv = sv || { id: "", model: "", port: 8090, threads: 8, gpu: false };
|
||||||
|
const idIn = h("input", { value: sv.id || "", placeholder: "cmf-id", style: "width:130px" });
|
||||||
|
// pick from existing .cmf files rather than typing a path
|
||||||
|
const modelSel = h("select", { style: "flex:2;min-width:150px" });
|
||||||
|
const seen = new Set();
|
||||||
|
modelSel.appendChild(h("option", { value: "" }, "— " + t("settings.cmf.pick") + " —"));
|
||||||
|
cmfFiles.forEach((f) => { modelSel.appendChild(h("option", { value: f.path }, fileLabel(f))); seen.add(f.path); });
|
||||||
|
if (sv.model && !seen.has(sv.model)) modelSel.appendChild(h("option", { value: sv.model }, sv.model));
|
||||||
|
modelSel.value = sv.model || "";
|
||||||
|
const portIn = h("input", { type: "number", value: sv.port ?? 8090, style: "width:82px" });
|
||||||
|
const threadsIn = h("input", { type: "number", min: 0, value: sv.threads ?? 8, style: "width:60px", title: t("settings.cmf.threadsHelp") });
|
||||||
|
const gpuIn = h("input", { type: "checkbox", checked: sv.gpu ? true : null, title: t("settings.cmf.gpuHelp") });
|
||||||
|
const checkBtn = h("button", { class: "btn small", type: "button" }, t("settings.cmf.checkPort"));
|
||||||
|
const removeBtn = h("button", { class: "btn small danger", type: "button", title: t("common.delete") }, "✕");
|
||||||
|
const st = h("span", { class: "small" });
|
||||||
|
modelSel.addEventListener("change", () => { if (!idIn.value || idIn._auto) { idIn.value = autoId(modelSel.value); idIn._auto = true; } });
|
||||||
|
idIn.addEventListener("input", () => { idIn._auto = false; });
|
||||||
|
checkBtn.addEventListener("click", async () => {
|
||||||
|
const p = parseInt(portIn.value, 10); if (!p) return;
|
||||||
|
checkBtn.disabled = true; mount(st, t("status.checking"));
|
||||||
|
try {
|
||||||
|
const r = await api.cmfPortCheck(p);
|
||||||
|
if (r.available) mount(st, h("span", { style: "color:var(--good)" }, "✓ " + t("settings.cmf.portFree")));
|
||||||
|
else {
|
||||||
|
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); }
|
||||||
|
mount(st, h("span", {}, ...kids));
|
||||||
|
}
|
||||||
|
} catch (e) { mount(st, String(e.message || e)); }
|
||||||
|
checkBtn.disabled = false;
|
||||||
|
});
|
||||||
|
const row = h("div", { class: "flex wrap", style: "gap:6px;align-items:center;margin:6px 0" },
|
||||||
|
idIn, modelSel, portIn, checkBtn, threadsIn, h("label", { class: "flex", style: "gap:4px" }, gpuIn, "GPU"), removeBtn, st);
|
||||||
|
const entry = { node: row, read: () => ({ id: idIn.value.trim() || autoId(modelSel.value), model: modelSel.value.trim(), port: parseInt(portIn.value) || 8090, threads: parseInt(threadsIn.value) || 0, gpu: gpuIn.checked }) };
|
||||||
|
removeBtn.addEventListener("click", () => { const i = serverRows.indexOf(entry); if (i >= 0) serverRows.splice(i, 1); row.remove(); });
|
||||||
|
serverRows.push(entry);
|
||||||
|
serversWrap.appendChild(row);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
initServers.forEach((sv) => makeServerRow(sv));
|
||||||
|
const addServerBtn = h("button", { class: "btn small", type: "button" }, t("settings.cmf.addServer"));
|
||||||
|
addServerBtn.addEventListener("click", () => makeServerRow());
|
||||||
// "Install cortiq" — the CLI that RUNS the .cmf format. Must be present before
|
// "Install cortiq" — the CLI that RUNS the .cmf format. Must be present before
|
||||||
// a local model server can start; this is step 1 for a new user.
|
// a local model server can start; this is step 1 for a new user.
|
||||||
const cmfStatusEl = h("span", { class: "muted" }, "…");
|
const cmfStatusEl = h("span", { class: "muted" }, "…");
|
||||||
@@ -228,14 +280,17 @@ export async function renderSettings() {
|
|||||||
? h("span", {}, h("span", { class: "badge ok" }, "✓"), " cortiq " + (st.version || ""))
|
? h("span", {}, h("span", { class: "badge ok" }, "✓"), " cortiq " + (st.version || ""))
|
||||||
: h("span", {}, h("span", { class: "badge bad" }, "×"), " " + t("settings.cmf.notInstalled"))
|
: h("span", {}, h("span", { class: "badge bad" }, "×"), " " + t("settings.cmf.notInstalled"))
|
||||||
);
|
);
|
||||||
// reflect the managed server state under the "run locally" toggle
|
// per-server serving indicators
|
||||||
if (st.manage_server && st.running) {
|
const servers = st.servers || [];
|
||||||
mount(cmfServingEl, h("span", { style: "color:var(--good)" },
|
if (!servers.length) {
|
||||||
"▶ " + t("settings.cmf.serving", { model: st.model_id || "", port: st.local_port }) + (st.healthy ? "" : " …")));
|
|
||||||
} else if (st.last_error) {
|
|
||||||
mount(cmfServingEl, h("span", { style: "color:var(--bad)" }, "✗ " + st.last_error));
|
|
||||||
} else {
|
|
||||||
mount(cmfServingEl, "");
|
mount(cmfServingEl, "");
|
||||||
|
} else {
|
||||||
|
mount(cmfServingEl, h("div", {}, ...servers.map((sv) => {
|
||||||
|
if (sv.last_error) return h("div", { style: "color:var(--bad)" }, "✗ " + sv.id + ": " + sv.last_error);
|
||||||
|
if (sv.running) return h("div", { style: "color:var(--good)" },
|
||||||
|
"▶ " + t("settings.cmf.serving", { model: sv.id, port: sv.port }) + (sv.healthy ? "" : " …"));
|
||||||
|
return h("div", { class: "muted" }, "• " + sv.id + " :" + sv.port);
|
||||||
|
})));
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
mount(cmfStatusEl, "—");
|
mount(cmfStatusEl, "—");
|
||||||
@@ -251,7 +306,6 @@ export async function renderSettings() {
|
|||||||
await new Promise((r) => setTimeout(r, 3000));
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
const st = await api.cmfStatus();
|
const st = await api.cmfStatus();
|
||||||
if (st.installed) { toast(t("settings.cmf.installedOk"), "good"); break; }
|
if (st.installed) { toast(t("settings.cmf.installedOk"), "good"); break; }
|
||||||
if (st.last_error) { toast(st.last_error, "bad"); break; }
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(String(e.message || e), "bad");
|
toast(String(e.message || e), "bad");
|
||||||
@@ -261,36 +315,6 @@ export async function renderSettings() {
|
|||||||
});
|
});
|
||||||
refreshCmfStatus();
|
refreshCmfStatus();
|
||||||
|
|
||||||
// Port availability check — let the user confirm the chosen port is free
|
|
||||||
// before enabling the managed server (the default 8081 often clashes).
|
|
||||||
const cmfPortStatus = h("span", { class: "small", style: "margin-left:8px" });
|
|
||||||
const cmfPortCheckBtn = h("button", { class: "btn small", type: "button" }, t("settings.cmf.checkPort"));
|
|
||||||
async function checkPort() {
|
|
||||||
const p = parseInt(cmfPort.value, 10);
|
|
||||||
if (!p) { mount(cmfPortStatus, ""); return; }
|
|
||||||
cmfPortCheckBtn.disabled = true;
|
|
||||||
mount(cmfPortStatus, t("status.checking"));
|
|
||||||
try {
|
|
||||||
const r = await api.cmfPortCheck(p);
|
|
||||||
if (r.available) {
|
|
||||||
mount(cmfPortStatus, h("span", { style: "color:var(--good)" }, "✓ " + t("settings.cmf.portFree")));
|
|
||||||
} else {
|
|
||||||
const kids = [h("span", { style: "color:var(--bad)" }, "✗ " + t("settings.cmf.portBusy"))];
|
|
||||||
if (r.suggested) {
|
|
||||||
const useBtn = h("button", { class: "btn small", type: "button", style: "margin-left:8px" },
|
|
||||||
t("settings.cmf.useSuggested", { port: r.suggested }));
|
|
||||||
useBtn.addEventListener("click", () => { cmfPort.value = r.suggested; checkPort(); });
|
|
||||||
kids.push(useBtn);
|
|
||||||
}
|
|
||||||
mount(cmfPortStatus, h("span", {}, ...kids));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
mount(cmfPortStatus, String(e.message || e));
|
|
||||||
}
|
|
||||||
cmfPortCheckBtn.disabled = false;
|
|
||||||
}
|
|
||||||
cmfPortCheckBtn.addEventListener("click", checkPort);
|
|
||||||
|
|
||||||
const saveBtn = h("button", { class: "btn primary" }, t("common.save"));
|
const saveBtn = h("button", { class: "btn primary" }, t("common.save"));
|
||||||
saveBtn.addEventListener("click", async () => {
|
saveBtn.addEventListener("click", async () => {
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
@@ -335,11 +359,9 @@ export async function renderSettings() {
|
|||||||
manage_server: cmfManage.cb.checked,
|
manage_server: cmfManage.cb.checked,
|
||||||
auto_install: cmfAutoInstall.cb.checked,
|
auto_install: cmfAutoInstall.cb.checked,
|
||||||
auto_update: cmfAutoUpdate.cb.checked,
|
auto_update: cmfAutoUpdate.cb.checked,
|
||||||
local_model: cmfModel.value.trim(),
|
// the managed-models list is the source of truth; clear the legacy single
|
||||||
local_port: parseInt(cmfPort.value) || 8081,
|
servers: serverRows.map((r) => r.read()).filter((sv) => sv.model && sv.id),
|
||||||
model_id: cmfModelIdVal,
|
local_model: "",
|
||||||
threads: parseInt(cmfThreads.value) || 0,
|
|
||||||
gpu: cmfGpu.cb.checked,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// serde: drop null optionals
|
// serde: drop null optionals
|
||||||
@@ -477,24 +499,21 @@ export async function renderSettings() {
|
|||||||
),
|
),
|
||||||
h("div", { class: "row" }, cmfAutoInstall.node, cmfAutoUpdate.node),
|
h("div", { class: "row" }, cmfAutoInstall.node, cmfAutoUpdate.node),
|
||||||
h("div", { class: "divider" }),
|
h("div", { class: "divider" }),
|
||||||
// Step 2 — add a model. Models are imported in the Models/Import tab; here
|
// Step 2 — managed local models: one supervised cortiq serve per row, each
|
||||||
// you just point the local server at the .cmf file it produced.
|
// on its own port. Add as many as you have .cmf files + free ports.
|
||||||
h("div", { class: "field" }, h("span", {}, "2. " + t("settings.cmf.addModel"), " ", help(t("settings.cmf.addModelHelp"))),
|
h("div", { class: "field" },
|
||||||
|
h("span", {}, "2. " + t("settings.cmf.models"), " ", help(t("settings.cmf.modelsHelp"))),
|
||||||
|
h("div", { class: "hint" }, t("settings.cmf.rowHint")),
|
||||||
h("div", { class: "hint" }, t("settings.cmf.importNote"),
|
h("div", { class: "hint" }, t("settings.cmf.importNote"),
|
||||||
" ", h("a", { href: "#/import" }, t("settings.cmf.importLink") + " ↗"))),
|
" ", h("a", { href: "#/import" }, t("settings.cmf.importLink") + " ↗"))),
|
||||||
field(t("settings.cmf.model"), cmfModel, null, t("settings.cmf.modelHelp")),
|
serversWrap,
|
||||||
h("div", { class: "divider" }),
|
addServerBtn,
|
||||||
// Step 3 — run it locally + performance.
|
|
||||||
cmfManage.node,
|
|
||||||
cmfServingEl,
|
cmfServingEl,
|
||||||
h("div", { class: "row" },
|
h("div", { class: "hint" }, t("settings.cmf.speedNote")),
|
||||||
field(t("settings.cmf.port"),
|
h("div", { class: "divider" }),
|
||||||
h("div", { class: "flex wrap" }, cmfPort, cmfPortCheckBtn, cmfPortStatus),
|
// Step 3 — how the gateway uses them.
|
||||||
null, t("settings.cmf.portHelp")),
|
cmfManage.node,
|
||||||
field(t("settings.cmf.threads"), cmfThreads, null, t("settings.cmf.threadsHelp"))),
|
cmfLocalOnly.node
|
||||||
cmfGpu.node,
|
|
||||||
cmfLocalOnly.node,
|
|
||||||
h("div", { class: "hint" }, t("settings.cmf.speedNote"))
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
|
|||||||
Reference in New Issue
Block a user