feat: universal LLM gateway with embedded multilingual admin console

- OpenAI-compatible gateway with intelligent routing via cortiq-router (allaigate)
- Embedded build-free admin SPA at /admin: 7 languages (en/ru/de/fr/es/zh/tr),
  light/dark themes, hot config reload (no restart)
- Admin API, per-request statistics + Prometheus /metrics, secret store, playground
- English documentation (primary) with Russian translations (*.ru.md)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
infosave2007
2026-06-30 10:51:37 +03:00
co-authored by Claude Opus 4.8
commit 51487e6329
63 changed files with 11339 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
//! Embedded static assets for the admin panel (SPA). The `web/` directory is compiled
//! into the binary via `rust-embed`, so the gateway remains a single self-contained file.
//!
//! Served as the main router's fallback: everything under `/admin/*` not matched by
//! an explicit route is served as a file; an unknown path under `/admin` falls back to
//! `index.html` (the client-side hash router handles navigation).
use axum::{
http::{header, StatusCode, Uri},
response::{IntoResponse, Response},
};
use rust_embed::RustEmbed;
#[derive(RustEmbed)]
#[folder = "web/"]
struct WebAssets;
fn serve_file(path: &str) -> Response {
match WebAssets::get(path) {
Some(content) => {
let mime = mime_guess::from_path(path).first_or_octet_stream();
(
[(header::CONTENT_TYPE, mime.as_ref())],
content.data.into_owned(),
)
.into_response()
}
None => {
// SPA: unknown path under /admin → serve index.html
match WebAssets::get("index.html") {
Some(content) => (
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
content.data.into_owned(),
)
.into_response(),
None => (StatusCode::NOT_FOUND, "not found").into_response(),
}
}
}
}
/// Application-wide fallback: serves `/admin/*`; everything else → 404 JSON.
pub async fn fallback(uri: Uri) -> Response {
let path = uri.path();
if let Some(rest) = path.strip_prefix("/admin") {
let rest = rest.trim_start_matches('/');
let file = if rest.is_empty() { "index.html" } else { rest };
serve_file(file)
} else {
(
StatusCode::NOT_FOUND,
axum::Json(serde_json::json!({
"error": { "message": "not found", "type": "not_found", "code": "not_found" }
})),
)
.into_response()
}
}
+756
View File
@@ -0,0 +1,756 @@
//! Admin API for the web management panel (`/admin/api/*`) + SPA serving (`/admin/*`).
//!
//! All endpoints under `/admin/api` are protected by a Bearer admin token. Config changes
//! go through `AppState::reload` (validation → build `Live` → atomic TOML write
//! → swap), so they are applied without a restart. Secrets are never returned —
//! only their presence status.
pub mod assets;
use crate::config::{
AdminCfg, ApiKeyCfg, BreakerCfg, Config, CortiqCfg, LogCfg, ModelCfg, ProtocolsCfg, RouteCfg,
RouterCfg, RoutingCfg, RoutingPolicy, StatsCfg, TelemetryCfg, TierTargets,
};
use crate::model::{ChatRequest, GenParams, Message, RequestMeta, RoutingDirective};
use crate::state::SharedState;
use crate::stats::parse_duration_secs;
use axum::{
extract::{Path, Query, Request, State},
http::{header, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::time::Instant;
// ───────────────────────────── common types / helpers ───────────────────────
type ApiResult = Result<Json<Value>, ApiError>;
pub struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn bad(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: msg.into(),
}
}
fn not_found(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: msg.into(),
}
}
}
impl From<anyhow::Error> for ApiError {
fn from(e: anyhow::Error) -> Self {
Self::bad(e.to_string())
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.status,
Json(json!({"error": {"message": self.message, "type": "admin_error"}})),
)
.into_response()
}
}
fn ok(v: Value) -> ApiResult {
Ok(Json(v))
}
fn current_cfg(state: &SharedState) -> Config {
state.live().cfg.clone()
}
/// Random hex token from `n_bytes` bytes of cryptographic randomness.
pub fn random_token(n_bytes: usize) -> String {
let mut buf = vec![0u8; n_bytes];
if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
use std::io::Read;
let _ = f.read_exact(&mut buf);
} else {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
for (i, b) in buf.iter_mut().enumerate() {
*b = ((t >> ((i % 16) * 8)) as u8) ^ (i as u8).wrapping_mul(31);
}
}
buf.iter().map(|b| format!("{b:02x}")).collect()
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn mask_key(key: &str) -> String {
let n = key.chars().count();
if n <= 8 {
return "•".repeat(n.max(3));
}
let head: String = key.chars().take(6).collect();
let tail: String = key.chars().skip(n - 4).collect();
format!("{head}…{tail}")
}
fn tiers_to_map(routing: &RoutingCfg) -> HashMap<String, Vec<String>> {
routing
.tiers
.iter()
.map(|(k, v)| {
let list = match v {
TierTargets::List(l) => l.clone(),
TierTargets::One(s) => vec![s.clone()],
};
(k.clone(), list)
})
.collect()
}
fn parse_routing(model: &str) -> RoutingDirective {
if let Some(rest) = model.strip_prefix("cortiq-auto") {
let profile = rest.strip_prefix(':').map(|p| p.to_string());
RoutingDirective::Auto { profile }
} else {
RoutingDirective::Pinned {
model_id: model.to_string(),
}
}
}
// ───────────────────────────── router ───────────────────────────────────────
/// Admin API routes (token-protected). Mounted into the main router.
/// The token is captured in a middleware closure so it does not depend on State.
pub fn api_routes(admin_token: String) -> Router<SharedState> {
Router::new()
.route("/admin/api/health", get(health))
.route("/admin/api/meta", get(meta))
.route("/admin/api/config", get(get_config).put(put_config))
.route("/admin/api/models", get(list_models).post(create_model))
.route(
"/admin/api/models/:id",
axum::routing::put(update_model).delete(delete_model),
)
.route("/admin/api/models/:id/probe", post(probe_model))
.route("/admin/api/routing", get(get_routing).put(put_routing))
.route(
"/admin/api/protocols",
get(get_protocols).put(put_protocols),
)
.route("/admin/api/settings", get(get_settings).put(put_settings))
.route("/admin/api/keys", get(list_keys).post(create_key))
.route("/admin/api/keys/:key", axum::routing::delete(delete_key))
.route(
"/admin/api/secrets",
get(list_secrets).put(set_secret).delete(clear_secret),
)
.route("/admin/api/stats", get(get_stats))
.route("/admin/api/requests", get(get_requests))
.route("/admin/api/test", post(run_test))
.route_layer(middleware::from_fn(move |req: Request, next: Next| {
let expected = admin_token.clone();
async move { auth(expected, req, next).await }
}))
}
/// Extract Bearer admin token from the `Authorization` header.
fn bearer_token(req: &Request) -> String {
req.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.map(|v| {
v.strip_prefix("Bearer ")
.or_else(|| v.strip_prefix("bearer "))
.unwrap_or(v)
.trim()
})
.unwrap_or("")
.to_string()
}
/// Admin authentication middleware (constant-time comparison).
async fn auth(expected: String, req: Request, next: Next) -> Response {
let token = bearer_token(&req);
if !expected.is_empty() && constant_time_eq(token.as_bytes(), expected.as_bytes()) {
next.run(req).await
} else {
(
StatusCode::UNAUTHORIZED,
Json(json!({"error": {"message": "invalid or missing admin token", "type": "authentication_error", "code": "unauthorized"}})),
)
.into_response()
}
}
// ───────────────────────────── metrics ──────────────────────────────────────
/// `GET /metrics` (Prometheus). Exposed when `telemetry.metrics = true`.
pub async fn metrics(State(state): State<SharedState>) -> Response {
let live = state.live();
if !live.cfg.telemetry.metrics {
return (StatusCode::NOT_FOUND, "metrics disabled").into_response();
}
let body = state.stats.prometheus();
([(header::CONTENT_TYPE, "text/plain; version=0.0.4")], body).into_response()
}
// ───────────────────────────── health / meta ───────────────────────────────
async fn probe_router(url: &str) -> (bool, u64) {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(1500))
.danger_accept_invalid_certs(true)
.build()
{
Ok(c) => c,
Err(_) => return (false, 0),
};
let started = Instant::now();
let target = format!("{}/healthz", url.trim_end_matches('/'));
let ok = client
.get(&target)
.send()
.await
.map(|r| r.status().as_u16() < 500)
.unwrap_or(false);
(ok, started.elapsed().as_millis() as u64)
}
async fn health(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
let (router_ok, router_ms) = probe_router(&live.cfg.router.url).await;
let models: Vec<Value> = live
.cfg
.models
.iter()
.map(|m| {
let key = match &m.api_key_env {
Some(env) => state.secrets.source(env),
None => "none",
};
json!({
"id": m.id,
"provider": m.provider,
"model": m.model,
"kind": m.kind,
"cost_tier": m.cost_tier,
"key_source": key,
"in_registry": live.registry.get(&m.id).is_some(),
})
})
.collect();
ok(json!({
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
"listen": live.cfg.listen,
"router": { "url": live.cfg.router.url, "reachable": router_ok, "latency_ms": router_ms },
"models": models,
}))
}
async fn meta() -> ApiResult {
ok(json!({
"version": env!("CARGO_PKG_VERSION"),
"providers": ["openai", "anthropic", "ollama", "http"],
"provider_impl": { "openai": true, "ollama": true, "http": true, "anthropic": false },
"cost_tiers": ["cheap", "mid", "expensive"],
"kinds": ["chat", "embedding"],
"profiles": ["cost-saver", "balanced", "quality-first"],
"text_strategies": ["last_user", "last_user_plus_system", "concat_all"],
"policy_modes": ["fixed_table", "cost_aware"],
"tiers": ["low", "medium", "high"],
"caps": ["tools", "vision"],
"protocols_impl": {
"openai_chat": true, "openai_completions": false, "openai_embeddings": false,
"openai_models": false, "anthropic_messages": false, "mcp": false, "native_passthrough": false
},
"languages": ["en", "ru", "de", "fr", "es", "zh", "tr"],
}))
}
// ───────────────────────────── config ──────────────────────────────────────
async fn get_config(State(state): State<SharedState>) -> ApiResult {
let cfg = current_cfg(&state);
ok(serde_json::to_value(&cfg).map_err(|e| ApiError::bad(e.to_string()))?)
}
async fn put_config(State(state): State<SharedState>, Json(cfg): Json<Config>) -> ApiResult {
state.reload(cfg)?;
ok(json!({ "ok": true }))
}
// ───────────────────────────── models ──────────────────────────────────────
async fn list_models(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
let models: Vec<Value> = live
.cfg
.models
.iter()
.map(|m| {
let mut v = serde_json::to_value(m).unwrap_or(json!({}));
let key_source = match &m.api_key_env {
Some(env) => state.secrets.source(env),
None => "none",
};
v["key_source"] = json!(key_source);
v
})
.collect();
ok(json!({ "models": models }))
}
async fn create_model(State(state): State<SharedState>, Json(m): Json<ModelCfg>) -> ApiResult {
let mut cfg = current_cfg(&state);
if cfg.models.iter().any(|x| x.id == m.id) {
return Err(ApiError::bad(format!("model id '{}' already exists", m.id)));
}
cfg.models.push(m);
state.reload(cfg)?;
ok(json!({ "ok": true }))
}
async fn update_model(
State(state): State<SharedState>,
Path(id): Path<String>,
Json(m): Json<ModelCfg>,
) -> ApiResult {
let mut cfg = current_cfg(&state);
let idx = cfg
.models
.iter()
.position(|x| x.id == id)
.ok_or_else(|| ApiError::not_found(format!("model '{id}' not found")))?;
// if the id was renamed — must not conflict with an existing one
if m.id != id && cfg.models.iter().any(|x| x.id == m.id) {
return Err(ApiError::bad(format!("model id '{}' already exists", m.id)));
}
cfg.models[idx] = m;
state.reload(cfg)?;
ok(json!({ "ok": true }))
}
async fn delete_model(State(state): State<SharedState>, Path(id): Path<String>) -> ApiResult {
let mut cfg = current_cfg(&state);
let before = cfg.models.len();
cfg.models.retain(|x| x.id != id);
if cfg.models.len() == before {
return Err(ApiError::not_found(format!("model '{id}' not found")));
}
state.reload(cfg)?;
ok(json!({ "ok": true }))
}
async fn probe_model(State(state): State<SharedState>, Path(id): Path<String>) -> ApiResult {
let live = state.live();
let mcfg = live
.cfg
.models
.iter()
.find(|m| m.id == id)
.ok_or_else(|| ApiError::not_found(format!("model '{id}' not found")))?
.clone();
if mcfg.provider == "anthropic" {
return ok(json!({
"ok": false,
"error": "anthropic provider not yet implemented (planned v0.2)",
}));
}
let provider = live
.registry
.get(&id)
.ok_or_else(|| ApiError::bad(format!("model '{id}' has no runtime provider")))?;
let req = ChatRequest {
routing: RoutingDirective::Pinned {
model_id: id.clone(),
},
messages: vec![Message {
role: "user".into(),
content: "ping".into(),
tool_calls: vec![],
}],
tools: vec![],
params: GenParams {
temperature: Some(0.0),
max_tokens: Some(1),
..Default::default()
},
stream: false,
meta: RequestMeta {
protocol: "probe".into(),
..Default::default()
},
};
let started = Instant::now();
let res = provider.chat(req).await;
let latency_ms = started.elapsed().as_millis() as u64;
match res {
Ok(_) => ok(json!({ "ok": true, "latency_ms": latency_ms })),
Err(e) => ok(json!({ "ok": false, "latency_ms": latency_ms, "error": e.to_string() })),
}
}
// ───────────────────────────── routing ─────────────────────────────────────
async fn get_routing(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
ok(json!({
"tiers": tiers_to_map(&live.cfg.routing),
"default": live.cfg.routing.default,
"policy": live.cfg.routing.policy,
}))
}
#[derive(Deserialize)]
struct RoutingBody {
#[serde(default)]
tiers: HashMap<String, Vec<String>>,
default: String,
#[serde(default)]
policy: RoutingPolicy,
}
async fn put_routing(State(state): State<SharedState>, Json(body): Json<RoutingBody>) -> ApiResult {
let mut cfg = current_cfg(&state);
cfg.routing = RoutingCfg {
tiers: body
.tiers
.into_iter()
.map(|(k, v)| (k, TierTargets::List(v)))
.collect(),
default: body.default,
policy: body.policy,
};
state.reload(cfg)?;
ok(json!({ "ok": true }))
}
// ───────────────────────────── protocols ───────────────────────────────────
async fn get_protocols(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
ok(serde_json::to_value(&live.cfg.protocols).unwrap_or(json!({})))
}
async fn put_protocols(State(state): State<SharedState>, Json(p): Json<ProtocolsCfg>) -> ApiResult {
let mut cfg = current_cfg(&state);
cfg.protocols = p;
state.reload(cfg)?;
ok(json!({ "ok": true }))
}
// ───────────────────────────── settings ────────────────────────────────────
async fn get_settings(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
let c = &live.cfg;
// admin section without token value (not stored here — only the env name)
ok(json!({
"listen": c.listen,
"router": c.router,
"route": c.route,
"breaker": c.breaker,
"log": c.log,
"telemetry": c.telemetry,
"cortiq": c.cortiq,
"stats": c.stats,
"admin": c.admin,
}))
}
#[derive(Deserialize)]
struct SettingsBody {
listen: Option<String>,
router: Option<RouterCfg>,
route: Option<RouteCfg>,
breaker: Option<BreakerCfg>,
log: Option<LogCfg>,
telemetry: Option<TelemetryCfg>,
cortiq: Option<CortiqCfg>,
stats: Option<StatsCfg>,
admin: Option<AdminCfg>,
}
async fn put_settings(State(state): State<SharedState>, Json(b): Json<SettingsBody>) -> ApiResult {
let mut cfg = current_cfg(&state);
let mut needs_restart = false;
if let Some(v) = b.listen {
if v != cfg.listen {
needs_restart = true;
}
cfg.listen = v;
}
if let Some(v) = b.router {
cfg.router = v;
}
if let Some(v) = b.route {
cfg.route = v;
}
if let Some(v) = b.breaker {
cfg.breaker = v;
}
if let Some(v) = b.log {
cfg.log = v;
}
if let Some(v) = b.telemetry {
cfg.telemetry = v;
}
if let Some(v) = b.cortiq {
cfg.cortiq = v;
}
if let Some(v) = b.stats {
cfg.stats = v;
}
if let Some(v) = b.admin {
if v.listen != cfg.admin.listen {
needs_restart = true;
}
cfg.admin = v;
}
state.reload(cfg)?;
ok(json!({ "ok": true, "needs_restart": needs_restart }))
}
// ───────────────────────────── keys ────────────────────────────────────────
async fn list_keys(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
let keys: Vec<Value> = live
.cfg
.api_keys
.iter()
.map(|k| {
json!({
"key": k.key,
"key_masked": mask_key(&k.key),
"account": k.account,
"rate_per_min": k.rate_per_min,
"allow_models": k.allow_models,
})
})
.collect();
ok(json!({ "keys": keys, "open_mode": live.cfg.api_keys.is_empty() }))
}
#[derive(Deserialize)]
struct NewKey {
#[serde(default)]
key: String,
account: String,
#[serde(default)]
rate_per_min: u32,
#[serde(default)]
allow_models: Vec<String>,
}
async fn create_key(State(state): State<SharedState>, Json(b): Json<NewKey>) -> ApiResult {
let key = if b.key.trim().is_empty() {
format!("sk-gw-{}", random_token(18))
} else {
b.key.trim().to_string()
};
let mut cfg = current_cfg(&state);
if cfg.api_keys.iter().any(|k| k.key == key) {
return Err(ApiError::bad("key already exists"));
}
cfg.api_keys.push(ApiKeyCfg {
key: key.clone(),
account: b.account,
rate_per_min: b.rate_per_min,
allow_models: b.allow_models,
});
state.reload(cfg)?;
// show the full key only once
ok(json!({ "ok": true, "key": key }))
}
async fn delete_key(State(state): State<SharedState>, Path(key): Path<String>) -> ApiResult {
let mut cfg = current_cfg(&state);
let before = cfg.api_keys.len();
cfg.api_keys.retain(|k| k.key != key);
if cfg.api_keys.len() == before {
return Err(ApiError::not_found("key not found"));
}
state.reload(cfg)?;
ok(json!({ "ok": true }))
}
// ───────────────────────────── secrets ─────────────────────────────────────
async fn list_secrets(State(state): State<SharedState>) -> ApiResult {
let live = state.live();
// collect all env names referenced in the config
let mut names: Vec<String> = Vec::new();
for m in &live.cfg.models {
if let Some(env) = &m.api_key_env {
if !names.contains(env) {
names.push(env.clone());
}
}
}
if let Some(env) = &live.cfg.router.api_key_env {
if !names.contains(env) {
names.push(env.clone());
}
}
let secrets: Vec<Value> = names
.iter()
.map(|n| json!({ "name": n, "source": state.secrets.source(n) }))
.collect();
ok(json!({ "secrets": secrets }))
}
#[derive(Deserialize)]
struct SecretBody {
name: String,
value: String,
}
async fn set_secret(State(state): State<SharedState>, Json(b): Json<SecretBody>) -> ApiResult {
if b.name.trim().is_empty() {
return Err(ApiError::bad("secret name is required"));
}
state.secrets.set(b.name.trim(), &b.value)?;
// rebuild providers with the new key (without writing TOML)
state.rebuild()?;
ok(json!({ "ok": true }))
}
#[derive(Deserialize)]
struct NameQuery {
name: String,
}
async fn clear_secret(State(state): State<SharedState>, Query(q): Query<NameQuery>) -> ApiResult {
state.secrets.clear(q.name.trim())?;
state.rebuild()?;
ok(json!({ "ok": true }))
}
// ───────────────────────────── stats / requests ────────────────────────────
#[derive(Deserialize)]
struct StatsQuery {
#[serde(default)]
range: Option<String>,
#[serde(default)]
groupby: Option<String>,
}
async fn get_stats(State(state): State<SharedState>, Query(q): Query<StatsQuery>) -> ApiResult {
let range_secs = q
.range
.as_deref()
.and_then(parse_duration_secs)
.unwrap_or(24 * 3600);
let groupby = q.groupby.as_deref().unwrap_or("model");
ok(state.stats.snapshot(range_secs, groupby))
}
#[derive(Deserialize)]
struct RecentQuery {
#[serde(default)]
limit: Option<usize>,
#[serde(default)]
offset: Option<usize>,
}
async fn get_requests(State(state): State<SharedState>, Query(q): Query<RecentQuery>) -> ApiResult {
let limit = q.limit.unwrap_or(50).min(500);
let offset = q.offset.unwrap_or(0);
let items = state.stats.recent(limit, offset);
ok(json!({ "requests": items }))
}
// ───────────────────────────── playground (test) ───────────────────────────
#[derive(Deserialize)]
struct TestBody {
#[serde(default = "default_test_model")]
model: String,
messages: Vec<Message>,
#[serde(default)]
temperature: Option<f32>,
#[serde(default)]
max_tokens: Option<u32>,
}
fn default_test_model() -> String {
"cortiq-auto".into()
}
async fn run_test(State(state): State<SharedState>, Json(b): Json<TestBody>) -> ApiResult {
if b.messages.is_empty() {
return Err(ApiError::bad("messages must not be empty"));
}
let req = ChatRequest {
routing: parse_routing(&b.model),
messages: b.messages,
tools: vec![],
params: GenParams {
temperature: b.temperature,
max_tokens: b.max_tokens,
..Default::default()
},
stream: false,
meta: RequestMeta {
account: "playground".into(),
protocol: "playground".into(),
..Default::default()
},
};
let started = Instant::now();
let res = state.pipeline.run(req, &state).await;
let latency_ms = started.elapsed().as_millis() as u64;
match res {
Ok(resp) => {
let answer = resp
.choices
.first()
.map(|c| c.message.content.clone())
.unwrap_or_default();
ok(json!({
"ok": true,
"latency_ms": latency_ms,
"answer": answer,
"model_used": resp.model_used,
"usage": {
"prompt_tokens": resp.usage.prompt_tokens,
"completion_tokens": resp.usage.completion_tokens,
"total_tokens": resp.usage.total_tokens,
},
"cortiq": {
"task_label": resp.cortiq.task_label,
"complexity": { "score": resp.cortiq.complexity_score, "tier": resp.cortiq.complexity_tier },
"selected_model": resp.cortiq.selected_model,
"route_source": resp.cortiq.route_source,
"router_request_id": resp.cortiq.router_request_id,
"cost_usd": resp.cortiq.cost_usd,
"failover": resp.cortiq.failover,
},
}))
}
Err(e) => ok(json!({ "ok": false, "latency_ms": latency_ms, "error": e.to_string() })),
}
}
+474
View File
@@ -0,0 +1,474 @@
//! Configuration loading from TOML. Structs mirror config/gateway.example.toml.
//! Secrets are read from environment variables by the names given in `*_env` fields.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Config {
pub listen: String,
pub router: RouterCfg,
#[serde(default)]
pub route: RouteCfg,
#[serde(default)]
pub models: Vec<ModelCfg>,
pub routing: RoutingCfg,
#[serde(default)]
pub breaker: BreakerCfg,
#[serde(default)]
pub protocols: ProtocolsCfg,
#[serde(default)]
pub cortiq: CortiqCfg,
#[serde(default)]
pub api_keys: Vec<ApiKeyCfg>,
#[serde(default)]
pub idempotency: IdempotencyCfg,
#[serde(default)]
pub log: LogCfg,
#[serde(default)]
pub telemetry: TelemetryCfg,
#[serde(default)]
pub admin: AdminCfg,
#[serde(default)]
pub stats: StatsCfg,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RouterCfg {
pub url: String,
#[serde(default = "default_router_timeout")]
pub timeout_ms: u64,
#[serde(default = "default_verify_tls")]
pub verify_tls: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub taxonomy_id: Option<String>,
}
fn default_router_timeout() -> u64 {
800
}
fn default_verify_tls() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RouteCfg {
#[serde(default = "default_text_strategy")]
pub text_strategy: String,
#[serde(default = "default_max_chars")]
pub max_chars: usize,
#[serde(default = "default_cache_ttl")]
pub cache_ttl: String,
#[serde(default = "default_profile")]
pub profile: String,
}
impl Default for RouteCfg {
fn default() -> Self {
Self {
text_strategy: default_text_strategy(),
max_chars: default_max_chars(),
cache_ttl: default_cache_ttl(),
profile: default_profile(),
}
}
}
fn default_text_strategy() -> String {
"last_user".into()
}
fn default_max_chars() -> usize {
4000
}
fn default_cache_ttl() -> String {
"60s".into()
}
fn default_profile() -> String {
"balanced".into()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ModelCfg {
pub id: String,
pub provider: String, // openai | anthropic | ollama | http
pub base_url: String,
pub model: String,
#[serde(default)]
pub cost_tier: String,
#[serde(default)]
pub price_in: f64,
#[serde(default)]
pub price_out: f64,
#[serde(default = "default_kind")]
pub kind: String, // chat | embedding
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub caps: Vec<String>,
}
fn default_kind() -> String {
"chat".into()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RoutingCfg {
#[serde(flatten)]
pub tiers: HashMap<String, TierTargets>,
pub default: String,
#[serde(default)]
pub policy: RoutingPolicy,
}
/// A routing tier is either a list of model_ids or the string `default`.
/// (`default` is handled by a separate field; only tier lists appear here.)
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TierTargets {
List(Vec<String>),
One(String),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RoutingPolicy {
#[serde(default = "default_mode")]
pub mode: String, // fixed_table | cost_aware
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_cost_usd_per_request: Option<f64>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub min_class: HashMap<String, String>,
}
impl Default for RoutingPolicy {
fn default() -> Self {
Self {
mode: default_mode(),
min_class: HashMap::new(),
max_cost_usd_per_request: None,
}
}
}
fn default_mode() -> String {
"fixed_table".into()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BreakerCfg {
#[serde(default = "default_breaker_threshold")]
pub threshold: u32,
#[serde(default = "default_breaker_cooldown")]
pub cooldown: String,
}
impl Default for BreakerCfg {
fn default() -> Self {
Self {
threshold: default_breaker_threshold(),
cooldown: default_breaker_cooldown(),
}
}
}
fn default_breaker_threshold() -> u32 {
5
}
fn default_breaker_cooldown() -> String {
"30s".into()
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct ProtocolsCfg {
#[serde(default)]
pub openai_chat: bool,
#[serde(default)]
pub openai_completions: bool,
#[serde(default)]
pub openai_embeddings: bool,
#[serde(default)]
pub openai_models: bool,
#[serde(default)]
pub anthropic_messages: bool,
#[serde(default)]
pub mcp: bool,
#[serde(default)]
pub native_passthrough: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CortiqCfg {
#[serde(default)]
pub echo: bool,
}
impl Default for CortiqCfg {
fn default() -> Self {
Self { echo: true }
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApiKeyCfg {
pub key: String,
pub account: String,
#[serde(default)]
pub rate_per_min: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow_models: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct IdempotencyCfg {
#[serde(default = "default_idem_ttl")]
pub ttl: String,
}
impl Default for IdempotencyCfg {
fn default() -> Self {
Self {
ttl: default_idem_ttl(),
}
}
}
fn default_idem_ttl() -> String {
"10m".into()
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LogCfg {
#[serde(default)]
pub bodies: bool,
#[serde(default = "default_log_level")]
pub level: String,
}
impl Default for LogCfg {
fn default() -> Self {
Self {
bodies: false,
level: default_log_level(),
}
}
}
fn default_log_level() -> String {
"info".into()
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct TelemetryCfg {
#[serde(default)]
pub metrics: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub otlp_endpoint_env: Option<String>,
}
/// Access to the web management panel and admin API.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AdminCfg {
/// Whether the admin panel and admin API (`/admin`, `/admin/api/*`) are enabled.
#[serde(default = "default_admin_enabled")]
pub enabled: bool,
/// Name of the env variable holding the admin token. If unset (or empty),
/// a token is generated at startup and printed to the log.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env: Option<String>,
/// Optional separate bind address for the admin surface (e.g. `127.0.0.1:9001`).
/// If unset, the admin interface lives on the main `listen` address under `/admin`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listen: Option<String>,
}
impl Default for AdminCfg {
fn default() -> Self {
Self {
enabled: default_admin_enabled(),
token_env: None,
listen: None,
}
}
}
fn default_admin_enabled() -> bool {
true
}
/// Request statistics tracking (for the dashboard and `/metrics`).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StatsCfg {
/// Whether to track request statistics.
#[serde(default = "default_stats_enabled")]
pub enabled: bool,
/// Append-only request log file (JSONL). Empty means in-memory only.
#[serde(default = "default_stats_file")]
pub file: String,
/// How many recent requests to keep in memory (the "recent" ring buffer).
#[serde(default = "default_stats_ring")]
pub ring_size: usize,
/// Retention window for aggregates/time-series replayed on startup, e.g. `7d`.
#[serde(default = "default_stats_retention")]
pub retention: String,
}
impl Default for StatsCfg {
fn default() -> Self {
Self {
enabled: default_stats_enabled(),
file: default_stats_file(),
ring_size: default_stats_ring(),
retention: default_stats_retention(),
}
}
}
fn default_stats_enabled() -> bool {
true
}
fn default_stats_file() -> String {
"config/stats.jsonl".into()
}
fn default_stats_ring() -> usize {
500
}
fn default_stats_retention() -> String {
"7d".into()
}
impl Config {
/// Load and validate the config from a file.
pub fn load(path: &str) -> anyhow::Result<Self> {
let raw = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("cannot read config {path}: {e}"))?;
let cfg: Config = toml::from_str(&raw)?;
cfg.validate()?;
Ok(cfg)
}
/// Integrity validation: non-empty model pool, valid routing targets.
pub fn validate(&self) -> anyhow::Result<()> {
if self.models.is_empty() {
anyhow::bail!("config: at least one [[models]] entry is required");
}
// uniqueness of model ids
let mut seen = std::collections::HashSet::new();
for m in &self.models {
if !seen.insert(m.id.as_str()) {
anyhow::bail!("config: duplicate model id '{}'", m.id);
}
}
// every target in [routing] must exist in the model pool
let ids: std::collections::HashSet<&str> =
self.models.iter().map(|m| m.id.as_str()).collect();
if !ids.contains(self.routing.default.as_str()) {
anyhow::bail!(
"config: routing.default '{}' is not a known model id",
self.routing.default
);
}
for (tier, targets) in &self.routing.tiers {
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.{} references unknown model id '{}'",
tier,
id
);
}
}
}
Ok(())
}
/// Serialize the config to a TOML string (no secrets — they are intentionally absent here).
pub fn to_toml_string(&self) -> anyhow::Result<String> {
let header = "# Cortiq Gateway — configuration (managed via the admin panel).\n\
# This file is rewritten by the panel; manual edits are preserved\n\
# only until the next change made through the UI.\n\n";
let body = toml::to_string_pretty(self)?;
Ok(format!("{header}{body}"))
}
/// Atomically save the config to a file (write to a temporary file then rename).
pub fn save(&self, path: &str) -> anyhow::Result<()> {
self.validate()?;
let data = self.to_toml_string()?;
let p = std::path::Path::new(path);
let dir = p.parent().unwrap_or_else(|| std::path::Path::new("."));
std::fs::create_dir_all(dir).ok();
let tmp = p.with_extension("toml.tmp");
std::fs::write(&tmp, data.as_bytes())
.map_err(|e| anyhow::anyhow!("cannot write config tmp {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, p)
.map_err(|e| anyhow::anyhow!("cannot replace config {path}: {e}"))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
listen = "0.0.0.0:9000"
[router]
url = "http://localhost:8080"
api_key_env = "CORTIQ_ROUTER_KEY"
[[models]]
id = "local-qwen"
provider = "openai"
base_url = "http://localhost:8000/v1"
model = "qwen2.5-7b-instruct"
cost_tier = "cheap"
caps = ["tools"]
[[models]]
id = "gpt-4o-mini"
provider = "openai"
base_url = "https://api.openai.com/v1"
model = "gpt-4o-mini"
api_key_env = "OPENAI_API_KEY"
cost_tier = "mid"
price_in = 0.15
price_out = 0.6
[routing]
low = ["local-qwen"]
medium = ["gpt-4o-mini", "local-qwen"]
default = "gpt-4o-mini"
[routing.policy]
mode = "cost_aware"
max_cost_usd_per_request = 0.5
min_class = { low = "cheap", medium = "mid" }
"#;
#[test]
fn roundtrip_preserves_routing_and_models() {
let cfg: Config = toml::from_str(SAMPLE).expect("parse sample");
cfg.validate().expect("sample valid");
// key risk: serialization of flattened [routing] + nested policy
let serialized = cfg.to_toml_string().expect("serialize");
let back: Config = toml::from_str(&serialized).expect("re-parse serialized");
back.validate().expect("roundtrip valid");
assert_eq!(back.listen, "0.0.0.0:9000");
assert_eq!(back.models.len(), 2);
assert_eq!(back.routing.default, "gpt-4o-mini");
assert_eq!(back.routing.policy.mode, "cost_aware");
assert_eq!(back.routing.policy.max_cost_usd_per_request, Some(0.5));
assert_eq!(
back.routing.policy.min_class.get("low").map(String::as_str),
Some("cheap")
);
let low = back.routing.tiers.get("low").expect("low tier present");
match low {
TierTargets::List(v) => assert_eq!(v, &vec!["local-qwen".to_string()]),
TierTargets::One(s) => assert_eq!(s, "local-qwen"),
}
// admin/stats sections receive defaults
assert!(back.admin.enabled);
assert!(back.stats.enabled);
}
#[test]
fn validate_rejects_unknown_routing_target() {
let mut cfg: Config = toml::from_str(SAMPLE).unwrap();
cfg.routing.default = "does-not-exist".into();
assert!(cfg.validate().is_err());
}
}
+74
View File
@@ -0,0 +1,74 @@
//! Unified gateway error type + rendering into an OpenAI-compatible envelope
//! `{"error": {message, type, code, param}}` that SDK clients parse natively.
//! See docs/PROTOCOLS.md §8.5.
use axum::{http::StatusCode, response::IntoResponse, Json};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum GatewayError {
#[error("{0}")]
InvalidRequest(String),
#[error("authentication failed: {0}")]
Unauthorized(String),
#[error("rate limit exceeded")]
RateLimited { retry_after_secs: u64 },
#[error("upstream unavailable: {0}")]
UpstreamUnavailable(String),
#[error("internal error: {0}")]
Internal(String),
}
impl GatewayError {
fn parts(&self) -> (StatusCode, &'static str, &'static str) {
match self {
Self::InvalidRequest(_) => (
StatusCode::BAD_REQUEST,
"invalid_request_error",
"invalid_request",
),
Self::Unauthorized(_) => (
StatusCode::UNAUTHORIZED,
"authentication_error",
"unauthorized",
),
Self::RateLimited { .. } => (
StatusCode::TOO_MANY_REQUESTS,
"rate_limit_error",
"rate_limited",
),
Self::UpstreamUnavailable(_) => (
StatusCode::BAD_GATEWAY,
"upstream_unavailable",
"no_healthy_model",
),
Self::Internal(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
"internal",
),
}
}
}
impl IntoResponse for GatewayError {
fn into_response(self) -> axum::response::Response {
let (status, typ, code) = self.parts();
let body = Json(json!({
"error": {
"message": self.to_string(),
"type": typ,
"code": code,
"param": serde_json::Value::Null,
}
}));
let mut resp = (status, body).into_response();
if let Self::RateLimited { retry_after_secs } = self {
resp.headers_mut()
.insert("retry-after", retry_after_secs.to_string().parse().unwrap());
}
resp
}
}
pub type Result<T> = std::result::Result<T, GatewayError>;
+107
View File
@@ -0,0 +1,107 @@
//! Cortiq Gateway — entry point.
//!
//! Loads the config, builds the shared state, assembles routes from protocols and
//! the admin panel, and starts the HTTP server. See docs/ARCHITECTURE.md.
// Part of the canonical model / error API surface (auth, rate-limit, idempotency,
// traceparent, vision/streaming caps) is intentionally reserved for upcoming phases;
// allow the unused items until those features land.
#![allow(dead_code)]
mod admin;
mod config;
mod error;
mod model;
mod pipeline;
mod protocols;
mod providers;
mod registry;
mod router_client;
mod routing;
mod secrets;
mod state;
mod stats;
use axum::{routing::get, Router};
use clap::Parser;
use config::Config;
use state::SharedState;
#[derive(Parser)]
#[command(
name = "cortiq-gateway",
version,
about = "Universal LLM gateway with intelligent routing"
)]
struct Args {
/// Path to the TOML config file.
#[arg(long, default_value = "config/gateway.toml")]
config: String,
/// Admin token for the web panel (overrides `[admin].token_env`).
#[arg(long)]
admin_token: Option<String>,
}
/// Resolve the admin token: env from `[admin].token_env` → `--admin-token` →
/// generate a random one. Returns `(token, generated)`.
fn resolve_admin_token(cfg: &Config, args: &Args) -> (String, bool) {
if let Some(env) = &cfg.admin.token_env {
if let Ok(v) = std::env::var(env) {
if !v.is_empty() {
return (v, false);
}
}
}
if let Some(t) = &args.admin_token {
if !t.is_empty() {
return (t.clone(), false);
}
}
(admin::random_token(24), true)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let cfg = Config::load(&args.config)?;
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| cfg.log.level.clone().into()),
)
.init();
let listen = cfg.listen.clone();
let admin_enabled = cfg.admin.enabled;
let (admin_token, generated) = resolve_admin_token(&cfg, &args);
let state = SharedState::build(cfg, args.config.clone())?;
let mut app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/readyz", get(|| async { "ready" }))
.route("/metrics", get(admin::metrics))
.merge(protocols::build_router());
if admin_enabled {
app = app
.merge(admin::api_routes(admin_token.clone()))
.fallback(admin::assets::fallback);
}
let app = app.with_state(state);
let listener = tokio::net::TcpListener::bind(&listen).await?;
tracing::info!("cortiq-gateway listening on {listen}");
if admin_enabled {
tracing::info!("admin console: http://{listen}/admin");
if generated {
tracing::warn!(
"admin token (auto-generated, set [admin].token_env to persist): {admin_token}"
);
}
}
axum::serve(listener, app).await?;
Ok(())
}
+102
View File
@@ -0,0 +1,102 @@
//! Canonical (protocol-neutral) request/response model.
//!
//! All incoming protocols are translated into these types, and providers translate
//! from them. This way N protocols × M providers do not become N×M translations:
//! each adapter only knows its own protocol ↔ canonical mapping. See docs/ARCHITECTURE.md §3.
use serde::{Deserialize, Serialize};
/// How to handle routing for a specific request.
#[derive(Clone, Debug)]
pub enum RoutingDirective {
/// `model = "cortiq-auto"` — ask cortiq-router and pick a model from the pool.
Auto { profile: Option<String> },
/// `model = "<real id>"` — call a specific model directly, bypassing routing.
Pinned { model_id: String },
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Message {
pub role: String, // system | user | assistant | tool
#[serde(default)]
pub content: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<serde_json::Value>,
}
#[derive(Clone, Debug, Default)]
pub struct GenParams {
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
pub top_p: Option<f32>,
pub stop: Vec<String>,
/// Fields we do not interpret but must proxy through to the provider.
pub passthrough: serde_json::Map<String, serde_json::Value>,
}
#[derive(Clone, Debug, Default)]
pub struct RequestMeta {
pub account: String,
pub protocol: String,
pub idempotency_key: Option<String>,
pub traceparent: Option<String>,
}
/// Canonical form of a generation request.
#[derive(Clone, Debug)]
pub struct ChatRequest {
pub routing: RoutingDirective,
pub messages: Vec<Message>,
pub tools: Vec<serde_json::Value>,
pub params: GenParams,
pub stream: bool,
pub meta: RequestMeta,
}
#[derive(Clone, Debug, Serialize)]
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
/// Routing metadata attached to the response (X-Cortiq-* headers and the `cortiq` field).
#[derive(Clone, Debug, Serialize)]
pub struct RouteInfo {
pub task_label: String,
pub complexity_score: f32,
pub complexity_tier: String,
pub selected_model: String,
pub route_source: String, // router | cache | fallback | pinned
pub router_request_id: Option<String>,
pub cost_usd: f64,
#[serde(default)]
pub failover: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct Choice {
pub index: u32,
pub message: Message,
pub finish_reason: String,
}
/// Canonical form of a response.
#[derive(Clone, Debug)]
pub struct ChatResponse {
pub id: String,
pub model_used: String,
pub choices: Vec<Choice>,
pub usage: Usage,
pub cortiq: RouteInfo,
}
/// Decision from cortiq-router (the subset of its contract needed by the gateway).
#[derive(Clone, Debug)]
pub struct RouteDecision {
pub task_label: String,
pub complexity_score: f32,
pub complexity_tier: String,
pub router_request_id: Option<String>,
pub source: String, // router | cache | fallback
}
+251
View File
@@ -0,0 +1,251 @@
//! Canonical request processing pipeline (Pipeline).
//! Coordinates stages: Extract -> Route -> Select -> Translate & Call,
//! and also measures latency and records per-request statistics.
use crate::error::{GatewayError, Result};
use crate::model::{
ChatRequest, ChatResponse, Message, RouteDecision, RouteInfo, RoutingDirective,
};
use crate::state::SharedState;
use crate::stats::RequestRecord;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
pub struct Pipeline;
impl Pipeline {
pub fn new() -> Self {
Self
}
/// Run the routing and generation pipeline.
pub async fn run(&self, req: ChatRequest, state: &SharedState) -> Result<ChatResponse> {
let started = Instant::now();
let account = req.meta.account.clone();
let protocol = req.meta.protocol.clone();
let directive = match &req.routing {
RoutingDirective::Pinned { .. } => "pinned",
RoutingDirective::Auto { .. } => "auto",
}
.to_string();
let result = self.run_inner(req, state).await;
// record statistics (for both success and error)
let latency_ms = started.elapsed().as_millis() as u64;
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let rec = match &result {
Ok(resp) => RequestRecord {
ts,
account,
protocol,
directive,
task_label: resp.cortiq.task_label.clone(),
tier: resp.cortiq.complexity_tier.clone(),
score: resp.cortiq.complexity_score,
model_id: resp.cortiq.selected_model.clone(),
route_source: resp.cortiq.route_source.clone(),
prompt_tokens: resp.usage.prompt_tokens,
completion_tokens: resp.usage.completion_tokens,
cost_usd: resp.cortiq.cost_usd,
latency_ms,
outcome: "ok".to_string(),
failover: resp.cortiq.failover,
error: None,
},
Err(err) => RequestRecord {
ts,
account,
protocol,
directive,
task_label: String::new(),
tier: String::new(),
score: 0.0,
model_id: String::new(),
route_source: String::new(),
prompt_tokens: 0,
completion_tokens: 0,
cost_usd: 0.0,
latency_ms,
outcome: "error".to_string(),
failover: false,
error: Some(err.to_string()),
},
};
state.stats.record(rec);
result
}
async fn run_inner(&self, req: ChatRequest, state: &SharedState) -> Result<ChatResponse> {
let live = state.live();
// 1. Extract text for classification
let text = self.extract_text(
&req.messages,
&live.cfg.route.text_strategy,
live.cfg.route.max_chars,
);
// 2. Obtain the router decision (or use the pinned model)
let (decision, route_source) = match &req.routing {
RoutingDirective::Pinned { model_id: _ } => {
let dec = RouteDecision {
task_label: "pinned".to_string(),
complexity_score: 0.0,
complexity_tier: "pinned".to_string(),
router_request_id: None,
source: "pinned".to_string(),
};
(dec, "pinned".to_string())
}
RoutingDirective::Auto { profile } => {
let prof = profile.as_deref().unwrap_or(&live.cfg.route.profile);
match live.router.route(&text, prof).await {
Ok(Some(dec)) => {
let src = dec.source.clone();
(dec, src)
}
Ok(None) | Err(_) => {
tracing::warn!(
"Router is unavailable. Gracefully degrading to default model."
);
let dec = RouteDecision {
task_label: "degraded".to_string(),
complexity_score: 0.5,
complexity_tier: "degraded".to_string(),
router_request_id: None,
source: "fallback".to_string(),
};
(dec, "fallback".to_string())
}
}
}
};
// 3. Select candidates by complexity tier / pinned model
let candidates = match &req.routing {
RoutingDirective::Pinned { model_id } => vec![model_id.clone()],
RoutingDirective::Auto { .. } => {
if route_source == "fallback" {
vec![live.routing.default_model().to_string()]
} else {
live.routing.candidates(&decision.complexity_tier)
}
}
};
// Filter candidates by capabilities (e.g. tools support)
let needs_tools = !req.tools.is_empty();
let mut final_candidates = Vec::new();
for model_id in candidates {
if let Some(prov) = live.registry.get(&model_id) {
if needs_tools && !prov.caps().tools {
tracing::debug!(
"Candidate '{}' skipped: lacks support for requested tools",
model_id
);
continue;
}
final_candidates.push(model_id);
}
}
if final_candidates.is_empty() {
return Err(GatewayError::UpstreamUnavailable(
"No healthy model available that satisfies required capabilities (e.g. tools)"
.to_string(),
));
}
// 4. Attempt provider call with failover (fall back to alternatives on error)
let mut last_err = None;
let mut attempt = 0usize;
for model_id in final_candidates {
let provider = match live.registry.get(&model_id) {
Some(p) => p,
None => continue,
};
tracing::info!(
"Routing request (label: {}, tier: {}) to model '{}' via source '{}'",
decision.task_label,
decision.complexity_tier,
model_id,
route_source
);
match provider.chat(req.clone()).await {
Ok(mut resp) => {
let cost =
provider.price(resp.usage.prompt_tokens, resp.usage.completion_tokens);
resp.cortiq = RouteInfo {
task_label: decision.task_label.clone(),
complexity_score: decision.complexity_score,
complexity_tier: decision.complexity_tier.clone(),
selected_model: model_id,
route_source: route_source.clone(),
router_request_id: decision.router_request_id.clone(),
cost_usd: cost,
failover: attempt > 0,
};
return Ok(resp);
}
Err(err) => {
tracing::warn!(
"Provider '{}' call failed: {:?}. Attempting failover...",
model_id,
err
);
last_err = Some(err);
attempt += 1;
}
}
}
Err(last_err.unwrap_or_else(|| {
GatewayError::UpstreamUnavailable("All candidate models failed".to_string())
}))
}
fn extract_text(&self, messages: &[Message], strategy: &str, max_chars: usize) -> String {
let raw_text = match strategy {
"last_user" => messages
.iter()
.rfind(|m| m.role == "user")
.map(|m| m.content.clone())
.unwrap_or_default(),
"last_user_plus_system" => {
let system_msgs: Vec<String> = messages
.iter()
.filter(|m| m.role == "system")
.map(|m| m.content.clone())
.collect();
let last_user = messages
.iter()
.rfind(|m| m.role == "user")
.map(|m| m.content.as_str())
.unwrap_or("");
if system_msgs.is_empty() {
last_user.to_string()
} else {
format!("{}\n{}", system_msgs.join("\n"), last_user)
}
}
// "concat_all" and any unknown strategy fall back to concatenating all turns
_ => messages
.iter()
.map(|m| format!("{}: {}", m.role, m.content))
.collect::<Vec<String>>()
.join("\n"),
};
if raw_text.chars().count() > max_chars {
raw_text.chars().take(max_chars).collect()
} else {
raw_text
}
}
}
+17
View File
@@ -0,0 +1,17 @@
//! Incoming protocol adapters. Each one builds its own axum routes and translates
//! its protocol to/from the canonical model. Enabled via flags in `[protocols]`.
pub mod openai_chat;
use crate::state::SharedState;
use axum::Router;
/// Build the protocols router. Implemented adapters are **always** mounted;
/// each handler checks the live `protocols.*` flag from config itself, so
/// toggles in the admin panel take effect without a restart (hot switching).
pub fn build_router() -> Router<SharedState> {
Router::new().merge(openai_chat::routes())
// TODO: openai_completions, openai_embeddings, openai_models,
// anthropic_messages, mcp, native_passthrough — add here as well,
// with a check of the corresponding flag inside the handler.
}
+168
View File
@@ -0,0 +1,168 @@
//! Incoming adapter for OpenAI Chat Completions: `POST /v1/chat/completions`.
//! Translates the OpenAI request body into a canonical [`ChatRequest`], passes it
//! to the pipeline, then converts the canonical [`ChatResponse`] back to OpenAI format.
use crate::error::{GatewayError, Result};
use crate::model::{ChatRequest, GenParams, Message, RequestMeta, RoutingDirective};
use crate::state::SharedState;
use axum::{extract::State, routing::post, Json, Router};
use serde::Deserialize;
pub fn routes() -> Router<SharedState> {
Router::new().route("/v1/chat/completions", post(handler))
}
#[derive(Deserialize)]
struct OpenAiChatRequest {
model: String,
messages: Vec<Message>,
#[serde(default)]
temperature: Option<f32>,
#[serde(default)]
max_tokens: Option<u32>,
#[serde(default)]
top_p: Option<f32>,
#[serde(default)]
stream: bool,
#[serde(default)]
tools: Vec<serde_json::Value>,
#[serde(flatten)]
rest: serde_json::Map<String, serde_json::Value>,
}
/// Parse the `model` field value into a routing directive.
/// `cortiq-auto[:profile]` → Auto; otherwise → Pinned(real id).
fn parse_routing(model: &str) -> RoutingDirective {
if let Some(rest) = model.strip_prefix("cortiq-auto") {
let profile = rest.strip_prefix(':').map(|p| p.to_string());
RoutingDirective::Auto { profile }
} else {
RoutingDirective::Pinned {
model_id: model.to_string(),
}
}
}
async fn handler(
State(state): State<SharedState>,
Json(req): Json<OpenAiChatRequest>,
) -> Result<impl axum::response::IntoResponse> {
// hot protocol toggle: if the adapter is disabled in config — return 404
if !state.live().cfg.protocols.openai_chat {
return Err(GatewayError::InvalidRequest(
"openai_chat protocol is disabled".into(),
));
}
if req.messages.is_empty() {
return Err(GatewayError::InvalidRequest(
"messages must not be empty".into(),
));
}
let canonical = ChatRequest {
routing: parse_routing(&req.model),
messages: req.messages,
tools: req.tools,
params: GenParams {
temperature: req.temperature,
max_tokens: req.max_tokens,
top_p: req.top_p,
stop: Vec::new(),
passthrough: req.rest,
},
stream: req.stream,
meta: RequestMeta {
protocol: "openai_chat".into(),
..Default::default()
},
};
if req.stream {
return Err(GatewayError::InvalidRequest(
"Streaming is not yet supported in this version".into(),
));
}
let resp = state.pipeline.run(canonical, &state).await?;
let mut headers = axum::http::HeaderMap::new();
headers.insert(
"X-Cortiq-Task-Label",
axum::http::HeaderValue::from_str(&resp.cortiq.task_label)
.unwrap_or(axum::http::HeaderValue::from_static("")),
);
headers.insert(
"X-Cortiq-Complexity-Score",
axum::http::HeaderValue::from_str(&resp.cortiq.complexity_score.to_string())
.unwrap_or(axum::http::HeaderValue::from_static("0.0")),
);
headers.insert(
"X-Cortiq-Complexity-Tier",
axum::http::HeaderValue::from_str(&resp.cortiq.complexity_tier)
.unwrap_or(axum::http::HeaderValue::from_static("")),
);
headers.insert(
"X-Cortiq-Selected-Model",
axum::http::HeaderValue::from_str(&resp.cortiq.selected_model)
.unwrap_or(axum::http::HeaderValue::from_static("")),
);
headers.insert(
"X-Cortiq-Route-Source",
axum::http::HeaderValue::from_str(&resp.cortiq.route_source)
.unwrap_or(axum::http::HeaderValue::from_static("")),
);
headers.insert(
"X-Cortiq-Cost-Usd",
axum::http::HeaderValue::from_str(&resp.cortiq.cost_usd.to_string())
.unwrap_or(axum::http::HeaderValue::from_static("0.0")),
);
if let Some(req_id) = &resp.cortiq.router_request_id {
headers.insert(
"X-Cortiq-Request-Id",
axum::http::HeaderValue::from_str(req_id)
.unwrap_or(axum::http::HeaderValue::from_static("")),
);
}
let mut body = serde_json::json!({
"id": resp.id,
"object": "chat.completion",
"created": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
"model": resp.model_used,
"choices": resp.choices.iter().map(|c| {
serde_json::json!({
"index": c.index,
"message": {
"role": c.message.role,
"content": c.message.content,
"tool_calls": if c.message.tool_calls.is_empty() { serde_json::Value::Null } else { serde_json::Value::Array(c.message.tool_calls.clone()) },
},
"finish_reason": c.finish_reason,
})
}).collect::<Vec<_>>(),
"usage": {
"prompt_tokens": resp.usage.prompt_tokens,
"completion_tokens": resp.usage.completion_tokens,
"total_tokens": resp.usage.total_tokens,
}
});
if state.live().cfg.cortiq.echo {
body["cortiq"] = serde_json::json!({
"task_label": resp.cortiq.task_label,
"complexity": {
"score": resp.cortiq.complexity_score,
"tier": resp.cortiq.complexity_tier,
},
"selected_model": resp.cortiq.selected_model,
"route_source": resp.cortiq.route_source,
"router_request_id": resp.cortiq.router_request_id,
"cost_usd": resp.cortiq.cost_usd,
});
}
Ok((headers, Json(body)))
}
+57
View File
@@ -0,0 +1,57 @@
//! Anthropic Messages provider (v0.2). Translates the canonical ChatRequest to
//! `POST /v1/messages` and back. The `system` message is extracted into a separate field.
use super::{Caps, Provider};
use crate::config::ModelCfg;
use crate::error::{GatewayError, Result};
use crate::model::{ChatRequest, ChatResponse};
use async_trait::async_trait;
pub struct AnthropicProvider {
id: String,
#[allow(dead_code)]
base_url: String,
#[allow(dead_code)]
model: String,
#[allow(dead_code)]
api_key: Option<String>,
caps: Caps,
price_in: f64,
price_out: f64,
}
impl AnthropicProvider {
pub fn new(cfg: &ModelCfg, api_key: Option<String>) -> anyhow::Result<Self> {
Ok(Self {
id: cfg.id.clone(),
base_url: cfg.base_url.trim_end_matches('/').to_string(),
model: cfg.model.clone(),
api_key,
caps: Caps::from_list(&cfg.caps),
price_in: cfg.price_in,
price_out: cfg.price_out,
})
}
}
#[async_trait]
impl Provider for AnthropicProvider {
fn id(&self) -> &str {
&self.id
}
fn caps(&self) -> &Caps {
&self.caps
}
fn price(&self, prompt_tokens: u32, completion_tokens: u32) -> f64 {
(prompt_tokens as f64 * self.price_in + completion_tokens as f64 * self.price_out)
/ 1_000_000.0
}
async fn chat(&self, _req: ChatRequest) -> Result<ChatResponse> {
// TODO(v0.2): extract system from messages → "system" field; messages → content blocks;
// add x-api-key + anthropic-version headers; map response → canonical form.
// Returns an error (not a panic) so that failover/playground continue to work normally.
Err(GatewayError::UpstreamUnavailable(
"anthropic provider not yet implemented (planned v0.2)".to_string(),
))
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Outgoing provider adapters. The [`Provider`] trait is the single call site for LLMs;
//! adapters translate the canonical [`ChatRequest`] to and from each provider's API format.
//!
//! Most local servers (vLLM, llama.cpp, LM Studio, Ollama `/v1`) are OpenAI-compatible
//! → [`openai::OpenAiProvider`] is sufficient for all of them.
pub mod anthropic;
pub mod openai;
use crate::model::{ChatRequest, ChatResponse};
use async_trait::async_trait;
/// Model capabilities — checked at selection time (whether tools/vision are needed).
#[derive(Clone, Debug, Default)]
pub struct Caps {
pub tools: bool,
pub vision: bool,
pub streaming: bool,
}
impl Caps {
pub fn from_list(list: &[String]) -> Self {
Self {
tools: list.iter().any(|c| c == "tools"),
vision: list.iter().any(|c| c == "vision"),
streaming: true,
}
}
}
#[async_trait]
pub trait Provider: Send + Sync {
/// The model_id from config (not the provider's model name).
fn id(&self) -> &str;
fn caps(&self) -> &Caps;
/// Cost of a request in USD — computed from price_in/price_out × tokens (for billing).
fn price(&self, prompt_tokens: u32, completion_tokens: u32) -> f64;
/// Non-streaming chat call.
async fn chat(&self, req: ChatRequest) -> crate::error::Result<ChatResponse>;
// TODO(v0.1): async fn chat_stream(...) -> Result<ChatStream> (SSE).
// TODO(v0.1): async fn embed(...) -> Result<EmbedResponse>.
}
+157
View File
@@ -0,0 +1,157 @@
//! OpenAI-compatible provider. Covers OpenAI, OpenRouter, Together, Groq,
//! and local servers such as vLLM / llama.cpp / LM Studio / Ollama (`/v1`).
use super::{Caps, Provider};
use crate::config::ModelCfg;
use crate::error::{GatewayError, Result};
use crate::model::{ChatRequest, ChatResponse};
use async_trait::async_trait;
pub struct OpenAiProvider {
id: String,
base_url: String,
model: String,
api_key: Option<String>,
caps: Caps,
price_in: f64,
price_out: f64,
http: reqwest::Client,
}
impl OpenAiProvider {
pub fn new(cfg: &ModelCfg, api_key: Option<String>) -> anyhow::Result<Self> {
Ok(Self {
id: cfg.id.clone(),
base_url: cfg.base_url.trim_end_matches('/').to_string(),
model: cfg.model.clone(),
api_key,
caps: Caps::from_list(&cfg.caps),
price_in: cfg.price_in,
price_out: cfg.price_out,
http: reqwest::Client::new(),
})
}
/// Canonical request → OpenAI Chat Completions request body.
fn to_wire(&self, req: &ChatRequest) -> serde_json::Value {
let mut body = serde_json::json!({
"model": self.model,
"messages": req.messages,
"stream": req.stream,
});
if let Some(t) = req.params.temperature {
body["temperature"] = t.into();
}
if let Some(m) = req.params.max_tokens {
body["max_tokens"] = m.into();
}
if !req.tools.is_empty() {
body["tools"] = serde_json::Value::Array(req.tools.clone());
}
// uninterpreted fields are proxied as-is
for (k, v) in &req.params.passthrough {
body[k] = v.clone();
}
body
}
}
#[async_trait]
impl Provider for OpenAiProvider {
fn id(&self) -> &str {
&self.id
}
fn caps(&self) -> &Caps {
&self.caps
}
fn price(&self, prompt_tokens: u32, completion_tokens: u32) -> f64 {
(prompt_tokens as f64 * self.price_in + completion_tokens as f64 * self.price_out)
/ 1_000_000.0
}
async fn chat(&self, req: ChatRequest) -> Result<ChatResponse> {
let mut r = self
.http
.post(format!("{}/chat/completions", self.base_url))
.json(&self.to_wire(&req));
if let Some(key) = &self.api_key {
r = r.bearer_auth(key);
}
let resp = r
.send()
.await
.map_err(|e| GatewayError::UpstreamUnavailable(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status();
let err_body = resp.text().await.unwrap_or_default();
return Err(GatewayError::UpstreamUnavailable(format!(
"provider {} returned HTTP {}: {}",
self.id, status, err_body
)));
}
let v: serde_json::Value = resp
.json()
.await
.map_err(|e| GatewayError::Internal(format!("Failed to parse JSON response: {}", e)))?;
let id = v["id"].as_str().unwrap_or("").to_string();
let model_used = v["model"].as_str().unwrap_or(&self.model).to_string();
let choices_arr = v["choices"].as_array().ok_or_else(|| {
GatewayError::Internal(
"invalid openai response: choices is missing or not an array".to_string(),
)
})?;
let mut choices = Vec::new();
for (i, c) in choices_arr.iter().enumerate() {
let msg_val = &c["message"];
let role = msg_val["role"].as_str().unwrap_or("assistant").to_string();
let content = msg_val["content"].as_str().unwrap_or("").to_string();
let tool_calls = msg_val["tool_calls"]
.as_array()
.cloned()
.unwrap_or_default();
choices.push(crate::model::Choice {
index: c["index"].as_u64().unwrap_or(i as u64) as u32,
message: crate::model::Message {
role,
content,
tool_calls,
},
finish_reason: c["finish_reason"].as_str().unwrap_or("stop").to_string(),
});
}
let usage_val = &v["usage"];
let prompt_tokens = usage_val["prompt_tokens"].as_u64().unwrap_or(0) as u32;
let completion_tokens = usage_val["completion_tokens"].as_u64().unwrap_or(0) as u32;
let total_tokens = usage_val["total_tokens"]
.as_u64()
.unwrap_or((prompt_tokens + completion_tokens) as u64)
as u32;
Ok(ChatResponse {
id,
model_used,
choices,
usage: crate::model::Usage {
prompt_tokens,
completion_tokens,
total_tokens,
},
cortiq: crate::model::RouteInfo {
task_label: String::new(),
complexity_score: 0.0,
complexity_tier: String::new(),
selected_model: self.id.clone(),
route_source: String::new(),
router_request_id: None,
cost_usd: 0.0,
failover: false,
},
})
}
}
+43
View File
@@ -0,0 +1,43 @@
//! Model pool registry: builds providers from `[[models]]` and resolves
//! `model_id → Arc<dyn Provider>`.
use crate::config::Config;
use crate::providers::{anthropic::AnthropicProvider, openai::OpenAiProvider, Provider};
use crate::secrets::SecretStore;
use std::collections::HashMap;
use std::sync::Arc;
pub struct Registry {
by_id: HashMap<String, Arc<dyn Provider>>,
}
impl Registry {
pub fn from_config(cfg: &Config, secrets: &SecretStore) -> anyhow::Result<Self> {
let mut by_id: HashMap<String, Arc<dyn Provider>> = HashMap::new();
for m in &cfg.models {
if m.kind == "embedding" {
continue; // embedding models are handled by a separate branch (v0.1)
}
// provider key: secret store → environment variable
let api_key = m
.api_key_env
.as_ref()
.and_then(|name| secrets.resolve(name));
let provider: Arc<dyn Provider> = match m.provider.as_str() {
"openai" | "ollama" | "http" => Arc::new(OpenAiProvider::new(m, api_key)?),
"anthropic" => Arc::new(AnthropicProvider::new(m, api_key)?),
other => anyhow::bail!("unknown provider '{}' for model '{}'", other, m.id),
};
by_id.insert(m.id.clone(), provider);
}
Ok(Self { by_id })
}
pub fn get(&self, model_id: &str) -> Option<Arc<dyn Provider>> {
self.by_id.get(model_id).cloned()
}
pub fn ids(&self) -> impl Iterator<Item = &String> {
self.by_id.keys()
}
}
+78
View File
@@ -0,0 +1,78 @@
//! 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;
pub struct RouterClient {
http: reqwest::Client,
url: String,
api_key: Option<String>,
taxonomy_id: Option<String>,
}
impl RouterClient {
pub fn new(cfg: &RouterCfg) -> 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| std::env::var(name).ok());
Ok(Self {
http,
url: cfg.url.trim_end_matches('/').to_string(),
api_key,
taxonomy_id: cfg.taxonomy_id.clone(),
})
}
/// Classify text. Returns `Ok(None)` on network error/timeout —
/// the caller treats this as "router unavailable" and uses the default tier.
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(_) => return Ok(None), // degradation
};
if !resp.status().is_success() {
return Ok(None);
}
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(),
}))
}
}
+48
View File
@@ -0,0 +1,48 @@
//! Model selection: `(task_label, complexity_tier) → ordered list of model_ids`.
//! The list serves as both a priority order and a fallback chain. See docs/ROUTING.md.
use crate::config::{Config, TierTargets};
pub struct RoutingTable {
tiers: 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,
default: cfg.routing.default.clone(),
}
}
/// 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()])
}
/// Default model (used when the router is unavailable).
pub fn default_model(&self) -> &str {
&self.default
}
}
// 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.
+96
View File
@@ -0,0 +1,96 @@
//! Secret store (provider API key values) kept outside the main config.
//!
//! The file `config/secrets.toml` (in `.gitignore`, mode `0600`) is a map of
//! `env_name -> value`. Key resolution order: store entry > environment variable.
//! This allows setting keys directly from the admin panel without a restart,
//! without writing secrets into `gateway.toml`. Values are never exposed via the
//! API — only their presence status is returned (see `source`).
use std::collections::BTreeMap;
use std::sync::RwLock;
pub struct SecretStore {
path: String,
values: RwLock<BTreeMap<String, String>>,
}
impl SecretStore {
/// Load the store from a file (missing file or parse error → empty store).
pub fn load(path: &str) -> Self {
let values = std::fs::read_to_string(path)
.ok()
.and_then(|raw| toml::from_str::<BTreeMap<String, String>>(&raw).ok())
.unwrap_or_default();
Self {
path: path.to_string(),
values: RwLock::new(values),
}
}
/// Secret value by env name: store first, then environment variable.
pub fn resolve(&self, env_name: &str) -> Option<String> {
if let Some(v) = self.values.read().unwrap().get(env_name) {
if !v.is_empty() {
return Some(v.clone());
}
}
std::env::var(env_name).ok().filter(|v| !v.is_empty())
}
/// Value source for display in the UI: `store` | `env` | `missing`.
pub fn source(&self, env_name: &str) -> &'static str {
let in_store = self
.values
.read()
.unwrap()
.get(env_name)
.map(|v| !v.is_empty())
.unwrap_or(false);
if in_store {
"store"
} else if std::env::var(env_name)
.ok()
.filter(|v| !v.is_empty())
.is_some()
{
"env"
} else {
"missing"
}
}
/// Set a secret (persisted to file with mode 0600).
pub fn set(&self, env_name: &str, value: &str) -> anyhow::Result<()> {
self.values
.write()
.unwrap()
.insert(env_name.to_string(), value.to_string());
self.persist()
}
/// Remove a secret from the store (an environment variable value, if present, remains).
pub fn clear(&self, env_name: &str) -> anyhow::Result<()> {
self.values.write().unwrap().remove(env_name);
self.persist()
}
fn persist(&self) -> anyhow::Result<()> {
let data = {
let map = self.values.read().unwrap();
toml::to_string(&*map)?
};
let p = std::path::Path::new(&self.path);
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).ok();
}
let tmp = p.with_extension("toml.tmp");
std::fs::write(&tmp, data.as_bytes())?;
std::fs::rename(&tmp, p)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
}
+107
View File
@@ -0,0 +1,107 @@
//! Shared gateway state accessible across all request handlers.
//!
//! The config-derived part (`Live`) lives behind an `ArcSwap`, which allows
//! hot-reloading the model pool, routing table, and router connection without
//! a restart: the admin API validates the new config, builds a new `Live`,
//! atomically writes the TOML, and swaps the pointer.
use crate::config::Config;
use crate::registry::Registry;
use crate::router_client::RouterClient;
use crate::routing::RoutingTable;
use crate::secrets::SecretStore;
use crate::stats::Stats;
use arc_swap::ArcSwap;
use std::sync::Arc;
#[derive(Clone)]
pub struct SharedState(pub Arc<AppState>);
/// The portion of state that is rebuilt whenever the config changes.
pub struct Live {
pub cfg: Config,
pub registry: Registry,
pub routing: RoutingTable,
pub router: RouterClient,
}
impl Live {
pub fn build(cfg: Config, secrets: &SecretStore) -> anyhow::Result<Self> {
let router = RouterClient::new(&cfg.router)?;
let registry = Registry::from_config(&cfg, secrets)?;
let routing = RoutingTable::from_config(&cfg);
Ok(Self {
cfg,
registry,
routing,
router,
})
}
}
pub struct AppState {
pub live: ArcSwap<Live>,
pub stats: Arc<Stats>,
pub secrets: SecretStore,
pub config_path: String,
pub pipeline: crate::pipeline::Pipeline,
}
impl std::ops::Deref for SharedState {
type Target = AppState;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl SharedState {
pub fn build(cfg: Config, config_path: String) -> anyhow::Result<Self> {
let secrets_path = sibling(&config_path, "secrets.toml");
let secrets = SecretStore::load(&secrets_path);
let stats = Stats::new(&cfg.stats);
let live = Live::build(cfg, &secrets)?;
Ok(SharedState(Arc::new(AppState {
live: ArcSwap::from_pointee(live),
stats,
secrets,
config_path,
pipeline: crate::pipeline::Pipeline::new(),
})))
}
/// Current snapshot of the rebuildable state (safe to hold across await points).
pub fn live(&self) -> Arc<Live> {
self.0.live.load_full()
}
}
impl AppState {
/// Replace the config entirely: validate → build → write to disk → swap.
/// If any step fails, the active state and file are left unchanged.
pub fn reload(&self, new_cfg: Config) -> anyhow::Result<()> {
new_cfg.validate()?;
let live = Live::build(new_cfg.clone(), &self.secrets)?;
new_cfg.save(&self.config_path)?;
self.live.store(Arc::new(live));
Ok(())
}
/// Rebuild `Live` from the current config without writing to disk
/// (e.g. after a secret change — only provider keys changed).
pub fn rebuild(&self) -> anyhow::Result<()> {
let cfg = self.live.load_full().cfg.clone();
let live = Live::build(cfg, &self.secrets)?;
self.live.store(Arc::new(live));
Ok(())
}
}
/// Path to file `name` next to `path` (in the same directory).
fn sibling(path: &str, name: &str) -> String {
std::path::Path::new(path)
.parent()
.map(|d| d.join(name))
.unwrap_or_else(|| std::path::PathBuf::from(name))
.to_string_lossy()
.into_owned()
}
+395
View File
@@ -0,0 +1,395 @@
//! Request statistics tracking for the dashboard and `GET /metrics`.
//!
//! Everything is held in memory (aggregates + per-minute buckets for the retention
//! window + a ring buffer of recent requests) and optionally appended to a JSONL
//! file that is replayed on startup — so statistics survive a restart with no
//! database dependency.
use crate::config::StatsCfg;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::io::Write;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// A single record for a completed request (one JSONL line and one "recent" ring entry).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RequestRecord {
pub ts: u64,
#[serde(default)]
pub account: String,
#[serde(default)]
pub protocol: String,
#[serde(default)]
pub directive: String, // auto | pinned
#[serde(default)]
pub task_label: String,
#[serde(default)]
pub tier: String,
#[serde(default)]
pub score: f32,
#[serde(default)]
pub model_id: String,
#[serde(default)]
pub route_source: String,
#[serde(default)]
pub prompt_tokens: u32,
#[serde(default)]
pub completion_tokens: u32,
#[serde(default)]
pub cost_usd: f64,
#[serde(default)]
pub latency_ms: u64,
#[serde(default)]
pub outcome: String, // ok | error
#[serde(default)]
pub failover: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct Totals {
pub requests: u64,
pub ok: u64,
pub errors: u64,
pub failovers: u64,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub cost_usd: f64,
pub latency_ms_sum: u64,
}
impl Totals {
fn apply(&mut self, r: &RequestRecord) {
self.requests += 1;
if r.outcome == "ok" {
self.ok += 1;
} else {
self.errors += 1;
}
if r.failover {
self.failovers += 1;
}
self.prompt_tokens += r.prompt_tokens as u64;
self.completion_tokens += r.completion_tokens as u64;
self.cost_usd += r.cost_usd;
self.latency_ms_sum += r.latency_ms;
}
}
/// Per-minute bucket for time-series charts.
#[derive(Clone, Debug, Default, Serialize)]
pub struct Bucket {
pub minute: u64, // unix seconds, rounded down to the minute
pub requests: u64,
pub errors: u64,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub cost_usd: f64,
pub latency_ms_sum: u64,
}
#[derive(Default)]
struct Inner {
total: Totals,
by_model: HashMap<String, Totals>,
by_account: HashMap<String, Totals>,
by_tier: HashMap<String, Totals>,
by_label: HashMap<String, Totals>,
buckets: VecDeque<Bucket>,
recent: VecDeque<RequestRecord>,
}
pub struct Stats {
enabled: bool,
file: Option<String>,
ring_size: usize,
retention_secs: u64,
inner: Mutex<Inner>,
}
impl Stats {
pub fn new(cfg: &StatsCfg) -> std::sync::Arc<Self> {
let file = if cfg.file.trim().is_empty() {
None
} else {
Some(cfg.file.clone())
};
let s = Stats {
enabled: cfg.enabled,
file,
ring_size: cfg.ring_size.max(1),
retention_secs: parse_duration_secs(&cfg.retention).unwrap_or(7 * 86_400),
inner: Mutex::new(Inner::default()),
};
s.replay();
std::sync::Arc::new(s)
}
/// Replay the JSONL file into aggregates (within the retention window only).
fn replay(&self) {
let Some(path) = &self.file else { return };
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
let cutoff = now_secs().saturating_sub(self.retention_secs);
let mut inner = self.inner.lock().unwrap();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(rec) = serde_json::from_str::<RequestRecord>(line) {
// push everything to the "recent" ring (it self-limits by size),
// but apply to aggregates/series only entries within the window.
Self::push_recent(&mut inner, &rec, self.ring_size);
if rec.ts >= cutoff {
Self::apply_aggregates(&mut inner, &rec, self.retention_secs);
}
}
}
}
fn push_recent(inner: &mut Inner, rec: &RequestRecord, ring_size: usize) {
inner.recent.push_back(rec.clone());
while inner.recent.len() > ring_size {
inner.recent.pop_front();
}
}
fn apply_aggregates(inner: &mut Inner, rec: &RequestRecord, retention_secs: u64) {
inner.total.apply(rec);
inner
.by_model
.entry(rec.model_id.clone())
.or_default()
.apply(rec);
inner
.by_account
.entry(if rec.account.is_empty() {
"anonymous".to_string()
} else {
rec.account.clone()
})
.or_default()
.apply(rec);
inner
.by_tier
.entry(rec.tier.clone())
.or_default()
.apply(rec);
inner
.by_label
.entry(rec.task_label.clone())
.or_default()
.apply(rec);
let minute = rec.ts - (rec.ts % 60);
match inner.buckets.back_mut() {
Some(b) if b.minute == minute => fill_bucket(b, rec),
Some(b) if b.minute > minute => { /* arrived older than the tail — ignore for series */
}
_ => {
let mut b = Bucket {
minute,
..Default::default()
};
fill_bucket(&mut b, rec);
inner.buckets.push_back(b);
}
}
// trim old buckets outside the retention window
let cutoff = now_secs().saturating_sub(retention_secs);
while let Some(front) = inner.buckets.front() {
if front.minute < cutoff {
inner.buckets.pop_front();
} else {
break;
}
}
}
/// Record a new request event.
pub fn record(&self, rec: RequestRecord) {
if !self.enabled {
return;
}
{
let mut inner = self.inner.lock().unwrap();
Self::apply_aggregates(&mut inner, &rec, self.retention_secs);
Self::push_recent(&mut inner, &rec, self.ring_size);
}
if let Some(path) = &self.file {
if let Ok(line) = serde_json::to_string(&rec) {
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
let _ = writeln!(f, "{line}");
}
}
}
}
/// Snapshot for the admin API: aggregates + time series for `range_secs` + breakdown by `groupby`.
pub fn snapshot(&self, range_secs: u64, groupby: &str) -> serde_json::Value {
let inner = self.inner.lock().unwrap();
let cutoff = now_secs().saturating_sub(range_secs);
let series: Vec<&Bucket> = inner
.buckets
.iter()
.filter(|b| b.minute >= cutoff)
.collect();
let breakdown_src = match groupby {
"account" => &inner.by_account,
"tier" => &inner.by_tier,
"label" => &inner.by_label,
_ => &inner.by_model,
};
let mut breakdown: Vec<serde_json::Value> = breakdown_src
.iter()
.map(|(k, t)| {
serde_json::json!({
"key": k,
"requests": t.requests,
"ok": t.ok,
"errors": t.errors,
"prompt_tokens": t.prompt_tokens,
"completion_tokens": t.completion_tokens,
"cost_usd": t.cost_usd,
"avg_latency_ms": avg(t.latency_ms_sum, t.requests),
})
})
.collect();
breakdown.sort_by(|a, b| {
b["requests"]
.as_u64()
.unwrap_or(0)
.cmp(&a["requests"].as_u64().unwrap_or(0))
});
serde_json::json!({
"totals": {
"requests": inner.total.requests,
"ok": inner.total.ok,
"errors": inner.total.errors,
"failovers": inner.total.failovers,
"prompt_tokens": inner.total.prompt_tokens,
"completion_tokens": inner.total.completion_tokens,
"total_tokens": inner.total.prompt_tokens + inner.total.completion_tokens,
"cost_usd": inner.total.cost_usd,
"avg_latency_ms": avg(inner.total.latency_ms_sum, inner.total.requests),
"success_rate": if inner.total.requests > 0 {
inner.total.ok as f64 / inner.total.requests as f64
} else { 0.0 },
},
"groupby": groupby,
"breakdown": breakdown,
"series": series,
})
}
/// Recent requests (newest first), with pagination.
pub fn recent(&self, limit: usize, offset: usize) -> Vec<RequestRecord> {
let inner = self.inner.lock().unwrap();
inner
.recent
.iter()
.rev()
.skip(offset)
.take(limit)
.cloned()
.collect()
}
/// Metrics text in Prometheus format.
pub fn prometheus(&self) -> String {
let inner = self.inner.lock().unwrap();
let mut out = String::new();
let t = &inner.total;
out.push_str("# HELP gw_requests_total Total gateway requests.\n");
out.push_str("# TYPE gw_requests_total counter\n");
out.push_str(&format!("gw_requests_total {}\n", t.requests));
out.push_str("# HELP gw_failovers_total Total provider failovers.\n");
out.push_str("# TYPE gw_failovers_total counter\n");
out.push_str(&format!("gw_failovers_total {}\n", t.failovers));
out.push_str("# HELP gw_tokens_total Total tokens by direction.\n");
out.push_str("# TYPE gw_tokens_total counter\n");
out.push_str(&format!(
"gw_tokens_total{{direction=\"in\"}} {}\n",
t.prompt_tokens
));
out.push_str(&format!(
"gw_tokens_total{{direction=\"out\"}} {}\n",
t.completion_tokens
));
out.push_str("# HELP gw_cost_usd_total Total estimated cost in USD.\n");
out.push_str("# TYPE gw_cost_usd_total counter\n");
out.push_str(&format!("gw_cost_usd_total {:.6}\n", t.cost_usd));
out.push_str("# HELP gw_provider_calls_total Calls by model and outcome.\n");
out.push_str("# TYPE gw_provider_calls_total counter\n");
for (model, mt) in &inner.by_model {
let model = escape_label(model);
out.push_str(&format!(
"gw_provider_calls_total{{model_id=\"{model}\",outcome=\"ok\"}} {}\n",
mt.ok
));
out.push_str(&format!(
"gw_provider_calls_total{{model_id=\"{model}\",outcome=\"error\"}} {}\n",
mt.errors
));
}
out
}
}
fn fill_bucket(b: &mut Bucket, r: &RequestRecord) {
b.requests += 1;
if r.outcome != "ok" {
b.errors += 1;
}
b.prompt_tokens += r.prompt_tokens as u64;
b.completion_tokens += r.completion_tokens as u64;
b.cost_usd += r.cost_usd;
b.latency_ms_sum += r.latency_ms;
}
fn avg(sum: u64, n: u64) -> u64 {
sum.checked_div(n).unwrap_or(0)
}
fn escape_label(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', " ")
}
/// Parse a duration string of the form `7d` / `24h` / `90m` / `3600s` into seconds.
pub fn parse_duration_secs(s: &str) -> Option<u64> {
let s = s.trim();
if s.is_empty() {
return None;
}
let (num, mult) = if let Some(n) = s.strip_suffix('d') {
(n, 86_400)
} else if let Some(n) = s.strip_suffix('h') {
(n, 3_600)
} else if let Some(n) = s.strip_suffix('m') {
(n, 60)
} else if let Some(n) = s.strip_suffix('s') {
(n, 1)
} else {
(s, 1)
};
num.trim().parse::<u64>().ok().map(|v| v * mult)
}