feat(routing): per-task rules on top of complexity tiers; hosted router is the default

The router has always returned two signals — task label and complexity tier —
but only the tier picked models. [routing.tasks] adds `label → ordered models`:
a matching rule's models lead the candidate chain and the tier chain follows as
fallback, so pinning translations to a local model never weakens failover.
Requests whose chain was headed by a task rule report
route_source "router+task-rule".

The Routing page grows a per-task editor built from the account's LIVE
taxonomy (GET /v1/taxonomies via the new /admin/api/routing/labels proxy;
built-in label set when the router is unreachable), with localized task names,
each task's share of 7-day traffic, "by complexity (no rule)" as the explicit
empty state, and custom labels for other taxonomies. Deleting a model scrubs
it from task rules like it always did from tiers.

Also fixes the default config pointing at a placeholder localhost:8080 router
with a nonexistent taxonomy — a fresh install now targets the hosted allaigate
router (IP endpoint, verify_tls off, data-assistant, 12 s oracle timeout) and
degrades gracefully until a key is set. Verified end-to-end against the live
router: translation → task-rule model, math → tier path.

16 new strings in all 7 languages.
This commit is contained in:
infosave2007
2026-08-07 17:39:38 +03:00
parent 15de36be3b
commit 2ede0ee452
18 changed files with 507 additions and 58 deletions
+4 -4
View File
@@ -14,11 +14,11 @@ listen = "0.0.0.0:9000"
# NOTE: on hard prompts the router escalates to an oracle LLM (~10 s) — for the
# hosted router set timeout_ms = 12000+, otherwise the gateway degrades to default.
[router]
url = "http://localhost:8080"
url = "https://138.226.222.209" # hosted allaigate router (or your own cortiq-router URL)
api_key_env = "CORTIQ_ROUTER_KEY" # name of the env var holding the router key
timeout_ms = 800 # timeout for the /v1/route call (12000+ for oracle)
taxonomy_id = "general.task-type" # for allaigate: "data-assistant"
verify_tls = true # false if the router uses an IP / self-signed cert
timeout_ms = 12000 # hard prompts escalate to an oracle (~10 s)
taxonomy_id = "data-assistant" # the hosted router's task taxonomy
verify_tls = false # the hosted endpoint is IP-bound; true for your own domain
# --- how to pick the text to classify from the conversation -----------------
[route]
+20
View File
@@ -162,3 +162,23 @@ fine-tune the task mask. Over time this makes routing fit your specific pool mor
enabled = false
# the ground-truth source is set by the integrator (e.g. the X-Cortiq-Correct-Label header)
```
## Task-based routing
The router classifies every request twice: a **task label** (what kind of work it
is) and a **complexity tier** (how hard it is). Tiers pick *how much model* you
need; task rules pick *which* model is best at the job:
```toml
[routing.tasks]
translation = ["local-qwen"] # translations are cheap — keep them local
code = ["claude-sonnet", "gpt"] # code goes to the strongest coder first
```
A matching rule's models go **first** in the candidate chain; the complexity
chain follows as fallback, so a task pin never weakens failover. Tasks without
a rule route by complexity alone. The available labels come from the router
(`GET /v1/taxonomies`) — the admin console's Routing page builds the editor
from your account's live taxonomy and shows each task's share of recent
traffic. When a rule supplied the head of the chain the response reports
`route_source: "router+task-rule"`.
+19
View File
@@ -160,3 +160,22 @@ cooldown = "30s"
enabled = false
# источник истины задаёт интегратор (например, заголовок X-Cortiq-Correct-Label)
```
## Распределение по задачам
Роутер классифицирует каждый запрос дважды: **тип задачи** (что это за работа)
и **уровень сложности** (насколько она трудна). Уровни выбирают, *сколько
модели* нужно; правила по задачам — *какая* модель лучше в этом деле:
```toml
[routing.tasks]
translation = ["local-qwen"] # переводы дёшевы — оставляем локально
code = ["claude-sonnet", "gpt"] # код — сильнейшему кодеру
```
Модели подходящего правила идут **первыми** в цепочке кандидатов; цепочка по
сложности следует за ними как резерв — правило по задаче не ослабляет failover.
Задачи без правила маршрутизируются только по сложности. Список меток приходит
с роутера (`GET /v1/taxonomies`) — вкладка Роутинг строит редактор по живой
таксономии аккаунта и показывает долю каждой задачи в трафике. Когда цепочку
возглавило правило задачи, ответ сообщает `route_source: "router+task-rule"`.
+66 -1
View File
@@ -155,6 +155,7 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
)
.route("/admin/api/models/:id/probe", post(probe_model))
.route("/admin/api/routing", get(get_routing).put(put_routing))
.route("/admin/api/routing/labels", get(get_routing_labels))
.route(
"/admin/api/protocols",
get(get_protocols).put(put_protocols),
@@ -744,7 +745,7 @@ 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() {
for tt in routing.tiers.values_mut().chain(routing.tasks.values_mut()) {
match tt {
crate::config::TierTargets::List(v) => v.retain(|x| x != id),
crate::config::TierTargets::One(s) if s == id => {
@@ -753,6 +754,11 @@ fn strip_id_from_routing(routing: &mut RoutingCfg, id: &str) {
crate::config::TierTargets::One(_) => {}
}
}
// an emptied task rule means "no rule", not "no candidates"
routing.tasks.retain(|_, tt| match tt {
crate::config::TierTargets::List(v) => !v.is_empty(),
crate::config::TierTargets::One(s) => !s.is_empty(),
});
}
/// Whether any managed server or static model in `cfg` still uses `path` as its
@@ -893,17 +899,70 @@ async fn probe_model(State(state): State<SharedState>, Path(id): Path<String>) -
async fn get_routing(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
let tasks: HashMap<String, Vec<String>> = live
.cfg
.routing
.tasks
.iter()
.map(|(k, v)| {
(
k.clone(),
match v {
TierTargets::List(l) => l.clone(),
TierTargets::One(s) => vec![s.clone()],
},
)
})
.collect();
ok(json!({
"tiers": tiers_to_map(&live.cfg.routing),
"tasks": tasks,
"default": live.cfg.routing.default,
"policy": live.cfg.routing.policy,
}))
}
/// Task labels of the active taxonomy, straight from the router (`GET
/// /v1/taxonomies`) — the UI builds the per-task rule editor from this, so a
/// custom taxonomy shows its own labels without a gateway update.
async fn get_routing_labels(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
match live.router.taxonomies().await {
Ok(Some(v)) => {
let want = live.cfg.router.taxonomy_id.clone().unwrap_or_default();
let mut labels: Vec<String> = Vec::new();
if let Some(arr) = v["taxonomies"].as_array() {
for tx in arr {
let id = tx["taxonomy_id"].as_str().unwrap_or("");
if want.is_empty() || id == want || labels.is_empty() {
if let Some(ls) = tx["labels"].as_array() {
let this: Vec<String> = ls
.iter()
.filter_map(|l| l.as_str().map(String::from))
.collect();
if id == want || labels.is_empty() {
labels = this;
}
}
}
}
}
if labels.is_empty() {
ok(json!({ "labels": Value::Null, "source": "router-empty" }))
} else {
ok(json!({ "labels": labels, "source": "router" }))
}
}
_ => ok(json!({ "labels": Value::Null, "source": "unavailable" })),
}
}
#[derive(Deserialize)]
struct RoutingBody {
#[serde(default)]
tiers: HashMap<String, Vec<String>>,
#[serde(default)]
tasks: HashMap<String, Vec<String>>,
default: String,
#[serde(default)]
policy: RoutingPolicy,
@@ -917,6 +976,12 @@ async fn put_routing(State(state): State<SharedState>, Json(body): Json<RoutingB
.into_iter()
.map(|(k, v)| (k, TierTargets::List(v)))
.collect(),
tasks: body
.tasks
.into_iter()
.filter(|(_, v)| !v.is_empty()) // empty rule = "по сложности", not a rule
.map(|(k, v)| (k, TierTargets::List(v)))
.collect(),
default: body.default,
policy: body.policy,
};
+29 -4
View File
@@ -396,6 +396,12 @@ pub struct RoutingCfg {
pub default: String,
#[serde(default)]
pub policy: RoutingPolicy,
/// Per-task rules: `task_label → ordered model list`. A matching rule's
/// models go FIRST; the complexity-tier chain follows as fallback, so a
/// task pin never weakens failover. Named field, so a tier cannot be
/// called "tasks" — serde extracts it before the flattened tier map.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub tasks: HashMap<String, TierTargets>,
}
/// A routing tier is either a list of model_ids or the string `default`.
@@ -724,13 +730,16 @@ impl Default for Config {
fn default() -> Self {
Self {
listen: "0.0.0.0:9000".to_string(),
// The hosted allaigate router — the thing this gateway is built
// around. Without a key the pipeline degrades gracefully to local
// models and the console asks for one; nothing breaks.
router: RouterCfg {
enabled: true,
url: "http://localhost:8080".to_string(),
timeout_ms: default_router_timeout(),
verify_tls: true,
url: "https://138.226.222.209".to_string(),
timeout_ms: 12_000, // hard prompts escalate to an oracle (~10 s)
verify_tls: false, // the hosted endpoint is IP-bound
api_key_env: Some("CORTIQ_ROUTER_KEY".to_string()),
taxonomy_id: Some("general.task-type".to_string()),
taxonomy_id: Some("data-assistant".to_string()),
},
route: RouteCfg::default(),
models: Vec::new(),
@@ -738,6 +747,7 @@ impl Default for Config {
tiers: HashMap::new(),
default: String::new(),
policy: RoutingPolicy::default(),
tasks: HashMap::new(),
},
breaker: BreakerCfg::default(),
protocols: ProtocolsCfg {
@@ -862,6 +872,21 @@ impl Config {
}
}
}
for (task, targets) in &self.routing.tasks {
let list = match targets {
TierTargets::List(v) => v.clone(),
TierTargets::One(s) => vec![s.clone()],
};
for id in &list {
if !ids.contains(id.as_str()) {
anyhow::bail!(
"config: routing.tasks.{} references unknown model id '{}'",
task,
id
);
}
}
}
Ok(())
}
+10 -2
View File
@@ -232,8 +232,16 @@ impl Pipeline {
match live.router.route(&text, prof).await {
Ok(Some(dec)) => {
let src = dec.source.clone();
let tier = dec.complexity_tier.clone();
(dec, src, live.routing.candidates(&tier))
let (cands, task_rule) = live
.routing
.candidates_for(&dec.task_label, &dec.complexity_tier);
// surface which rule chose the head of the chain
let src = if task_rule {
format!("{src}+task-rule")
} else {
src
};
(dec, src, cands)
}
Ok(None) | Err(_) => {
tracing::warn!(
+17
View File
@@ -71,6 +71,23 @@ impl RouterClient {
/// Classify text. Returns `Ok(None)` on network error/timeout —
/// the caller treats this as "router unavailable" and uses the default tier.
/// `GET /v1/taxonomies` — the account's taxonomies with their label sets.
/// `Ok(None)` when the router is disabled or has no key configured.
pub async fn taxonomies(&self) -> anyhow::Result<Option<serde_json::Value>> {
if self.url.is_empty() {
return Ok(None);
}
let mut req = self.http.get(format!("{}/v1/taxonomies", self.url));
if let Some(k) = &self.api_key {
req = req.bearer_auth(k);
}
let resp = req.send().await?;
if !resp.status().is_success() {
anyhow::bail!("router /v1/taxonomies: HTTP {}", resp.status());
}
Ok(Some(resp.json().await?))
}
pub async fn route(&self, text: &str, profile: &str) -> anyhow::Result<Option<RouteDecision>> {
let mut body = serde_json::json!({
"input": { "text": text },
+92 -17
View File
@@ -3,38 +3,70 @@
use crate::config::{Config, TierTargets};
fn to_list(t: &TierTargets) -> Vec<String> {
match t {
TierTargets::List(v) => v.clone(),
TierTargets::One(s) => vec![s.clone()],
}
}
pub struct RoutingTable {
tiers: std::collections::HashMap<String, Vec<String>>,
/// Per-task rules — a matching rule's models lead the candidate chain.
tasks: std::collections::HashMap<String, Vec<String>>,
default: String,
}
impl RoutingTable {
pub fn from_config(cfg: &Config) -> Self {
let tiers = cfg
.routing
.tiers
.iter()
.map(|(tier, targets)| {
let list = match targets {
TierTargets::List(v) => v.clone(),
TierTargets::One(s) => vec![s.clone()],
};
(tier.clone(), list)
})
.collect();
Self {
tiers,
tiers: cfg
.routing
.tiers
.iter()
.map(|(tier, targets)| (tier.clone(), to_list(targets)))
.collect(),
tasks: cfg
.routing
.tasks
.iter()
.map(|(task, targets)| (task.clone(), to_list(targets)))
.filter(|(_, v)| !v.is_empty())
.collect(),
default: cfg.routing.default.clone(),
}
}
/// Candidates for `(task, tier)`, in preference order, deduplicated:
/// the task rule's models first (when one exists), then the tier chain —
/// so a task pin adds preference without ever weakening failover.
/// Returns `(candidates, task_rule_matched)`.
pub fn candidates_for(&self, task: &str, tier: &str) -> (Vec<String>, bool) {
let mut out: Vec<String> = Vec::new();
let matched = if let Some(rule) = self.tasks.get(task) {
out.extend(rule.iter().cloned());
true
} else {
false
};
let tier_chain = self
.tiers
.get(tier)
.cloned()
.unwrap_or_else(|| vec![self.default.clone()]);
for id in tier_chain {
if !out.contains(&id) {
out.push(id);
}
}
out.retain(|id| !id.is_empty());
(out, matched)
}
/// Candidates for the given complexity tier, in preference order.
/// If the tier is not defined, the sole candidate is `default`.
pub fn candidates(&self, tier: &str) -> Vec<String> {
self.tiers
.get(tier)
.cloned()
.unwrap_or_else(|| vec![self.default.clone()])
self.candidates_for("", tier).0
}
/// Default model (used when the router is unavailable).
@@ -46,3 +78,46 @@ impl RoutingTable {
// TODO(v0.2): cost_aware mode and circuit breaker — pick the cheapest available
// model no lower than min_class[tier], respecting max_cost_usd_per_request,
// and skip models whose circuit breaker is open.
#[cfg(test)]
mod tests {
use super::*;
fn table(tasks: &[(&str, &[&str])], tiers: &[(&str, &[&str])], default: &str) -> RoutingTable {
RoutingTable {
tiers: tiers
.iter()
.map(|(k, v)| (k.to_string(), v.iter().map(|s| s.to_string()).collect()))
.collect(),
tasks: tasks
.iter()
.map(|(k, v)| (k.to_string(), v.iter().map(|s| s.to_string()).collect()))
.collect(),
default: default.to_string(),
}
}
#[test]
fn task_rule_leads_tier_chain_follows() {
let t = table(
&[("translation", &["local"])],
&[("low", &["cheap", "local"]), ("high", &["big"])],
"cheap",
);
let (c, matched) = t.candidates_for("translation", "high");
assert!(matched);
assert_eq!(c, vec!["local", "big"]);
// dedup: rule model already in the tier chain appears once, first
let (c, _) = t.candidates_for("translation", "low");
assert_eq!(c, vec!["local", "cheap"]);
}
#[test]
fn no_rule_falls_back_to_tier_then_default() {
let t = table(&[], &[("low", &["a"])], "d");
assert_eq!(t.candidates_for("qa", "low").0, vec!["a"]);
let (c, matched) = t.candidates_for("qa", "unknown-tier");
assert!(!matched);
assert_eq!(c, vec!["d"]);
}
}
+6
View File
@@ -585,6 +585,12 @@ body.modal-open { overflow: hidden; }
/* Featured gallery: the list grows as new models are published, so give it more
room than the search grid before it starts scrolling. */
.featured-section .hf-grid { max-height: min(62vh, 760px); }
/* Routing: per-task rule rows */
.taskrow { align-items: flex-start; }
.taskname { min-width: 190px; line-height: 1.35; }
.taskname .mono { font-size: 10.5px; opacity: .75; }
.chips-empty { padding: 4px 0; font-style: italic; }
/* Shell notices (open admin warning, update available) */
.notice {
display: flex; align-items: center; gap: 8px;
+1
View File
@@ -68,6 +68,7 @@ export const api = {
// routing
getRouting: () => j("GET", "/routing"),
routingLabels: () => j("GET", "/routing/labels"),
putRouting: (b) => j("PUT", "/routing", b),
// protocols
+18
View File
@@ -545,4 +545,22 @@ export default {
"bench.running": "misst…",
"bench.failed": "fehlgeschlagen",
"bench.none": "Noch keine lokalen .cmf-Chat-Modelle.",
// task-based routing
"task.chitchat": "Smalltalk",
"task.code": "Code",
"task.creative-writing": "Kreatives Schreiben",
"task.extraction": "Datenextraktion",
"task.math": "Mathematik",
"task.qa": "Fragen & Antworten",
"task.summarization": "Zusammenfassung",
"task.translation": "Übersetzung",
"routing.tasks.title": "Aufgabenbasiertes Routing",
"routing.tasks.hint": "Die Modelle einer Regel kommen für ihre Aufgabe zuerst; die Komplexitätskette bleibt als Fallback. Leer = nur nach Komplexität.",
"routing.tasks.inherit": "nach Komplexität (keine Regel)",
"routing.tasks.share": "{p}% der Anfragen (7 T)",
"routing.tasks.fromRouter": "Labels live vom Router",
"routing.tasks.builtin": "Router nicht erreichbar — eingebauter Labelsatz",
"routing.tasks.addCustom": "Aufgabe hinzufügen",
"routing.tasks.addPlaceholder": "eigenes Label…",
};
+18
View File
@@ -538,4 +538,22 @@ export default {
"bench.running": "measuring…",
"bench.failed": "failed",
"bench.none": "No local .cmf chat models yet.",
// task-based routing
"task.chitchat": "Small talk",
"task.code": "Code",
"task.creative-writing": "Creative writing",
"task.extraction": "Data extraction",
"task.math": "Math",
"task.qa": "Q&A",
"task.summarization": "Summarization",
"task.translation": "Translation",
"routing.tasks.title": "Task-based routing",
"routing.tasks.hint": "A rule's models go first for that task; the complexity chain below still applies as fallback. Empty = route by complexity only.",
"routing.tasks.inherit": "by complexity (no rule)",
"routing.tasks.share": "{p}% of requests (7d)",
"routing.tasks.fromRouter": "labels live from the router",
"routing.tasks.builtin": "router unreachable — built-in label set",
"routing.tasks.addCustom": "Add task",
"routing.tasks.addPlaceholder": "custom label…",
};
+18
View File
@@ -545,4 +545,22 @@ export default {
"bench.running": "midiendo…",
"bench.failed": "falló",
"bench.none": "Aún no hay modelos de chat .cmf locales.",
// task-based routing
"task.chitchat": "Charla",
"task.code": "Código",
"task.creative-writing": "Escritura creativa",
"task.extraction": "Extracción de datos",
"task.math": "Matemáticas",
"task.qa": "Preguntas y respuestas",
"task.summarization": "Resumen",
"task.translation": "Traducción",
"routing.tasks.title": "Enrutado por tarea",
"routing.tasks.hint": "Los modelos de una regla van primero para su tarea; la cadena por complejidad sigue como respaldo. Vacío = solo por complejidad.",
"routing.tasks.inherit": "por complejidad (sin regla)",
"routing.tasks.share": "{p}% de las peticiones (7 d)",
"routing.tasks.fromRouter": "etiquetas en vivo del router",
"routing.tasks.builtin": "router inaccesible — conjunto integrado",
"routing.tasks.addCustom": "Añadir tarea",
"routing.tasks.addPlaceholder": "etiqueta propia…",
};
+18
View File
@@ -545,4 +545,22 @@ export default {
"bench.running": "mesure…",
"bench.failed": "échec",
"bench.none": "Pas encore de modèles de chat .cmf locaux.",
// task-based routing
"task.chitchat": "Bavardage",
"task.code": "Code",
"task.creative-writing": "Écriture créative",
"task.extraction": "Extraction de données",
"task.math": "Mathématiques",
"task.qa": "Questions-réponses",
"task.summarization": "Résumé",
"task.translation": "Traduction",
"routing.tasks.title": "Routage par tâche",
"routing.tasks.hint": "Les modèles d'une règle passent en premier pour leur tâche ; la chaîne par complexité reste en secours. Vide = complexité seule.",
"routing.tasks.inherit": "par complexité (aucune règle)",
"routing.tasks.share": "{p}% des requêtes (7 j)",
"routing.tasks.fromRouter": "étiquettes reçues du routeur",
"routing.tasks.builtin": "routeur injoignable — jeu d'étiquettes intégré",
"routing.tasks.addCustom": "Ajouter une tâche",
"routing.tasks.addPlaceholder": "étiquette personnalisée…",
};
+18
View File
@@ -537,4 +537,22 @@ export default {
"bench.running": "замер…",
"bench.failed": "ошибка",
"bench.none": "Локальных чат-моделей .cmf пока нет.",
// task-based routing
"task.chitchat": "Светская беседа",
"task.code": "Код",
"task.creative-writing": "Творческие тексты",
"task.extraction": "Извлечение данных",
"task.math": "Математика",
"task.qa": "Вопросы и ответы",
"task.summarization": "Суммаризация",
"task.translation": "Перевод",
"routing.tasks.title": "Распределение по задачам",
"routing.tasks.hint": "Модели правила идут первыми для своей задачи; цепочка по сложности остаётся резервом. Пусто = только по сложности.",
"routing.tasks.inherit": "по сложности (без правила)",
"routing.tasks.share": "{p}% запросов (7д)",
"routing.tasks.fromRouter": "метки получены с роутера",
"routing.tasks.builtin": "роутер недоступен — встроенный набор меток",
"routing.tasks.addCustom": "Добавить задачу",
"routing.tasks.addPlaceholder": "своя метка…",
};
+18
View File
@@ -545,4 +545,22 @@ export default {
"bench.running": "ölçülüyor…",
"bench.failed": "başarısız",
"bench.none": "Henüz yerel .cmf sohbet modeli yok.",
// task-based routing
"task.chitchat": "Sohbet",
"task.code": "Kod",
"task.creative-writing": "Yaratıcı yazarlık",
"task.extraction": "Veri çıkarma",
"task.math": "Matematik",
"task.qa": "Soru-cevap",
"task.summarization": "Özetleme",
"task.translation": "Çeviri",
"routing.tasks.title": "Göreve göre yönlendirme",
"routing.tasks.hint": "Kuralın modelleri kendi görevi için önce gelir; karmaşıklık zinciri yedek olarak kalır. Boş = yalnızca karmaşıklığa göre.",
"routing.tasks.inherit": "karmaşıklığa göre (kural yok)",
"routing.tasks.share": "isteklerin %{p}'i (7 g)",
"routing.tasks.fromRouter": "etiketler yönlendiriciden canlı",
"routing.tasks.builtin": "yönlendiriciye ulaşılamıyor — yerleşik etiket seti",
"routing.tasks.addCustom": "Görev ekle",
"routing.tasks.addPlaceholder": "özel etiket…",
};
+18
View File
@@ -545,4 +545,22 @@ export default {
"bench.running": "测速中……",
"bench.failed": "失败",
"bench.none": "还没有本地 .cmf 聊天模型。",
// task-based routing
"task.chitchat": "闲聊",
"task.code": "代码",
"task.creative-writing": "创意写作",
"task.extraction": "数据提取",
"task.math": "数学",
"task.qa": "问答",
"task.summarization": "摘要",
"task.translation": "翻译",
"routing.tasks.title": "按任务路由",
"routing.tasks.hint": "规则中的模型对该任务优先;下方的复杂度链仍作为回退。留空 = 仅按复杂度。",
"routing.tasks.inherit": "按复杂度(无规则)",
"routing.tasks.share": "{p}% 的请求(7 天)",
"routing.tasks.fromRouter": "标签实时来自路由器",
"routing.tasks.builtin": "路由器不可达 — 使用内置标签集",
"routing.tasks.addCustom": "添加任务",
"routing.tasks.addPlaceholder": "自定义标签……",
};
+117 -30
View File
@@ -4,71 +4,155 @@ import { t } from "../i18n.js";
import { api } from "../api.js";
import { appState } from "../app.js";
// Known labels of the hosted taxonomy — used for localized names/descriptions
// and as the offline fallback; the live list comes from /routing/labels.
const TASK_META = {
chitchat: { icon: "💬" },
code: { icon: "⌨" },
"creative-writing": { icon: "✍" },
extraction: { icon: "📑" },
math: { icon: "∑" },
qa: { icon: "❓" },
summarization: { icon: "📋" },
translation: { icon: "🌍" },
};
export async function renderRouting() {
const meta = appState.meta || (await api.meta());
const [routing, modelsData] = await Promise.all([api.getRouting(), api.listModels()]);
const ids = (modelsData.models || []).map((m) => m.id);
// Task labels: live from the router when reachable, built-in set otherwise;
// labels that already have a rule are always shown (custom taxonomies).
let taskLabels = Object.keys(TASK_META);
let labelsSource = "builtin";
try {
const r = await api.routingLabels();
if (r.labels && r.labels.length) {
taskLabels = r.labels;
labelsSource = "router";
}
} catch {
/* offline — builtin list */
}
for (const k of Object.keys(routing.tasks || {})) {
if (!taskLabels.includes(k)) taskLabels.push(k);
}
// Share of recent traffic per task — shows which rules actually matter.
let taskShare = {};
try {
const stat = await api.stats("7d", "label");
const total = (stat.breakdown || []).reduce((a, b) => a + (b.requests || 0), 0);
if (total > 0) {
for (const b of stat.breakdown || []) taskShare[b.key] = (b.requests || 0) / total;
}
} catch {
/* no stats yet */
}
// local editable state — ensure standard tiers exist for a friendly editor
const tiers = Object.assign({ low: [], medium: [], high: [] }, routing.tiers || {});
const tasks = {};
for (const l of taskLabels) tasks[l] = (routing.tasks || {})[l] || [];
const st = {
tiers,
tasks,
default: routing.default || ids[0] || "",
policy: routing.policy || { mode: "fixed_table", min_class: {} },
};
const root = h("div");
function moveChip(tier, i, dir) {
const arr = st.tiers[tier];
function moveChip(map, key, i, dir) {
const arr = map[key];
const ni = i + dir;
if (ni < 0 || ni >= arr.length) return;
[arr[i], arr[ni]] = [arr[ni], arr[i]];
render();
}
function removeChip(tier, i) {
st.tiers[tier].splice(i, 1);
function removeChip(map, key, i) {
map[key].splice(i, 1);
render();
}
function addChip(tier, id) {
if (id && !st.tiers[tier].includes(id)) st.tiers[tier].push(id);
function addChip(map, key, id) {
if (id && !map[key].includes(id)) map[key].push(id);
render();
}
function tierRow(tier) {
const arr = st.tiers[tier];
function chipEditor(map, key, emptyLabel) {
const arr = map[key];
const avail = ids.filter((id) => !arr.includes(id));
const addSel = h(
"select",
{
style: "width:auto",
onChange: (e) => {
addChip(tier, e.target.value);
},
},
{ style: "width:auto", onChange: (e) => addChip(map, key, e.target.value) },
h("option", { value: "" }, t("routing.addModel")),
...avail.map((id) => h("option", { value: id }, id))
);
return h(
"div",
{ class: "chips" },
arr.length === 0 && emptyLabel ? h("span", { class: "muted small chips-empty" }, emptyLabel) : null,
...arr.map((id, i) =>
h(
"div",
{ class: "chip" },
h("span", { class: "ord" }, i + 1),
id,
h("span", { class: "arrow", title: "←", onClick: () => moveChip(map, key, i, -1) }, "◄"),
h("span", { class: "arrow", title: "→", onClick: () => moveChip(map, key, i, 1) }, "►"),
h("span", { class: "x", onClick: () => removeChip(map, key, i) }, "×")
)
),
avail.length ? addSel : null
);
}
function tierRow(tier) {
return h(
"div",
{ class: "tierrow" },
h("div", { class: "tname" }, h("span", { class: "badge tier-" + tier }, tier)),
chipEditor(st.tiers, tier)
);
}
function taskRow(label) {
const known = TASK_META[label];
const name = known ? t("task." + label) : label;
const share = taskShare[label];
return h(
"div",
{ class: "tierrow taskrow" },
h(
"div",
{ class: "chips" },
...arr.map((id, i) =>
h(
"div",
{ class: "chip" },
h("span", { class: "ord" }, i + 1),
id,
h("span", { class: "arrow", title: "←", onClick: () => moveChip(tier, i, -1) }, "◄"),
h("span", { class: "arrow", title: "→", onClick: () => moveChip(tier, i, 1) }, "►"),
h("span", { class: "x", onClick: () => removeChip(tier, i) }, "×")
)
),
avail.length ? addSel : null
)
{ class: "tname taskname" },
h("div", {}, (known ? known.icon + " " : "") + name),
h("div", { class: "muted small mono" }, label),
share != null ? h("div", { class: "muted small" }, t("routing.tasks.share", { p: Math.round(share * 100) })) : null
),
chipEditor(st.tasks, label, t("routing.tasks.inherit"))
);
}
function tasksCard() {
const addIn = h("input", { placeholder: t("routing.tasks.addPlaceholder"), style: "max-width:220px" });
const addBtn = h("button", { class: "btn ghost sm" }, "+ " + t("routing.tasks.addCustom"));
addBtn.addEventListener("click", () => {
const v = addIn.value.trim().toLowerCase();
if (!v || st.tasks[v]) return;
st.tasks[v] = [];
render();
});
return h(
"div",
{ class: "card" },
h("div", { class: "card-head" },
h("h3", {}, "🎯 " + t("routing.tasks.title")),
h("span", { class: "card-head sub" },
labelsSource === "router" ? t("routing.tasks.fromRouter") : t("routing.tasks.builtin"))),
h("div", { class: "muted small", style: "margin-bottom:10px" }, t("routing.tasks.hint")),
...Object.keys(st.tasks).map(taskRow),
h("div", { class: "flex", style: "gap:8px; margin-top:10px" }, addIn, addBtn)
);
}
@@ -116,7 +200,9 @@ export async function renderRouting() {
saveBtn.addEventListener("click", async () => {
saveBtn.disabled = true;
try {
await api.putRouting({ tiers: st.tiers, default: st.default, policy: st.policy });
const tasks = {};
for (const [k, v] of Object.entries(st.tasks)) if (v.length) tasks[k] = v;
await api.putRouting({ tiers: st.tiers, tasks, default: st.default, policy: st.policy });
toast(t("toast.saved"), "good");
} catch (e) {
toast(String(e.message || e), "bad");
@@ -143,6 +229,7 @@ export async function renderRouting() {
h("div", { class: "card-head" }, h("h3", {}, t("routing.tiers")), h("span", { class: "card-head sub" }, t("routing.tierHint"))),
...Object.keys(st.tiers).map(tierRow)
),
tasksCard(),
h(
"div",
{ class: "card" },