Compare commits

..
54 Commits
Author SHA1 Message Date
infosave2007 a75cacd0ec chore: bump v0.2.34
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-07-23 16:41:08 +03:00
infosave2007 850a54acdf fix: enable_thinking not sent in streaming path
Two bugs:
1. run_stream() never merged per-model/server config (think_budget,
   temperature, etc.) into the request — unlike run_inner(). Now both
   paths apply the same config merge.
2. to_wire() was missing the enable_thinking bool conversion line
   (lost in a prior revert). Restored: think_budget=0 → enable_thinking=false.
2026-07-23 16:34:49 +03:00
infosave2007 22110ac524 style(admin): apply cargo fmt to fix CI 2026-07-22 19:04:12 +03:00
infosave2007 b3b37d27f4 chore: update Cargo.lock 2026-07-22 18:59:46 +03:00
infosave2007 a7c8ed72c7 feat(admin): sync openrouter capabilities with emojis
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
2026-07-22 18:59:31 +03:00
infosave2007 f2e6047bff fix: satisfy clippy in CMF import detection 2026-07-22 11:54:51 +03:00
infosave2007 0b28aa3d59 release: v0.2.32 mobile admin console 2026-07-22 11:45:00 +03:00
infosave2007 1414ad1b52 release: v0.2.31 - O(1) attention knobs, reasoning toggle & admin token persistence 2026-07-22 11:12:40 +03:00
infosave2007 94c8551206 feat(release): v0.2.30 — per-model generation, O(1) attention, MTP & reasoning parameters 2026-07-22 10:40:44 +03:00
infosave2007 6ecc709fd2 chore(release): v0.2.29 — fix duplicate managed model registration conflict 2026-07-21 23:39:47 +03:00
infosave2007 1661c19550 chore(release): v0.2.28 — auto-detect ready CMF quant & update section title 2026-07-21 23:35:14 +03:00
infosave2007 66ee32b233 chore(release): v0.2.27 — auto-spawn local CMF server on model registration 2026-07-21 23:15:33 +03:00
infosave2007 f6ee045989 chore(release): v0.2.26 — robust multi-GB streaming downloads & ready CMF quants 2026-07-21 23:08:02 +03:00
infosave2007 59e2eb3215 chore(release): v0.2.25 — direct stream download for ready CMF repos 2026-07-21 22:51:58 +03:00
infosave2007 5b743a4691 chore(release): v0.2.24 — new quantizations (q4t, q1t, q1s, q1p, q1) and featured CMF models 2026-07-21 22:45:43 +03:00
infosave2007 c59ed9c949 feat(import): O(1) attention options in Advanced (cortiq >= 0.2.0)
- New Advanced controls on the import page: O(1) attention spec (off / all /
  deep N / explicit layer list) plus the --o1-m / --o1-window / --o1-sink knobs
  with validated defaults (32 / 128 / 4) and explanatory hints.
- Backend passes --o1* to the native `cortiq convert`; fails fast with clear
  messages when combined with Python-only options, used on GGUF imports, or
  when the installed cortiq predates 0.2.0 (the hint needs the new runtime).
- The hint is recorded in the .cmf header (weights byte-identical); the runtime
  applies it at load. Verified end-to-end: converted a model with --o1 all and
  the runtime logged "o1 nystrom attention: 2/2 layer(s)".
2026-07-14 10:16:54 +03:00
infosave2007 b55e72789f feat(dashboard): windowed stats per range + clear-logs button
- Stats snapshot now computes the KPI totals AND the breakdown from the
  in-window per-minute buckets (previously they used all-time accumulators, so
  switching 1h/24h/7d changed only the chart, not the numbers). Buckets now
  carry failovers + per-group breakdown; Prometheus keeps all-time counters.
- New DELETE /admin/api/stats clears all in-memory stats and truncates the
  JSONL log; the dashboard gets a "Clear logs" button (with confirm).
2026-07-09 17:07:05 +03:00
infosave2007 0d1283c3fa docs(settings): add help tooltips to routing-input fields (strategy, max chars, cache TTL, profile) 2026-07-09 16:49:16 +03:00
infosave2007 0105a43ef5 feat(models): auto-derive id from model name; actionable probe auth errors
- The model id is now auto-filled from the model name as you type, and if left
  blank it's derived on save (slug of the last path segment, e.g.
  "qwen/qwen3.7-max" → "qwen3-7-max") — saving no longer errors on a blank id.
- probe/test auth failures (401 / "User not found" / "No auth") now append a
  hint to check the API key (for OpenRouter: use an inference key, not a
  provisioning/management key).
2026-07-09 16:30:48 +03:00
infosave2007 b8d48c31f7 fix(models): Add button, probe of managed models, delete list refresh
- ui.js: mount() now wraps non-node children in text nodes (like h()), so
  mount(el, "") no longer throws in appendChild — this is what broke the
  "Add model" form (applyDefaults cleared a status via mount(x,"")).
- probe_model: resolve managed local models too (they are synthesized into the
  registry, not [[models]]) — probing them no longer returns "model not found".
- models.js: await reload() after delete so the row is removed immediately.
2026-07-09 16:13:07 +03:00
infosave2007 196ff52f0f feat(models): friendlier add-model form — OpenRouter/LM Studio, direct key, test & load
- Providers: add openrouter (https://openrouter.ai/api/v1) and lmstudio
  (http://localhost:1234/v1), both OpenAI-compatible. Selecting a provider now
  auto-fills its verified base URL, cost tier and default capabilities.
- API key is typed directly in the form and stored securely as a secret under the
  provider's env name — no manual env var to manage.
- New POST /admin/api/provider/models validates the key and lists the provider's
  models; a "Test & load models" button fills a searchable dropdown. The model
  field now sits below the key (some providers list models from the key).
- Fuller capability set: tools, vision, audio, reasoning, json, caching.
2026-07-09 15:45:21 +03:00
infosave2007 e25814a54c feat(settings): pick a local model from a dropdown of existing .cmf files
- New GET /admin/api/cmf/files lists the .cmf files (and tensor-dir .cmf) under
  models_dir, with sizes.
- Settings → Local models: the per-row model field is now a dropdown of those
  files instead of a hand-typed path, and auto-fills the id from the filename.
2026-07-09 15:25:30 +03:00
infosave2007 0c20eb34f0 chore(release): v0.2.8 — multiple managed local models
- The gateway can now run several local .cmf models at once: a [[cmf.servers]]
  list, one supervised `cortiq serve` per entry on its own port, each registered
  in the pool and independently routable/pinnable. The single legacy
  local_model/local_port still works (back-compat via effective_servers()).
- Settings → Local models is now a rows editor (id · path · port + check · threads
  · GPU) with add/remove and per-server serving status.
- Deleting a managed model stops just its server and removes its file — unless
  another model still points at the same file (shared-file guard).
2026-07-09 12:00:10 +03:00
infosave2007 f8c1bb0914 chore(release): v0.2.7 — honest pins, visible managed model, physical delete
- A pinned playground model that is unavailable now errors clearly instead of
  silently answering from the managed local model (route_source no longer lies).
- The managed CMF model is surfaced in GET /admin/api/models (marked "managed",
  shown read-only in the UI) so the real running model is visible and pinnable
  without a hand-maintained [[models]] entry; a fresh install still lists nothing.
- Deleting a model now removes its local .cmf physically (file or tensor-dir);
  deleting the managed model also stops the server and clears the [cmf] config.
  Routing is stripped of the deleted id so the config still validates.
2026-07-08 21:14:03 +03:00
infosave2007 0f299b77e6 chore: fix clippy lints to green the lint CI
- import.rs / promotion.rs: sort_by(|a,b| b.k.cmp(&a.k)) -> sort_by_key(Reverse(k))
- pipeline.rs: skip_while(!p).next() -> find(p)
Behaviour-preserving; completes the CI fix started by the cargo fmt commit.
2026-07-08 20:09:54 +03:00
infosave2007 7c49510c46 chore: cargo fmt — fix lint 'Format' CI job (red since v0.2.3)
Whole-repo rustfmt drift; behaviour unchanged (whitespace/layout only).
All test platforms already passed; this greens the lint job.
2026-07-08 19:32:35 +03:00
infosave2007 31da84cc45 chore(release): v0.2.6 — Settings UX, empty pool by default, CMF port check
- Settings: "?" help tooltips on every option; router key auto-probes the
  connection right after saving (no more hanging "unavailable")
- Local models card reworked into 3 clear steps (install cortiq → add model →
  run locally); removed the hand-entered .cmf path / pool-id fields — models
  are added via the Import tab and the managed server registers them
- Fresh install ships an EMPTY model pool; the local .cmf is auto-registered by
  the managed CMF server, so it needs no static [[models]] entry
- New: port-availability check — GET /admin/api/cmf/port + "Check port" button
  with next-free-port suggestion. bind()+connect() probe so Docker-proxied /
  dual-stack ports (e.g. 8081) are correctly detected as occupied
- /admin/api/cmf now reports the serving model + port; card shows "▶ serving …"
- On-demand runtime install: POST /admin/api/cmf/install
2026-07-08 19:07:24 +03:00
infosave2007 749a47ce9b chore: ship the full working frontend + backend
The crates.io/release build shipped a STALE frontend (committed web/js was behind
the working tree), so the admin console hung on load. Commit the current working
web/js (app, api, dashboard, login, import view, i18n de/es/fr/tr/zh, css) and the
backend WIP (import.rs, admin/assets.rs) so the released console matches the dev
build. Verified: clean `cargo build --release`, empty-pool start, working
dashboard served.
2026-07-08 17:58:31 +03:00
infosave2007 d2b1d50eb8 chore(release): v0.2.4 2026-07-08 17:39:43 +03:00
infosave2007 d48349a144 feat: fresh install starts with an empty model pool
- config.rs: validate() no longer errors on zero models — an empty pool is
  allowed (warns instead), so the admin console comes up on a fresh install and
  the user adds models in /admin -> Models (or points [cmf] at a local .cmf).
- gateway.example.toml: ships an EMPTY pool (no seed cmf-local/local-embed, empty
  routing), so `cp example -> gateway.toml && run` shows an empty list — what a
  new user expects.
2026-07-08 17:39:43 +03:00
infosave2007 f05d26bfbb fix: add promotion + router-status modules referenced by the CMF integration
The v0.2.3 CMF-integration commit referenced crate::promotion (main.rs),
router_client::RouterLastStatus (state.rs) and router_client::classify_status
(admin), but those definitions were not part of the commit, so a clean build
failed (E0583/E0432/E0425). Add the two source files; cargo build --release now
compiles.
2026-07-08 16:59:58 +03:00
infosave2007 39f1fcb600 chore(release): v0.2.3 2026-07-08 16:39:30 +03:00
infosave2007 42377a75d4 config: example ships a local CMF model, drop cloud placeholders
The example config pool now shows one local CMF model (served by the managed
cortiq server) instead of local-qwen/gpt-4o-mini/claude-opus placeholders, with
routing pointing at it, and a documented [cmf] section (manage_server, threads,
gpu, ...). Add cloud/local providers by copying the [[models]] shape.
2026-07-08 16:39:30 +03:00
infosave2007 6cf2608fc8 admin: surface CMF worker threads + GPU toggle in Settings
Expose the two speed knobs in the /admin Settings panel instead of TOML-only:
- [cmf].gpu (new) -> exports CMF_GPU=1 to the managed cortiq serve (Metal/wgpu).
  On Apple Silicon decode is memory-bandwidth bound so this is ~neutral for
  generation (helps prefill); off by default.
- [cmf].threads (added earlier) now has a UI number input.

- config.rs: CmfCfg.gpu bool (serde default false).
- cmf_runtime.rs: spawn_serve sets CMF_GPU=1 when gpu is on.
- admin/mod.rs: put_settings flags needs_restart on threads/gpu change (the
  server is spawned once at startup with those env vars).
- settings.js: threads input + gpu checkbox + save payload + speed hint.
- i18n en/ru: threads/gpu/speedNote keys (other locales fall back to en, as the
  whole cmf section already does there).
2026-07-08 16:18:31 +03:00
infosave2007 e6d1a630c9 cmf: parallelise the managed local server (CMF_THREADS)
The gateway spawned `cortiq serve` with no thread hint, so the runtime ran the
decode loop single-threaded (~0.25 tok/s on a 15B CPU model). Export CMF_THREADS
from a new [cmf].threads config field (default 8) in spawn_serve.

Measured: 0.25 -> 0.81 tok/s (~3.2x) on qwopus-vbit7 (15B). Scaling is sublinear
(GatedDeltaNet state recurrence is serial per token, Amdahl-bound); a real jump
needs the GPU path.
2026-07-08 16:18:31 +03:00
infosave2007 8dd77ca7ff 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.
2026-07-08 16:18:31 +03:00
infosave2007 630149d141 docs: add docker-compose quickstart (gateway + Ollama) and GHCR badge
- docker-compose.yml + config/gateway.compose.toml (routes to a local Ollama service)
- README (EN+RU): ghcr badge + Docker Compose section
2026-07-01 13:40:24 +03:00
infosave2007 85fc508f04 chore(release): v0.2.2
Benchmarks (latency + accuracy), router docs + LAUNCH1 promo, Docker image on ghcr.
2026-07-01 11:28:56 +03:00
infosave2007 010171e05c feat: Docker image + ghcr publishing
- multi-stage Dockerfile (non-root, embeds SPA, default config, ~160MB) + .dockerignore
- .github/workflows/docker.yml: push to ghcr.io/infosave2007/cortiq-gateway
  (master -> :edge/:sha amd64; tags -> :X.Y.Z/:latest, linux/amd64+arm64)
- README (EN+RU): docker run instructions; CHANGELOG
- verified locally: image builds, container serves /healthz + /admin
2026-07-01 11:23:09 +03:00
infosave2007 39c56145ed docs: document the allaigate router + launch promo
- docs/ROUTER.md + docs/ROUTER.ru.md: what the semantic router does, hosted vs
  self-hosted, config, API contract, pricing, and the LAUNCH1 $1 Starter promo
- README (EN+RU): "The router (allaigate)" section with the promo callout
2026-07-01 10:35:29 +03:00
infosave2007 17c8186383 docs: add task-type accuracy benchmark (router vs keyword heuristic)
- bench/tasks.jsonl (37 natural-language prompts, 7 task types) + bench/accuracy.py
- measured on the live allaigate router: 100% (37/37) vs keyword heuristic 32%
- BENCHMARKS.md accuracy section; README (EN+RU) perf+accuracy tables and test matrix
- run.sh runs accuracy when CORTIQ_ROUTER_KEY is set
2026-07-01 07:45:16 +03:00
infosave2007 e773e18efd docs: add test/verification matrix to README (EN+RU) 2026-06-30 18:31:13 +03:00
infosave2007 6466006de6 docs: add latency benchmark vs LiteLLM/Portkey
- bench/ reproducible harness: instant mock backend + Apache Bench (ab -k -r)
- measures gateway overhead in passthrough mode (apples-to-apples)
- BENCHMARKS.md: methodology, caveats, results (cortiq ~57k rps p99 1ms vs
  Portkey ~5.8k p99 9ms vs LiteLLM 4-workers ~1.2k p99 59ms)
- README (EN+RU): Performance section + highlight
2026-06-30 18:11:26 +03:00
infosave2007 6a25d8ac08 feat: finish remaining inbound protocols (models, completions, native /route)
- GET /v1/models: pool + virtual cortiq-auto
- POST /v1/completions (legacy): non-stream + streaming text_completion
- POST /route: native passthrough returning the routing decision
- all inbound protocol adapters now implemented; admin meta + README/CHANGELOG updated
- bump to 0.2.1
2026-06-30 17:42:45 +03:00
infosave2007 e486617e51 chore(release): v0.2.0
Streaming (SSE), Anthropic provider/inbound, embeddings, semantic cache, MCP server.
2026-06-30 17:17:04 +03:00
infosave2007 729a8bfbba feat: MCP (Model Context Protocol) server
- POST /mcp (JSON-RPC 2.0): initialize, ping, tools/list, tools/call; notifications -> 202
- tools: cortiq_route (classify -> routing decision) and cortiq_chat (route -> answer)
- gated by protocols.mcp; admin meta marks it implemented
- docs: README/README.ru protocol tables, highlights and status updated for
  streaming, Anthropic, embeddings, semantic cache and MCP
2026-06-30 17:12:55 +03:00
infosave2007 5759de153b feat: semantic (embedding-based) response cache
- cache.rs: cosine-similarity store (threshold, TTL, ring buffer), partitioned by
  routing signature; tracks hit rate and model cost saved
- pipeline: embed routing text, look up before the model call (route_source=cache on
  hit), insert successful responses; stats record cache hits
- config [cache] + admin settings card; dashboard shows hit-rate and savings
- embedding model resolved from the pool (or first kind="embedding")
2026-06-30 17:04:53 +03:00
infosave2007 bf8534f13d feat: Anthropic provider/inbound + embeddings
- Anthropic outbound provider: /v1/messages, system extraction, x-api-key +
  anthropic-version; streaming translates Anthropic SSE -> unified OpenAI wire format
- Anthropic inbound adapter (POST /v1/messages): non-streaming JSON + streaming SSE
  (OpenAI -> Anthropic event translation)
- Embeddings: POST /v1/embeddings inbound + Provider::embed(); embedding models
  resolvable from the pool (registry no longer skips them)
- protocols mounted always, gated on live flags; admin meta marks them implemented
2026-06-30 16:50:22 +03:00
infosave2007 70d8cde258 feat: streaming (SSE) end-to-end
- Provider::chat_stream; OpenAI provider streams via stream_options.include_usage
- pipeline.run_stream: shared route/select, failover before first byte
- /v1/chat/completions forwards SSE verbatim with X-Cortiq-* headers; usage tapped for stats
- admin /test/stream + live streaming in Playground (stream toggle, 7 langs)
2026-06-30 16:37:35 +03:00
infosave2007 3d71b03788 ci: build Windows binaries; add cross-platform CI test matrix
- release.yml: add x86_64-pc-windows-msvc target, OS-aware packaging (.exe + .zip)
- ci.yml: split into lint (fmt/clippy) + test matrix (ubuntu/windows/macos)
- use getrandom for the admin/key token CSPRNG (secure on Windows too)
2026-06-30 14:56:54 +03:00
infosave2007 492953d4c2 docs: add crates.io install instructions to both READMEs 2026-06-30 14:11:07 +03:00
infosave2007 3b0231b32d ci(release): use env-mapped token guard for crates.io publish step 2026-06-30 13:54:35 +03:00
infosave2007 fd2f2a165b chore(release): prepare v0.1.0 for crates.io
- bump version to 0.1.0; add crates.io metadata (include list, homepage, rust-version)
- add .github/workflows/release.yml: on tag v* → publish to crates.io + GitHub Release with binaries
- README: absolute links/images and badges so it renders on crates.io; add install section
- CHANGELOG: 0.1.0
2026-06-30 13:47:42 +03:00
infosave2007 cbc385f6c7 feat: universal LLM gateway with embedded multilingual admin console
- OpenAI-compatible gateway with intelligent routing via cortiq-router (allaigate)
- Embedded build-free admin SPA at /admin: 7 languages (en/ru/de/fr/es/zh/tr),
  light/dark themes, hot config reload (no restart)
- Admin API, per-request statistics + Prometheus /metrics, secret store, playground
- English documentation (primary) with Russian translations (*.ru.md)
2026-06-30 10:51:37 +03:00
17 changed files with 574 additions and 80 deletions
+20
View File
@@ -3,6 +3,26 @@
All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
## [0.2.32] - 2026-07-22
### Added
- **Responsive mobile admin console**: Added a hamburger navigation drawer, touch-friendly controls, phone-sized forms and cards, scrollable tables, compact toolbars, safe-area support, and mobile bottom-sheet dialogs across the admin interface.
- **Accessible mobile interactions**: Navigation and dialogs now support backdrop dismissal, Escape-key closing, reduced-motion preferences, and explicit accessibility attributes.
### Fixed
- **Add model dialog**: Restored the missing `opt` helper import that prevented the Add model button from opening its form.
- **Local model settings on phones**: Reflowed managed CMF server controls to avoid clipped fields and actions on narrow screens.
## [0.2.31] - 2026-07-22
### Added
- **Structured O(1) Nyström Attention & Reasoning Control**: Enhanced settings drawer with structured parameter sections for O(1) Nyström Streaming Attention (`o1_m`, `o1_window`, `o1_sink`), Generation (Temp, Top-P, MaxTokens, System Prompt), Reasoning ("Disable reasoning" toggle mapping to `think_budget = 0` for Qwen3/3.5 models), and Runtime (`skip_mtp` via `CMF_MTP=0`).
- **Persistent Admin Token Configuration**: Support for explicit `token` field in `config/gateway.toml` under `[admin]` block to prevent authorization invalidation across restarts.
### Fixed
- **JavaScript UI Helper Restoration**: Restored `opt(...)` helper in `web/js/ui.js` and `web/js/views/import.js` to ensure HF model import modal options render smoothly.
- **Engine Command Flag Accuracy**: Fixed `CMF_MTP=0` environment flag pass-through for `cortiq serve` processes.
## [0.2.30] - 2026-07-22
### Added
Generated
+1 -1
View File
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cortiq-gateway"
version = "0.2.30"
version = "0.2.33"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cortiq-gateway"
version = "0.2.30"
version = "0.2.34"
edition = "2021"
rust-version = "1.75"
license = "Apache-2.0"
+102 -4
View File
@@ -467,16 +467,111 @@ async fn provider_models(
}
// OpenAI + Anthropic both return { "data": [ { "id": ... } ] }.
let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));
let mut models: Vec<String> = v["data"]
let mut models: Vec<Value> = v["data"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
.filter_map(|m| {
let id = m["id"].as_str()?.to_string();
let mut obj = json!({ "id": id });
let mut caps = vec![];
if let Some(pricing) = m.get("pricing").filter(|v| !v.is_null()) {
if let Some(prompt) = pricing
.get("prompt")
.and_then(|s| s.as_str())
.and_then(|s| s.parse::<f64>().ok())
{
obj["price_in"] = json!(prompt * 1_000_000.0);
}
if let Some(comp) = pricing
.get("completion")
.and_then(|s| s.as_str())
.and_then(|s| s.parse::<f64>().ok())
{
obj["price_out"] = json!(comp * 1_000_000.0);
}
if pricing
.get("input_cache_read")
.filter(|v| !v.is_null())
.is_some()
|| pricing
.get("input_cache_write")
.filter(|v| !v.is_null())
.is_some()
{
caps.push("caching");
}
}
if let Some(params) = m.get("supported_parameters").and_then(|p| p.as_array()) {
let has_tools = params.iter().any(|p| {
p.as_str() == Some("tools") || p.as_str() == Some("tool_choice")
});
if has_tools {
caps.push("tools");
}
let has_json = params.iter().any(|p| p.as_str() == Some("response_format"));
if has_json {
caps.push("json");
}
let has_reasoning = params.iter().any(|p| {
p.as_str() == Some("reasoning")
|| p.as_str() == Some("include_reasoning")
});
if has_reasoning || m.get("reasoning").filter(|v| !v.is_null()).is_some() {
caps.push("reasoning");
}
}
if let Some(arch) = m.get("architecture").filter(|v| !v.is_null()) {
if let Some(inputs) =
arch.get("input_modalities").and_then(|i| i.as_array())
{
if inputs.iter().any(|i| i.as_str() == Some("image")) {
caps.push("vision");
}
if inputs.iter().any(|i| i.as_str() == Some("audio")) {
caps.push("audio");
}
}
}
if !caps.is_empty() {
obj["caps"] = json!(caps);
}
Some(obj)
})
.collect()
})
.unwrap_or_default();
models.sort();
ok(json!({ "ok": true, "count": models.len(), "models": models }))
models.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
let mut resp = json!({ "ok": true, "count": models.len(), "models": models });
if b.provider == "openrouter" {
if let Some(k) = key {
if let Ok(cred_resp) = client
.get("https://openrouter.ai/api/v1/credits")
.header("authorization", format!("Bearer {k}"))
.send()
.await
{
if let Ok(cred_body) = cred_resp.text().await {
let cred_v: Value = serde_json::from_str(&cred_body).unwrap_or(json!({}));
if let Some(data) = cred_v.get("data") {
let total = data
.get("total_credits")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
let usage = data
.get("total_usage")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
resp["balance"] = json!(total - usage);
}
}
}
}
}
ok(resp)
}
async fn meta() -> ApiResult {
@@ -1336,6 +1431,9 @@ async fn register_import(State(state): State<SharedState>, Path(job): Path<Strin
think_budget: None,
system_prompt: None,
o1: None,
o1_m: None,
o1_window: None,
o1_sink: None,
skip_mtp: false,
});
} else {
+17 -3
View File
@@ -293,13 +293,27 @@ fn spawn_serve(server: &CmfServer, cfg: &CmfCfg) -> Result<Child, String> {
if server.gpu {
cmd.env("CMF_GPU", "1");
}
// O(1) Nyström attention: layers spec + sub-parameters.
if let Some(o1) = &server.o1 {
if !o1.trim().is_empty() && o1 != "off" {
cmd.arg("--o1").arg(o1);
let s = o1.trim();
if !s.is_empty() {
// Pass even "off" so it explicitly overrides a file converter hint.
cmd.arg("--o1").arg(s);
}
}
if let Some(m) = server.o1_m {
cmd.arg("--o1-m").arg(m.to_string());
}
if let Some(w) = server.o1_window {
cmd.arg("--o1-window").arg(w.to_string());
}
if let Some(sink) = server.o1_sink {
cmd.arg("--o1-sink").arg(sink.to_string());
}
// MTP (Multi-Token Prediction) speculative decoding: the engine reads
// CMF_MTP env; "0" disables. There is no CLI flag for this.
if server.skip_mtp {
cmd.arg("--skip-mtp");
cmd.env("CMF_MTP", "0");
}
cmd.stdout(Stdio::null())
.stderr(Stdio::null())
+19 -4
View File
@@ -98,10 +98,19 @@ pub struct CmfServer {
/// Default system prompt prepended if request lacks one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
/// O(1) constant-memory streaming attention hint (all, deep, custom, off).
/// O(1) Nyström attention layer spec (all | deepN | i,j,k | off).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub o1: Option<String>,
/// Skip Multi-Token Prediction (MTP) heads.
/// O(1) landmark budget (default 32; ≥4).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub o1_m: Option<u32>,
/// O(1) exact-window width (default 128).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub o1_window: Option<u32>,
/// O(1) permanent exact sink keys (default 4, StreamingLLM discipline).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub o1_sink: Option<u32>,
/// Disable Multi-Token Prediction speculative decoding (CMF_MTP=0).
#[serde(default)]
pub skip_mtp: bool,
}
@@ -199,6 +208,9 @@ impl CmfCfg {
think_budget: None,
system_prompt: None,
o1: None,
o1_m: None,
o1_window: None,
o1_sink: None,
skip_mtp: false,
}];
}
@@ -510,10 +522,12 @@ pub struct AdminCfg {
/// Whether the admin panel and admin API (`/admin`, `/admin/api/*`) are enabled.
#[serde(default = "default_admin_enabled")]
pub enabled: bool,
/// Name of the env variable holding the admin token. If unset (or empty),
/// a token is generated at startup and printed to the log.
/// Name of the env variable holding the admin token.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env: Option<String>,
/// Persistent admin token configured directly in toml.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
/// Optional separate bind address for the admin surface (e.g. `127.0.0.1:9001`).
/// If unset, the admin interface lives on the main `listen` address under `/admin`.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -524,6 +538,7 @@ impl Default for AdminCfg {
Self {
enabled: default_admin_enabled(),
token_env: None,
token: None,
listen: None,
}
}
+8 -4
View File
@@ -324,7 +324,7 @@ async fn download_cmf_repo(
let cmf_path = match cmf_rel_path {
Some(p) => p,
None => {
let repo_name = repo.split('/').last().unwrap_or(&repo);
let repo_name = repo.rsplit('/').next().unwrap_or(&repo);
format!("{repo_name}.cmf")
}
};
@@ -491,9 +491,13 @@ pub fn start_import(store: Arc<JobStore>, cfg: &CmfCfg, p: ImportParams) -> Resu
let r = p.repo.to_lowercase();
if r.contains("2bit") || r.contains("q1t") {
"Q1T".to_string()
} else if r.contains("1.7bcmf") || r.contains("27bcmf") || r.contains("cmf") {
"Q1".to_string()
} else if p.quant.is_empty() || p.quant == "auto" || p.quant == "Q8_2F" {
} else if r.contains("1.7bcmf")
|| r.contains("27bcmf")
|| r.contains("cmf")
|| p.quant.is_empty()
|| p.quant == "auto"
|| p.quant == "Q8_2F"
{
"Q1".to_string()
} else {
p.quant.clone()
+5
View File
@@ -61,6 +61,11 @@ fn resolve_admin_token(cfg: &Config, args: &Args) -> (String, bool) {
return (t.clone(), false);
}
}
if let Some(t) = &cfg.admin.token {
if !t.is_empty() {
return (t.clone(), false);
}
}
(admin::random_token(24), true)
}
+31 -1
View File
@@ -472,7 +472,37 @@ impl Pipeline {
model_id,
route_source
);
match provider.chat_stream(req.clone()).await {
// Merge per-model/server config (same as run_inner).
let mut req_call = req.clone();
if let Some(m_cfg) = live.cfg.models.iter().find(|m| m.id == model_id) {
if req_call.params.temperature.is_none() {
req_call.params.temperature = m_cfg.temperature;
}
if req_call.params.top_p.is_none() {
req_call.params.top_p = m_cfg.top_p;
}
if req_call.params.max_tokens.is_none() {
req_call.params.max_tokens = m_cfg.max_tokens;
}
if req_call.params.think_budget.is_none() {
req_call.params.think_budget = m_cfg.think_budget;
}
} else if let Some(s_cfg) = live.cfg.cmf.servers.iter().find(|s| s.id == model_id) {
if req_call.params.temperature.is_none() {
req_call.params.temperature = s_cfg.temperature;
}
if req_call.params.top_p.is_none() {
req_call.params.top_p = s_cfg.top_p;
}
if req_call.params.max_tokens.is_none() {
req_call.params.max_tokens = s_cfg.max_tokens;
}
if req_call.params.think_budget.is_none() {
req_call.params.think_budget = s_cfg.think_budget;
}
}
match provider.chat_stream(req_call).await {
Ok(stream) => {
let info = RouteInfo {
task_label: decision.task_label.clone(),
+2
View File
@@ -51,6 +51,8 @@ impl OpenAiProvider {
if let Some(tb) = req.params.think_budget {
body["think_budget"] = tb.into();
body["reasoning_effort"] = tb.into();
// cortiq serve uses enable_thinking (bool): 0 = off, >0 = on
body["enable_thinking"] = (tb > 0).into();
}
if !req.tools.is_empty() {
body["tools"] = serde_json::Value::Array(req.tools.clone());
+167
View File
@@ -397,3 +397,170 @@ tbody tr:hover td { background: var(--hover); }
.mono.kv { display: flex; gap: 6px; padding: 1px 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; }
.mono.kv .kv-k { color: var(--muted, #9aa4b2); flex: 0 0 auto; }
.mono.kv .kv-v { font-weight: 600; overflow-wrap: anywhere; word-break: break-word; min-width: 0; }
/* ---- responsive shell + touch layout ----------------------------------- */
.menu-toggle, .nav-close, .mobile-nav-backdrop { display: none; }
.hamburger { width: 18px; display: flex; flex-direction: column; gap: 4px; }
.hamburger i { display: block; height: 2px; border-radius: 2px; background: currentColor; }
body.modal-open { overflow: hidden; }
@media (max-width: 980px) {
.app { display: block; min-height: 100dvh; }
.main { min-height: 100dvh; }
.menu-toggle { display: inline-flex; flex: 0 0 auto; }
.nav-close { display: inline-flex; margin-left: auto; flex: 0 0 auto; font-size: 24px; line-height: 1; }
.brand-copy { min-width: 0; }
.sidebar {
display: flex;
position: fixed;
inset: 0 auto 0 0;
width: min(84vw, 310px);
height: 100dvh;
padding: max(18px, env(safe-area-inset-top)) 14px max(14px, env(safe-area-inset-bottom));
z-index: 70;
box-shadow: 18px 0 45px rgba(0, 0, 0, .42);
transform: translateX(-104%);
visibility: hidden;
transition: transform .22s ease, visibility 0s linear .22s;
overflow-y: auto;
}
.app.nav-open .sidebar {
transform: translateX(0);
visibility: visible;
transition: transform .22s ease;
}
.mobile-nav-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 65;
width: 100%;
height: 100%;
border: 0;
background: rgba(3, 7, 18, .62);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity .22s ease, visibility 0s linear .22s;
}
.app.nav-open .mobile-nav-backdrop {
opacity: 1;
visibility: visible;
pointer-events: auto;
transition: opacity .22s ease;
}
body.mobile-nav-open { overflow: hidden; }
.topbar { padding: 10px 18px; min-height: 58px; gap: 10px; }
.topbar .title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar .right { gap: 8px; min-width: 0; }
.content { max-width: none; padding: 22px 20px 52px; }
}
@media (max-width: 720px) {
body { font-size: 14px; }
.status-pills { display: none; }
.topbar { padding: 9px 12px; }
.content { padding: 18px 14px 42px; }
.page-head { margin-bottom: 16px; }
.page-head > .flex { align-items: stretch; flex-direction: column; }
.page-head > .flex > .btn { width: 100%; }
.page-head p { line-height: 1.45; }
.card { padding: 15px; margin-bottom: 14px; border-radius: 12px; }
.card .card-head { align-items: flex-start; flex-wrap: wrap; }
.card .card-head .right { width: 100%; margin-left: 0; flex-wrap: wrap; }
.grid { gap: 14px; }
.kpis { gap: 10px; margin-bottom: 14px; }
.kpi { padding: 12px; border-radius: 11px; }
.kpi .big { font-size: 21px; }
.row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0; }
.row > * { min-width: 0; width: 100%; }
input, select, textarea { min-height: 44px; font-size: 16px; }
input[type="checkbox"], input[type="radio"] { min-height: 0; }
.check { min-height: 36px; margin-bottom: 8px; }
.btn, .icon-btn { min-height: 44px; }
.btn { padding: 10px 14px; }
.btn.sm, .btn.small { min-height: 38px; padding: 8px 11px; }
.table-wrap {
width: 100%;
overflow-x: auto;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.table-wrap table { min-width: max-content; }
th, td { padding: 10px; }
.tierrow { align-items: flex-start; flex-direction: column; gap: 8px; }
.tierrow .tname { width: auto; }
.chips { width: 100%; }
.togglerow { align-items: flex-start; }
.pg { gap: 14px; }
.routecard .kv { flex-wrap: wrap; }
.routecard .kv .v { max-width: 100%; overflow-wrap: anywhere; }
.answer { padding: 12px; }
.seg { max-width: 100%; overflow-x: auto; }
.seg button { min-height: 40px; white-space: nowrap; }
.hf-grid { grid-template-columns: 1fr; max-height: 56dvh; }
.job-head, .actions { align-items: stretch; flex-wrap: wrap; }
.actions .btn { flex: 1 1 150px; }
.key-row { flex-direction: column; }
.key-row .btn { width: 100%; }
.cmf-server-row { align-items: stretch !important; padding: 10px; border: 1px solid var(--border-soft); border-radius: 10px; }
.cmf-server-row > input, .cmf-server-row > select { width: 100% !important; min-width: 0 !important; flex: 1 1 100% !important; }
.cmf-server-row > .btn { flex: 1 1 calc(50% - 6px); }
.cmf-server-row > .small { flex: 1 1 100%; }
.modal-backdrop { align-items: flex-end; padding: 8px 0 0; }
.modal {
max-width: none;
max-height: calc(100dvh - 8px);
overflow-y: auto;
padding: 18px 15px max(14px, env(safe-area-inset-bottom));
border-radius: 18px 18px 0 0;
}
.modal .modal-foot {
position: sticky;
bottom: calc(-1 * max(14px, env(safe-area-inset-bottom)));
margin: 12px -15px 0;
padding: 12px 15px max(14px, env(safe-area-inset-bottom));
background: color-mix(in srgb, var(--card) 94%, transparent);
backdrop-filter: blur(10px);
border-top: 1px solid var(--border-soft);
}
.modal .modal-foot .btn { flex: 1; }
.toasts { left: 12px; right: 12px; bottom: max(12px, env(safe-area-inset-bottom)); }
.toast { min-width: 0; width: 100%; }
.login-wrap { align-items: flex-start; padding: max(20px, env(safe-area-inset-top)) 14px; }
.login-card { padding: 22px 18px; margin-top: 8dvh; }
}
@media (max-width: 480px) {
.topbar .title { font-size: 14px; }
.topbar .right { gap: 6px; }
.lang-select { width: 54px; min-height: 44px; padding: 6px; }
.icon-btn { width: 42px; height: 42px; }
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.kpi .lbl { line-height: 1.25; }
.barlist .b { grid-template-columns: minmax(0, 1fr) auto; gap: 6px 10px; }
.barlist .b .track { grid-column: 1 / -1; grid-row: 2; }
.barlist .b .val { min-width: 0; }
.empty { padding: 28px 12px; }
}
@media (max-width: 360px) {
.topbar .title { display: none; }
.kpis { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
.sidebar, .mobile-nav-backdrop { transition: none; }
}
+1 -1
View File
@@ -19,6 +19,6 @@
<noscript>This console requires JavaScript.</noscript>
<div id="app"><div class="boot">Loading…</div></div>
<div id="toasts" class="toasts"></div>
<script type="module" src="/admin/js/app.js"></script>
<script type="module" src="/admin/js/app.js?v=3"></script>
</body>
</html>
+39 -5
View File
@@ -4,7 +4,7 @@ import { t, getLang, setLang, onLangChange, LANGS } from "./i18n.js";
import { api, getToken, setToken, setUnauthorizedHandler } from "./api.js";
import { renderLogin } from "./views/login.js";
import { renderDashboard } from "./views/dashboard.js";
import { renderModels } from "./views/models.js";
import { renderModels } from "./views/models.js?v=3";
import { renderImport } from "./views/import.js";
import { renderRouting } from "./views/routing.js";
import { renderProtocols } from "./views/protocols.js";
@@ -28,6 +28,17 @@ export const appState = { meta: null, health: null, version: "" };
// allaigate site — where router keys are issued and topped up
export const SITE_URL = "https://api.allaigate.com/";
let mobileNavOpen = false;
function setMobileNav(open) {
mobileNavOpen = !!open;
const app = document.querySelector(".app");
if (app) app.classList.toggle("nav-open", mobileNavOpen);
document.body.classList.toggle("mobile-nav-open", mobileNavOpen);
const toggle = document.querySelector(".menu-toggle");
if (toggle) toggle.setAttribute("aria-expanded", String(mobileNavOpen));
}
function currentRoute() {
const id = location.hash.replace(/^#\/?/, "") || "dashboard";
return ROUTES.find((r) => r.id === id) || ROUTES[0];
@@ -42,17 +53,18 @@ const FAVICON =
function sidebar(activeId) {
return h(
"aside",
{ class: "sidebar", id: "sidebar" },
{ class: "sidebar", id: "sidebar", "aria-label": "Main navigation" },
h(
"div",
{ class: "brand" },
h("img", { src: FAVICON, alt: "" }),
h("div", {}, h("b", {}, "allaigate"), h("div", { class: "sub" }, t("brand.sub")))
h("div", { class: "brand-copy" }, h("b", {}, "allaigate"), h("div", { class: "sub" }, t("brand.sub"))),
h("button", { class: "icon-btn nav-close", type: "button", title: "Close menu", "aria-label": "Close menu", onClick: () => setMobileNav(false) }, "×")
),
...ROUTES.map((r) =>
h(
"a",
{ class: "nav-item" + (r.id === activeId ? " active" : ""), href: "#/" + r.id },
{ class: "nav-item" + (r.id === activeId ? " active" : ""), href: "#/" + r.id, onClick: () => setMobileNav(false) },
h("span", { class: "ico" }, r.icon),
t("nav." + r.id)
)
@@ -85,7 +97,7 @@ function statusPills() {
: t("status.checking");
return h(
"div",
{ class: "flex" },
{ class: "flex status-pills" },
h(
"span",
{ class: "pill" },
@@ -136,6 +148,19 @@ function topbar(activeId) {
return h(
"header",
{ class: "topbar" },
h(
"button",
{
class: "icon-btn menu-toggle",
type: "button",
title: "Menu",
"aria-label": "Open menu",
"aria-controls": "sidebar",
"aria-expanded": String(mobileNavOpen),
onClick: () => setMobileNav(!mobileNavOpen),
},
h("span", { class: "hamburger", "aria-hidden": "true" }, h("i"), h("i"), h("i"))
),
h("div", { class: "title" }, t("nav." + activeId)),
h(
"div",
@@ -175,12 +200,15 @@ async function renderView() {
function renderShell() {
const route = currentRoute();
mobileNavOpen = false;
document.body.classList.remove("mobile-nav-open");
mount(
document.getElementById("app"),
h(
"div",
{ class: "app" },
sidebar(route.id),
h("button", { class: "mobile-nav-backdrop", type: "button", tabindex: "-1", "aria-label": "Close menu", onClick: () => setMobileNav(false) }),
h("main", { class: "main" }, topbar(route.id), h("div", { class: "content", id: "view" }))
)
);
@@ -243,6 +271,12 @@ onLangChange(() => {
window.addEventListener("hashchange", () => {
if (document.querySelector(".app")) renderShell();
});
window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && mobileNavOpen) setMobileNav(false);
});
window.addEventListener("resize", () => {
if (window.innerWidth > 980 && mobileNavOpen) setMobileNav(false);
});
setInterval(() => {
if (document.querySelector(".app")) refreshHealth();
}, 20000);
+14 -1
View File
@@ -23,6 +23,10 @@ export function clear(node) {
while (node && node.firstChild) node.removeChild(node.firstChild);
}
export function opt(v, label, sel) {
return h("option", { value: v, selected: sel ? true : null }, label || v);
}
export function mount(node, ...children) {
clear(node);
for (const c of children.flat())
@@ -46,7 +50,14 @@ export function toast(msg, kind = "info", ms = 3200) {
// Modal dialog. `body` is a DOM node; `onSave` returns a Promise; returns nothing.
export function modal(title, body, onSave, saveLabel) {
const backdrop = h("div", { class: "modal-backdrop" });
const close = () => backdrop.remove();
const onKeydown = (e) => {
if (e.key === "Escape") close();
};
const close = () => {
backdrop.remove();
document.body.classList.remove("modal-open");
document.removeEventListener("keydown", onKeydown);
};
backdrop.addEventListener("click", (e) => {
if (e.target === backdrop) close();
});
@@ -73,6 +84,8 @@ export function modal(title, body, onSave, saveLabel) {
)
);
backdrop.appendChild(card);
document.body.classList.add("modal-open");
document.addEventListener("keydown", onKeydown);
document.body.appendChild(backdrop);
return { close };
}
+1 -4
View File
@@ -2,7 +2,7 @@
// live progress view. Search HF, pick quantization + advanced params, watch
// the conversion (multiple in parallel, resumable across tab switches), then
// register the result as a local model.
import { h, mount, toast } from "../ui.js";
import { h, mount, toast, opt } from "../ui.js";
import { t } from "../i18n.js";
import { api } from "../api.js";
@@ -31,9 +31,6 @@ function field(label, control, hint) {
return h("label", { class: "field" }, h("span", {}, label), control,
hint ? h("div", { class: "hint" }, hint) : null);
}
function opt(v, label, sel) {
return h("option", { value: v, selected: sel ? true : null }, label || v);
}
function fmtNum(n) {
if (n == null) return "—";
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
+69 -26
View File
@@ -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, confirmDialog, money, opt } from "../ui.js";
import { t } from "../i18n.js";
import { api } from "../api.js";
import { appState } from "../app.js";
@@ -7,9 +7,6 @@ import { appState } from "../app.js";
function field(label, control, hint) {
return h("label", { class: "field" }, h("span", {}, label), control, hint ? h("div", { class: "hint" }, hint) : null);
}
function opt(v, label, sel) {
return h("option", { value: v, selected: sel ? true : null }, label || v);
}
function keyBadge(src) {
const map = { store: "store", env: "env", missing: "bad", none: "" };
@@ -54,7 +51,16 @@ function modelForm(meta, existing) {
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 capInputs = (meta.caps || ["tools", "vision"]).map((c) => {
const CAP_EMOJIS = {
tools: "🔧 tools",
vision: "👁️ vision",
audio: "🎙️ audio",
reasoning: "🧠 reasoning",
json: "📄 json",
caching: "⚡ caching"
};
const capInputs = (meta.caps || Object.keys(CAP_EMOJIS)).map((c) => {
const cb = h("input", { type: "checkbox", checked: (m.caps || []).includes(c) ? true : null });
return { c, cb };
});
@@ -73,25 +79,55 @@ function modelForm(meta, existing) {
capInputs.forEach(({ c, cb }) => { cb.checked = d.caps.includes(c); });
keyIn.placeholder = d.needsKey ? t("models.form.apiKeyPlaceholder") : t("models.form.apiKeyNotNeeded");
mount(keyStatus, "");
// Automatically pre-fetch models (especially useful for OpenRouter which doesn't need a key)
testKey(true);
}
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;
let cachedModels = [];
const autofillData = () => {
console.log("autofillData triggered. modelIn.value:", modelIn.value);
console.log("cachedModels length:", cachedModels.length);
if (!existing && (!idIn.value || idIn._auto)) {
idIn.value = slugId(modelIn.value);
idIn._auto = true;
console.log("auto-filled idIn:", idIn.value);
}
const found = cachedModels.find((m) => (m.id || m) === modelIn.value);
console.log("found model:", found);
if (found) {
if (found.price_in !== undefined) {
priceInIn.value = parseFloat(Number(found.price_in).toFixed(4));
priceOutIn.value = parseFloat(Number(found.price_out).toFixed(4));
console.log("Updated prices:", priceInIn.value, priceOutIn.value);
}
});
if (found.caps && Array.isArray(found.caps)) {
capInputs.forEach(({ c, cb }) => {
cb.checked = found.caps.includes(c);
});
console.log("Updated caps:", found.caps);
}
}
};
modelIn.addEventListener("input", autofillData);
modelIn.addEventListener("change", autofillData);
if (!existing) {
idIn.addEventListener("input", () => { idIn._auto = false; });
} else {
console.log("Editing existing model, fetching testKey(true)");
testKey(true);
}
async function testKey() {
if (!baseUrlIn.value.trim()) { mount(keyStatus, h("span", { style: "color:var(--bad)" }, t("models.form.baseUrl") + "?")); return; }
async function testKey(silent = false) {
if (!baseUrlIn.value.trim()) {
if (!silent) mount(keyStatus, h("span", { style: "color:var(--bad)" }, t("models.form.baseUrl") + "?"));
return;
}
testBtn.disabled = true;
mount(keyStatus, t("models.form.testing"));
if (!silent) mount(keyStatus, t("models.form.testing"));
try {
const r = await api.providerModels({
provider: providerSel.value,
@@ -100,18 +136,25 @@ function modelForm(meta, existing) {
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();
cachedModels = r.models || [];
dl.replaceChildren(...cachedModels.map((mm) => h("option", { value: mm.id || mm })));
let msg = "✓ " + t("models.form.foundModels", { n: r.count });
if (r.balance !== undefined) {
msg += ` (Balance: $${r.balance.toFixed(4)})`;
}
mount(keyStatus, h("span", { style: "color:var(--good)" }, msg));
if (!silent) modelIn.focus();
} else {
mount(keyStatus, h("span", { style: "color:var(--bad)" }, "✗ " + (r.error || t("common.error"))));
if (!silent) 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)));
if (!silent) mount(keyStatus, h("span", { style: "color:var(--bad)" }, "✗ " + String(e.message || e)));
}
testBtn.disabled = false;
}
testBtn.addEventListener("click", testKey);
testBtn.addEventListener("click", () => testKey(false));
keyIn.addEventListener("change", () => testKey(true));
const keyHint =
existing && existing.key_source === "store" ? t("models.form.keyStored")
@@ -124,10 +167,10 @@ function modelForm(meta, existing) {
const maxTokIn = h("input", { type: "number", min: "1", placeholder: "2048", value: m.max_tokens ?? "" });
const thinkIn = h("input", { type: "number", min: "0", placeholder: "1024", value: m.think_budget ?? "" });
const o1Sel = h("select", {},
opt("", t("import.adv.auto")),
opt("all", "all — O(1) Attention"),
opt("deep", "deep — O(1) Deep"),
opt("off", "off — Standard"));
h("option", { value: "" }, t("import.adv.auto")),
h("option", { value: "all" }, "all — O(1) Attention"),
h("option", { value: "deep" }, "deep — O(1) Deep"),
h("option", { value: "off" }, "off — Standard"));
if (m.o1) o1Sel.value = m.o1;
const skipMtpCb = h("input", { type: "checkbox", checked: m.skip_mtp ? true : null });
const sysPromptIn = h("textarea", { rows: 2, placeholder: t("models.form.systemPromptPlaceholder"), value: m.system_prompt || "" });
@@ -158,7 +201,7 @@ function modelForm(meta, existing) {
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.caps"), h("div", { class: "flex wrap", style: "gap:10px" }, ...capInputs.map(({ c, cb }) => h("label", { class: "check", style: "margin:0" }, cb, CAP_EMOJIS[c] || c)))),
advParams
);
+77 -25
View File
@@ -253,36 +253,85 @@ export async function renderSettings() {
} catch (e) { mount(st, String(e.message || e)); }
checkBtn.disabled = false;
});
// ⚙ Model Generation & Thinking Parameters drawer
const tempIn = h("input", { type: "number", step: "0.05", min: "0", max: "2.0", placeholder: "0.7", value: sv.temperature ?? "", style: "width:65px" });
const topPIn = h("input", { type: "number", step: "0.05", min: "0", max: "1.0", placeholder: "0.9", value: sv.top_p ?? "", style: "width:65px" });
const maxTokIn = h("input", { type: "number", min: "1", placeholder: "2048", value: sv.max_tokens ?? "", style: "width:80px" });
const thinkIn = h("input", { type: "number", min: "0", placeholder: "1024", value: sv.think_budget ?? "", style: "width:80px" });
const o1Sel = h("select", { style: "width:110px" },
opt("", "O(1) auto"),
opt("all", "O(1) all"),
opt("deep", "O(1) deep"),
opt("off", "O(1) off"));
if (sv.o1) o1Sel.value = sv.o1;
const skipMtpCb = h("input", { type: "checkbox", checked: sv.skip_mtp ? true : null });
const sysIn = h("input", { placeholder: t("models.form.systemPromptPlaceholder"), value: sv.system_prompt || "", style: "flex:1" });
const drawer = h("div", { class: "flex wrap gap-sm", style: "display:none;width:100%;margin-top:4px;padding:6px;background:var(--bg-subtle);border-radius:4px;align-items:center" },
h("span", { class: "small muted" }, "Temp:"), tempIn,
h("span", { class: "small muted" }, "Top-P:"), topPIn,
h("span", { class: "small muted" }, "MaxTokens:"), maxTokIn,
h("span", { class: "small muted" }, "ThinkBudget:"), thinkIn,
o1Sel,
h("label", { class: "check small" }, skipMtpCb, "Skip MTP"),
h("span", { class: "small muted" }, "System:"), sysIn
// ── O(1) Nyström Attention ──
// NOT a "reasoning mode". This is a memory-efficient streaming attention
// kernel (Nyström/landmark) that replaces standard KV-cache attention,
// giving O(1) memory per decode step instead of O(n).
const o1Sel = h("select", { style: "width:120px", title: "O(1) Nyström attention layer spec: which layers replace KV-cache with streaming Nyström attention" },
h("option", { value: "" }, "O(1) авто"),
h("option", { value: "all" }, "O(1) все слои"),
h("option", { value: "deep" }, "O(1) глубокие"),
h("option", { value: "off" }, "O(1) выкл."));
if (sv.o1) o1Sel.value = sv.o1;
const o1mIn = h("input", { type: "number", min: "4", placeholder: "32", value: sv.o1_m ?? "", style: "width:55px", title: "Landmark budget (≥4, default 32). More landmarks ≠ better: m=64 measured worse." });
const o1wIn = h("input", { type: "number", min: "1", placeholder: "128", value: sv.o1_window ?? "", style: "width:60px", title: "Exact-window width — main quality lever (default 128)" });
const o1sinkIn = h("input", { type: "number", min: "0", placeholder: "4", value: sv.o1_sink ?? "", style: "width:50px", title: "Permanent exact sink keys, StreamingLLM discipline (default 4)" });
// ── Generation parameters ──
const tempIn = h("input", { type: "number", step: "0.05", min: "0", max: "2.0", placeholder: "0.7", value: sv.temperature ?? "", style: "width:60px" });
const topPIn = h("input", { type: "number", step: "0.05", min: "0", max: "1.0", placeholder: "0.9", value: sv.top_p ?? "", style: "width:60px" });
const maxTokIn = h("input", { type: "number", min: "1", placeholder: "2048", value: sv.max_tokens ?? "", style: "width:75px" });
// think_budget = 0 means reasoning disabled (enable_thinking=false);
// null = model decides; N>0 = token budget for the <think> block.
const noThinkCb = h("input", { type: "checkbox", checked: (sv.think_budget === 0) ? true : null, title: "Отключить режим размышлений (think_budget=0, enable_thinking=false для Qwen3/3.5)" });
const thinkIn = h("input", { type: "number", min: "1", placeholder: "авто", value: (sv.think_budget && sv.think_budget > 0) ? sv.think_budget : "", style: "width:75px", title: "Бюджет токенов на размышления (think_budget). Пустое = модель решает сама." });
if (sv.think_budget === 0) thinkIn.disabled = true;
noThinkCb.addEventListener("change", () => {
thinkIn.disabled = noThinkCb.checked;
if (noThinkCb.checked) thinkIn.value = "";
});
const sysIn = h("input", { placeholder: "System prompt (optional)", value: sv.system_prompt || "", style: "flex:1;min-width:200px" });
// ── MTP (Multi-Token Prediction): speculative decoding, NOT reasoning ──
const skipMtpCb = h("input", { type: "checkbox", checked: sv.skip_mtp ? true : null, title: "Disable MTP speculative decoding (env CMF_MTP=0)" });
// ── Settings drawer ──
const drawerStyle = "display:none;width:100%;margin-top:6px;padding:10px 12px;background:var(--bg-subtle);border-radius:6px;border:1px solid var(--border)";
const groupStyle = "display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:4px 0";
const labelStyle = "font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.5px;width:100%;margin-bottom:2px";
const drawer = h("div", { style: drawerStyle },
// Group 1: O(1) Nyström Attention
h("div", { style: labelStyle }, "O(1) Nyström Attention"),
h("div", { style: groupStyle },
h("span", { class: "small muted" }, "Слои:"), o1Sel,
h("span", { class: "small muted" }, "m:"), o1mIn,
h("span", { class: "small muted" }, "Window:"), o1wIn,
h("span", { class: "small muted" }, "Sink:"), o1sinkIn
),
h("hr", { style: "border:none;border-top:1px solid var(--border);margin:6px 0" }),
// Group 2: Generation
h("div", { style: labelStyle }, "Генерация"),
h("div", { style: groupStyle },
h("span", { class: "small muted" }, "Temp:"), tempIn,
h("span", { class: "small muted" }, "Top-P:"), topPIn,
h("span", { class: "small muted" }, "Max токенов:"), maxTokIn
),
h("div", { style: groupStyle },
h("span", { class: "small muted" }, "System prompt:"), sysIn
),
h("hr", { style: "border:none;border-top:1px solid var(--border);margin:6px 0" }),
// Group 3: Reasoning (thinking)
h("div", { style: labelStyle }, "Режим размышлений"),
h("div", { style: groupStyle },
h("label", { class: "check small", title: "Для моделей Qwen3/3.5: enable_thinking=false — модель отвечает напрямую без блока <think>" }, noThinkCb, "Отключить размышления"),
h("span", { class: "small muted" }, "Think budget:"), thinkIn
),
h("hr", { style: "border:none;border-top:1px solid var(--border);margin:6px 0" }),
// Group 4: Runtime
h("div", { style: labelStyle }, "Runtime"),
h("div", { style: groupStyle },
h("label", { class: "check small", title: "MTP — Multi-Token Prediction (спекулятивное декодирование). Отключение замедляет генерацию, но стабилизирует output." }, skipMtpCb, "Отключить MTP")
)
);
const gearBtn = h("button", { class: "btn small", type: "button", title: "⚙ " + t("models.form.paramsTitle") }, "⚙");
const gearBtn = h("button", { class: "btn small", type: "button", title: "⚙ Настройки модели" }, "⚙");
gearBtn.addEventListener("click", () => {
drawer.style.display = drawer.style.display === "none" ? "flex" : "none";
drawer.style.display = drawer.style.display === "none" ? "block" : "none";
});
const row = h("div", { class: "flex wrap", style: "gap:6px;align-items:center;margin:6px 0" },
const row = h("div", { class: "flex wrap cmf-server-row", style: "gap:6px;align-items:center;margin:6px 0" },
idIn, modelSel, portIn, checkBtn, threadsIn, h("label", { class: "flex", style: "gap:4px" }, gpuIn, "GPU"), gearBtn, removeBtn, st, drawer);
const entry = { node: row, read: () => ({
id: idIn.value.trim() || autoId(modelSel.value),
@@ -293,8 +342,11 @@ export async function renderSettings() {
temperature: tempIn.value !== "" ? parseFloat(tempIn.value) : null,
top_p: topPIn.value !== "" ? parseFloat(topPIn.value) : null,
max_tokens: maxTokIn.value !== "" ? parseInt(maxTokIn.value) : null,
think_budget: thinkIn.value !== "" ? parseInt(thinkIn.value) : null,
think_budget: noThinkCb.checked ? 0 : (thinkIn.value !== "" ? parseInt(thinkIn.value) : null),
o1: o1Sel.value || null,
o1_m: o1mIn.value !== "" ? parseInt(o1mIn.value) : null,
o1_window: o1wIn.value !== "" ? parseInt(o1wIn.value) : null,
o1_sink: o1sinkIn.value !== "" ? parseInt(o1sinkIn.value) : null,
skip_mtp: skipMtpCb.checked,
system_prompt: sysIn.value.trim() || null,
}) };