Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
851cc7f1cb | ||
|
|
d93ac537c8 | ||
|
|
4220bc27c2 | ||
|
|
6fdbe0e159 | ||
|
|
243d8f4c7d | ||
|
|
4f72711a1e | ||
|
|
7855472499 | ||
|
|
a6ed974ece | ||
|
|
4c11b97197 | ||
|
|
87f00ee502 | ||
|
|
4fa1b8ea22 | ||
|
|
357441e0da | ||
|
|
9d2181e82b | ||
|
|
30378f7b6a | ||
|
|
b4f78bd192 | ||
|
|
3c90dad9b9 |
Generated
+1
-1
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "cortiq-gateway"
|
||||
version = "0.2.5"
|
||||
version = "0.2.19"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cortiq-gateway"
|
||||
version = "0.2.5"
|
||||
version = "0.2.19"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -142,7 +142,7 @@ max_entries = 1000
|
||||
manage_server = false # true → gateway spawns `cortiq serve` at startup
|
||||
local_model = "models/model.cmf" # path to the .cmf to serve
|
||||
local_host = "127.0.0.1"
|
||||
local_port = 8081 # must match the cmf-local base_url above
|
||||
local_port = 8081 # confirm it's free in Settings → "Check port" (8081 often clashes with Docker/Apache)
|
||||
model_id = "cmf-local" # id it registers under (matches [[models]])
|
||||
threads = 8 # decode worker threads (CMF_THREADS); ~3x vs serial
|
||||
gpu = false # offload matvecs to GPU (Metal/wgpu); mainly a prefill win
|
||||
|
||||
+495
-30
@@ -26,6 +26,7 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::time::Instant;
|
||||
|
||||
// ───────────────────────────── common types / helpers ───────────────────────
|
||||
@@ -164,13 +165,21 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
|
||||
"/admin/api/secrets",
|
||||
get(list_secrets).put(set_secret).delete(clear_secret),
|
||||
)
|
||||
.route("/admin/api/stats", get(get_stats))
|
||||
.route("/admin/api/stats", get(get_stats).delete(clear_stats))
|
||||
.route("/admin/api/shadow", get(get_shadow))
|
||||
.route("/admin/api/hf/search", get(hf_search))
|
||||
.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/register", post(register_import))
|
||||
.route("/admin/api/cmf", get(cmf_status))
|
||||
.route("/admin/api/cmf/install", post(cmf_install))
|
||||
.route("/admin/api/cmf/port", get(cmf_port_check))
|
||||
.route("/admin/api/cmf/files", get(cmf_files))
|
||||
.route("/admin/api/provider/models", post(provider_models))
|
||||
.route("/admin/api/requests", get(get_requests))
|
||||
.route("/admin/api/test", post(run_test))
|
||||
.route("/admin/api/test/stream", post(run_test_stream))
|
||||
@@ -383,18 +392,105 @@ async fn router_probe(State(state): State<SharedState>) -> ApiResult {
|
||||
ok(json!({ "ok": true, "status": "ok", "latency_ms": latency_ms, "usage": usage }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProviderModelsBody {
|
||||
provider: String,
|
||||
base_url: String,
|
||||
/// Key typed into the form (takes priority)…
|
||||
#[serde(default)]
|
||||
api_key: Option<String>,
|
||||
/// …or the name of a stored secret to reuse if no key was typed.
|
||||
#[serde(default)]
|
||||
api_key_env: Option<String>,
|
||||
}
|
||||
|
||||
/// Concise error line from a provider's JSON error body.
|
||||
fn provider_err(body: &str, status: u16) -> String {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(body) {
|
||||
if let Some(m) = v["error"]["message"]
|
||||
.as_str()
|
||||
.or_else(|| v["error"].as_str())
|
||||
.or_else(|| v["message"].as_str())
|
||||
{
|
||||
return format!("{status}: {m}");
|
||||
}
|
||||
}
|
||||
format!("HTTP {status}")
|
||||
}
|
||||
|
||||
/// Validate a provider API key and list its models by calling the provider's
|
||||
/// `/models` endpoint — powers the "Test key & load models" button. The key is
|
||||
/// used transiently (from the form) or resolved from a stored secret; it is not
|
||||
/// persisted here.
|
||||
async fn provider_models(
|
||||
State(state): State<SharedState>,
|
||||
Json(b): Json<ProviderModelsBody>,
|
||||
) -> ApiResult {
|
||||
let base = b.base_url.trim_end_matches('/').to_string();
|
||||
if base.is_empty() {
|
||||
return Err(ApiError::bad("base URL is required"));
|
||||
}
|
||||
let key = b.api_key.filter(|k| !k.trim().is_empty()).or_else(|| {
|
||||
b.api_key_env
|
||||
.as_deref()
|
||||
.and_then(|e| state.secrets.resolve(e))
|
||||
});
|
||||
let key = key.as_deref();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| ApiError::bad(e.to_string()))?;
|
||||
|
||||
// Anthropic uses x-api-key + anthropic-version and /v1/models; every other
|
||||
// (OpenAI-compatible) provider uses a Bearer token and /models.
|
||||
let resp = if b.provider == "anthropic" {
|
||||
let mut r = client
|
||||
.get(format!("{base}/v1/models"))
|
||||
.header("anthropic-version", "2023-06-01");
|
||||
if let Some(k) = key {
|
||||
r = r.header("x-api-key", k);
|
||||
}
|
||||
r.send().await
|
||||
} else {
|
||||
let mut r = client.get(format!("{base}/models"));
|
||||
if let Some(k) = key {
|
||||
r = r.header("authorization", format!("Bearer {k}"));
|
||||
}
|
||||
r.send().await
|
||||
};
|
||||
let resp = resp.map_err(|e| ApiError::bad(format!("request failed: {e}")))?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if !(200..300).contains(&status) {
|
||||
return ok(json!({ "ok": false, "status": status, "error": provider_err(&body, status) }));
|
||||
}
|
||||
// OpenAI + Anthropic both return { "data": [ { "id": ... } ] }.
|
||||
let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));
|
||||
let mut models: Vec<String> = v["data"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
models.sort();
|
||||
ok(json!({ "ok": true, "count": models.len(), "models": models }))
|
||||
}
|
||||
|
||||
async fn meta() -> ApiResult {
|
||||
ok(json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"providers": ["openai", "anthropic", "ollama", "http"],
|
||||
"provider_impl": { "openai": true, "ollama": true, "http": true, "anthropic": true },
|
||||
"cost_tiers": ["cheap", "mid", "expensive"],
|
||||
"providers": ["openai", "anthropic", "openrouter", "lmstudio", "ollama", "http"],
|
||||
"provider_impl": { "openai": true, "anthropic": true, "openrouter": true, "lmstudio": true, "ollama": true, "http": true },
|
||||
"cost_tiers": ["cheap", "mid", "expensive", "local"],
|
||||
"kinds": ["chat", "embedding"],
|
||||
"profiles": ["cost-saver", "balanced", "quality-first"],
|
||||
"text_strategies": ["last_user", "last_user_plus_system", "concat_all"],
|
||||
"policy_modes": ["fixed_table", "cost_aware"],
|
||||
"tiers": ["low", "medium", "high"],
|
||||
"caps": ["tools", "vision"],
|
||||
"caps": ["tools", "vision", "audio", "reasoning", "json", "caching"],
|
||||
"protocols_impl": {
|
||||
"openai_chat": true, "openai_completions": true, "openai_embeddings": true,
|
||||
"openai_models": true, "anthropic_messages": true, "mcp": true, "native_passthrough": true
|
||||
@@ -419,7 +515,7 @@ async fn put_config(State(state): State<SharedState>, Json(cfg): Json<Config>) -
|
||||
|
||||
async fn list_models(State(state): State<SharedState>) -> ApiResult {
|
||||
let live = state.live();
|
||||
let models: Vec<Value> = live
|
||||
let mut models: Vec<Value> = live
|
||||
.cfg
|
||||
.models
|
||||
.iter()
|
||||
@@ -433,6 +529,31 @@ async fn list_models(State(state): State<SharedState>) -> ApiResult {
|
||||
v
|
||||
})
|
||||
.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 }))
|
||||
}
|
||||
|
||||
@@ -466,28 +587,147 @@ async fn update_model(
|
||||
ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
async fn delete_model(State(state): State<SharedState>, Path(id): Path<String>) -> ApiResult {
|
||||
/// 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> {
|
||||
// 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();
|
||||
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))
|
||||
}
|
||||
|
||||
#[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 (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) {
|
||||
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 delete_file && !shared {
|
||||
remove_cmf_path(&path)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
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();
|
||||
cfg.models.retain(|x| x.id != id);
|
||||
if cfg.models.len() == before {
|
||||
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 asked to AND no remaining model still references it.
|
||||
let file = file.filter(|p| delete_file && !cmf_path_referenced(&cfg, p));
|
||||
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 {
|
||||
let live = state.live();
|
||||
let mcfg = live
|
||||
// Provider string: from the static pool, or "openai" for a managed local
|
||||
// model (which is synthesized into the registry, not [[models]]).
|
||||
let provider_str = live
|
||||
.cfg
|
||||
.models
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.ok_or_else(|| ApiError::not_found(format!("model '{id}' not found")))?
|
||||
.clone();
|
||||
.map(|m| m.provider.clone())
|
||||
.or_else(|| {
|
||||
live.cfg
|
||||
.cmf
|
||||
.effective_servers()
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.map(|_| "openai".to_string())
|
||||
})
|
||||
.ok_or_else(|| ApiError::not_found(format!("model '{id}' not found")))?;
|
||||
|
||||
if mcfg.provider == "anthropic" {
|
||||
if provider_str == "anthropic" {
|
||||
return ok(json!({
|
||||
"ok": false,
|
||||
"error": "anthropic provider not yet implemented (planned v0.2)",
|
||||
@@ -524,7 +764,21 @@ async fn probe_model(State(state): State<SharedState>, Path(id): Path<String>) -
|
||||
let latency_ms = started.elapsed().as_millis() as u64;
|
||||
match res {
|
||||
Ok(_) => ok(json!({ "ok": true, "latency_ms": latency_ms })),
|
||||
Err(e) => ok(json!({ "ok": false, "latency_ms": latency_ms, "error": e.to_string() })),
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
// Make auth failures actionable — a raw "User not found" (OpenRouter's
|
||||
// 401) usually means the API key is wrong or the wrong type.
|
||||
let hint = if msg.contains("401")
|
||||
|| msg.contains("Unauthorized")
|
||||
|| msg.contains("User not found")
|
||||
|| msg.contains("No auth")
|
||||
{
|
||||
" — check the API key (for OpenRouter use an inference key, not a provisioning/management key)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
ok(json!({ "ok": false, "latency_ms": latency_ms, "error": format!("{msg}{hint}") }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,15 +916,231 @@ async fn put_settings(State(state): State<SharedState>, Json(b): Json<SettingsBo
|
||||
|| 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;
|
||||
// Replacing or renaming a managed model must not brick the save: routing
|
||||
// may still reference the old id. Map a vanished id to its replacement
|
||||
// (the new server on the same port), strip ids that are truly gone, and
|
||||
// repair the default so validate() passes.
|
||||
heal_routing_after_cmf_change(&mut cfg, &old_servers);
|
||||
}
|
||||
state.reload(cfg)?;
|
||||
ok(json!({ "ok": true, "needs_restart": needs_restart }))
|
||||
}
|
||||
|
||||
/// After the managed-servers list changed, rewrite [routing] so it only
|
||||
/// references ids that still exist. A removed server whose port is now held by
|
||||
/// a NEW server is treated as a replacement (old id → new id); other vanished
|
||||
/// ids are stripped. An emptied default falls back to the first managed server,
|
||||
/// then the first pool model.
|
||||
fn heal_routing_after_cmf_change(cfg: &mut Config, old_servers: &[crate::config::CmfServer]) {
|
||||
let servers = cfg.cmf.effective_servers();
|
||||
let known: std::collections::HashSet<String> = cfg
|
||||
.models
|
||||
.iter()
|
||||
.map(|m| m.id.clone())
|
||||
.chain(servers.iter().map(|s| s.id.clone()))
|
||||
.collect();
|
||||
let new_by_port: HashMap<u16, String> =
|
||||
servers.iter().map(|s| (s.port, s.id.clone())).collect();
|
||||
let mut rename: HashMap<String, String> = HashMap::new();
|
||||
for old in old_servers {
|
||||
if !known.contains(&old.id) {
|
||||
if let Some(new_id) = new_by_port.get(&old.port) {
|
||||
rename.insert(old.id.clone(), new_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let fix = |id: &str| -> Option<String> {
|
||||
if known.contains(id) {
|
||||
Some(id.to_string())
|
||||
} else {
|
||||
rename.get(id).cloned()
|
||||
}
|
||||
};
|
||||
if !cfg.routing.default.is_empty() {
|
||||
cfg.routing.default = fix(&cfg.routing.default).unwrap_or_default();
|
||||
}
|
||||
if cfg.routing.default.is_empty() {
|
||||
cfg.routing.default = servers
|
||||
.first()
|
||||
.map(|s| s.id.clone())
|
||||
.or_else(|| cfg.models.first().map(|m| m.id.clone()))
|
||||
.unwrap_or_default();
|
||||
}
|
||||
for tt in cfg.routing.tiers.values_mut() {
|
||||
let list = match tt {
|
||||
TierTargets::List(v) => v.clone(),
|
||||
TierTargets::One(s) => vec![s.clone()],
|
||||
};
|
||||
let mut healed: Vec<String> = Vec::new();
|
||||
for id in &list {
|
||||
if let Some(new_id) = fix(id) {
|
||||
if !healed.contains(&new_id) {
|
||||
healed.push(new_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
*tt = TierTargets::List(healed);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── cmf runtime ─────────────────────────────────
|
||||
/// Status of the `cortiq` CLI (the CMF format runtime): is it installed, which
|
||||
/// version, and whether a managed server is running. Drives the "Install cortiq"
|
||||
/// button + status in the Settings → Local models card.
|
||||
async fn cmf_status(State(state): State<SharedState>) -> ApiResult {
|
||||
let cmf = state.live().cfg.cmf.clone();
|
||||
let ver = crate::cmf_runtime::installed_version(&cmf.cortiq_bin);
|
||||
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,
|
||||
"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()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
ok(json!({
|
||||
"installed": ver.is_some(),
|
||||
"version": ver,
|
||||
"latest": st.latest_version,
|
||||
"manage_server": cmf.manage_server,
|
||||
"local_host": cmf.local_host,
|
||||
"servers": servers,
|
||||
"log": st.log,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Install/upgrade the `cortiq` CLI from crates.io on demand (spawned in the
|
||||
/// background; poll `GET /admin/api/cmf` for `installed`/`version`).
|
||||
async fn cmf_install(State(state): State<SharedState>) -> ApiResult {
|
||||
let bin = state.live().cfg.cmf.cortiq_bin.clone();
|
||||
tokio::spawn(crate::cmf_runtime::install_now(state.cmf.clone(), bin));
|
||||
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)]
|
||||
struct PortQuery {
|
||||
port: u16,
|
||||
}
|
||||
|
||||
/// Check whether a TCP port is free to bind on the CMF local host, so a user can
|
||||
/// pick an available port before enabling the managed server (avoids clashing
|
||||
/// with something already on the default 8081). If the port is the one this
|
||||
/// gateway's own managed server already runs on, that is reported distinctly.
|
||||
/// When occupied, `suggested` holds the next free port so the UI can offer it.
|
||||
async fn cmf_port_check(State(state): State<SharedState>, Query(q): Query<PortQuery>) -> ApiResult {
|
||||
let cmf = state.live().cfg.cmf.clone();
|
||||
let host = if cmf.local_host.trim().is_empty() {
|
||||
"127.0.0.1".to_string()
|
||||
} else {
|
||||
cmf.local_host.clone()
|
||||
};
|
||||
let st = state.cmf.status();
|
||||
let port = q.port;
|
||||
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.
|
||||
let (available, detail, suggested) = tokio::task::spawn_blocking(move || {
|
||||
if running_here {
|
||||
return (false, "in use by this gateway's managed CMF server", None);
|
||||
}
|
||||
if port_is_free(&host, port) {
|
||||
(true, "free", None)
|
||||
} else {
|
||||
(
|
||||
false,
|
||||
"occupied by another process",
|
||||
next_free_port(&host, port),
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or((false, "port check failed", None));
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether `port` is truly free on `host`. A bind test alone is not enough: a
|
||||
/// Docker-published / dual-stack listener can leave an IPv4 bind succeeding even
|
||||
/// though connections to the port are forwarded to a container. So a port counts
|
||||
/// as free only if it is BOTH bindable AND nothing answers a connect attempt.
|
||||
fn port_is_free(host: &str, port: u16) -> bool {
|
||||
if std::net::TcpListener::bind((host, port)).is_err() {
|
||||
return false; // something is bound to exactly this address
|
||||
}
|
||||
// catch proxied/forwarded ports a bind test misses (e.g. Docker on macOS)
|
||||
let reachable = (host, port)
|
||||
.to_socket_addrs()
|
||||
.ok()
|
||||
.and_then(|mut it| it.next())
|
||||
.map(|addr| {
|
||||
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200))
|
||||
.is_ok()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
!reachable
|
||||
}
|
||||
|
||||
/// First free port in (from, from+50], or None if all are taken.
|
||||
fn next_free_port(host: &str, from: u16) -> Option<u16> {
|
||||
(from.saturating_add(1)..=from.saturating_add(50)).find(|p| port_is_free(host, *p))
|
||||
}
|
||||
|
||||
// ───────────────────────────── keys ────────────────────────────────────────
|
||||
|
||||
async fn list_keys(State(state): State<SharedState>) -> ApiResult {
|
||||
@@ -809,6 +1279,13 @@ async fn get_stats(State(state): State<SharedState>, Query(q): Query<StatsQuery>
|
||||
ok(snap)
|
||||
}
|
||||
|
||||
/// Clear all recorded stats/logs (in-memory aggregates, series, recent requests)
|
||||
/// and truncate the JSONL log — the dashboard "Clear logs" button.
|
||||
async fn clear_stats(State(state): State<SharedState>) -> ApiResult {
|
||||
state.stats.clear();
|
||||
ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
/// Self-warming shadow loop: per-task-type promotion state, Wilson-LB of the
|
||||
/// judged pass-rate, and whether the local model is serving it yet.
|
||||
async fn get_shadow(State(state): State<SharedState>) -> ApiResult {
|
||||
@@ -869,10 +1346,7 @@ async fn start_import(
|
||||
ok(json!({ "job": id }))
|
||||
}
|
||||
|
||||
async fn import_status(
|
||||
State(state): State<SharedState>,
|
||||
Path(job): Path<String>,
|
||||
) -> ApiResult {
|
||||
async fn import_status(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
|
||||
match state.imports.get(&job) {
|
||||
Some(j) => ok(serde_json::to_value(j).unwrap_or_else(|_| json!({}))),
|
||||
None => Err(ApiError::not_found(format!("job '{job}' not found"))),
|
||||
@@ -880,10 +1354,7 @@ async fn import_status(
|
||||
}
|
||||
|
||||
/// Cancel a running conversion: kills the converter and removes partial output.
|
||||
async fn cancel_import(
|
||||
State(state): State<SharedState>,
|
||||
Path(job): Path<String>,
|
||||
) -> ApiResult {
|
||||
async fn cancel_import(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
|
||||
if state.imports.get(&job).is_none() {
|
||||
return Err(ApiError::not_found(format!("job '{job}' not found")));
|
||||
}
|
||||
@@ -892,10 +1363,7 @@ async fn cancel_import(
|
||||
}
|
||||
|
||||
/// Delete a finished conversion and its converted `.cmf` file(s) from disk.
|
||||
async fn delete_import(
|
||||
State(state): State<SharedState>,
|
||||
Path(job): Path<String>,
|
||||
) -> ApiResult {
|
||||
async fn delete_import(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
|
||||
if state.imports.get(&job).is_none() {
|
||||
return Err(ApiError::not_found(format!("job '{job}' not found")));
|
||||
}
|
||||
@@ -904,10 +1372,7 @@ async fn delete_import(
|
||||
}
|
||||
|
||||
/// Register a finished `.cmf` as a local model (OpenAI provider → cortiq-server).
|
||||
async fn register_import(
|
||||
State(state): State<SharedState>,
|
||||
Path(job): Path<String>,
|
||||
) -> ApiResult {
|
||||
async fn register_import(State(state): State<SharedState>, Path(job): Path<String>) -> ApiResult {
|
||||
let j = state
|
||||
.imports
|
||||
.get(&job)
|
||||
|
||||
+226
-85
@@ -1,51 +1,68 @@
|
||||
//! Local CMF model server lifecycle.
|
||||
//!
|
||||
//! 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
|
||||
//! managed child process. The served model exposes an OpenAI-compatible API, so
|
||||
//! the registry registers it as an ordinary provider and the gateway routes to
|
||||
//! it like any other backend.
|
||||
//! `cortiq` CLI from crates.io, keep it up to date, and run one `cortiq serve`
|
||||
//! per configured local model as a managed child process. Each served model
|
||||
//! exposes an OpenAI-compatible API, so the registry registers it as an ordinary
|
||||
//! 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::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
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)]
|
||||
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,
|
||||
/// Version reported by `cortiq --version` (None = not installed).
|
||||
pub installed_version: Option<String>,
|
||||
/// Newest `cortiq-cli` on crates.io (populated when `auto_update` runs).
|
||||
pub latest_version: Option<String>,
|
||||
/// The child `cortiq serve` process has been spawned.
|
||||
pub running: bool,
|
||||
/// `/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>,
|
||||
/// Per-model server status.
|
||||
pub servers: Vec<CmfServerStatus>,
|
||||
/// Recent lifecycle log lines (bounded).
|
||||
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 {
|
||||
status: Mutex<CmfStatus>,
|
||||
child: Mutex<Option<Child>>,
|
||||
children: Mutex<Vec<Managed>>,
|
||||
}
|
||||
|
||||
impl CmfRuntime {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
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();
|
||||
s.log.push(msg);
|
||||
let n = s.log.len();
|
||||
if n > 50 {
|
||||
s.log.drain(0..n - 50);
|
||||
if n > 80 {
|
||||
s.log.drain(0..n - 80);
|
||||
}
|
||||
}
|
||||
|
||||
/// A global (install/update) failure — not tied to a single server.
|
||||
fn fail(&self, msg: impl Into<String>) {
|
||||
let msg = msg.into();
|
||||
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();
|
||||
s.last_error = Some(msg.clone());
|
||||
s.log.push(format!("✗ {msg}"));
|
||||
if let Some(sv) = s.servers.iter_mut().find(|x| x.id == id) {
|
||||
sv.last_error = Some(msg.clone());
|
||||
}
|
||||
s.log.push(format!("✗ [{id}] {msg}"));
|
||||
}
|
||||
|
||||
fn set_child(&self, child: Child) {
|
||||
*self.child.lock().unwrap() = Some(child);
|
||||
self.status.lock().unwrap().running = true;
|
||||
/// Seed the per-server status slots from the effective server list.
|
||||
fn init_servers(&self, servers: &[CmfServer], host: &str) {
|
||||
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) {
|
||||
if let Some(mut child) = self.child.lock().unwrap().take() {
|
||||
let _ = child.start_kill();
|
||||
let mut kids = std::mem::take(&mut *self.children.lock().unwrap());
|
||||
for m in kids.iter_mut() {
|
||||
let _ = m.child.start_kill();
|
||||
}
|
||||
let mut s = self.status.lock().unwrap();
|
||||
s.running = false;
|
||||
s.healthy = false;
|
||||
for sv in s.servers.iter_mut() {
|
||||
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 {
|
||||
Self {
|
||||
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
|
||||
.ok()?;
|
||||
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
|
||||
v["crate"]["newest_version"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
v["crate"]["newest_version"].as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// `<bin> --version` → the trailing version token (e.g. "cortiq 0.1.2" → "0.1.2").
|
||||
fn installed_version(bin: &str) -> Option<String> {
|
||||
let out = std::process::Command::new(bin).arg("--version").output().ok()?;
|
||||
pub(crate) fn installed_version(bin: &str) -> Option<String> {
|
||||
let out = std::process::Command::new(bin)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
@@ -130,10 +231,14 @@ fn installed_version(bin: &str) -> Option<String> {
|
||||
|
||||
fn parse_ver(s: &str) -> (u32, u32, u32) {
|
||||
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 {
|
||||
pub(crate) fn version_lt(a: &str, b: &str) -> bool {
|
||||
parse_ver(a) < parse_ver(b)
|
||||
}
|
||||
|
||||
@@ -152,26 +257,49 @@ async fn cargo_install(force: bool) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn `cortiq serve <model> --host <host> --port <port>`; the child is killed
|
||||
/// when its handle is dropped (i.e. when the gateway shuts down).
|
||||
fn spawn_serve(cfg: &CmfCfg) -> Result<Child, String> {
|
||||
/// Install/upgrade `cortiq-cli` from crates.io on demand (the admin "Install
|
||||
/// cortiq" button). Runs in the background; progress + result land in the CMF
|
||||
/// status log, and `installed_version` is refreshed on success.
|
||||
pub async fn install_now(rt: Arc<CmfRuntime>, bin: String) {
|
||||
rt.log("installing cortiq-cli from crates.io (this compiles — a few minutes)…");
|
||||
match cargo_install(true).await {
|
||||
Ok(()) => {
|
||||
let v = installed_version(&bin);
|
||||
rt.status.lock().unwrap().installed_version = v.clone();
|
||||
rt.log(format!(
|
||||
"installed cortiq-cli {}",
|
||||
v.as_deref().unwrap_or("?")
|
||||
));
|
||||
}
|
||||
Err(e) => rt.fail(format!("cargo install cortiq-cli failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn `cortiq serve <model> --host <host> --port <port>` for one server; the
|
||||
/// child is killed when its handle is dropped (i.e. when the gateway shuts down).
|
||||
fn spawn_serve(server: &CmfServer, cfg: &CmfCfg) -> Result<Child, String> {
|
||||
let mut cmd = Command::new(&cfg.cortiq_bin);
|
||||
cmd.arg("serve")
|
||||
.arg(&cfg.local_model)
|
||||
.arg(&server.model)
|
||||
.arg("--host")
|
||||
.arg(&cfg.local_host)
|
||||
.arg("--port")
|
||||
.arg(cfg.local_port.to_string());
|
||||
// Parallelise decode matvecs across cores (CMF_THREADS). 0 = leave the
|
||||
.arg(server.port.to_string());
|
||||
// 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.
|
||||
if cfg.threads > 1 {
|
||||
cmd.env("CMF_THREADS", cfg.threads.to_string());
|
||||
if server.threads > 1 {
|
||||
cmd.env("CMF_THREADS", server.threads.to_string());
|
||||
}
|
||||
// Offload large matvecs to the GPU (Metal on macOS). Memory-bandwidth-bound
|
||||
// on unified memory, so mostly a prefill win; opt-in.
|
||||
if cfg.gpu {
|
||||
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)
|
||||
@@ -199,24 +327,17 @@ async fn wait_healthy(host: &str, port: u16, tries: u32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Orchestrate the whole lifecycle: ensure installed → maybe update → spawn →
|
||||
/// wait healthy. Safe to call in a background task; it never panics.
|
||||
/// 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.
|
||||
pub async fn manage(rt: Arc<CmfRuntime>, cfg: CmfCfg) {
|
||||
if !cfg.manage_server || cfg.local_model.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
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));
|
||||
let servers = cfg.effective_servers();
|
||||
if servers.is_empty() {
|
||||
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);
|
||||
if ver.is_none() {
|
||||
if cfg.auto_install {
|
||||
@@ -230,7 +351,9 @@ pub async fn manage(rt: Arc<CmfRuntime>, cfg: CmfCfg) {
|
||||
rt.log("installed cortiq-cli");
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -240,8 +363,15 @@ pub async fn manage(rt: Arc<CmfRuntime>, cfg: CmfCfg) {
|
||||
if cfg.auto_update {
|
||||
if let Some(latest) = latest_crates_version().await {
|
||||
rt.status.lock().unwrap().latest_version = Some(latest.clone());
|
||||
if ver.as_deref().map(|c| version_lt(c, &latest)).unwrap_or(false) {
|
||||
rt.log(format!("updating cortiq-cli {} → {latest}…", ver.as_deref().unwrap_or("?")));
|
||||
if ver
|
||||
.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 {
|
||||
rt.log(format!("update failed (keeping current): {e}"));
|
||||
} else {
|
||||
@@ -253,27 +383,38 @@ pub async fn manage(rt: Arc<CmfRuntime>, cfg: CmfCfg) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Spawn the local server.
|
||||
rt.log(format!(
|
||||
"starting: cortiq serve {} --host {} --port {}",
|
||||
cfg.local_model, cfg.local_host, cfg.local_port
|
||||
));
|
||||
match spawn_serve(&cfg) {
|
||||
Ok(child) => rt.set_child(child),
|
||||
Err(e) => {
|
||||
rt.fail(format!("failed to spawn cortiq serve: {e}"));
|
||||
return;
|
||||
// 3. Spawn each server (fast).
|
||||
for server in &servers {
|
||||
if !std::path::Path::new(&server.model).exists() {
|
||||
rt.fail_server(
|
||||
&server.id,
|
||||
format!("model file not found: {}", server.model),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
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).
|
||||
if wait_healthy(&cfg.local_host, cfg.local_port, 240).await {
|
||||
rt.status.lock().unwrap().healthy = true;
|
||||
rt.log(format!(
|
||||
"local CMF server ready — registered as model '{}' at http://{}:{}/v1",
|
||||
cfg.model_id, cfg.local_host, cfg.local_port
|
||||
));
|
||||
} else {
|
||||
rt.fail("local CMF server did not become healthy in time");
|
||||
// 4. Wait until each spawned server is serving (model load can be slow).
|
||||
for server in &servers {
|
||||
if !rt.is_running(&server.id) {
|
||||
continue; // failed to spawn / file missing
|
||||
}
|
||||
if wait_healthy(&cfg.local_host, server.port, 240).await {
|
||||
rt.set_healthy(&server.id, true);
|
||||
rt.log(format!(
|
||||
"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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+76
-5
@@ -10,10 +10,18 @@ use std::path::PathBuf;
|
||||
/// `~/.cortiq-gateway` — so defaults work regardless of the process CWD
|
||||
/// (important once the gateway is installed rather than run from its repo).
|
||||
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 !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,30 @@ pub struct Config {
|
||||
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,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// CMF-format model factory: import a HuggingFace model, convert it to a
|
||||
/// local `.cmf` (quantized), and serve it via cortiq-server. Points the
|
||||
/// gateway at the Python converter + runtime on the host.
|
||||
@@ -121,6 +153,36 @@ pub struct CmfCfg {
|
||||
/// (helps prefill of long prompts). Off by default.
|
||||
#[serde(default)]
|
||||
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,
|
||||
o1: String::new(),
|
||||
}];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
impl Default for CmfCfg {
|
||||
fn default() -> Self {
|
||||
@@ -141,6 +203,7 @@ impl Default for CmfCfg {
|
||||
local_only: false,
|
||||
threads: default_cmf_threads(),
|
||||
gpu: false,
|
||||
servers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -597,7 +660,8 @@ impl Config {
|
||||
// A managed local CMF model counts as an available model even though it
|
||||
// is registered dynamically rather than via a [[models]] entry — this
|
||||
// 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 {
|
||||
// Fresh install: start with an EMPTY model pool so the admin console
|
||||
// comes up — the user adds models in /admin → Models (or points [cmf]
|
||||
@@ -616,8 +680,15 @@ impl Config {
|
||||
// every target in [routing] must exist in the model pool
|
||||
let mut ids: std::collections::HashSet<&str> =
|
||||
self.models.iter().map(|m| m.id.as_str()).collect();
|
||||
if has_local {
|
||||
ids.insert(self.cmf.model_id.as_str());
|
||||
// managed local servers are known ids too; also enforce unique ports/ids
|
||||
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()) {
|
||||
anyhow::bail!(
|
||||
|
||||
+155
-29
@@ -31,13 +31,19 @@ fn cleanup_output(path: &str) {
|
||||
}
|
||||
|
||||
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.
|
||||
fn gen_id(repo: &str) -> String {
|
||||
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();
|
||||
repo.hash(&mut h);
|
||||
nanos.hash(&mut h);
|
||||
@@ -65,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()
|
||||
@@ -82,8 +102,8 @@ pub struct Job {
|
||||
pub started: u64,
|
||||
pub finished: Option<u64>,
|
||||
pub size_bytes: Option<u64>,
|
||||
pub progress: Option<f32>, // 0..1, parsed from converter @PROGRESS
|
||||
pub phase: Option<String>, // current phase label
|
||||
pub progress: Option<f32>, // 0..1, parsed from converter @PROGRESS
|
||||
pub phase: Option<String>, // current phase label
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -178,16 +198,24 @@ impl JobStore {
|
||||
}
|
||||
pub fn list(&self) -> Vec<Job> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 sort = if query.trim().is_empty() { "trendingScore" } else { "downloads" };
|
||||
let sort = if query.trim().is_empty() {
|
||||
"trendingScore"
|
||||
} else {
|
||||
"downloads"
|
||||
};
|
||||
let url = format!(
|
||||
"https://huggingface.co/api/models?search={q}&sort={sort}&direction=-1&limit={limit}&full=false"
|
||||
);
|
||||
@@ -218,7 +246,13 @@ fn url_escape(s: &str) -> String {
|
||||
|
||||
fn sanitize(s: &str) -> String {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -244,7 +278,38 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
||||
// 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();
|
||||
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
|
||||
));
|
||||
}
|
||||
// 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() {
|
||||
@@ -288,18 +353,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 conv = std::path::Path::new(&cfg.converter);
|
||||
let mut a = vec![
|
||||
std::fs::canonicalize(conv).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| cfg.converter.clone()),
|
||||
"--model".into(), p.repo.clone(),
|
||||
"--quant".into(), p.quant.clone(),
|
||||
"--output".into(), output_abs.clone(),
|
||||
std::fs::canonicalize(conv)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| cfg.converter.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(n) = p.nphase { a.push("--nphase".into()); 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("."));
|
||||
if let Some(lc) = &p.linear_core {
|
||||
a.push("--linear-core".into());
|
||||
a.push(lc.clone());
|
||||
}
|
||||
if let Some(n) = p.nphase {
|
||||
a.push("--nphase".into());
|
||||
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)
|
||||
} else if p.repo.to_ascii_lowercase().contains("gguf") {
|
||||
// GGUF repo → native `cortiq import-gguf` (downloads + dequantizes any
|
||||
@@ -307,21 +397,57 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
|
||||
let mut a = vec![
|
||||
"import-gguf".into(),
|
||||
p.repo.clone(),
|
||||
"--quant".into(), map_quant(&p.quant).into(),
|
||||
"--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()); }
|
||||
(cfg.cortiq_bin.clone(), a, std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")))
|
||||
if let Some(t) = &hf_token {
|
||||
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 {
|
||||
// safetensors → native `cortiq convert` (dense / MoE / GatedDeltaNet).
|
||||
let mut a = vec![
|
||||
"convert".into(),
|
||||
"--model".into(), p.repo.clone(),
|
||||
"--quant".into(), map_quant(&p.quant).into(),
|
||||
"--output".into(), output_abs.clone(),
|
||||
"--model".into(),
|
||||
p.repo.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()); }
|
||||
(cfg.cortiq_bin.clone(), a, std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")))
|
||||
if let Some(t) = &hf_token {
|
||||
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,
|
||||
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
|
||||
)
|
||||
};
|
||||
|
||||
let ret_id = id.clone();
|
||||
|
||||
+44
-12
@@ -208,15 +208,25 @@ impl Pipeline {
|
||||
};
|
||||
|
||||
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 {
|
||||
match &req.routing {
|
||||
RoutingDirective::Pinned { model_id } => {
|
||||
(mk("pinned", "pinned"), "pinned".to_string(), vec![model_id.clone()])
|
||||
}
|
||||
RoutingDirective::Pinned { model_id } => (
|
||||
mk("pinned", "pinned"),
|
||||
"pinned".to_string(),
|
||||
vec![model_id.clone()],
|
||||
),
|
||||
RoutingDirective::Auto { profile } => {
|
||||
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 {
|
||||
let prof = profile.as_deref().unwrap_or(&live.cfg.route.profile);
|
||||
match live.router.route(&text, prof).await {
|
||||
@@ -229,7 +239,11 @@ impl Pipeline {
|
||||
tracing::warn!(
|
||||
"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 unknown pinned model while the router is off), fall back to the
|
||||
// managed local model so a configured local backend still answers.
|
||||
// An explicit pin must never silently answer from a different model: if
|
||||
// the pinned id is unknown or was filtered out, fail loudly so the caller
|
||||
// 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 {
|
||||
for id in live.local_candidates() {
|
||||
if let Some(prov) = live.registry.get(&id) {
|
||||
@@ -289,7 +315,10 @@ impl Pipeline {
|
||||
&& !sh.local_model_id.is_empty()
|
||||
&& matches!(req.routing, RoutingDirective::Auto { .. })
|
||||
&& 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.insert(0, sh.local_model_id.clone());
|
||||
@@ -713,7 +742,7 @@ fn parse_judge(text: &str) -> (u8, bool) {
|
||||
let score = text
|
||||
.split("score")
|
||||
.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))
|
||||
.map(|d| d.min(4) as u8);
|
||||
let hard_fail = text
|
||||
@@ -747,7 +776,10 @@ async fn shadow_capture(
|
||||
};
|
||||
|
||||
// 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
|
||||
.choices
|
||||
.into_iter()
|
||||
|
||||
+96
-30
@@ -18,7 +18,10 @@ use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
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).
|
||||
@@ -62,14 +65,14 @@ pub enum PromotionState {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PromotionCfg {
|
||||
pub enabled: bool,
|
||||
pub window: usize, // rolling judgments per label (W)
|
||||
pub n_min: usize, // min judgments before promotion is even considered
|
||||
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 hardfail_ub_max: f64,// Wilson-95 UPPER bound of hard_fail must stay under this
|
||||
pub soak: usize, // extra Canary judgments before LocalServed
|
||||
pub z: f64, // 1.96 = 95%
|
||||
pub file: Option<String>,// judge.jsonl path (append + replay)
|
||||
pub window: usize, // rolling judgments per label (W)
|
||||
pub n_min: usize, // min judgments before promotion is even considered
|
||||
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 hardfail_ub_max: f64, // Wilson-95 UPPER bound of hard_fail must stay under this
|
||||
pub soak: usize, // extra Canary judgments before LocalServed
|
||||
pub z: f64, // 1.96 = 95%
|
||||
pub file: Option<String>, // judge.jsonl path (append + replay)
|
||||
}
|
||||
|
||||
impl Default for PromotionCfg {
|
||||
@@ -92,7 +95,7 @@ struct LabelState {
|
||||
passes: VecDeque<bool>, // rolling window of pass/fail
|
||||
hard_fails: VecDeque<bool>, // rolling window of hard_fail flags
|
||||
state: PromotionState,
|
||||
soaked: usize, // judgments accumulated while in Canary
|
||||
soaked: usize, // judgments accumulated while in Canary
|
||||
}
|
||||
|
||||
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 centre = p + z2 / (2.0 * n);
|
||||
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)
|
||||
}
|
||||
|
||||
impl Promotion {
|
||||
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
|
||||
}
|
||||
|
||||
fn replay(&self) {
|
||||
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() {
|
||||
if let Ok(rec) = serde_json::from_str::<JudgeRecord>(line) {
|
||||
self.apply(&rec);
|
||||
@@ -151,8 +163,10 @@ impl Promotion {
|
||||
let st = self.apply(&rec);
|
||||
if let Some(path) = &self.cfg.file {
|
||||
if let Ok(line) = serde_json::to_string(&rec) {
|
||||
if let Ok(mut f) =
|
||||
std::fs::OpenOptions::new().create(true).append(true).open(path)
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
{
|
||||
let _ = writeln!(f, "{line}");
|
||||
}
|
||||
@@ -163,7 +177,9 @@ impl Promotion {
|
||||
|
||||
fn apply(&self, rec: &JudgeRecord) -> PromotionState {
|
||||
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.hard_fails, rec.hard_fail, self.cfg.window);
|
||||
self.advance(ls);
|
||||
@@ -188,7 +204,12 @@ impl Promotion {
|
||||
|
||||
ls.state = match ls.state {
|
||||
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 => {
|
||||
if regressed {
|
||||
@@ -203,7 +224,11 @@ impl Promotion {
|
||||
}
|
||||
}
|
||||
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).
|
||||
PromotionState::Demoted => PromotionState::ShadowJudged,
|
||||
@@ -223,7 +248,10 @@ impl Promotion {
|
||||
/// Should a request of this label be SERVED by the local model?
|
||||
/// (Canary/LocalServed; caller still applies the per-request veto.)
|
||||
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.
|
||||
@@ -235,10 +263,16 @@ impl Promotion {
|
||||
let n = ls.passes.len();
|
||||
let s = ls.passes.iter().filter(|&&p| p).count();
|
||||
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();
|
||||
out.sort_by(|a, b| b.2.cmp(&a.2));
|
||||
out.sort_by_key(|x| std::cmp::Reverse(x.2));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -259,12 +293,24 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
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 {
|
||||
JudgeRecord {
|
||||
ts: 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,
|
||||
ts: 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 {
|
||||
Some((_, st, n, pr, lb)) => println!(
|
||||
" {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)"),
|
||||
}
|
||||
@@ -324,19 +373,29 @@ mod tests {
|
||||
|
||||
println!("\n── self-warming loop, live ──");
|
||||
// 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 {
|
||||
p.record(rec("ru_tech", i % 100 != 0, false));
|
||||
}
|
||||
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:");
|
||||
for i in 0..60 {
|
||||
p.record(rec("ru_tech", i % 100 != 0, false));
|
||||
}
|
||||
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"), "теперь обслуживается локально");
|
||||
|
||||
// The code dataset: local fails the checker → never promotes.
|
||||
@@ -345,7 +404,11 @@ mod tests {
|
||||
p.record(rec("code", false, false));
|
||||
}
|
||||
show("code");
|
||||
assert_eq!(p.state("code"), PromotionState::ShadowJudged, "остаётся на облаке — безопасно");
|
||||
assert_eq!(
|
||||
p.state("code"),
|
||||
PromotionState::ShadowJudged,
|
||||
"остаётся на облаке — безопасно"
|
||||
);
|
||||
assert!(!p.serves_local("code"));
|
||||
|
||||
// Drift on the promoted label → auto-demote back to cloud.
|
||||
@@ -376,6 +439,9 @@ mod tests {
|
||||
for _ in 0..300 {
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+13
-10
@@ -21,24 +21,27 @@ impl Registry {
|
||||
.as_ref()
|
||||
.and_then(|name| secrets.resolve(name));
|
||||
let provider: Arc<dyn Provider> = match m.provider.as_str() {
|
||||
"openai" | "ollama" | "http" => Arc::new(OpenAiProvider::new(m, api_key)?),
|
||||
// openrouter + lmstudio are OpenAI-compatible (Bearer + /chat/completions)
|
||||
"openai" | "ollama" | "http" | "openrouter" | "lmstudio" => {
|
||||
Arc::new(OpenAiProvider::new(m, api_key)?)
|
||||
}
|
||||
"anthropic" => Arc::new(AnthropicProvider::new(m, api_key)?),
|
||||
other => anyhow::bail!("unknown provider '{}' for model '{}'", other, m.id),
|
||||
};
|
||||
by_id.insert(m.id.clone(), provider);
|
||||
}
|
||||
|
||||
// Managed local CMF server: register it as an OpenAI-compatible provider
|
||||
// so it can be routed to like any other backend — including when the
|
||||
// router is unavailable or the gateway is in local-only mode.
|
||||
if cfg.cmf.manage_server
|
||||
&& !cfg.cmf.local_model.trim().is_empty()
|
||||
&& !by_id.contains_key(&cfg.cmf.model_id)
|
||||
{
|
||||
// Managed local CMF servers: register each as an OpenAI-compatible
|
||||
// provider so it can be routed to like any other backend — including when
|
||||
// the router is unavailable or the gateway is in local-only mode.
|
||||
for s in cfg.cmf.effective_servers() {
|
||||
if by_id.contains_key(&s.id) {
|
||||
continue; // a static [[models]] entry with the same id wins
|
||||
}
|
||||
let m = ModelCfg {
|
||||
id: cfg.cmf.model_id.clone(),
|
||||
id: s.id.clone(),
|
||||
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(),
|
||||
cost_tier: "local".into(),
|
||||
price_in: 0.0,
|
||||
|
||||
+7
-3
@@ -49,8 +49,8 @@ impl Live {
|
||||
/// (local-only / disabled) or unavailable, so CMF models serve with no router.
|
||||
pub fn local_candidates(&self) -> Vec<String> {
|
||||
let mut v = Vec::new();
|
||||
if self.cfg.cmf.manage_server && !self.cfg.cmf.local_model.trim().is_empty() {
|
||||
v.push(self.cfg.cmf.model_id.clone());
|
||||
for s in self.cfg.cmf.effective_servers() {
|
||||
v.push(s.id);
|
||||
}
|
||||
let def = self.routing.default_model().to_string();
|
||||
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,
|
||||
promote_lb: s.promote_lb,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
+93
-41
@@ -83,9 +83,23 @@ impl Totals {
|
||||
self.cost_usd += r.cost_usd;
|
||||
self.latency_ms_sum += r.latency_ms;
|
||||
}
|
||||
/// Accumulate another Totals into this one (for windowed aggregation).
|
||||
fn merge(&mut self, o: &Totals) {
|
||||
self.requests += o.requests;
|
||||
self.ok += o.ok;
|
||||
self.errors += o.errors;
|
||||
self.failovers += o.failovers;
|
||||
self.prompt_tokens += o.prompt_tokens;
|
||||
self.completion_tokens += o.completion_tokens;
|
||||
self.cost_usd += o.cost_usd;
|
||||
self.latency_ms_sum += o.latency_ms_sum;
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-minute bucket for time-series charts.
|
||||
/// Per-minute bucket. Beyond the chart fields it carries the per-group breakdown
|
||||
/// and failovers so the admin snapshot can be computed for ANY time window by
|
||||
/// summing the in-window buckets (the internal fields are not serialized into
|
||||
/// the `series` payload).
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct Bucket {
|
||||
pub minute: u64, // unix seconds, rounded down to the minute
|
||||
@@ -95,15 +109,25 @@ pub struct Bucket {
|
||||
pub completion_tokens: u64,
|
||||
pub cost_usd: f64,
|
||||
pub latency_ms_sum: u64,
|
||||
#[serde(skip)]
|
||||
pub failovers: u64,
|
||||
#[serde(skip)]
|
||||
pub by_model: HashMap<String, Totals>,
|
||||
#[serde(skip)]
|
||||
pub by_account: HashMap<String, Totals>,
|
||||
#[serde(skip)]
|
||||
pub by_tier: HashMap<String, Totals>,
|
||||
#[serde(skip)]
|
||||
pub by_label: HashMap<String, Totals>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
/// All-time totals + per-model (used by the Prometheus counters, which must
|
||||
/// be monotonic — the windowed admin snapshot is derived from the buckets).
|
||||
total: Totals,
|
||||
by_model: HashMap<String, Totals>,
|
||||
by_account: HashMap<String, Totals>,
|
||||
by_tier: HashMap<String, Totals>,
|
||||
by_label: HashMap<String, Totals>,
|
||||
/// Per-minute buckets — the single source for the windowed admin snapshot.
|
||||
buckets: VecDeque<Bucket>,
|
||||
recent: VecDeque<RequestRecord>,
|
||||
}
|
||||
@@ -166,31 +190,14 @@ impl Stats {
|
||||
}
|
||||
|
||||
fn apply_aggregates(inner: &mut Inner, rec: &RequestRecord, retention_secs: u64) {
|
||||
// all-time counters for Prometheus; the windowed snapshot is derived
|
||||
// from the buckets below.
|
||||
inner.total.apply(rec);
|
||||
inner
|
||||
.by_model
|
||||
.entry(rec.model_id.clone())
|
||||
.or_default()
|
||||
.apply(rec);
|
||||
inner
|
||||
.by_account
|
||||
.entry(if rec.account.is_empty() {
|
||||
"anonymous".to_string()
|
||||
} else {
|
||||
rec.account.clone()
|
||||
})
|
||||
.or_default()
|
||||
.apply(rec);
|
||||
inner
|
||||
.by_tier
|
||||
.entry(rec.tier.clone())
|
||||
.or_default()
|
||||
.apply(rec);
|
||||
inner
|
||||
.by_label
|
||||
.entry(rec.task_label.clone())
|
||||
.or_default()
|
||||
.apply(rec);
|
||||
|
||||
let minute = rec.ts - (rec.ts % 60);
|
||||
match inner.buckets.back_mut() {
|
||||
@@ -250,13 +257,31 @@ impl Stats {
|
||||
.filter(|b| b.minute >= cutoff)
|
||||
.collect();
|
||||
|
||||
let breakdown_src = match groupby {
|
||||
"account" => &inner.by_account,
|
||||
"tier" => &inner.by_tier,
|
||||
"label" => &inner.by_label,
|
||||
_ => &inner.by_model,
|
||||
};
|
||||
let mut breakdown: Vec<serde_json::Value> = breakdown_src
|
||||
// Windowed totals + breakdown = sum of the in-window buckets, so the KPIs
|
||||
// and the breakdown recompute whenever the range changes.
|
||||
let mut win = Totals::default();
|
||||
let mut group: HashMap<String, Totals> = HashMap::new();
|
||||
for b in &series {
|
||||
win.requests += b.requests;
|
||||
win.errors += b.errors;
|
||||
win.ok += b.requests.saturating_sub(b.errors);
|
||||
win.failovers += b.failovers;
|
||||
win.prompt_tokens += b.prompt_tokens;
|
||||
win.completion_tokens += b.completion_tokens;
|
||||
win.cost_usd += b.cost_usd;
|
||||
win.latency_ms_sum += b.latency_ms_sum;
|
||||
let src = match groupby {
|
||||
"account" => &b.by_account,
|
||||
"tier" => &b.by_tier,
|
||||
"label" => &b.by_label,
|
||||
_ => &b.by_model,
|
||||
};
|
||||
for (k, t) in src {
|
||||
group.entry(k.clone()).or_default().merge(t);
|
||||
}
|
||||
}
|
||||
|
||||
let mut breakdown: Vec<serde_json::Value> = group
|
||||
.iter()
|
||||
.map(|(k, t)| {
|
||||
serde_json::json!({
|
||||
@@ -280,17 +305,17 @@ impl Stats {
|
||||
|
||||
serde_json::json!({
|
||||
"totals": {
|
||||
"requests": inner.total.requests,
|
||||
"ok": inner.total.ok,
|
||||
"errors": inner.total.errors,
|
||||
"failovers": inner.total.failovers,
|
||||
"prompt_tokens": inner.total.prompt_tokens,
|
||||
"completion_tokens": inner.total.completion_tokens,
|
||||
"total_tokens": inner.total.prompt_tokens + inner.total.completion_tokens,
|
||||
"cost_usd": inner.total.cost_usd,
|
||||
"avg_latency_ms": avg(inner.total.latency_ms_sum, inner.total.requests),
|
||||
"success_rate": if inner.total.requests > 0 {
|
||||
inner.total.ok as f64 / inner.total.requests as f64
|
||||
"requests": win.requests,
|
||||
"ok": win.ok,
|
||||
"errors": win.errors,
|
||||
"failovers": win.failovers,
|
||||
"prompt_tokens": win.prompt_tokens,
|
||||
"completion_tokens": win.completion_tokens,
|
||||
"total_tokens": win.prompt_tokens + win.completion_tokens,
|
||||
"cost_usd": win.cost_usd,
|
||||
"avg_latency_ms": avg(win.latency_ms_sum, win.requests),
|
||||
"success_rate": if win.requests > 0 {
|
||||
win.ok as f64 / win.requests as f64
|
||||
} else { 0.0 },
|
||||
},
|
||||
"groupby": groupby,
|
||||
@@ -299,6 +324,18 @@ impl Stats {
|
||||
})
|
||||
}
|
||||
|
||||
/// Reset all in-memory stats and truncate the JSONL log — the "clear logs"
|
||||
/// admin action.
|
||||
pub fn clear(&self) {
|
||||
{
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
*inner = Inner::default();
|
||||
}
|
||||
if let Some(path) = &self.file {
|
||||
let _ = std::fs::write(path, "");
|
||||
}
|
||||
}
|
||||
|
||||
/// Recent requests (newest first), with pagination.
|
||||
pub fn recent(&self, limit: usize, offset: usize) -> Vec<RequestRecord> {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
@@ -358,10 +395,25 @@ fn fill_bucket(b: &mut Bucket, r: &RequestRecord) {
|
||||
if r.outcome != "ok" {
|
||||
b.errors += 1;
|
||||
}
|
||||
if r.failover {
|
||||
b.failovers += 1;
|
||||
}
|
||||
b.prompt_tokens += r.prompt_tokens as u64;
|
||||
b.completion_tokens += r.completion_tokens as u64;
|
||||
b.cost_usd += r.cost_usd;
|
||||
b.latency_ms_sum += r.latency_ms;
|
||||
// per-group breakdown, so the snapshot can be windowed by summing buckets
|
||||
b.by_model.entry(r.model_id.clone()).or_default().apply(r);
|
||||
b.by_account
|
||||
.entry(if r.account.is_empty() {
|
||||
"anonymous".to_string()
|
||||
} else {
|
||||
r.account.clone()
|
||||
})
|
||||
.or_default()
|
||||
.apply(r);
|
||||
b.by_tier.entry(r.tier.clone()).or_default().apply(r);
|
||||
b.by_label.entry(r.task_label.clone()).or_default().apply(r);
|
||||
}
|
||||
|
||||
fn avg(sum: u64, n: u64) -> u64 {
|
||||
|
||||
Vendored
+12
@@ -177,6 +177,18 @@ textarea { resize: vertical; min-height: 64px; }
|
||||
.check input { width: auto; }
|
||||
.hint { color: var(--faint); font-size: 12px; margin-top: 4px; }
|
||||
|
||||
/* "?" help badge — hover/focus shows the native title tooltip explaining a setting */
|
||||
.help {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 15px; height: 15px; border-radius: 50%;
|
||||
border: 1px solid var(--border); background: var(--panel);
|
||||
color: var(--muted); font-size: 10px; font-weight: 700; line-height: 1;
|
||||
cursor: help; vertical-align: middle; user-select: none;
|
||||
transition: 0.12s;
|
||||
}
|
||||
.help:hover, .help:focus { color: var(--accent); border-color: var(--accent); outline: none; }
|
||||
label.field > span .help { text-transform: none; }
|
||||
|
||||
/* ---- buttons ------------------------------------------------------------- */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||
|
||||
Vendored
+10
-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
|
||||
@@ -87,6 +88,7 @@ export const api = {
|
||||
|
||||
// stats
|
||||
stats: (range, groupby) => j("GET", `/stats?range=${range}&groupby=${groupby}`),
|
||||
clearStats: () => j("DELETE", "/stats"),
|
||||
requests: (limit = 50, offset = 0) => j("GET", `/requests?limit=${limit}&offset=${offset}`),
|
||||
|
||||
// playground
|
||||
@@ -95,6 +97,13 @@ export const api = {
|
||||
// shadow / self-warming loop
|
||||
shadow: () => j("GET", "/shadow"),
|
||||
|
||||
// CMF runtime (the `cortiq` CLI that runs the .cmf format)
|
||||
cmfStatus: () => j("GET", "/cmf"),
|
||||
cmfInstall: () => j("POST", "/cmf/install"),
|
||||
cmfPortCheck: (port) => j("GET", "/cmf/port?port=" + encodeURIComponent(port)),
|
||||
cmfFiles: () => j("GET", "/cmf/files"),
|
||||
providerModels: (b) => j("POST", "/provider/models", b),
|
||||
|
||||
// CMF model factory (HuggingFace → local .cmf)
|
||||
hfSearch: (q, limit = 24) =>
|
||||
j("GET", `/hf/search?q=${encodeURIComponent(q || "")}&limit=${limit}`),
|
||||
|
||||
Vendored
+80
@@ -59,6 +59,9 @@ export default {
|
||||
// dashboard
|
||||
"dash.title": "Dashboard",
|
||||
"dash.subtitle": "Live traffic, cost and routing analytics for your model pool.",
|
||||
"dash.clear": "Clear logs",
|
||||
"dash.clearConfirm": "Clear all recorded stats and logs? This cannot be undone.",
|
||||
"dash.cleared": "Logs cleared",
|
||||
"dash.range": "Range",
|
||||
"dash.range.1h": "1h",
|
||||
"dash.range.24h": "24h",
|
||||
@@ -129,6 +132,22 @@ 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",
|
||||
"models.form.apiKeyNotNeeded": "not required (local server)",
|
||||
"models.form.apiKeyHint": "Stored securely on the gateway; it is not shown again.",
|
||||
"models.form.keyStored": "A key is saved — leave blank to keep it, or paste a new one.",
|
||||
"models.form.keyFromEnv": "Using a key from an environment variable.",
|
||||
"models.form.testKey": "Test & load models",
|
||||
"models.form.testing": "checking…",
|
||||
"models.form.foundModels": "{n} models loaded",
|
||||
"models.form.modelPlaceholder": "type to search…",
|
||||
"models.form.modelHint": "Click “Test & load models” to pull the list from your key, then pick one.",
|
||||
"models.managed": "managed",
|
||||
"models.managedHint": "Provided by the managed CMF server (Settings → Local models). Configure it there.",
|
||||
|
||||
// routing
|
||||
"routing.title": "Routing",
|
||||
@@ -242,6 +261,46 @@ export default {
|
||||
"settings.cmf.threads": "Worker threads",
|
||||
"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.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 · O(1). Changes apply on restart; deleting a row stops that server.",
|
||||
"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.",
|
||||
"settings.cmf.runtime": "cortiq runtime",
|
||||
"settings.cmf.runtimeHelp": "The `cortiq` command-line tool that loads and runs .cmf models. Install it once before starting a local server.",
|
||||
"settings.cmf.install": "Install cortiq",
|
||||
"settings.cmf.reinstall": "Reinstall / update",
|
||||
"settings.cmf.installing": "installing… (compiles, a few minutes)",
|
||||
"settings.cmf.installedOk": "cortiq installed",
|
||||
"settings.cmf.notInstalled": "not installed",
|
||||
"settings.cmf.addModel": "Add a model",
|
||||
"settings.cmf.addModelHelp": "Models are downloaded and converted in the Import tab (HuggingFace → .cmf). Then point the local server at the .cmf file below.",
|
||||
"settings.cmf.importNote": "No model yet? Import one:",
|
||||
"settings.cmf.importLink": "Open Import",
|
||||
"settings.cmf.modelHelp": "Path to the .cmf file to serve (e.g. models/qwen.cmf), produced by the Import tab. Running several models for different tasks? Import them and switch this path.",
|
||||
"settings.cmf.portHelp": "TCP port the local model server listens on (default 8081). Change only if it clashes with another service.",
|
||||
"settings.cmf.threadsHelp": "How many CPU cores to use for generation. More = faster (~3× vs 1 core); 0 lets the runtime decide.",
|
||||
"settings.cmf.gpuHelp": "Offload matrix math to the GPU (Metal/wgpu). On Apple Silicon this mostly speeds up long prompts — decode is memory-bandwidth-bound.",
|
||||
"settings.cmf.localOnlyHelp": "Route every request to the local model, ignoring the router and any cloud providers.",
|
||||
"settings.cmf.manageHelp": "The gateway starts `cortiq serve` for the .cmf below at launch and routes to it. Needs cortiq installed (step 1). Applies on restart.",
|
||||
"settings.cmf.autoInstallHelp": "If cortiq is missing at startup, install it automatically from crates.io.",
|
||||
"settings.cmf.autoUpdateHelp": "On startup, upgrade cortiq to the newest release when a newer one exists.",
|
||||
"settings.router.enabledHelp": "Use the cortiq-router to classify each request (task type + complexity) and pick a model. Off = use the local routing table only.",
|
||||
"settings.router.verifyTlsHelp": "Verify the router's TLS certificate. Turn off only for a self-signed / IP-bound certificate.",
|
||||
"settings.telemetry.metricsHelp": "Expose Prometheus metrics at GET /metrics.",
|
||||
"settings.cortiq.echoHelp": "Add a `cortiq` field (task, model, cost) to each response body. Response headers are always sent.",
|
||||
"settings.stats.enabledHelp": "Record recent requests to power the dashboard and charts.",
|
||||
"settings.cache.enabledHelp": "Semantic cache: return a saved answer when a new prompt is very similar to a past one, skipping the model call. Needs an embedding model in the pool.",
|
||||
"settings.log.bodiesHelp": "Log full request/response message bodies. Off by default for privacy.",
|
||||
"settings.router.taxonomy": "Taxonomy id",
|
||||
"settings.router.getKey": "Get or top up a router key at",
|
||||
"settings.router.secret": "Router key (value)",
|
||||
@@ -265,6 +324,10 @@ export default {
|
||||
"settings.route.maxChars": "Max chars",
|
||||
"settings.route.cacheTtl": "Cache TTL",
|
||||
"settings.route.profile": "Default profile",
|
||||
"settings.route.strategyHelp": "What text is sent to the router for classification: last_user = only the last user message; last_user_plus_system = last user message + system prompt; concat_all = the whole conversation.",
|
||||
"settings.route.maxCharsHelp": "Max characters of that text sent to the router (longer is truncated). More = more accurate classification, but slower/pricier router calls.",
|
||||
"settings.route.cacheTtlHelp": "How long a routing decision is cached for an identical request before the router is asked again (e.g. 60s, 5m).",
|
||||
"settings.route.profileHelp": "Default routing profile when a request doesn't specify one: cost-saver (cheapest), balanced, or quality-first (best model). Biases which model the router picks.",
|
||||
"settings.breaker": "Circuit breaker",
|
||||
"settings.breaker.threshold": "Error threshold",
|
||||
"settings.breaker.cooldown": "Cooldown",
|
||||
@@ -342,4 +405,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
+80
@@ -58,6 +58,9 @@ export default {
|
||||
// dashboard
|
||||
"dash.title": "Дашборд",
|
||||
"dash.subtitle": "Трафик в реальном времени, расходы и аналитика роутинга по пулу моделей.",
|
||||
"dash.clear": "Очистить логи",
|
||||
"dash.clearConfirm": "Очистить всю статистику и логи? Отменить нельзя.",
|
||||
"dash.cleared": "Логи очищены",
|
||||
"dash.range": "Период",
|
||||
"dash.range.1h": "1ч",
|
||||
"dash.range.24h": "24ч",
|
||||
@@ -128,6 +131,22 @@ 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-ключ",
|
||||
"models.form.apiKeyNotNeeded": "не нужен (локальный сервер)",
|
||||
"models.form.apiKeyHint": "Хранится безопасно на шлюзе; больше не показывается.",
|
||||
"models.form.keyStored": "Ключ сохранён — оставьте пустым, чтобы не менять, или вставьте новый.",
|
||||
"models.form.keyFromEnv": "Используется ключ из переменной окружения.",
|
||||
"models.form.testKey": "Проверить и загрузить модели",
|
||||
"models.form.testing": "проверка…",
|
||||
"models.form.foundModels": "загружено моделей: {n}",
|
||||
"models.form.modelPlaceholder": "введите для поиска…",
|
||||
"models.form.modelHint": "Нажмите «Проверить и загрузить модели», чтобы подтянуть список по ключу, затем выберите.",
|
||||
"models.managed": "управляемая",
|
||||
"models.managedHint": "Предоставляется CMF-сервером (Настройки → Локальные модели). Настраивается там.",
|
||||
|
||||
// routing
|
||||
"routing.title": "Роутинг",
|
||||
@@ -241,6 +260,46 @@ export default {
|
||||
"settings.cmf.threads": "Рабочие потоки",
|
||||
"settings.cmf.gpu": "Использовать GPU (Metal / wgpu)",
|
||||
"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 · O(1). Изменения применяются после перезапуска; удаление строки останавливает сервер.",
|
||||
"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 — работает даже офлайн или когда роутер недоступен.",
|
||||
"settings.cmf.runtime": "Среда cortiq",
|
||||
"settings.cmf.runtimeHelp": "Инструмент командной строки `cortiq`, который загружает и запускает .cmf-модели. Установите один раз перед запуском локального сервера.",
|
||||
"settings.cmf.install": "Установить cortiq",
|
||||
"settings.cmf.reinstall": "Переустановить / обновить",
|
||||
"settings.cmf.installing": "устанавливается… (компилируется, пара минут)",
|
||||
"settings.cmf.installedOk": "cortiq установлен",
|
||||
"settings.cmf.notInstalled": "не установлен",
|
||||
"settings.cmf.addModel": "Добавить модель",
|
||||
"settings.cmf.addModelHelp": "Модели скачиваются и конвертируются во вкладке Импорт (HuggingFace → .cmf). Затем укажите путь к .cmf ниже.",
|
||||
"settings.cmf.importNote": "Ещё нет модели? Импортируйте:",
|
||||
"settings.cmf.importLink": "Открыть Импорт",
|
||||
"settings.cmf.modelHelp": "Путь к .cmf-файлу для запуска (напр. models/qwen.cmf), создаётся во вкладке Импорт. Несколько моделей под разные задачи? Импортируйте их и меняйте этот путь.",
|
||||
"settings.cmf.portHelp": "TCP-порт локального сервера моделей (по умолчанию 8081). Меняйте, только если конфликтует с другим сервисом.",
|
||||
"settings.cmf.threadsHelp": "Сколько ядер CPU использовать для генерации. Больше = быстрее (~×3 против 1 ядра); 0 — на усмотрение рантайма.",
|
||||
"settings.cmf.gpuHelp": "Выгрузить матричные операции на GPU (Metal/wgpu). На Apple Silicon ускоряет в основном длинные промпты — декод упирается в пропускную способность памяти.",
|
||||
"settings.cmf.localOnlyHelp": "Направлять все запросы в локальную модель, игнорируя роутер и облачные провайдеры.",
|
||||
"settings.cmf.manageHelp": "Шлюз запускает `cortiq serve` для .cmf ниже при старте и маршрутизирует в него. Нужен установленный cortiq (шаг 1). Применяется после перезапуска.",
|
||||
"settings.cmf.autoInstallHelp": "Если cortiq не найден при старте — установить автоматически с crates.io.",
|
||||
"settings.cmf.autoUpdateHelp": "При старте обновлять cortiq до новейшей версии, когда есть новее.",
|
||||
"settings.router.enabledHelp": "Использовать cortiq-router для классификации запроса (тип задачи + сложность) и выбора модели. Выкл = только локальная таблица маршрутизации.",
|
||||
"settings.router.verifyTlsHelp": "Проверять TLS-сертификат роутера. Выключайте только для самоподписанного / привязанного к IP сертификата.",
|
||||
"settings.telemetry.metricsHelp": "Отдавать метрики Prometheus по GET /metrics.",
|
||||
"settings.cortiq.echoHelp": "Добавлять поле `cortiq` (задача, модель, стоимость) в тело каждого ответа. Заголовки ответа шлются всегда.",
|
||||
"settings.stats.enabledHelp": "Записывать недавние запросы для дашборда и графиков.",
|
||||
"settings.cache.enabledHelp": "Семантический кэш: возвращать сохранённый ответ, когда новый промпт очень похож на прошлый, пропуская вызов модели. Нужна embedding-модель в пуле.",
|
||||
"settings.log.bodiesHelp": "Логировать полные тела запросов/ответов. По умолчанию выкл ради приватности.",
|
||||
"settings.router.taxonomy": "ID таксономии",
|
||||
"settings.router.getKey": "Получить или пополнить ключ роутера:",
|
||||
"settings.router.secret": "Ключ роутера (значение)",
|
||||
@@ -264,6 +323,10 @@ export default {
|
||||
"settings.route.maxChars": "Макс. символов",
|
||||
"settings.route.cacheTtl": "TTL кэша",
|
||||
"settings.route.profile": "Профиль по умолчанию",
|
||||
"settings.route.strategyHelp": "Какой текст отправляется роутеру для классификации: last_user — только последнее сообщение пользователя; last_user_plus_system — последнее сообщение + системный промпт; concat_all — вся переписка.",
|
||||
"settings.route.maxCharsHelp": "Максимум символов этого текста, отправляемого роутеру (длиннее — обрезается). Больше = точнее классификация, но медленнее/дороже вызовы роутера.",
|
||||
"settings.route.cacheTtlHelp": "Сколько кэшируется решение роутинга для одинакового запроса, прежде чем роутер спрашивается снова (напр. 60s, 5m).",
|
||||
"settings.route.profileHelp": "Профиль роутинга по умолчанию, если запрос его не задаёт: cost-saver (дешевле), balanced (баланс) или quality-first (качество). Влияет на выбор модели роутером.",
|
||||
"settings.breaker": "Автоматический выключатель",
|
||||
"settings.breaker.threshold": "Порог ошибок",
|
||||
"settings.breaker.cooldown": "Время восстановления",
|
||||
@@ -341,4 +404,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
+3
-1
@@ -25,7 +25,9 @@ export function clear(node) {
|
||||
|
||||
export function mount(node, ...children) {
|
||||
clear(node);
|
||||
for (const c of children.flat()) if (c != null && c !== false) node.appendChild(c);
|
||||
for (const c of children.flat())
|
||||
if (c != null && c !== false)
|
||||
node.appendChild(typeof c === "object" ? c : document.createTextNode(String(c)));
|
||||
return node;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+19
-5
@@ -1,5 +1,5 @@
|
||||
// Dashboard — KPIs, time-series charts, breakdown, recent requests, health.
|
||||
import { h, mount, money, num, ms, pct, timeAgo } from "../ui.js";
|
||||
import { h, mount, money, num, ms, pct, timeAgo, toast, confirmDialog } from "../ui.js";
|
||||
import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { lineChart, barList } from "../charts.js";
|
||||
@@ -53,10 +53,24 @@ export async function renderDashboard() {
|
||||
"div",
|
||||
{ class: "flex" },
|
||||
h("div", { class: "grow" }, h("h2", {}, t("dash.title")), h("p", {}, t("dash.subtitle"))),
|
||||
seg(RANGES, range, (r) => {
|
||||
range = r;
|
||||
load();
|
||||
}, (r) => t("dash.range." + r))
|
||||
h("div", { class: "flex", style: "gap:8px;align-items:center" },
|
||||
seg(RANGES, range, (r) => {
|
||||
range = r;
|
||||
load();
|
||||
}, (r) => t("dash.range." + r)),
|
||||
h("button", {
|
||||
class: "btn sm danger",
|
||||
onClick: async () => {
|
||||
if (!(await confirmDialog(t("dash.clearConfirm")))) return;
|
||||
try {
|
||||
await api.clearStats();
|
||||
toast(t("dash.cleared"), "good");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
},
|
||||
}, t("dash.clear")))
|
||||
)
|
||||
),
|
||||
|
||||
|
||||
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
+150
-41
@@ -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";
|
||||
@@ -16,52 +16,142 @@ function keyBadge(src) {
|
||||
return h("span", { class: "badge " + (map[src] ?? "") }, src);
|
||||
}
|
||||
|
||||
// Verified per-provider defaults (base URL, key env, whether a key is needed).
|
||||
const PROVIDER_DEFAULTS = {
|
||||
openai: { base: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY", needsKey: true, tier: "mid", caps: ["tools", "vision"] },
|
||||
anthropic: { base: "https://api.anthropic.com", keyEnv: "ANTHROPIC_API_KEY", needsKey: true, tier: "expensive", caps: ["tools", "vision"] },
|
||||
openrouter: { base: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY", needsKey: true, tier: "mid", caps: ["tools", "vision"] },
|
||||
lmstudio: { base: "http://localhost:1234/v1", keyEnv: "", needsKey: false, tier: "local", caps: ["tools", "vision"] },
|
||||
ollama: { base: "http://localhost:11434/v1", keyEnv: "", needsKey: false, tier: "local", caps: ["tools"] },
|
||||
http: { base: "", keyEnv: "", needsKey: false, tier: "cheap", caps: ["tools"] },
|
||||
};
|
||||
|
||||
// Derive a clean model id from a model name (last path segment, slugified).
|
||||
// e.g. "qwen/qwen3.7-max" → "qwen3-7-max".
|
||||
function slugId(name) {
|
||||
const base = (name || "").split("/").pop() || "";
|
||||
return base.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function modelForm(meta, existing) {
|
||||
const m = existing || { provider: "openai", kind: "chat", cost_tier: "cheap", caps: [], price_in: 0, price_out: 0 };
|
||||
const idIn = h("input", { value: m.id || "", placeholder: "local-qwen", disabled: existing ? true : null });
|
||||
const m = existing || { provider: "openai", kind: "chat", cost_tier: "mid", caps: ["tools"], price_in: 0, price_out: 0 };
|
||||
const idIn = h("input", { value: m.id || "", placeholder: "my-model", disabled: existing ? true : null });
|
||||
const providerSel = h("select", {}, ...(meta.providers || ["openai"]).map((p) => opt(p, p, p === m.provider)));
|
||||
const baseUrlIn = h("input", { value: m.base_url || "", placeholder: "http://localhost:8000/v1" });
|
||||
const modelIn = h("input", { value: m.model || "", placeholder: "qwen2.5-7b-instruct" });
|
||||
const baseUrlIn = h("input", { value: m.base_url || "", placeholder: "https://api.openai.com/v1" });
|
||||
|
||||
// API key — typed directly, stored securely as a secret under a provider env name.
|
||||
const keyIn = h("input", { type: "password", placeholder: t("models.form.apiKeyPlaceholder"), autocomplete: "new-password" });
|
||||
const testBtn = h("button", { class: "btn small", type: "button" }, t("models.form.testKey"));
|
||||
const keyStatus = h("span", { class: "small", style: "margin-left:8px" });
|
||||
|
||||
// Model — below the key (some providers list models from the key); searchable datalist.
|
||||
const dlId = "model-opts-" + (m.id || "new");
|
||||
const dl = h("datalist", { id: dlId });
|
||||
if (m.model) dl.appendChild(h("option", { value: m.model }));
|
||||
const modelIn = h("input", { value: m.model || "", placeholder: t("models.form.modelPlaceholder"), list: dlId, autocomplete: "off" });
|
||||
|
||||
const kindSel = h("select", {}, ...(meta.kinds || ["chat"]).map((k) => opt(k, k, k === m.kind)));
|
||||
const tierSel = h("select", {}, ...(meta.cost_tiers || ["cheap"]).map((c) => opt(c, c, c === m.cost_tier)));
|
||||
const priceInIn = h("input", { type: "number", step: "0.01", value: m.price_in ?? 0 });
|
||||
const priceOutIn = h("input", { type: "number", step: "0.01", value: m.price_out ?? 0 });
|
||||
const keyEnvIn = h("input", { value: m.api_key_env || "", placeholder: "OPENAI_API_KEY" });
|
||||
const secretIn = h("input", { type: "password", placeholder: t("models.form.secretPlaceholder") });
|
||||
const capInputs = (meta.caps || ["tools", "vision"]).map((c) => {
|
||||
const cb = h("input", { type: "checkbox", checked: (m.caps || []).includes(c) ? true : null });
|
||||
return { c, cb };
|
||||
});
|
||||
|
||||
const envFor = () =>
|
||||
(existing && existing.api_key_env) ||
|
||||
(PROVIDER_DEFAULTS[providerSel.value] || {}).keyEnv ||
|
||||
providerSel.value.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_API_KEY";
|
||||
|
||||
// Fill in a provider's verified defaults when it is selected.
|
||||
function applyDefaults() {
|
||||
const d = PROVIDER_DEFAULTS[providerSel.value];
|
||||
if (!d) return;
|
||||
baseUrlIn.value = d.base;
|
||||
tierSel.value = d.tier;
|
||||
capInputs.forEach(({ c, cb }) => { cb.checked = d.caps.includes(c); });
|
||||
keyIn.placeholder = d.needsKey ? t("models.form.apiKeyPlaceholder") : t("models.form.apiKeyNotNeeded");
|
||||
mount(keyStatus, "");
|
||||
}
|
||||
providerSel.addEventListener("change", applyDefaults);
|
||||
if (!existing) applyDefaults();
|
||||
|
||||
// Auto-fill the id from the model name (until the user edits id by hand).
|
||||
if (!existing) {
|
||||
modelIn.addEventListener("input", () => {
|
||||
if (!idIn.value || idIn._auto) {
|
||||
idIn.value = slugId(modelIn.value);
|
||||
idIn._auto = true;
|
||||
}
|
||||
});
|
||||
idIn.addEventListener("input", () => { idIn._auto = false; });
|
||||
}
|
||||
|
||||
async function testKey() {
|
||||
if (!baseUrlIn.value.trim()) { mount(keyStatus, h("span", { style: "color:var(--bad)" }, t("models.form.baseUrl") + "?")); return; }
|
||||
testBtn.disabled = true;
|
||||
mount(keyStatus, t("models.form.testing"));
|
||||
try {
|
||||
const r = await api.providerModels({
|
||||
provider: providerSel.value,
|
||||
base_url: baseUrlIn.value.trim(),
|
||||
api_key: keyIn.value.trim() || null,
|
||||
api_key_env: envFor(),
|
||||
});
|
||||
if (r.ok) {
|
||||
dl.replaceChildren(...(r.models || []).map((mm) => h("option", { value: mm })));
|
||||
mount(keyStatus, h("span", { style: "color:var(--good)" }, "✓ " + t("models.form.foundModels", { n: r.count })));
|
||||
modelIn.focus();
|
||||
} else {
|
||||
mount(keyStatus, h("span", { style: "color:var(--bad)" }, "✗ " + (r.error || t("common.error"))));
|
||||
}
|
||||
} catch (e) {
|
||||
mount(keyStatus, h("span", { style: "color:var(--bad)" }, "✗ " + String(e.message || e)));
|
||||
}
|
||||
testBtn.disabled = false;
|
||||
}
|
||||
testBtn.addEventListener("click", testKey);
|
||||
|
||||
const keyHint =
|
||||
existing && existing.key_source === "store" ? t("models.form.keyStored")
|
||||
: existing && existing.key_source === "env" ? t("models.form.keyFromEnv")
|
||||
: t("models.form.apiKeyHint");
|
||||
|
||||
const node = h(
|
||||
"div",
|
||||
{},
|
||||
h("div", { class: "row" }, field(t("models.form.id"), idIn, existing ? null : t("models.form.idHint")), field(t("models.form.provider"), providerSel)),
|
||||
field(t("models.form.baseUrl"), baseUrlIn),
|
||||
field(t("models.form.model"), modelIn),
|
||||
field(t("models.form.apiKey"), h("div", { class: "flex wrap", style: "gap:8px;align-items:center" }, keyIn, testBtn, keyStatus), keyHint),
|
||||
field(t("models.form.model"), modelIn, t("models.form.modelHint")),
|
||||
dl,
|
||||
h("div", { class: "row" }, field(t("models.form.kind"), kindSel), field(t("models.form.costTier"), tierSel)),
|
||||
h("div", { class: "row" }, field(t("models.form.priceIn"), priceInIn), field(t("models.form.priceOut"), priceOutIn)),
|
||||
field(t("models.form.caps"), h("div", { class: "flex wrap" }, ...capInputs.map(({ c, cb }) => h("label", { class: "check" }, cb, c)))),
|
||||
field(t("models.form.apiKeyEnv"), keyEnvIn, t("models.form.apiKeyEnvHint")),
|
||||
field(t("models.form.secret"), secretIn)
|
||||
field(t("models.form.caps"), h("div", { class: "flex wrap" }, ...capInputs.map(({ c, cb }) => h("label", { class: "check" }, cb, c))))
|
||||
);
|
||||
|
||||
const getValue = () => ({
|
||||
model: {
|
||||
id: existing ? m.id : idIn.value.trim(),
|
||||
provider: providerSel.value,
|
||||
base_url: baseUrlIn.value.trim(),
|
||||
model: modelIn.value.trim(),
|
||||
kind: kindSel.value,
|
||||
cost_tier: tierSel.value,
|
||||
price_in: parseFloat(priceInIn.value) || 0,
|
||||
price_out: parseFloat(priceOutIn.value) || 0,
|
||||
api_key_env: keyEnvIn.value.trim() || null,
|
||||
caps: capInputs.filter(({ cb }) => cb.checked).map(({ c }) => c),
|
||||
},
|
||||
secret: secretIn.value,
|
||||
keyEnv: keyEnvIn.value.trim(),
|
||||
});
|
||||
const getValue = () => {
|
||||
const typedKey = keyIn.value.trim();
|
||||
const hasKey = !!typedKey || !!(existing && existing.api_key_env);
|
||||
const keyEnv = envFor();
|
||||
return {
|
||||
model: {
|
||||
id: existing ? m.id : idIn.value.trim(),
|
||||
provider: providerSel.value,
|
||||
base_url: baseUrlIn.value.trim(),
|
||||
model: modelIn.value.trim(),
|
||||
kind: kindSel.value,
|
||||
cost_tier: tierSel.value,
|
||||
price_in: parseFloat(priceInIn.value) || 0,
|
||||
price_out: parseFloat(priceOutIn.value) || 0,
|
||||
api_key_env: hasKey ? keyEnv : null,
|
||||
caps: capInputs.filter(({ cb }) => cb.checked).map(({ c }) => c),
|
||||
},
|
||||
secret: typedKey,
|
||||
keyEnv,
|
||||
};
|
||||
};
|
||||
|
||||
return { node, getValue };
|
||||
}
|
||||
@@ -70,12 +160,14 @@ async function openModal(meta, existing, reload) {
|
||||
const { node, getValue } = modelForm(meta, existing);
|
||||
modal(existing ? t("models.form.editTitle") : t("models.form.addTitle"), node, async () => {
|
||||
const { model, secret, keyEnv } = getValue();
|
||||
// Forgot the id? Derive it from the model name so saving never errors on a
|
||||
// blank id.
|
||||
if (!model.id) model.id = slugId(model.model);
|
||||
if (!model.id) {
|
||||
toast(t("models.form.id") + "?", "bad");
|
||||
toast(t("models.form.idOrModel"), "bad");
|
||||
return false;
|
||||
}
|
||||
// strip null api_key_env so serde keeps it optional
|
||||
if (!model.api_key_env) delete model.api_key_env;
|
||||
if (!model.api_key_env) delete model.api_key_env; // keep serde field optional
|
||||
try {
|
||||
if (existing) await api.updateModel(existing.id, model);
|
||||
else await api.createModel(model);
|
||||
@@ -168,7 +260,8 @@ function row(m, meta, reload) {
|
||||
return h(
|
||||
"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", { class: "mono" }, m.model),
|
||||
h("td", {}, h("span", { class: "badge " + (m.cost_tier || "") }, m.cost_tier || "—")),
|
||||
@@ -182,20 +275,36 @@ function row(m, meta, reload) {
|
||||
"div",
|
||||
{ class: "flex", style: "justify-content:flex-end" },
|
||||
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(
|
||||
"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");
|
||||
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 || "");
|
||||
const fileCb = h("input", { type: "checkbox", checked: isLocal ? true : null });
|
||||
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
+206
-60
@@ -4,12 +4,22 @@ import { t } from "../i18n.js";
|
||||
import { api } from "../api.js";
|
||||
import { appState, SITE_URL } from "../app.js";
|
||||
|
||||
function field(label, control, hint) {
|
||||
return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("div", { class: "hint" }, hint) : null);
|
||||
// A small "?" badge with a hover/focus tooltip explaining a setting.
|
||||
function help(text) {
|
||||
return text ? h("span", { class: "help", title: text, tabindex: "0", role: "img", "aria-label": text }, "?") : null;
|
||||
}
|
||||
function check(label, checked) {
|
||||
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 check(label, checked, helpText) {
|
||||
const cb = h("input", { type: "checkbox", checked: checked ? true : null });
|
||||
return { node: h("label", { class: "check" }, cb, label), cb };
|
||||
return { node: h("label", { class: "check" }, cb, label, helpText ? " " : null, help(helpText)), cb };
|
||||
}
|
||||
|
||||
// Flatten router usage (nested account/usage objects) into readable, wrapping
|
||||
@@ -59,8 +69,8 @@ export async function renderSettings() {
|
||||
const rKeyEnv = h("input", { value: s.router?.api_key_env || "", placeholder: "CORTIQ_ROUTER_KEY" });
|
||||
const rTimeout = h("input", { type: "number", value: s.router?.timeout_ms ?? 800 });
|
||||
const rTax = h("input", { value: s.router?.taxonomy_id || "" });
|
||||
const rVerify = check(t("settings.router.verifyTls"), s.router?.verify_tls);
|
||||
const rEnabled = check(t("settings.router.enabled"), s.router?.enabled !== false);
|
||||
const rVerify = check(t("settings.router.verifyTls"), s.router?.verify_tls, t("settings.router.verifyTlsHelp"));
|
||||
const rEnabled = check(t("settings.router.enabled"), s.router?.enabled !== false, t("settings.router.enabledHelp"));
|
||||
// router key value (write-only; stored in the secret store, like model keys)
|
||||
const rKeySource =
|
||||
(secretsData.secrets || []).find((x) => x.name === (s.router?.api_key_env || "CORTIQ_ROUTER_KEY"))?.source ||
|
||||
@@ -108,37 +118,16 @@ export async function renderSettings() {
|
||||
rKeyBadge.className = "badge store";
|
||||
return true;
|
||||
}
|
||||
const saveKeyBtn = h("button", { class: "btn sm" }, "✓ " + t("settings.router.saveKey"));
|
||||
saveKeyBtn.addEventListener("click", async () => {
|
||||
saveKeyBtn.disabled = true;
|
||||
try {
|
||||
if (await saveKey()) toast(t("settings.router.keySaved"), "good");
|
||||
else toast(t("settings.router.keyEmpty"), "warn");
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
saveKeyBtn.disabled = false;
|
||||
});
|
||||
// "Test router" — real /v1/route call: tells key problems apart from a down router
|
||||
const probeBtn = h("button", { class: "btn" }, t("settings.router.test"));
|
||||
// Shared connection test (real /v1/route call) — tells key problems apart from
|
||||
// a down router. Rendered into probeOut; also used right after saving the key.
|
||||
const probeOut = h("div", { class: "hint", style: "margin-top:8px" });
|
||||
probeBtn.addEventListener("click", async () => {
|
||||
probeBtn.disabled = true;
|
||||
async function runProbe() {
|
||||
mount(probeOut, h("span", { class: "spinner" }));
|
||||
try {
|
||||
// If a key was just pasted but not saved, persist it first so the test uses it.
|
||||
if (rSecret.value.trim()) {
|
||||
try {
|
||||
await saveKey();
|
||||
} catch (_) {
|
||||
/* fall through — probe will report no_key/auth */
|
||||
}
|
||||
}
|
||||
const r = await api.probeRouter();
|
||||
const parts = [t(probeMsgKey(r.status), { ms: r.latency_ms ?? "—" })];
|
||||
if (r.message) parts.push(" — " + r.message);
|
||||
const line = h("span", {}, ...parts);
|
||||
const out = [line];
|
||||
const out = [h("span", {}, ...parts)];
|
||||
if (PROBE_SITE_STATUSES.includes(r.status)) {
|
||||
out.push(" · ", h("a", { href: SITE_URL, target: "_blank", rel: "noopener" }, t("dash.health.getKey") + " ↗"));
|
||||
}
|
||||
@@ -150,9 +139,37 @@ export async function renderSettings() {
|
||||
}
|
||||
mount(probeOut, h("div", {}, h("span", { class: "badge " + (r.ok ? "ok" : "bad") }, r.ok ? "OK" : "×"), " ", ...out));
|
||||
toast(t(probeMsgKey(r.status), { ms: r.latency_ms ?? "—" }), r.ok ? "good" : "bad");
|
||||
return r.ok;
|
||||
} catch (e) {
|
||||
mount(probeOut, String(e.message || e));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const saveKeyBtn = h("button", { class: "btn sm" }, "✓ " + t("settings.router.saveKey"));
|
||||
saveKeyBtn.addEventListener("click", async () => {
|
||||
saveKeyBtn.disabled = true;
|
||||
try {
|
||||
if (await saveKey()) {
|
||||
toast(t("settings.router.keySaved"), "good");
|
||||
// Immediately test the connection so the user sees reachable/error at once,
|
||||
// instead of a stale "unavailable" until something re-checks.
|
||||
if (rEnabled.cb.checked) await runProbe();
|
||||
} else {
|
||||
toast(t("settings.router.keyEmpty"), "warn");
|
||||
}
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
saveKeyBtn.disabled = false;
|
||||
});
|
||||
const probeBtn = h("button", { class: "btn" }, t("settings.router.test"));
|
||||
probeBtn.addEventListener("click", async () => {
|
||||
probeBtn.disabled = true;
|
||||
// If a key was pasted but not saved, persist it first so the test uses it.
|
||||
if (rSecret.value.trim()) {
|
||||
try { await saveKey(); } catch (_) { /* probe will report no_key/auth */ }
|
||||
}
|
||||
await runProbe();
|
||||
probeBtn.disabled = false;
|
||||
});
|
||||
// route
|
||||
@@ -165,18 +182,18 @@ export async function renderSettings() {
|
||||
const brCool = h("input", { value: s.breaker?.cooldown || "30s" });
|
||||
// log
|
||||
const logSel = h("select", {}, ...["error", "warn", "info", "debug", "trace"].map((x) => h("option", { value: x, selected: x === s.log?.level }, x)));
|
||||
const logBodies = check(t("settings.log.bodies"), s.log?.bodies);
|
||||
const logBodies = check(t("settings.log.bodies"), s.log?.bodies, t("settings.log.bodiesHelp"));
|
||||
// telemetry / cortiq
|
||||
const metricsCb = check(t("settings.telemetry.metrics"), s.telemetry?.metrics);
|
||||
const echoCb = check(t("settings.cortiq.echo"), s.cortiq?.echo);
|
||||
const metricsCb = check(t("settings.telemetry.metrics"), s.telemetry?.metrics, t("settings.telemetry.metricsHelp"));
|
||||
const echoCb = check(t("settings.cortiq.echo"), s.cortiq?.echo, t("settings.cortiq.echoHelp"));
|
||||
// stats
|
||||
const statsEnabled = check(t("settings.stats.enabled"), s.stats?.enabled);
|
||||
const statsEnabled = check(t("settings.stats.enabled"), s.stats?.enabled, t("settings.stats.enabledHelp"));
|
||||
const statsFile = h("input", { value: s.stats?.file || "" });
|
||||
const statsRet = h("input", { value: s.stats?.retention || "7d" });
|
||||
// cache
|
||||
const modelsData = await api.listModels();
|
||||
const embedModels = (modelsData.models || []).filter((m) => m.kind === "embedding").map((m) => m.id);
|
||||
const cacheEnabled = check(t("settings.cache.enabled"), s.cache?.enabled);
|
||||
const cacheEnabled = check(t("settings.cache.enabled"), s.cache?.enabled, t("settings.cache.enabledHelp"));
|
||||
const cacheThresh = h("input", { type: "number", step: "0.01", value: s.cache?.threshold ?? 0.92 });
|
||||
const cacheTtlIn = h("input", { value: s.cache?.ttl || "1h" });
|
||||
const cacheEmbedSel = h(
|
||||
@@ -186,15 +203,127 @@ export async function renderSettings() {
|
||||
...embedModels.map((id) => h("option", { value: id, selected: id === s.cache?.embed_model }, id))
|
||||
);
|
||||
// local models (CMF) — run a local .cmf server and/or force local-only routing
|
||||
const cmfLocalOnly = check(t("settings.cmf.localOnly"), s.cmf?.local_only);
|
||||
const cmfManage = check(t("settings.cmf.manage"), s.cmf?.manage_server);
|
||||
const cmfAutoInstall = check(t("settings.cmf.autoInstall"), s.cmf?.auto_install);
|
||||
const cmfAutoUpdate = check(t("settings.cmf.autoUpdate"), s.cmf?.auto_update);
|
||||
const cmfModel = h("input", { value: s.cmf?.local_model || "", placeholder: "models/model.cmf" });
|
||||
const cmfPort = h("input", { type: "number", value: s.cmf?.local_port ?? 8081 });
|
||||
const cmfModelId = h("input", { value: s.cmf?.model_id || "cmf-local" });
|
||||
const cmfThreads = h("input", { type: "number", min: 0, value: s.cmf?.threads ?? 8 });
|
||||
const cmfGpu = check(t("settings.cmf.gpu"), s.cmf?.gpu);
|
||||
const cmfLocalOnly = check(t("settings.cmf.localOnly"), s.cmf?.local_only, t("settings.cmf.localOnlyHelp"));
|
||||
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 cmfAutoUpdate = check(t("settings.cmf.autoUpdate"), s.cmf?.auto_update, t("settings.cmf.autoUpdateHelp"));
|
||||
// ── managed local models: one supervised `cortiq serve` per row ──
|
||||
const serverRows = [];
|
||||
const serversWrap = h("div", {});
|
||||
const legacyServers = s.cmf?.local_model
|
||||
? [{ 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") });
|
||||
// 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));
|
||||
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 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));
|
||||
}
|
||||
} 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"), o1In, 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, o1: o1In.value }) };
|
||||
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
|
||||
// a local model server can start; this is step 1 for a new user.
|
||||
const cmfStatusEl = h("span", { class: "muted" }, "…");
|
||||
const cmfInstallBtn = h("button", { class: "btn" }, t("settings.cmf.install"));
|
||||
// live "serving <model> on :<port>" indicator for the managed local server
|
||||
const cmfServingEl = h("div", { class: "hint" });
|
||||
async function refreshCmfStatus() {
|
||||
try {
|
||||
const st = await api.cmfStatus();
|
||||
cmfInstallBtn.textContent = st.installed ? t("settings.cmf.reinstall") : t("settings.cmf.install");
|
||||
mount(
|
||||
cmfStatusEl,
|
||||
st.installed
|
||||
? h("span", {}, h("span", { class: "badge ok" }, "✓"), " cortiq " + (st.version || ""))
|
||||
: h("span", {}, h("span", { class: "badge bad" }, "×"), " " + t("settings.cmf.notInstalled"))
|
||||
);
|
||||
// per-server serving indicators
|
||||
const servers = st.servers || [];
|
||||
if (!servers.length) {
|
||||
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 (_) {
|
||||
mount(cmfStatusEl, "—");
|
||||
}
|
||||
}
|
||||
cmfInstallBtn.addEventListener("click", async () => {
|
||||
cmfInstallBtn.disabled = true;
|
||||
mount(cmfStatusEl, h("span", {}, h("span", { class: "spinner" }), " " + t("settings.cmf.installing")));
|
||||
try {
|
||||
await api.cmfInstall();
|
||||
// install compiles from crates.io — poll status for a few minutes
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
const st = await api.cmfStatus();
|
||||
if (st.installed) { toast(t("settings.cmf.installedOk"), "good"); break; }
|
||||
}
|
||||
} catch (e) {
|
||||
toast(String(e.message || e), "bad");
|
||||
}
|
||||
await refreshCmfStatus();
|
||||
cmfInstallBtn.disabled = false;
|
||||
});
|
||||
refreshCmfStatus();
|
||||
|
||||
const saveBtn = h("button", { class: "btn primary" }, t("common.save"));
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
@@ -240,11 +369,9 @@ export async function renderSettings() {
|
||||
manage_server: cmfManage.cb.checked,
|
||||
auto_install: cmfAutoInstall.cb.checked,
|
||||
auto_update: cmfAutoUpdate.cb.checked,
|
||||
local_model: cmfModel.value.trim(),
|
||||
local_port: parseInt(cmfPort.value) || 8081,
|
||||
model_id: cmfModelId.value.trim() || "cmf-local",
|
||||
threads: parseInt(cmfThreads.value) || 0,
|
||||
gpu: cmfGpu.cb.checked,
|
||||
// the managed-models list is the source of truth; clear the legacy single
|
||||
servers: serverRows.map((r) => r.read()).filter((sv) => sv.model && sv.id),
|
||||
local_model: "",
|
||||
},
|
||||
};
|
||||
// serde: drop null optionals
|
||||
@@ -325,9 +452,11 @@ export async function renderSettings() {
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.route"))),
|
||||
field(t("settings.route.strategy"), strategySel),
|
||||
h("div", { class: "row" }, field(t("settings.route.maxChars"), maxChars), field(t("settings.route.cacheTtl"), cacheTtl)),
|
||||
field(t("settings.route.profile"), profileSel)
|
||||
field(t("settings.route.strategy"), strategySel, null, t("settings.route.strategyHelp")),
|
||||
h("div", { class: "row" },
|
||||
field(t("settings.route.maxChars"), maxChars, null, t("settings.route.maxCharsHelp")),
|
||||
field(t("settings.route.cacheTtl"), cacheTtl, null, t("settings.route.cacheTtlHelp"))),
|
||||
field(t("settings.route.profile"), profileSel, null, t("settings.route.profileHelp"))
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
@@ -372,14 +501,31 @@ export async function renderSettings() {
|
||||
"div",
|
||||
{ class: "card" },
|
||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.cmf"))),
|
||||
cmfLocalOnly.node,
|
||||
cmfManage.node,
|
||||
h("div", { class: "hint" }, t("settings.cmf.intro")),
|
||||
// Step 1 — install the cortiq runtime (the CLI that runs .cmf models).
|
||||
h(
|
||||
"label",
|
||||
{ class: "field" },
|
||||
h("span", {}, "1. " + t("settings.cmf.runtime"), " ", help(t("settings.cmf.runtimeHelp"))),
|
||||
h("div", { class: "flex", style: "gap:8px;align-items:center;flex-wrap:wrap" }, cmfInstallBtn, cmfStatusEl)
|
||||
),
|
||||
h("div", { class: "row" }, cmfAutoInstall.node, cmfAutoUpdate.node),
|
||||
field(t("settings.cmf.model"), cmfModel),
|
||||
h("div", { class: "row" }, field(t("settings.cmf.port"), cmfPort), field(t("settings.cmf.modelId"), cmfModelId)),
|
||||
h("div", { class: "row" }, field(t("settings.cmf.threads"), cmfThreads), cmfGpu.node),
|
||||
h("div", { class: "hint" }, t("settings.cmf.note")),
|
||||
h("div", { class: "hint" }, t("settings.cmf.speedNote"))
|
||||
h("div", { class: "divider" }),
|
||||
// Step 2 — managed local models: one supervised cortiq serve per row, each
|
||||
// on its own port. Add as many as you have .cmf files + free ports.
|
||||
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("a", { href: "#/import" }, t("settings.cmf.importLink") + " ↗"))),
|
||||
serversWrap,
|
||||
addServerBtn,
|
||||
cmfServingEl,
|
||||
h("div", { class: "hint" }, t("settings.cmf.speedNote")),
|
||||
h("div", { class: "divider" }),
|
||||
// Step 3 — how the gateway uses them.
|
||||
cmfManage.node,
|
||||
cmfLocalOnly.node
|
||||
)
|
||||
),
|
||||
h(
|
||||
|
||||
Reference in New Issue
Block a user