Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s
Verified against a real Open WebUI container: it attaches its ~26 built-in tools (knowledge bases, notes, calendar, ...) to every single message, and resolve() treated that offer as a hard capability requirement. On a pool of tools-less local models a plain "hello" therefore failed with "No healthy model available that satisfies required capabilities", and a pinned local model was reported as "not available - not in the pool (deleted?)". In the OpenAI contract `tools` is an offer; only tool_choice: "required" (or a named function) makes the call impossible without them. Keep preferring a tool-capable model, but fall back to serving the request with the tools stripped - together with tool_choice/parallel_tool_calls, which a provider rejects on their own. A pin whose only shortcoming is tool support is now honoured; an unknown pin and tool_choice: "required" still fail loudly. The downgrade is never silent: X-Cortiq-Tools-Dropped: true, plus cortiq.tools_dropped in the echo block.
149 lines
5.1 KiB
Rust
149 lines
5.1 KiB
Rust
//! 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
|
||
}
|