Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e369f99af | ||
|
|
4754e1a065 | ||
|
|
3c4be5e966 | ||
|
|
46c11b9b88 | ||
|
|
851cc7f1cb | ||
|
|
d93ac537c8 | ||
|
|
4220bc27c2 | ||
|
|
6fdbe0e159 | ||
|
|
243d8f4c7d |
Generated
+1
-1
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "cortiq-gateway"
|
||||
version = "0.2.14"
|
||||
version = "0.2.23"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cortiq-gateway"
|
||||
version = "0.2.14"
|
||||
version = "0.2.23"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
license = "Apache-2.0"
|
||||
|
||||
+145
-30
@@ -606,7 +606,10 @@ fn cmf_file_for_model(
|
||||
cmf: &crate::config::CmfCfg,
|
||||
m: &crate::config::ModelCfg,
|
||||
) -> Option<std::path::PathBuf> {
|
||||
// Any loopback URL counts as local — managed servers may sit on any port.
|
||||
let local = m.base_url == cmf.cortiq_server_url
|
||||
|| m.base_url.contains("//127.0.0.1:")
|
||||
|| m.base_url.contains("//localhost:")
|
||||
|| m.base_url
|
||||
.contains(&format!("{}:{}", cmf.local_host, cmf.local_port));
|
||||
let stem = m.model.trim();
|
||||
@@ -646,12 +649,24 @@ fn cmf_path_referenced(cfg: &crate::config::Config, path: &std::path::Path) -> b
|
||||
.any(|m| cmf_file_for_model(&cfg.cmf, m).as_deref() == Some(path))
|
||||
}
|
||||
|
||||
async fn delete_model(State(state): State<SharedState>, Path(id): Path<String>) -> ApiResult {
|
||||
#[derive(Deserialize)]
|
||||
struct DeleteModelQuery {
|
||||
/// Also remove the local `.cmf` from disk. Defaults to true (the historical
|
||||
/// behaviour); the UI asks the user and passes it explicitly.
|
||||
delete_file: Option<bool>,
|
||||
}
|
||||
|
||||
async fn delete_model(
|
||||
State(state): State<SharedState>,
|
||||
Path(id): Path<String>,
|
||||
Query(q): Query<DeleteModelQuery>,
|
||||
) -> ApiResult {
|
||||
let mut cfg = current_cfg(&state);
|
||||
let delete_file = q.delete_file.unwrap_or(true);
|
||||
|
||||
// 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.
|
||||
// server, removes it from the managed config, and (when asked) 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) {
|
||||
@@ -666,10 +681,10 @@ async fn delete_model(State(state): State<SharedState>, Path(id): Path<String>)
|
||||
let shared = cmf_path_referenced(&cfg, &path);
|
||||
state.cmf.stop_one(&id).await;
|
||||
state.reload(cfg)?;
|
||||
let file_removed = if shared {
|
||||
false
|
||||
} else {
|
||||
let file_removed = if delete_file && !shared {
|
||||
remove_cmf_path(&path)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
return ok(json!({ "ok": true, "file_removed": file_removed }));
|
||||
}
|
||||
@@ -685,8 +700,8 @@ async fn delete_model(State(state): State<SharedState>, Path(id): Path<String>)
|
||||
let file = victim
|
||||
.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));
|
||||
// Only remove the file if asked to AND no remaining model still references it.
|
||||
let file = file.filter(|p| delete_file && !cmf_path_referenced(&cfg, p));
|
||||
state.reload(cfg)?;
|
||||
let file_removed = file.map(|p| remove_cmf_path(&p)).unwrap_or(false);
|
||||
ok(json!({ "ok": true, "file_removed": file_removed }))
|
||||
@@ -893,22 +908,101 @@ async fn put_settings(State(state): State<SharedState>, Json(b): Json<SettingsBo
|
||||
needs_restart = true;
|
||||
cfg.cache = v;
|
||||
}
|
||||
let mut cmf_apply: Option<(Vec<crate::config::CmfServer>, CmfCfg)> = None;
|
||||
if let Some(v) = b.cmf {
|
||||
// manage_server / local_model / threads / gpu apply on restart (the server
|
||||
// is spawned once at startup with those env vars); local_only / router
|
||||
// routing apply immediately.
|
||||
if v.manage_server != cfg.cmf.manage_server
|
||||
|| v.local_model != cfg.cmf.local_model
|
||||
|| v.threads != cfg.cmf.threads
|
||||
|| v.gpu != cfg.cmf.gpu
|
||||
|| v.servers != cfg.cmf.servers
|
||||
{
|
||||
needs_restart = true;
|
||||
}
|
||||
let old_servers = cfg.cmf.effective_servers();
|
||||
cfg.cmf = v;
|
||||
// A managed row supersedes a same-id STATIC pool entry that points at a
|
||||
// local server (the shape Import→register creates): drop the static one
|
||||
// instead of failing validation with "duplicate managed model id".
|
||||
let managed_ids: std::collections::HashSet<String> = cfg
|
||||
.cmf
|
||||
.effective_servers()
|
||||
.into_iter()
|
||||
.map(|s| s.id)
|
||||
.collect();
|
||||
cfg.models.retain(|m| {
|
||||
!(managed_ids.contains(&m.id)
|
||||
&& (m.base_url.contains("//127.0.0.1:") || m.base_url.contains("//localhost:")))
|
||||
});
|
||||
// Replacing or renaming a managed model must not brick the save: routing
|
||||
// may still reference the old id. Map a vanished id to its replacement
|
||||
// (the new server on the same port), strip ids that are truly gone, and
|
||||
// repair the default so validate() passes.
|
||||
heal_routing_after_cmf_change(&mut cfg, &old_servers);
|
||||
// Server-list changes apply LIVE after a successful reload: removed /
|
||||
// redefined servers are stopped (RAM freed), new ones are spawned.
|
||||
if cfg.cmf.effective_servers() != old_servers {
|
||||
cmf_apply = Some((old_servers, cfg.cmf.clone()));
|
||||
}
|
||||
}
|
||||
state.reload(cfg)?;
|
||||
ok(json!({ "ok": true, "needs_restart": needs_restart }))
|
||||
let applied_live = cmf_apply.is_some();
|
||||
if let Some((old, new_cfg)) = cmf_apply {
|
||||
tokio::spawn(crate::cmf_runtime::apply_server_changes(
|
||||
state.cmf.clone(),
|
||||
old,
|
||||
new_cfg,
|
||||
));
|
||||
}
|
||||
ok(json!({ "ok": true, "needs_restart": needs_restart, "applied_live": applied_live }))
|
||||
}
|
||||
|
||||
/// After the managed-servers list changed, rewrite [routing] so it only
|
||||
/// references ids that still exist. A removed server whose port is now held by
|
||||
/// a NEW server is treated as a replacement (old id → new id); other vanished
|
||||
/// ids are stripped. An emptied default falls back to the first managed server,
|
||||
/// then the first pool model.
|
||||
fn heal_routing_after_cmf_change(cfg: &mut Config, old_servers: &[crate::config::CmfServer]) {
|
||||
let servers = cfg.cmf.effective_servers();
|
||||
let known: std::collections::HashSet<String> = cfg
|
||||
.models
|
||||
.iter()
|
||||
.map(|m| m.id.clone())
|
||||
.chain(servers.iter().map(|s| s.id.clone()))
|
||||
.collect();
|
||||
let new_by_port: HashMap<u16, String> =
|
||||
servers.iter().map(|s| (s.port, s.id.clone())).collect();
|
||||
let mut rename: HashMap<String, String> = HashMap::new();
|
||||
for old in old_servers {
|
||||
if !known.contains(&old.id) {
|
||||
if let Some(new_id) = new_by_port.get(&old.port) {
|
||||
rename.insert(old.id.clone(), new_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let fix = |id: &str| -> Option<String> {
|
||||
if known.contains(id) {
|
||||
Some(id.to_string())
|
||||
} else {
|
||||
rename.get(id).cloned()
|
||||
}
|
||||
};
|
||||
if !cfg.routing.default.is_empty() {
|
||||
cfg.routing.default = fix(&cfg.routing.default).unwrap_or_default();
|
||||
}
|
||||
if cfg.routing.default.is_empty() {
|
||||
cfg.routing.default = servers
|
||||
.first()
|
||||
.map(|s| s.id.clone())
|
||||
.or_else(|| cfg.models.first().map(|m| m.id.clone()))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
for tt in cfg.routing.tiers.values_mut() {
|
||||
let list = match tt {
|
||||
TierTargets::List(v) => v.clone(),
|
||||
TierTargets::One(s) => vec![s.clone()],
|
||||
};
|
||||
let mut healed: Vec<String> = Vec::new();
|
||||
for id in &list {
|
||||
if let Some(new_id) = fix(id) {
|
||||
if !healed.contains(&new_id) {
|
||||
healed.push(new_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
*tt = TierTargets::List(healed);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── cmf runtime ─────────────────────────────────
|
||||
@@ -931,6 +1025,7 @@ async fn cmf_status(State(state): State<SharedState>) -> ApiResult {
|
||||
"port": s.port,
|
||||
"threads": s.threads,
|
||||
"gpu": s.gpu,
|
||||
"o1": s.o1,
|
||||
"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()),
|
||||
@@ -1028,6 +1123,9 @@ async fn cmf_port_check(State(state): State<SharedState>, Query(q): Query<PortQu
|
||||
ok(json!({
|
||||
"port": port,
|
||||
"available": available,
|
||||
// true = held by this gateway's own managed server (a replaced/edited
|
||||
// row frees it on restart) — the UI shows this as info, not an error
|
||||
"managed": running_here,
|
||||
"detail": detail,
|
||||
"suggested": suggested,
|
||||
}))
|
||||
@@ -1349,24 +1447,44 @@ struct TestBody {
|
||||
temperature: Option<f32>,
|
||||
#[serde(default)]
|
||||
max_tokens: Option<u32>,
|
||||
#[serde(default)]
|
||||
top_p: Option<f32>,
|
||||
/// Reasoning switch (cortiq ≥ 0.2.1): false = answer without the thinking
|
||||
/// phase; true = force it on. Absent = model/row default.
|
||||
#[serde(default)]
|
||||
enable_thinking: Option<bool>,
|
||||
}
|
||||
fn default_test_model() -> String {
|
||||
"cortiq-auto".into()
|
||||
}
|
||||
|
||||
/// GenParams for a playground request: native sampling fields + the reasoning
|
||||
/// switch proxied via passthrough (providers forward unknown fields as-is).
|
||||
fn test_gen_params(b: &TestBody) -> GenParams {
|
||||
let mut params = GenParams {
|
||||
temperature: b.temperature,
|
||||
max_tokens: b.max_tokens,
|
||||
top_p: b.top_p,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(v) = b.enable_thinking {
|
||||
params
|
||||
.passthrough
|
||||
.insert("enable_thinking".into(), serde_json::Value::Bool(v));
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
async fn run_test(State(state): State<SharedState>, Json(b): Json<TestBody>) -> ApiResult {
|
||||
if b.messages.is_empty() {
|
||||
return Err(ApiError::bad("messages must not be empty"));
|
||||
}
|
||||
let params = test_gen_params(&b);
|
||||
let req = ChatRequest {
|
||||
routing: parse_routing(&b.model),
|
||||
messages: b.messages,
|
||||
tools: vec![],
|
||||
params: GenParams {
|
||||
temperature: b.temperature,
|
||||
max_tokens: b.max_tokens,
|
||||
..Default::default()
|
||||
},
|
||||
params,
|
||||
stream: false,
|
||||
meta: RequestMeta {
|
||||
account: "playground".into(),
|
||||
@@ -1415,15 +1533,12 @@ async fn run_test_stream(State(state): State<SharedState>, Json(b): Json<TestBod
|
||||
if b.messages.is_empty() {
|
||||
return ApiError::bad("messages must not be empty").into_response();
|
||||
}
|
||||
let params = test_gen_params(&b);
|
||||
let req = ChatRequest {
|
||||
routing: parse_routing(&b.model),
|
||||
messages: b.messages,
|
||||
tools: vec![],
|
||||
params: GenParams {
|
||||
temperature: b.temperature,
|
||||
max_tokens: b.max_tokens,
|
||||
..Default::default()
|
||||
},
|
||||
params,
|
||||
stream: true,
|
||||
meta: RequestMeta {
|
||||
account: "playground".into(),
|
||||
|
||||
+87
-1
@@ -173,6 +173,31 @@ impl CmfRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure a status slot exists for `server` (used when servers are added at
|
||||
/// runtime); refreshes model/port if the slot already exists.
|
||||
fn add_server_slot(&self, server: &CmfServer, host: &str) {
|
||||
let mut s = self.status.lock().unwrap();
|
||||
s.enabled = true;
|
||||
if let Some(sv) = s.servers.iter_mut().find(|x| x.id == server.id) {
|
||||
sv.model = server.model.clone();
|
||||
sv.port = server.port;
|
||||
sv.base_url = format!("http://{host}:{}/v1", server.port);
|
||||
sv.running = false;
|
||||
sv.healthy = false;
|
||||
sv.last_error = None;
|
||||
} else {
|
||||
s.servers.push(CmfServerStatus {
|
||||
id: server.id.clone(),
|
||||
model: server.model.clone(),
|
||||
port: server.port,
|
||||
base_url: format!("http://{host}:{}/v1", server.port),
|
||||
running: false,
|
||||
healthy: false,
|
||||
last_error: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop and forget a single managed server (used when a model is deleted).
|
||||
pub async fn stop_one(&self, id: &str) {
|
||||
let removed = {
|
||||
@@ -238,7 +263,7 @@ fn parse_ver(s: &str) -> (u32, u32, u32) {
|
||||
)
|
||||
}
|
||||
|
||||
fn version_lt(a: &str, b: &str) -> bool {
|
||||
pub(crate) fn version_lt(a: &str, b: &str) -> bool {
|
||||
parse_ver(a) < parse_ver(b)
|
||||
}
|
||||
|
||||
@@ -295,6 +320,11 @@ fn spawn_serve(server: &CmfServer, cfg: &CmfCfg) -> Result<Child, String> {
|
||||
if server.gpu {
|
||||
cmd.env("CMF_GPU", "1");
|
||||
}
|
||||
// Serve-time O(1) attention override: "off" forces exact attention (undoes a
|
||||
// too-aggressive --o1 conversion hint without re-converting the file).
|
||||
if !server.o1.trim().is_empty() {
|
||||
cmd.env("CMF_O1", server.o1.trim());
|
||||
}
|
||||
cmd.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
@@ -322,6 +352,62 @@ async fn wait_healthy(host: &str, port: u16, tries: u32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Apply a changed managed-servers list LIVE (no gateway restart): stop servers
|
||||
/// that were removed or redefined — freeing their memory immediately — and
|
||||
/// spawn servers that are new or redefined.
|
||||
pub async fn apply_server_changes(rt: Arc<CmfRuntime>, old: Vec<CmfServer>, cfg: CmfCfg) {
|
||||
let new = cfg.effective_servers();
|
||||
// 1. Stop what's gone or changed (kills the child → RAM released).
|
||||
for o in &old {
|
||||
let unchanged = new.iter().any(|n| n == o);
|
||||
if !unchanged {
|
||||
rt.stop_one(&o.id).await;
|
||||
rt.log(format!("stopped '{}' (removed or changed)", o.id));
|
||||
}
|
||||
}
|
||||
if new.is_empty() {
|
||||
return;
|
||||
}
|
||||
// 2. Spawn what's new or changed.
|
||||
if installed_version(&cfg.cortiq_bin).is_none() {
|
||||
rt.fail("cortiq binary not found — cannot start local model servers");
|
||||
return;
|
||||
}
|
||||
for n in &new {
|
||||
let unchanged = old.iter().any(|o| o == n);
|
||||
if unchanged {
|
||||
continue; // already running with the same definition
|
||||
}
|
||||
rt.add_server_slot(n, &cfg.local_host);
|
||||
if !std::path::Path::new(&n.model).exists() {
|
||||
rt.fail_server(&n.id, format!("model file not found: {}", n.model));
|
||||
continue;
|
||||
}
|
||||
rt.log(format!(
|
||||
"starting: cortiq serve {} --host {} --port {}",
|
||||
n.model, cfg.local_host, n.port
|
||||
));
|
||||
match spawn_serve(n, &cfg) {
|
||||
Ok(child) => rt.add_child(n.id.clone(), child),
|
||||
Err(e) => {
|
||||
rt.fail_server(&n.id, format!("failed to spawn: {e}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let (rtc, host, id, port) = (rt.clone(), cfg.local_host.clone(), n.id.clone(), n.port);
|
||||
tokio::spawn(async move {
|
||||
if wait_healthy(&host, port, 240).await {
|
||||
rtc.set_healthy(&id, true);
|
||||
rtc.log(format!(
|
||||
"local CMF server ready — '{id}' at http://{host}:{port}/v1"
|
||||
));
|
||||
} else {
|
||||
rtc.fail_server(&id, "did not become healthy in time");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestrate the whole lifecycle: ensure installed → maybe update → spawn each
|
||||
/// configured local model → wait healthy. Safe to call in a background task; it
|
||||
/// never panics.
|
||||
|
||||
+36
-1
@@ -83,6 +83,26 @@ pub struct CmfServer {
|
||||
/// Offload matvecs to the GPU (CMF_GPU=1).
|
||||
#[serde(default)]
|
||||
pub gpu: bool,
|
||||
/// Serve-time O(1) attention override (exported as CMF_O1). Empty = follow
|
||||
/// the hint recorded in the .cmf; "off" = force exact attention (fixes a
|
||||
/// too-aggressive conversion without re-converting); "all"/"deepN"/"i,j,k"
|
||||
/// = force-enable on those layers.
|
||||
#[serde(default)]
|
||||
pub o1: String,
|
||||
/// Default generation params for this model — applied by the gateway when
|
||||
/// the incoming request does not set them (an explicit request value wins).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Reasoning switch for thinking models (Qwen3/3.5 …): "off" injects
|
||||
/// enable_thinking=false into requests that don't set it themselves —
|
||||
/// the model answers directly, skipping the <think> phase. Needs
|
||||
/// cortiq ≥ 0.2.1. Empty = model/template default.
|
||||
#[serde(default)]
|
||||
pub thinking: String,
|
||||
}
|
||||
|
||||
/// CMF-format model factory: import a HuggingFace model, convert it to a
|
||||
@@ -172,6 +192,11 @@ impl CmfCfg {
|
||||
port: self.local_port,
|
||||
threads: self.threads,
|
||||
gpu: self.gpu,
|
||||
o1: String::new(),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
max_tokens: None,
|
||||
thinking: String::new(),
|
||||
}];
|
||||
}
|
||||
Vec::new()
|
||||
@@ -675,9 +700,19 @@ impl Config {
|
||||
self.models.iter().map(|m| m.id.as_str()).collect();
|
||||
// managed local servers are known ids too; also enforce unique ports/ids
|
||||
let mut ports = std::collections::HashSet::new();
|
||||
let mut managed_seen = std::collections::HashSet::new();
|
||||
for s in &servers {
|
||||
if !managed_seen.insert(s.id.as_str()) {
|
||||
anyhow::bail!(
|
||||
"config: two managed local models share the id '{}' — give one of the rows a different id",
|
||||
s.id
|
||||
);
|
||||
}
|
||||
if !ids.insert(s.id.as_str()) {
|
||||
anyhow::bail!("config: duplicate managed model id '{}'", s.id);
|
||||
anyhow::bail!(
|
||||
"config: managed local model '{}' has the same id as a pool model — rename the row or delete the pool entry",
|
||||
s.id
|
||||
);
|
||||
}
|
||||
if !ports.insert(s.port) {
|
||||
anyhow::bail!("config: two managed models share port {}", s.port);
|
||||
|
||||
@@ -71,6 +71,20 @@ pub struct ImportParams {
|
||||
pub shard_max_gb: Option<f32>,
|
||||
#[serde(default)]
|
||||
pub skip_mtp: bool,
|
||||
/// O(1) Nyström attention hint (native converter, cortiq ≥ 0.2.0):
|
||||
/// `all` | `deepN` | `i,j,k`. Weights pass through unchanged — the runtime
|
||||
/// reads the hint at load. Empty/None/"off" = exact attention.
|
||||
#[serde(default)]
|
||||
pub o1: Option<String>,
|
||||
/// Landmark budget for the o1 hint (validated default 32).
|
||||
#[serde(default)]
|
||||
pub o1_m: Option<usize>,
|
||||
/// Exact-window width for the o1 hint (validated default 128).
|
||||
#[serde(default)]
|
||||
pub o1_window: Option<usize>,
|
||||
/// Permanent exact sink keys for the o1 hint (validated default 4).
|
||||
#[serde(default)]
|
||||
pub o1_sink: Option<usize>,
|
||||
}
|
||||
fn default_quant() -> String {
|
||||
"Q8_2F".into()
|
||||
@@ -269,6 +283,34 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
||||
cfg.converter
|
||||
));
|
||||
}
|
||||
// O(1) attention hint — native-converter feature (cortiq ≥ 0.2.0). Fail fast
|
||||
// with a clear message instead of a mid-job "unexpected argument" error.
|
||||
let o1 =
|
||||
p.o1.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty() && *s != "off")
|
||||
.map(str::to_string);
|
||||
if o1.is_some() {
|
||||
if use_python {
|
||||
return Err(
|
||||
"O(1) attention is supported by the native converter only — remove the \
|
||||
linear-core / v-bit shape / mean-bits options to use it"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if p.repo.to_ascii_lowercase().contains("gguf") {
|
||||
return Err("O(1) attention is not supported for GGUF imports yet".into());
|
||||
}
|
||||
let ver = crate::cmf_runtime::installed_version(&cfg.cortiq_bin);
|
||||
if let Some(v) = &ver {
|
||||
if crate::cmf_runtime::version_lt(v, "0.2.0") {
|
||||
return Err(format!(
|
||||
"O(1) attention needs cortiq ≥ 0.2.0 (installed: {v}) — update the runtime \
|
||||
in Settings → Local models"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = std::fs::create_dir_all(&cfg.models_dir);
|
||||
let base = if p.name.trim().is_empty() {
|
||||
sanitize(p.repo.rsplit('/').next().unwrap_or(&p.repo))
|
||||
@@ -384,6 +426,23 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
||||
a.push("--hf-token".into());
|
||||
a.push(t.clone());
|
||||
}
|
||||
// O(1) attention hint (weights unchanged; the runtime reads it at load)
|
||||
if let Some(spec) = &o1 {
|
||||
a.push("--o1".into());
|
||||
a.push(spec.clone());
|
||||
if let Some(m) = p.o1_m {
|
||||
a.push("--o1-m".into());
|
||||
a.push(m.to_string());
|
||||
}
|
||||
if let Some(w) = p.o1_window {
|
||||
a.push("--o1-window".into());
|
||||
a.push(w.to_string());
|
||||
}
|
||||
if let Some(s) = p.o1_sink {
|
||||
a.push("--o1-sink".into());
|
||||
a.push(s.to_string());
|
||||
}
|
||||
}
|
||||
(
|
||||
cfg.cortiq_bin.clone(),
|
||||
a,
|
||||
|
||||
+35
-2
@@ -301,6 +301,35 @@ impl Pipeline {
|
||||
Ok((decision, route_source, final_candidates))
|
||||
}
|
||||
|
||||
/// Fill in the model's default generation params (from its Local-models row)
|
||||
/// for anything the request left unset — an explicit request value wins.
|
||||
fn apply_gen_defaults(req: &mut ChatRequest, cfg: &crate::config::Config, model_id: &str) {
|
||||
if let Some(s) = cfg
|
||||
.cmf
|
||||
.effective_servers()
|
||||
.into_iter()
|
||||
.find(|s| s.id == model_id)
|
||||
{
|
||||
if req.params.temperature.is_none() {
|
||||
req.params.temperature = s.temperature;
|
||||
}
|
||||
if req.params.top_p.is_none() {
|
||||
req.params.top_p = s.top_p;
|
||||
}
|
||||
if req.params.max_tokens.is_none() {
|
||||
req.params.max_tokens = s.max_tokens;
|
||||
}
|
||||
// reasoning switch: "off" → enable_thinking=false (cortiq ≥ 0.2.1
|
||||
// renders the chat template with an empty <think> block); a client
|
||||
// that sets enable_thinking itself wins.
|
||||
if s.thinking == "off" && !req.params.passthrough.contains_key("enable_thinking") {
|
||||
req.params
|
||||
.passthrough
|
||||
.insert("enable_thinking".into(), serde_json::Value::Bool(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_inner(&self, req: ChatRequest, state: &SharedState) -> Result<ChatResponse> {
|
||||
let live = state.live();
|
||||
let (decision, route_source, mut final_candidates) = self.resolve(&req, &live).await?;
|
||||
@@ -341,7 +370,9 @@ impl Pipeline {
|
||||
route_source
|
||||
);
|
||||
|
||||
match provider.chat(req.clone()).await {
|
||||
let mut attempt_req = req.clone();
|
||||
Self::apply_gen_defaults(&mut attempt_req, &live.cfg, &model_id);
|
||||
match provider.chat(attempt_req).await {
|
||||
Ok(mut resp) => {
|
||||
let cost =
|
||||
provider.price(resp.usage.prompt_tokens, resp.usage.completion_tokens);
|
||||
@@ -415,7 +446,9 @@ impl Pipeline {
|
||||
model_id,
|
||||
route_source
|
||||
);
|
||||
match provider.chat_stream(req.clone()).await {
|
||||
let mut attempt_req = req.clone();
|
||||
Self::apply_gen_defaults(&mut attempt_req, &live.cfg, &model_id);
|
||||
match provider.chat_stream(attempt_req).await {
|
||||
Ok(stream) => {
|
||||
let info = RouteInfo {
|
||||
task_label: decision.task_label.clone(),
|
||||
|
||||
@@ -45,6 +45,9 @@ impl OpenAiProvider {
|
||||
if let Some(m) = req.params.max_tokens {
|
||||
body["max_tokens"] = m.into();
|
||||
}
|
||||
if let Some(p) = req.params.top_p {
|
||||
body["top_p"] = p.into();
|
||||
}
|
||||
if !req.tools.is_empty() {
|
||||
body["tools"] = serde_json::Value::Array(req.tools.clone());
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -60,7 +60,8 @@ export const api = {
|
||||
listModels: () => j("GET", "/models"),
|
||||
createModel: (b) => j("POST", "/models", b),
|
||||
updateModel: (id, b) => j("PUT", "/models/" + encodeURIComponent(id), b),
|
||||
deleteModel: (id) => j("DELETE", "/models/" + encodeURIComponent(id)),
|
||||
deleteModel: (id, deleteFile = true) =>
|
||||
j("DELETE", "/models/" + encodeURIComponent(id) + "?delete_file=" + (deleteFile ? "true" : "false")),
|
||||
probeModel: (id) => j("POST", "/models/" + encodeURIComponent(id) + "/probe"),
|
||||
|
||||
// routing
|
||||
|
||||
Vendored
+35
-1
@@ -132,6 +132,8 @@ export default {
|
||||
"models.form.secretMissing": "not set",
|
||||
"models.form.secretPlaceholder": "set or replace key (write-only)",
|
||||
"models.deleteConfirm": "Delete model \"{id}\"?",
|
||||
"models.deleteFile": "also delete the model file from disk",
|
||||
"models.deletedWithFile": "Deleted (file removed from disk)",
|
||||
"models.form.idOrModel": "Enter an id or a model name",
|
||||
"models.form.apiKey": "API Key",
|
||||
"models.form.apiKeyPlaceholder": "paste your API key",
|
||||
@@ -221,6 +223,12 @@ export default {
|
||||
"pg.model": "Model",
|
||||
"pg.temp": "Temperature",
|
||||
"pg.maxTokens": "Max tokens",
|
||||
"pg.think": "Thoughts",
|
||||
"pg.think.auto": "auto (model default)",
|
||||
"pg.think.off": "off — answer directly",
|
||||
"pg.think.on": "on — force reasoning",
|
||||
"pg.thinkHelp": "Reasoning phase of thinking models. Auto = model/row default; off/on override for this request (needs cortiq >= 0.2.1 for local models).",
|
||||
"pg.topPHelp": "Nucleus sampling: only tokens within this cumulative probability are sampled. Empty = default.",
|
||||
"pg.send": "Send",
|
||||
"pg.stream": "Stream",
|
||||
"pg.sending": "Sending…",
|
||||
@@ -262,11 +270,20 @@ export default {
|
||||
"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.rowHint": "id · .cmf file · port · threads · GPU · O(1) · T°/top-p/max tok. Applied on save: a removed row stops its server (memory freed), a new row starts one.",
|
||||
"settings.cmf.thinkAuto": "thoughts: auto",
|
||||
"settings.cmf.thinkOff": "thoughts: off",
|
||||
"settings.cmf.thinkHelp": "Reasoning models (Qwen3/3.5) write out a thinking phase before the answer. Off = answer directly without it (faster, shorter; needs cortiq >= 0.2.1). A request that sets enable_thinking itself always wins.",
|
||||
"settings.cmf.tempHelp": "Default temperature for this model (0 = deterministic, 0.7 = balanced, >1 = creative). Used when the request doesn't set one; a request value always wins. Empty = provider default.",
|
||||
"settings.cmf.topPHelp": "Default nucleus sampling (top-p) for this model: only tokens within this cumulative probability are sampled (0.9 typical). Empty = provider default.",
|
||||
"settings.cmf.maxTokHelp": "Default max output tokens for this model when the request doesn't set one. Protects against runaway long generations. Empty = provider default.",
|
||||
"settings.cmf.o1Auto": "O(1): auto",
|
||||
"settings.cmf.o1Help": "O(1) attention at serve time: auto — follow the hint recorded in the .cmf; off — force exact attention (fixes incoherent output after a too-aggressive --o1 conversion, no re-convert needed); all/deepN — force-enable on those layers.",
|
||||
"settings.cmf.addServer": "+ Add local model",
|
||||
"settings.cmf.checkPort": "Check port",
|
||||
"settings.cmf.portFree": "port is free",
|
||||
"settings.cmf.portBusy": "port is in use",
|
||||
"settings.cmf.portOwn": "in use by this gateway's current server — freed on restart, replacing is fine",
|
||||
"settings.cmf.useSuggested": "use {port}",
|
||||
"settings.cmf.serving": "serving {model} on :{port}",
|
||||
"settings.cmf.intro": "Run models locally with the cortiq engine — works even offline or when the router is down.",
|
||||
@@ -400,4 +417,21 @@ export default {
|
||||
"import.adv.shard": "Shard max (GB)",
|
||||
"import.adv.noShard": "no sharding",
|
||||
"import.adv.skipMtp": "skip MTP head",
|
||||
"import.adv.o1": "O(1) attention",
|
||||
"import.adv.o1Hint": "Constant-memory streaming attention: the KV cache is released after prefill, so long contexts don't grow memory. Weights stay byte-identical — only a load-time hint is recorded; you can switch back anytime (serve with --o1 off). Validated on Qwen3-0.6B: all layers ≈ ×1.18 perplexity.",
|
||||
"import.adv.o1Off": "off — exact attention (default)",
|
||||
"import.adv.o1All": "all — convert every softmax layer",
|
||||
"import.adv.o1Deep": "deep N — only the N deepest layers",
|
||||
"import.adv.o1Custom": "custom — explicit layer list",
|
||||
"import.adv.o1DeepN": "N deep layers",
|
||||
"import.adv.o1Layers": "Layers (comma-separated)",
|
||||
"import.adv.o1mHint": "landmark budget (default 32)",
|
||||
"import.adv.o1mLabel": "Landmarks (--o1-m)",
|
||||
"import.adv.o1mHelp": "How many landmark tokens summarize the whole older history when attention is approximated. More = closer to exact attention but slower and more memory; fewer = faster but rougher. 32 is the validated sweet spot — change only if you know why.",
|
||||
"import.adv.o1windowLabel": "Exact window (--o1-window)",
|
||||
"import.adv.o1windowHelp": "The last N tokens always get exact (non-approximated) attention. This protects quality on the text the model just wrote/read. Bigger = better local quality, more memory. Default 128.",
|
||||
"import.adv.o1sinkLabel": "Sink keys (--o1-sink)",
|
||||
"import.adv.o1sinkHelp": "The first N tokens of the context are kept exact forever ('attention sinks'). Models lean on these anchors — keeping them exact stabilizes long generations. Default 4; rarely needs changing.",
|
||||
"import.adv.o1windowHint": "exact-window width (default 128)",
|
||||
"import.adv.o1sinkHint": "permanent exact sink keys (default 4)",
|
||||
};
|
||||
|
||||
Vendored
+35
-1
@@ -131,6 +131,8 @@ export default {
|
||||
"models.form.secretMissing": "не задан",
|
||||
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
|
||||
"models.deleteConfirm": "Удалить модель \"{id}\"?",
|
||||
"models.deleteFile": "также удалить файл модели с диска",
|
||||
"models.deletedWithFile": "Удалено (файл стёрт с диска)",
|
||||
"models.form.idOrModel": "Укажите id или имя модели",
|
||||
"models.form.apiKey": "API-ключ",
|
||||
"models.form.apiKeyPlaceholder": "вставьте API-ключ",
|
||||
@@ -220,6 +222,12 @@ export default {
|
||||
"pg.model": "Модель",
|
||||
"pg.temp": "Температура",
|
||||
"pg.maxTokens": "Макс. токены",
|
||||
"pg.think": "Мысли",
|
||||
"pg.think.auto": "авто (по настройке модели)",
|
||||
"pg.think.off": "выкл — отвечать сразу",
|
||||
"pg.think.on": "вкл — принудительно размышлять",
|
||||
"pg.thinkHelp": "Фаза размышлений reasoning-моделей. Авто = настройка модели; выкл/вкл переопределяют для этого запроса (для локальных нужен cortiq ≥ 0.2.1).",
|
||||
"pg.topPHelp": "Nucleus-сэмплинг: выбираются токены в пределах этой суммарной вероятности. Пусто = по умолчанию.",
|
||||
"pg.send": "Отправить",
|
||||
"pg.stream": "Стриминг",
|
||||
"pg.sending": "Отправка…",
|
||||
@@ -261,11 +269,20 @@ export default {
|
||||
"settings.cmf.pick": "выберите модель",
|
||||
"settings.cmf.models": "Локальные модели",
|
||||
"settings.cmf.modelsHelp": "Каждая строка — отдельная модель: шлюз поднимает свой cortiq serve на своём порту и регистрирует её в пуле. Добавляйте столько, сколько есть .cmf-файлов и свободных портов.",
|
||||
"settings.cmf.rowHint": "id · файл .cmf · порт · потоки · GPU. Изменения применяются после перезапуска; удаление строки останавливает сервер.",
|
||||
"settings.cmf.rowHint": "id · файл .cmf · порт · потоки · GPU · O(1) · T°/top-p/max tok. Применяется сразу при сохранении: удалённая строка останавливает сервер (память освобождается), новая — запускает.",
|
||||
"settings.cmf.thinkAuto": "мысли: авто",
|
||||
"settings.cmf.thinkOff": "мысли: выкл",
|
||||
"settings.cmf.thinkHelp": "Рассуждающие модели (Qwen3/3.5) сначала пишут фазу размышлений. Выкл = отвечать сразу, без неё (быстрее и короче; нужен cortiq ≥ 0.2.1). Если запрос сам задал enable_thinking — он важнее.",
|
||||
"settings.cmf.tempHelp": "Температура по умолчанию для этой модели (0 = детерминированно, 0.7 = баланс, >1 = креативно). Подставляется, если запрос её не задал; значение из запроса всегда важнее. Пусто = дефолт провайдера.",
|
||||
"settings.cmf.topPHelp": "Nucleus-сэмплинг (top-p) по умолчанию: выбираются только токены в пределах этой суммарной вероятности (обычно 0.9). Пусто = дефолт провайдера.",
|
||||
"settings.cmf.maxTokHelp": "Максимум выходных токенов по умолчанию, если запрос не задал свой. Защищает от бесконечных генераций. Пусто = дефолт провайдера.",
|
||||
"settings.cmf.o1Auto": "O(1): авто",
|
||||
"settings.cmf.o1Help": "O(1)-внимание при запуске: авто — использовать подсказку из .cmf; off — принудительно точное внимание (лечит бессвязный вывод после слишком агрессивной --o1 конвертации, без переконвертации); all/deepN — принудительно включить на этих слоях.",
|
||||
"settings.cmf.addServer": "+ Добавить модель",
|
||||
"settings.cmf.checkPort": "Проверить порт",
|
||||
"settings.cmf.portFree": "порт свободен",
|
||||
"settings.cmf.portBusy": "порт занят",
|
||||
"settings.cmf.portOwn": "занят текущим сервером шлюза — освободится после перезапуска, замена допустима",
|
||||
"settings.cmf.useSuggested": "занять {port}",
|
||||
"settings.cmf.serving": "работает: {model} на :{port}",
|
||||
"settings.cmf.intro": "Запускайте модели локально движком cortiq — работает даже офлайн или когда роутер недоступен.",
|
||||
@@ -399,4 +416,21 @@ export default {
|
||||
"import.adv.shard": "Шард макс (ГБ)",
|
||||
"import.adv.noShard": "без шардинга",
|
||||
"import.adv.skipMtp": "пропустить MTP-голову",
|
||||
"import.adv.o1": "O(1)-внимание",
|
||||
"import.adv.o1Hint": "Потоковое внимание с постоянной памятью: KV-кэш освобождается после prefill, длинный контекст не раздувает память. Веса не меняются — записывается лишь подсказка на загрузку; вернуться можно всегда (запуск с --o1 off). Проверено на Qwen3-0.6B: все слои ≈ ×1.18 перплексии.",
|
||||
"import.adv.o1Off": "выкл — точное внимание (по умолчанию)",
|
||||
"import.adv.o1All": "all — конвертировать все softmax-слои",
|
||||
"import.adv.o1Deep": "deep N — только N самых глубоких слоёв",
|
||||
"import.adv.o1Custom": "вручную — явный список слоёв",
|
||||
"import.adv.o1DeepN": "N глубоких слоёв",
|
||||
"import.adv.o1Layers": "Слои (через запятую)",
|
||||
"import.adv.o1mHint": "число опорных точек (по умолчанию 32)",
|
||||
"import.adv.o1mLabel": "Опорные точки (--o1-m)",
|
||||
"import.adv.o1mHelp": "Сколько опорных токенов представляют всю старую историю при аппроксимации внимания. Больше — ближе к точному вниманию, но медленнее и больше памяти; меньше — быстрее, но грубее. 32 — проверенное значение; меняйте, только если понимаете зачем.",
|
||||
"import.adv.o1windowLabel": "Точное окно (--o1-window)",
|
||||
"import.adv.o1windowHelp": "Последние N токенов всегда обрабатываются точным (не аппроксимированным) вниманием — это защищает качество на только что написанном/прочитанном тексте. Больше — лучше локальное качество, больше памяти. По умолчанию 128.",
|
||||
"import.adv.o1sinkLabel": "Sink-ключи (--o1-sink)",
|
||||
"import.adv.o1sinkHelp": "Первые N токенов контекста навсегда остаются точными («якоря внимания»). Модели опираются на эти якоря — их точность стабилизирует длинную генерацию. По умолчанию 4; менять почти никогда не нужно.",
|
||||
"import.adv.o1windowHint": "ширина точного окна (по умолчанию 128)",
|
||||
"import.adv.o1sinkHint": "постоянные точные sink-ключи (по умолчанию 4)",
|
||||
};
|
||||
|
||||
Vendored
+50
-2
@@ -15,8 +15,14 @@ const QUANTS = [
|
||||
["F32", "import.q.f32"],
|
||||
];
|
||||
|
||||
function field(label, control, hint) {
|
||||
return h("label", { class: "field" }, h("span", {}, label), control,
|
||||
// A small "?" badge with a hover/focus tooltip (same style as Settings).
|
||||
function help(text) {
|
||||
return text ? h("span", { class: "help", title: text, tabindex: "0", role: "img", "aria-label": text }, "?") : null;
|
||||
}
|
||||
function field(label, control, hint, helpText) {
|
||||
return h("label", { class: "field" },
|
||||
h("span", {}, label, helpText ? " " : null, help(helpText)),
|
||||
control,
|
||||
hint ? h("div", { class: "hint" }, hint) : null);
|
||||
}
|
||||
function opt(v, label, sel) {
|
||||
@@ -125,6 +131,32 @@ export function renderImport() {
|
||||
const meanBitsIn = h("input", { type: "number", step: "0.1", placeholder: "5.0" });
|
||||
const shardIn = h("input", { type: "number", step: "0.5", placeholder: t("import.adv.noShard") });
|
||||
const skipMtp = h("input", { type: "checkbox" });
|
||||
// O(1) attention (cortiq ≥ 0.2.0): constant-memory streaming attention hint,
|
||||
// weights byte-identical — the runtime applies it at load.
|
||||
const o1Sel = h("select", {},
|
||||
opt("", t("import.adv.o1Off")),
|
||||
opt("all", t("import.adv.o1All")),
|
||||
opt("deep", t("import.adv.o1Deep")),
|
||||
opt("custom", t("import.adv.o1Custom")));
|
||||
const o1DeepIn = h("input", { type: "number", min: "1", placeholder: "12" });
|
||||
const o1CustomIn = h("input", { placeholder: "0,4,8" });
|
||||
const o1M = h("input", { type: "number", min: "1", placeholder: "32" });
|
||||
const o1Window = h("input", { type: "number", min: "0", placeholder: "128" });
|
||||
const o1Sink = h("input", { type: "number", min: "0", placeholder: "4" });
|
||||
const o1SpecRow = h("div", { class: "row", style: "display:none" },
|
||||
field(t("import.adv.o1DeepN"), o1DeepIn),
|
||||
field(t("import.adv.o1Layers"), o1CustomIn));
|
||||
const o1Knobs = h("div", { class: "row", style: "display:none" },
|
||||
field(t("import.adv.o1mLabel"), o1M, t("import.adv.o1mHint"), t("import.adv.o1mHelp")),
|
||||
field(t("import.adv.o1windowLabel"), o1Window, t("import.adv.o1windowHint"), t("import.adv.o1windowHelp")),
|
||||
field(t("import.adv.o1sinkLabel"), o1Sink, t("import.adv.o1sinkHint"), t("import.adv.o1sinkHelp")));
|
||||
o1Sel.addEventListener("change", () => {
|
||||
const on = !!o1Sel.value;
|
||||
o1Knobs.style.display = on ? "" : "none";
|
||||
o1SpecRow.style.display = o1Sel.value === "deep" || o1Sel.value === "custom" ? "" : "none";
|
||||
o1DeepIn.parentElement.style.display = o1Sel.value === "deep" ? "" : "none";
|
||||
o1CustomIn.parentElement.style.display = o1Sel.value === "custom" ? "" : "none";
|
||||
});
|
||||
const advBody = h(
|
||||
"div",
|
||||
{ class: "adv-body", style: "display:none" },
|
||||
@@ -137,6 +169,9 @@ export function renderImport() {
|
||||
h("div", { class: "row" },
|
||||
field(t("import.adv.shard"), shardIn),
|
||||
field("MTP", h("label", { class: "check" }, skipMtp, t("import.adv.skipMtp")))),
|
||||
field(t("import.adv.o1"), o1Sel, t("import.adv.o1Hint")),
|
||||
o1SpecRow,
|
||||
o1Knobs,
|
||||
);
|
||||
const advToggle = h("button", { class: "btn ghost sm" }, "⚙ " + t("import.adv.title"));
|
||||
advToggle.addEventListener("click", () => {
|
||||
@@ -156,6 +191,19 @@ export function renderImport() {
|
||||
if (vbitSel.value) params.vbit_shape = vbitSel.value;
|
||||
if (meanBitsIn.value) params.mean_bits = parseFloat(meanBitsIn.value);
|
||||
if (shardIn.value) params.shard_max_gb = parseFloat(shardIn.value);
|
||||
if (o1Sel.value) {
|
||||
// spec: all | deepN | explicit layer list
|
||||
params.o1 =
|
||||
o1Sel.value === "deep" ? "deep" + (parseInt(o1DeepIn.value) || 12)
|
||||
: o1Sel.value === "custom" ? o1CustomIn.value.trim()
|
||||
: "all";
|
||||
if (!params.o1) delete params.o1;
|
||||
else {
|
||||
if (o1M.value) params.o1_m = parseInt(o1M.value);
|
||||
if (o1Window.value) params.o1_window = parseInt(o1Window.value);
|
||||
if (o1Sink.value) params.o1_sink = parseInt(o1Sink.value);
|
||||
}
|
||||
}
|
||||
goBtn.disabled = true;
|
||||
try {
|
||||
await api.startImport(params);
|
||||
|
||||
Vendored
+28
-10
@@ -1,5 +1,5 @@
|
||||
// Models — pool table with add/edit modal, probe, and per-model secret entry.
|
||||
import { h, mount, modal, toast, confirmDialog, money } from "../ui.js";
|
||||
import { h, mount, modal, toast, money } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { appState } from "../app.js";
|
||||
@@ -280,15 +280,33 @@ function row(m, meta, reload) {
|
||||
"button",
|
||||
{
|
||||
class: "btn sm danger",
|
||||
onClick: async () => {
|
||||
if (!(await confirmDialog(t("models.deleteConfirm", { id: m.id })))) return;
|
||||
try {
|
||||
await api.deleteModel(m.id);
|
||||
toast(t("toast.deleted"), "good");
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
onClick: () => {
|
||||
// local models may have a .cmf on disk — let the user decide its fate
|
||||
const isLocal = m.managed || /127\.0\.0\.1|localhost/.test(m.base_url || "");
|
||||
// unchecked by default: deleting a pool entry should not silently
|
||||
// cost the user a multi-GB re-download/conversion
|
||||
const fileCb = h("input", { type: "checkbox" });
|
||||
const body = h(
|
||||
"div",
|
||||
{},
|
||||
h("p", {}, t("models.deleteConfirm", { id: m.id })),
|
||||
isLocal
|
||||
? h("label", { class: "check" }, fileCb, t("models.deleteFile"))
|
||||
: null
|
||||
);
|
||||
modal(t("common.delete") + ": " + m.id, body, async () => {
|
||||
try {
|
||||
const r = await api.deleteModel(m.id, isLocal && fileCb.checked);
|
||||
toast(
|
||||
r.file_removed ? t("models.deletedWithFile") : t("toast.deleted"),
|
||||
"good"
|
||||
);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
return false;
|
||||
}
|
||||
}, t("common.delete"));
|
||||
},
|
||||
},
|
||||
t("common.delete")
|
||||
|
||||
Vendored
+11
@@ -16,6 +16,12 @@ export async function renderPlayground() {
|
||||
const promptIn = h("textarea", { placeholder: t("pg.promptPlaceholder"), rows: 5 });
|
||||
const tempIn = h("input", { type: "number", step: "0.1", value: 0.7 });
|
||||
const maxTokIn = h("input", { type: "number", value: 512 });
|
||||
const topPIn = h("input", { type: "number", step: "0.05", min: 0, max: 1, placeholder: "0.9", title: t("pg.topPHelp") });
|
||||
// reasoning switch: auto = model/row default; off/on override per request
|
||||
const thinkSel = h("select", { title: t("pg.thinkHelp") },
|
||||
h("option", { value: "" }, t("pg.think.auto")),
|
||||
h("option", { value: "off" }, t("pg.think.off")),
|
||||
h("option", { value: "on" }, t("pg.think.on")));
|
||||
const streamCb = h("input", { type: "checkbox", checked: true });
|
||||
const profileSel = h("select", {}, ...(meta.profiles || []).map((p) => h("option", { value: p }, p)));
|
||||
const modelSel = h("select", {}, ...ids.map((id) => h("option", { value: id }, id)));
|
||||
@@ -69,6 +75,8 @@ export async function renderPlayground() {
|
||||
temperature: parseFloat(tempIn.value),
|
||||
max_tokens: parseInt(maxTokIn.value) || null,
|
||||
};
|
||||
if (topPIn.value !== "") body.top_p = parseFloat(topPIn.value);
|
||||
if (thinkSel.value) body.enable_thinking = thinkSel.value === "on";
|
||||
try {
|
||||
if (streamCb.checked) {
|
||||
const started = performance.now();
|
||||
@@ -151,6 +159,9 @@ export async function renderPlayground() {
|
||||
h("label", { class: "field" }, h("span", {}, t("pg.mode")), modeSeg),
|
||||
modeExtra,
|
||||
h("div", { class: "row" }, h("label", { class: "field" }, h("span", {}, t("pg.temp")), tempIn), h("label", { class: "field" }, h("span", {}, t("pg.maxTokens")), maxTokIn)),
|
||||
h("div", { class: "row" },
|
||||
h("label", { class: "field" }, h("span", {}, "top-p"), topPIn),
|
||||
h("label", { class: "field" }, h("span", {}, t("pg.think")), thinkSel)),
|
||||
h(
|
||||
"div",
|
||||
{ class: "flex", style: "justify-content:space-between" },
|
||||
|
||||
Vendored
+27
-3
@@ -234,6 +234,20 @@ export async function renderSettings() {
|
||||
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") });
|
||||
// serve-time O(1) override: off cures a too-aggressive --o1 conversion
|
||||
const o1Opts = ["", "off", "all", "deep4", "deep8", "deep12"];
|
||||
const o1In = h("select", { style: "width:110px", title: t("settings.cmf.o1Help") },
|
||||
...o1Opts.map((v) => h("option", { value: v, selected: (sv.o1 || "") === v ? true : null },
|
||||
v === "" ? t("settings.cmf.o1Auto") : v)));
|
||||
if (sv.o1 && !o1Opts.includes(sv.o1)) o1In.appendChild(h("option", { value: sv.o1, selected: true }, sv.o1));
|
||||
// reasoning switch for thinking models (off → enable_thinking=false)
|
||||
const thinkIn = h("select", { style: "width:118px", title: t("settings.cmf.thinkHelp") },
|
||||
h("option", { value: "", selected: !sv.thinking ? true : null }, t("settings.cmf.thinkAuto")),
|
||||
h("option", { value: "off", selected: sv.thinking === "off" ? true : null }, t("settings.cmf.thinkOff")));
|
||||
// per-model generation defaults (applied when the request doesn't set them)
|
||||
const tempIn = h("input", { type: "number", step: "0.1", min: 0, max: 2, value: sv.temperature ?? "", placeholder: "T° 0.7", style: "width:74px", title: t("settings.cmf.tempHelp") });
|
||||
const topPIn = h("input", { type: "number", step: "0.05", min: 0, max: 1, value: sv.top_p ?? "", placeholder: "top-p", style: "width:70px", title: t("settings.cmf.topPHelp") });
|
||||
const maxTokIn = h("input", { type: "number", min: 1, value: sv.max_tokens ?? "", placeholder: "max tok", style: "width:82px", title: t("settings.cmf.maxTokHelp") });
|
||||
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" });
|
||||
@@ -245,7 +259,11 @@ export async function renderSettings() {
|
||||
try {
|
||||
const r = await api.cmfPortCheck(p);
|
||||
if (r.available) mount(st, h("span", { style: "color:var(--good)" }, "✓ " + t("settings.cmf.portFree")));
|
||||
else {
|
||||
else if (r.managed) {
|
||||
// held by this gateway's own managed server — replacing the model on
|
||||
// this port is fine, it frees up on restart
|
||||
mount(st, h("span", { style: "color:var(--accent)" }, "ℹ " + t("settings.cmf.portOwn")));
|
||||
} else {
|
||||
const kids = [h("span", { style: "color:var(--bad)" }, "✗ " + t("settings.cmf.portBusy"))];
|
||||
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));
|
||||
@@ -254,8 +272,14 @@ export async function renderSettings() {
|
||||
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 }) };
|
||||
idIn, modelSel, portIn, checkBtn, threadsIn, h("label", { class: "flex", style: "gap:4px" }, gpuIn, "GPU"), o1In, thinkIn, tempIn, topPIn, maxTokIn, removeBtn, st);
|
||||
const entry = { node: row, read: () => {
|
||||
const v = { id: idIn.value.trim() || autoId(modelSel.value), model: modelSel.value.trim(), port: parseInt(portIn.value) || 8090, threads: parseInt(threadsIn.value) || 0, gpu: gpuIn.checked, o1: o1In.value, thinking: thinkIn.value };
|
||||
if (tempIn.value !== "") v.temperature = parseFloat(tempIn.value);
|
||||
if (topPIn.value !== "") v.top_p = parseFloat(topPIn.value);
|
||||
if (maxTokIn.value !== "") v.max_tokens = parseInt(maxTokIn.value);
|
||||
return v;
|
||||
} };
|
||||
removeBtn.addEventListener("click", () => { const i = serverRows.indexOf(entry); if (i >= 0) serverRows.splice(i, 1); row.remove(); });
|
||||
serverRows.push(entry);
|
||||
serversWrap.appendChild(row);
|
||||
|
||||
Reference in New Issue
Block a user