cortiq-gateway 0.2.49

Universal LLM gateway with intelligent routing and an embedded multilingual admin console
//! 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, Default, Serialize, Deserialize)]
pub struct Message {
    pub role: String, // system | user | assistant | tool
    /// Always a plain string internally. On the wire OpenAI clients legitimately
    /// send `null` (assistant turns that only carry tool calls) or an array of
    /// content parts (Open WebUI and every other multimodal UI), so
    /// [`de_content`] flattens both shapes instead of rejecting the request.
    #[serde(default, deserialize_with = "de_content")]
    pub content: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tool_calls: Vec<serde_json::Value>,
    /// Speaker name (`messages[].name`) — proxied through when the client sends it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// Set on `role = "tool"` replies; dropping it breaks multi-turn tool calling.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_call_id: Option<String>,
}

/// Deserialize `messages[].content` from any shape an OpenAI-compatible client
/// may send: a string, `null`, or an array of content parts. Text parts are
/// concatenated; non-text parts (images, audio, files) are skipped — this
/// gateway routes text, and a picture must not turn into a 422.
fn de_content<'de, D>(d: D) -> std::result::Result<String, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::Deserialize as _;
    Ok(match serde_json::Value::deserialize(d)? {
        serde_json::Value::Null => String::new(),
        serde_json::Value::String(s) => s,
        serde_json::Value::Array(parts) => {
            let mut out = String::new();
            for part in parts {
                // {"type":"text","text":"..."} / {"type":"input_text","text":"..."}
                let text = part
                    .get("text")
                    .and_then(|t| t.as_str())
                    .or_else(|| part.as_str());
                if let Some(t) = text {
                    out.push_str(t);
                }
            }
            out
        }
        other => other.to_string(),
    })
}

#[derive(Clone, Debug, Default)]
pub struct GenParams {
    pub temperature: Option<f32>,
    pub max_tokens: Option<u32>,
    pub top_p: Option<f32>,
    pub think_budget: Option<u32>,
    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,
    /// The client offered `tools` but the model that answered cannot use them,
    /// so they were dropped from the upstream call (see `Pipeline::resolve`).
    /// Surfaced as `X-Cortiq-Tools-Dropped` so a silent downgrade is debuggable.
    #[serde(default)]
    pub tools_dropped: 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
}