feat(cmf): integrate CMF as a managed local model server

Run models in the CMF (.cmf) format locally, via the native `cortiq`
CLI from crates.io — no Python and no external model server.

- cmf_runtime: install/update `cortiq-cli` from crates.io, spawn and
  health-check `cortiq serve` as a managed local backend.
- import: model-import jobs shell out to the native `cortiq convert`
  (HF repo -> .cmf, quant mapped Q8_2F->q8_2f / Q8_ROW->q8 / Q4->q4);
  the Python converter is used only for advanced/exotic options.
- pipeline: local-first routing — serve from the local CMF model when
  the router is disabled/unreachable, with a "local only" hard switch.
- registry/state: register the local CMF server as an OpenAI provider
  and track local candidates.
- config: data-dir resolution + CmfCfg (auto-install/update, managed
  server, host/port/model) and relaxed validation for the managed model.
- admin + settings UI (en/ru): "Local models (CMF)" settings card.
This commit is contained in:
infosave2007
2026-07-08 16:18:31 +03:00
parent 7af90a5eef
commit c19edee7bb
11 changed files with 1812 additions and 64 deletions
+73
View File
@@ -28,12 +28,14 @@ export default {
// nav
"nav.dashboard": "Dashboard",
"nav.models": "Models",
"nav.import": "Import model",
"nav.routing": "Routing",
"nav.protocols": "Protocols",
"nav.keys": "API keys",
"nav.playground": "Playground",
"nav.settings": "Settings",
"nav.foot": "Gateway · v{v}",
"nav.billing": "Router key & billing",
// topbar / status
"status.router": "Router",
@@ -41,6 +43,7 @@ export default {
"status.online": "online",
"status.offline": "offline",
"status.checking": "checking…",
"status.noKey": "no key",
"theme.toggle": "Theme",
"logout": "Log out",
@@ -89,6 +92,7 @@ export default {
"dash.health.title": "Health",
"dash.health.reachable": "reachable",
"dash.health.unreachable": "unreachable",
"dash.health.getKey": "Get a router key",
// models
"models.title": "Models",
@@ -225,7 +229,34 @@ export default {
"settings.router.keyEnv": "Router key env name",
"settings.router.timeout": "Timeout (ms)",
"settings.router.verifyTls": "Verify TLS certificate",
"settings.router.enabled": "Use the router (off = local models only)",
"settings.cmf": "Local models (CMF)",
"settings.cmf.localOnly": "Use only local models",
"settings.cmf.manage": "Run a local model server",
"settings.cmf.autoInstall": "Auto-install cortiq (crates.io)",
"settings.cmf.autoUpdate": "Auto-update cortiq",
"settings.cmf.model": "Local .cmf model path",
"settings.cmf.port": "Local server port",
"settings.cmf.modelId": "Model id in the pool",
"settings.cmf.note": "Serves a local .cmf via cortiq (github.com/infosave2007/cmf). Works even when the router is unavailable.",
"settings.router.taxonomy": "Taxonomy id",
"settings.router.getKey": "Get or top up a router key at",
"settings.router.secret": "Router key (value)",
"settings.router.saveKey": "Save key",
"settings.router.keySaved": "Router key saved",
"settings.router.keyEmpty": "Paste the key first",
"settings.router.secretHelp": "Paste the key from your api.allaigate.com email, then Save key. This is NOT a “Keys” value — those are for clients calling your gateway.",
"settings.router.keyStoredMask": "•••••••••••• — key saved (paste again to replace)",
"settings.router.test": "Test router",
"router.probe.ok": "Router OK ({ms} ms)",
"router.probe.noKey": "Router key is not set",
"router.probe.auth": "Key rejected — invalid or revoked",
"router.probe.payment": "Payment required — plan expired or quota exhausted",
"router.probe.quota": "Rate limit exceeded",
"router.probe.timeout": "Router timed out",
"router.probe.unreachable": "Router unreachable",
"router.probe.error": "Router error",
"router.usage": "Usage",
"settings.route": "Routing input",
"settings.route.strategy": "Text strategy",
"settings.route.maxChars": "Max chars",
@@ -266,4 +297,46 @@ export default {
"settings.cache.ttl": "Entry TTL",
"settings.cache.embed": "Embedding model",
"settings.cache.note": "Cache settings take effect after a restart.",
// import (HuggingFace → CMF)
"import.subtitle": "Search HuggingFace, convert to a local .cmf, and serve it.",
"import.searchPlaceholder": "Search HuggingFace models (e.g. qwen3, llama)…",
"import.searching": "Searching…",
"import.noResults": "No models found.",
"import.configure": "Configure conversion",
"import.outName": "Output name",
"import.outNameHint": "The .cmf file name (letters, digits, - and _).",
"import.quant": "Quantization",
"import.convert": "Convert & load",
"import.converting": "Converting",
"import.started": "Conversion started",
"import.failed": "Conversion failed — see the log.",
"import.register": "Register as local model",
"import.registered": "Registered as '{id}'. Run cortiq-server to serve it.",
"import.jobs": "Conversions",
"import.cancel": "Cancel",
"import.cancelling": "Cancelling — cleaning up partial output…",
"import.delete": "Delete",
"import.deleteConfirm": "Delete this model and its converted .cmf file from disk?",
"import.deleted": "Deleted with its files",
"import.state.running": "running",
"import.state.done": "done",
"import.state.error": "error",
"import.state.cancelled": "cancelled",
"import.q.q8_2f": "8-bit two-field (𝒲×θ) — recommended: best quality/size.",
"import.q.q8_row": "8-bit per-row — simple, robust.",
"import.q.q4": "4-bit block — smallest, lower quality.",
"import.q.vbit": "Variable 3–8 bit — per-expert bit budgets.",
"import.q.f16": "16-bit — no quantization.",
"import.q.f32": "32-bit — full precision (large file).",
"import.adv.title": "Advanced settings",
"import.adv.auto": "auto",
"import.adv.linearCore": "Linear core",
"import.adv.linearHint": "How linear-attention layers are carried/folded.",
"import.adv.nphase": "vmf_phase phases/head",
"import.adv.vbitShape": "VBIT budget shape",
"import.adv.meanBits": "VBIT mean bits",
"import.adv.shard": "Shard max (GB)",
"import.adv.noShard": "no sharding",
"import.adv.skipMtp": "skip MTP head",
};
+73
View File
@@ -27,12 +27,14 @@ export default {
// nav
"nav.dashboard": "Дашборд",
"nav.models": "Модели",
"nav.import": "Импорт модели",
"nav.routing": "Роутинг",
"nav.protocols": "Протоколы",
"nav.keys": "API-ключи",
"nav.playground": "Playground",
"nav.settings": "Настройки",
"nav.foot": "Шлюз · v{v}",
"nav.billing": "Ключ роутера и оплата",
// topbar / status
"status.router": "Роутер",
@@ -40,6 +42,7 @@ export default {
"status.online": "в сети",
"status.offline": "недоступен",
"status.checking": "проверка…",
"status.noKey": "нет ключа",
"theme.toggle": "Тема",
"logout": "Выйти",
@@ -88,6 +91,7 @@ export default {
"dash.health.title": "Состояние",
"dash.health.reachable": "доступен",
"dash.health.unreachable": "недоступен",
"dash.health.getKey": "Получить ключ роутера",
// models
"models.title": "Модели",
@@ -224,7 +228,34 @@ export default {
"settings.router.keyEnv": "Имя env-переменной с ключом роутера",
"settings.router.timeout": "Таймаут (мс)",
"settings.router.verifyTls": "Проверять TLS-сертификат",
"settings.router.enabled": "Использовать роутер (выкл = только локальные модели)",
"settings.cmf": "Локальные модели (CMF)",
"settings.cmf.localOnly": "Использовать только локальные модели",
"settings.cmf.manage": "Запускать локальный сервер моделей",
"settings.cmf.autoInstall": "Автоустановка cortiq (crates.io)",
"settings.cmf.autoUpdate": "Автообновление cortiq",
"settings.cmf.model": "Путь к локальной .cmf-модели",
"settings.cmf.port": "Порт локального сервера",
"settings.cmf.modelId": "ID модели в пуле",
"settings.cmf.note": "Обслуживает локальный .cmf через cortiq (github.com/infosave2007/cmf). Работает даже когда роутер недоступен.",
"settings.router.taxonomy": "ID таксономии",
"settings.router.getKey": "Получить или пополнить ключ роутера:",
"settings.router.secret": "Ключ роутера (значение)",
"settings.router.saveKey": "Сохранить ключ",
"settings.router.keySaved": "Ключ роутера сохранён",
"settings.router.keyEmpty": "Сначала вставьте ключ",
"settings.router.secretHelp": "Вставьте ключ из письма с api.allaigate.com и нажмите «Сохранить ключ». Это НЕ ключ из раздела «Ключи» — те выдаются клиентам для доступа к вашему шлюзу.",
"settings.router.keyStoredMask": "•••••••••••• — ключ сохранён (вставьте снова, чтобы заменить)",
"settings.router.test": "Проверить роутер",
"router.probe.ok": "Роутер работает ({ms} мс)",
"router.probe.noKey": "Ключ роутера не задан",
"router.probe.auth": "Ключ отклонён — недействителен или отозван",
"router.probe.payment": "Требуется оплата — тариф истёк или квота исчерпана",
"router.probe.quota": "Превышен лимит запросов",
"router.probe.timeout": "Роутер не ответил вовремя",
"router.probe.unreachable": "Роутер недоступен",
"router.probe.error": "Ошибка роутера",
"router.usage": "Использование",
"settings.route": "Входные данные роутинга",
"settings.route.strategy": "Стратегия текста",
"settings.route.maxChars": "Макс. символов",
@@ -265,4 +296,46 @@ export default {
"settings.cache.ttl": "TTL записи",
"settings.cache.embed": "Модель эмбеддингов",
"settings.cache.note": "Настройки кэша применяются после перезапуска.",
// импорт (HuggingFace → CMF)
"import.subtitle": "Найдите модель на HuggingFace, сконвертируйте в локальный .cmf и подключите.",
"import.searchPlaceholder": "Поиск моделей на HuggingFace (напр. qwen3, llama)…",
"import.searching": "Ищу…",
"import.noResults": "Модели не найдены.",
"import.configure": "Настройка конвертации",
"import.outName": "Имя файла",
"import.outNameHint": "Имя .cmf (буквы, цифры, - и _).",
"import.quant": "Квантизация",
"import.convert": "Сконвертировать и загрузить",
"import.converting": "Конвертация",
"import.started": "Конвертация запущена",
"import.failed": "Конвертация не удалась — смотрите лог.",
"import.register": "Подключить как локальную модель",
"import.registered": "Подключено как '{id}'. Запустите cortiq-server для обслуживания.",
"import.jobs": "Конвертации",
"import.cancel": "Отменить",
"import.cancelling": "Отмена — удаляю недописанный файл…",
"import.delete": "Удалить",
"import.deleteConfirm": "Удалить эту модель и сконвертированный .cmf-файл с диска?",
"import.deleted": "Удалено вместе с файлами",
"import.state.running": "идёт",
"import.state.done": "готово",
"import.state.error": "ошибка",
"import.state.cancelled": "отменено",
"import.q.q8_2f": "8-бит двухполевой (𝒲×θ) — рекомендуется: лучшее качество/размер.",
"import.q.q8_row": "8-бит построчно — просто, надёжно.",
"import.q.q4": "4-бит блочно — компактнее, ниже качество.",
"import.q.vbit": "Переменная 3–8 бит — per-expert бюджеты.",
"import.q.f16": "16-бит — без квантования.",
"import.q.f32": "32-бит — полная точность (большой файл).",
"import.adv.title": "Расширенные настройки",
"import.adv.auto": "авто",
"import.adv.linearCore": "Линейное ядро",
"import.adv.linearHint": "Как переносятся/сворачиваются линейные слои внимания.",
"import.adv.nphase": "vmf_phase фаз/голову",
"import.adv.vbitShape": "Форма бюджета VBIT",
"import.adv.meanBits": "VBIT средних бит",
"import.adv.shard": "Шард макс (ГБ)",
"import.adv.noShard": "без шардинга",
"import.adv.skipMtp": "пропустить MTP-голову",
};
+178 -3
View File
@@ -2,7 +2,7 @@
import { h, mount, toast } from "../ui.js";
import { t } from "../i18n.js";
import { api } from "../api.js";
import { appState } from "../app.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);
@@ -12,9 +12,45 @@ function check(label, checked) {
return { node: h("label", { class: "check" }, cb, label), cb };
}
// Flatten router usage (nested account/usage objects) into readable, wrapping
// key: value rows — a raw JSON.stringify dump overflows the card horizontally.
function usageRows(obj, prefix) {
const rows = [];
for (const [k, v] of Object.entries(obj || {})) {
const key = prefix ? prefix + "." + k : k;
if (v && typeof v === "object" && !Array.isArray(v)) {
rows.push(...usageRows(v, key));
} else {
rows.push(
h("div", { class: "mono kv" },
h("span", { class: "kv-k" }, key + ": "),
h("span", { class: "kv-v" }, Array.isArray(v) ? JSON.stringify(v) : String(v)))
);
}
}
return rows;
}
// Probe status → i18n key suffix (see /admin/api/router/probe).
function probeMsgKey(status) {
const map = {
ok: "ok",
no_key: "noKey",
auth: "auth",
payment: "payment",
quota: "quota",
timeout: "timeout",
unreachable: "unreachable",
};
return "router.probe." + (map[status] || "error");
}
// Statuses where the fix is on the site (get / pay for a key).
const PROBE_SITE_STATUSES = ["no_key", "auth", "payment", "quota"];
export async function renderSettings() {
const meta = appState.meta || (await api.meta());
const s = await api.getSettings();
const [s, secretsData] = await Promise.all([api.getSettings(), api.listSecrets()]);
const root = h("div");
const listenIn = h("input", { value: s.listen || "" });
@@ -24,6 +60,101 @@ export async function renderSettings() {
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);
// 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 ||
"missing";
const keyStored = rKeySource === "store" || rKeySource === "env";
// When a key is already saved, show a masked "saved" hint instead of an empty
// field (an empty field reads as "nothing is set").
const rSecret = h("input", {
type: "password",
placeholder: keyStored ? t("settings.router.keyStoredMask") : t("models.form.secretPlaceholder"),
});
const rKeyBadge = h(
"span",
{ class: "badge " + (rKeySource === "store" ? "store" : rKeySource === "env" ? "env" : "bad") },
rKeySource === "store"
? t("models.form.secretStored")
: rKeySource === "env"
? t("models.form.secretEnv")
: t("models.form.secretMissing")
);
const rSecretHint = h(
"div",
{},
h(
"div",
{},
rKeyBadge,
" · ",
t("settings.router.getKey") + " ",
h("a", { href: SITE_URL, target: "_blank", rel: "noopener" }, "api.allaigate.com")
),
h("div", { class: "muted", style: "margin-top:4px" }, t("settings.router.secretHelp"))
);
// The key lives in the secret store under this env name (config default: CORTIQ_ROUTER_KEY).
const keyName = () => rKeyEnv.value.trim() || "CORTIQ_ROUTER_KEY";
// Persist just the pasted router key (independent of the big Save at page top).
async function saveKey() {
const v = rSecret.value.trim();
if (!v) return false;
if (!rKeyEnv.value.trim()) rKeyEnv.value = "CORTIQ_ROUTER_KEY";
await api.setSecret(keyName(), v);
rSecret.value = "";
rSecret.placeholder = t("settings.router.keyStoredMask"); // show "saved", not empty
rKeyBadge.textContent = t("models.form.secretStored");
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"));
const probeOut = h("div", { class: "hint", style: "margin-top:8px" });
probeBtn.addEventListener("click", async () => {
probeBtn.disabled = true;
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];
if (PROBE_SITE_STATUSES.includes(r.status)) {
out.push(" · ", h("a", { href: SITE_URL, target: "_blank", rel: "noopener" }, t("dash.health.getKey") + " ↗"));
}
if (r.usage && typeof r.usage === "object") {
out.push(
h("div", { class: "usage-box", style: "margin-top:4px" },
h("b", {}, t("router.usage")), ...usageRows(r.usage))
);
}
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");
} catch (e) {
mount(probeOut, String(e.message || e));
}
probeBtn.disabled = false;
});
// route
const strategySel = h("select", {}, ...(meta.text_strategies || []).map((x) => h("option", { value: x, selected: x === s.route?.text_strategy }, x)));
const maxChars = h("input", { type: "number", value: s.route?.max_chars ?? 4000 });
@@ -54,13 +185,24 @@ export async function renderSettings() {
h("option", { value: "" }, "auto"),
...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 saveBtn = h("button", { class: "btn primary" }, t("common.save"));
saveBtn.addEventListener("click", async () => {
saveBtn.disabled = true;
// a pasted router key needs an env name to live under — default it
if (rSecret.value.trim() && !rKeyEnv.value.trim()) rKeyEnv.value = "CORTIQ_ROUTER_KEY";
const patch = {
listen: listenIn.value.trim(),
router: {
enabled: rEnabled.cb.checked,
url: rUrl.value.trim(),
api_key_env: rKeyEnv.value.trim() || null,
timeout_ms: parseInt(rTimeout.value) || 800,
@@ -90,6 +232,16 @@ export async function renderSettings() {
max_entries: s.cache?.max_entries ?? 1000,
embed_model: cacheEmbedSel.value || null,
},
cmf: {
...(s.cmf || {}),
local_only: cmfLocalOnly.cb.checked,
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",
},
};
// serde: drop null optionals
if (!patch.router.api_key_env) delete patch.router.api_key_env;
@@ -98,6 +250,13 @@ export async function renderSettings() {
if (!patch.cache.embed_model) delete patch.cache.embed_model;
try {
const r = await api.putSettings(patch);
const secretVal = rSecret.value.trim();
if (secretVal) {
await api.setSecret(rKeyEnv.value.trim(), secretVal);
rSecret.value = "";
rKeyBadge.textContent = t("models.form.secretStored");
rKeyBadge.className = "badge store";
}
toast(r.needs_restart ? t("settings.needsRestart") : t("toast.saved"), r.needs_restart ? "warn" : "good");
} catch (e) {
toast(String(e.message || e), "bad");
@@ -150,8 +309,13 @@ export async function renderSettings() {
h("div", { class: "card-head" }, h("h3", {}, t("settings.router"))),
field(t("settings.router.url"), rUrl),
field(t("settings.router.keyEnv"), rKeyEnv),
field(t("settings.router.secret"), h("div", { class: "key-row" }, rSecret, saveKeyBtn), rSecretHint),
h("div", { class: "row" }, field(t("settings.router.timeout"), rTimeout), field(t("settings.router.taxonomy"), rTax)),
rVerify.node
rEnabled.node,
rVerify.node,
h("div", { class: "divider" }),
h("div", { class: "flex" }, probeBtn),
probeOut
),
h(
"div",
@@ -199,6 +363,17 @@ export async function renderSettings() {
h("div", { class: "row" }, field(t("settings.cache.threshold"), cacheThresh), field(t("settings.cache.ttl"), cacheTtlIn)),
field(t("settings.cache.embed"), cacheEmbedSel),
h("div", { class: "hint" }, t("settings.cache.note"))
),
h(
"div",
{ class: "card" },
h("div", { class: "card-head" }, h("h3", {}, t("settings.cmf"))),
cmfLocalOnly.node,
cmfManage.node,
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: "hint" }, t("settings.cmf.note"))
)
),
h(