//! 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 }, /// `model = ""` — 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, /// Speaker name (`messages[].name`) — proxied through when the client sends it. #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, /// 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, } /// 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 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, pub max_tokens: Option, pub top_p: Option, pub think_budget: Option, pub stop: Vec, /// Fields we do not interpret but must proxy through to the provider. pub passthrough: serde_json::Map, } #[derive(Clone, Debug, Default)] pub struct RequestMeta { pub account: String, pub protocol: String, pub idempotency_key: Option, pub traceparent: Option, } /// Canonical form of a generation request. #[derive(Clone, Debug)] pub struct ChatRequest { pub routing: RoutingDirective, pub messages: Vec, pub tools: Vec, 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, 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, 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, pub source: String, // router | cache | fallback }