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:
@@ -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]
|
||||
|
||||
@@ -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"`.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -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;
|
||||
|
||||
Vendored
+1
@@ -68,6 +68,7 @@ export const api = {
|
||||
|
||||
// routing
|
||||
getRouting: () => j("GET", "/routing"),
|
||||
routingLabels: () => j("GET", "/routing/labels"),
|
||||
putRouting: (b) => j("PUT", "/routing", b),
|
||||
|
||||
// protocols
|
||||
|
||||
Vendored
+18
@@ -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…",
|
||||
};
|
||||
|
||||
Vendored
+18
@@ -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…",
|
||||
};
|
||||
|
||||
Vendored
+18
@@ -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…",
|
||||
};
|
||||
|
||||
Vendored
+18
@@ -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…",
|
||||
};
|
||||
|
||||
Vendored
+18
@@ -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": "своя метка…",
|
||||
};
|
||||
|
||||
Vendored
+18
@@ -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…",
|
||||
};
|
||||
|
||||
Vendored
+18
@@ -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": "自定义标签……",
|
||||
};
|
||||
|
||||
Vendored
+117
-30
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user