Files
cortiq-gateway/src/router_client.rs
T
infosave2007 2ede0ee452 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.
2026-08-07 17:39:38 +03:00

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(),
}))
}
}