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:
+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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user