Files
infosave2007 f65f131c41
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
fix(routing): offered tools must not make a local model unroutable
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.
2026-08-21 09:59:28 +03:00

149 lines
5.1 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
}