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.
140 lines
4.9 KiB
Rust
140 lines
4.9 KiB
Rust
//! Client for cortiq-router. Issues `POST /v1/route` and (optionally) `POST /v1/feedback`.
|
|
//! Returns the decision as a [`RouteDecision`]. When the router is unavailable the caller
|
|
//! falls back to `routing.default` (graceful degradation).
|
|
|
|
use crate::config::RouterCfg;
|
|
use crate::model::RouteDecision;
|
|
use crate::secrets::SecretStore;
|
|
use std::sync::atomic::{AtomicU16, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
/// Outcome of the most recent real `/v1/route` call, shared with the admin health
|
|
/// endpoint so the panel can tell "no money / bad key" apart from "router down".
|
|
/// 0 = ok (or no calls yet); [`STATUS_NETWORK`] / [`STATUS_TIMEOUT`] for transport
|
|
/// failures; otherwise the upstream HTTP status code.
|
|
pub type RouterLastStatus = Arc<AtomicU16>;
|
|
|
|
pub const STATUS_NETWORK: u16 = 1;
|
|
pub const STATUS_TIMEOUT: u16 = 2;
|
|
|
|
/// Error kind for the admin panel.
|
|
pub fn classify_status(code: u16) -> &'static str {
|
|
match code {
|
|
STATUS_NETWORK => "unreachable",
|
|
STATUS_TIMEOUT => "timeout",
|
|
401 | 403 => "auth",
|
|
402 => "payment",
|
|
429 => "quota",
|
|
_ => "error",
|
|
}
|
|
}
|
|
|
|
pub struct RouterClient {
|
|
http: reqwest::Client,
|
|
url: String,
|
|
api_key: Option<String>,
|
|
taxonomy_id: Option<String>,
|
|
last_status: RouterLastStatus,
|
|
}
|
|
|
|
impl RouterClient {
|
|
/// The key is resolved through the secret store (admin panel value first,
|
|
/// then the environment variable) so a key pasted in the console works
|
|
/// without a restart.
|
|
pub fn new(
|
|
cfg: &RouterCfg,
|
|
secrets: &SecretStore,
|
|
last_status: RouterLastStatus,
|
|
) -> anyhow::Result<Self> {
|
|
let mut builder =
|
|
reqwest::Client::builder().timeout(std::time::Duration::from_millis(cfg.timeout_ms));
|
|
|
|
if !cfg.verify_tls {
|
|
builder = builder
|
|
.danger_accept_invalid_certs(true)
|
|
.danger_accept_invalid_hostnames(true);
|
|
}
|
|
|
|
let http = builder.build()?;
|
|
let api_key = cfg
|
|
.api_key_env
|
|
.as_ref()
|
|
.and_then(|name| secrets.resolve(name));
|
|
Ok(Self {
|
|
http,
|
|
url: cfg.url.trim_end_matches('/').to_string(),
|
|
api_key,
|
|
taxonomy_id: cfg.taxonomy_id.clone(),
|
|
last_status,
|
|
})
|
|
}
|
|
|
|
/// 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 },
|
|
"options": { "policy_profile": profile, "allow_oracle": true }
|
|
});
|
|
if let Some(tax) = &self.taxonomy_id {
|
|
body["taxonomy_id"] = serde_json::Value::String(tax.clone());
|
|
}
|
|
|
|
let mut req = self.http.post(format!("{}/v1/route", self.url)).json(&body);
|
|
if let Some(key) = &self.api_key {
|
|
req = req.bearer_auth(key);
|
|
}
|
|
|
|
let resp = match req.send().await {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
let code = if e.is_timeout() {
|
|
STATUS_TIMEOUT
|
|
} else {
|
|
STATUS_NETWORK
|
|
};
|
|
self.last_status.store(code, Ordering::Relaxed);
|
|
return Ok(None); // degradation
|
|
}
|
|
};
|
|
if !resp.status().is_success() {
|
|
self.last_status
|
|
.store(resp.status().as_u16(), Ordering::Relaxed);
|
|
return Ok(None);
|
|
}
|
|
self.last_status.store(0, Ordering::Relaxed);
|
|
let v: serde_json::Value = resp.json().await?;
|
|
let d = &v["decision"];
|
|
Ok(Some(RouteDecision {
|
|
task_label: d["task_label"]
|
|
.as_str()
|
|
.unwrap_or("__unknown__")
|
|
.to_string(),
|
|
complexity_score: d["complexity"]["score"].as_f64().unwrap_or(0.5) as f32,
|
|
complexity_tier: d["complexity"]["tier"]
|
|
.as_str()
|
|
.unwrap_or("medium")
|
|
.to_string(),
|
|
router_request_id: v["request_id"].as_str().map(|s| s.to_string()),
|
|
source: "router".into(),
|
|
}))
|
|
}
|
|
}
|