feat(cmf): auto-detect tool support of managed local servers
cortiq serve >=0.5.62 advertises the loaded model's capabilities in
/healthz ({"capabilities":{"tools":true}} when the chat template has a
tools branch). The gateway now reads that handshake when the managed
server becomes healthy and treats detected tool support as equivalent
to declared caps in the candidate filter — so a low-tier chain made of
a local model no longer widens agent (tools) traffic to cloud models
once the local server can actually drive tools.
Also adds an optional manual "caps" override to [[cmf.servers]] for
older serve builds that do not advertise capabilities.
This commit is contained in:
@@ -2197,6 +2197,7 @@ fn register_cmf_output(cfg: &mut crate::config::Config, output: &str) -> Option<
|
|||||||
o1_window: None,
|
o1_window: None,
|
||||||
o1_sink: None,
|
o1_sink: None,
|
||||||
skip_mtp: false,
|
skip_mtp: false,
|
||||||
|
caps: Vec::new(),
|
||||||
});
|
});
|
||||||
Some(id)
|
Some(id)
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-5
@@ -56,6 +56,9 @@ struct Managed {
|
|||||||
pub struct CmfRuntime {
|
pub struct CmfRuntime {
|
||||||
status: Mutex<CmfStatus>,
|
status: Mutex<CmfStatus>,
|
||||||
children: Mutex<Vec<Managed>>,
|
children: Mutex<Vec<Managed>>,
|
||||||
|
/// Server ids whose healthz handshake advertised tool support — lets the
|
||||||
|
/// pipeline route tools traffic to local models with no manual caps.
|
||||||
|
pub detected_tools: Mutex<std::collections::HashSet<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CmfRuntime {
|
impl CmfRuntime {
|
||||||
@@ -63,6 +66,7 @@ impl CmfRuntime {
|
|||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
status: Mutex::new(CmfStatus::default()),
|
status: Mutex::new(CmfStatus::default()),
|
||||||
children: Mutex::new(Vec::new()),
|
children: Mutex::new(Vec::new()),
|
||||||
|
detected_tools: Mutex::new(std::collections::HashSet::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +197,7 @@ impl Default for CmfRuntime {
|
|||||||
Self {
|
Self {
|
||||||
status: Mutex::new(CmfStatus::default()),
|
status: Mutex::new(CmfStatus::default()),
|
||||||
children: Mutex::new(Vec::new()),
|
children: Mutex::new(Vec::new()),
|
||||||
|
detected_tools: Mutex::new(std::collections::HashSet::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,8 +337,11 @@ fn spawn_serve(server: &CmfServer, cfg: &CmfCfg) -> Result<Child, String> {
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Poll `/healthz` until the local server answers 200 (or the deadline passes).
|
/// Poll `/healthz` until the local server answers 200 (or the deadline
|
||||||
async fn wait_healthy(host: &str, port: u16, tries: u32) -> bool {
|
/// passes). Newer `cortiq serve` advertises the loaded model's capabilities
|
||||||
|
/// in the healthz body ({"capabilities":{"tools":true}}) — returned so the
|
||||||
|
/// caller can register them; older servers return `None` there.
|
||||||
|
async fn wait_healthy(host: &str, port: u16, tries: u32) -> (bool, Option<bool>) {
|
||||||
let url = format!("http://{host}:{port}/healthz");
|
let url = format!("http://{host}:{port}/healthz");
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
for _ in 0..tries {
|
for _ in 0..tries {
|
||||||
@@ -344,12 +352,17 @@ async fn wait_healthy(host: &str, port: u16, tries: u32) -> bool {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
if r.status().is_success() {
|
if r.status().is_success() {
|
||||||
return true;
|
let tools = r
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v["capabilities"]["tools"].as_bool());
|
||||||
|
return (true, tools);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
}
|
}
|
||||||
false
|
(false, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Orchestrate the whole lifecycle: ensure installed → maybe update → spawn each
|
/// Orchestrate the whole lifecycle: ensure installed → maybe update → spawn each
|
||||||
@@ -432,8 +445,17 @@ pub async fn manage(rt: Arc<CmfRuntime>, cfg: CmfCfg) {
|
|||||||
if !rt.is_running(&server.id) {
|
if !rt.is_running(&server.id) {
|
||||||
continue; // failed to spawn / file missing
|
continue; // failed to spawn / file missing
|
||||||
}
|
}
|
||||||
if wait_healthy(&cfg.local_host, server.port, 240).await {
|
let (ok, tools) = wait_healthy(&cfg.local_host, server.port, 240).await;
|
||||||
|
if ok {
|
||||||
rt.set_healthy(&server.id, true);
|
rt.set_healthy(&server.id, true);
|
||||||
|
// capability handshake: the server said whether its model's chat
|
||||||
|
// template can drive tool calling — no manual caps needed
|
||||||
|
if tools == Some(true) {
|
||||||
|
rt.detected_tools.lock().unwrap().insert(server.id.clone());
|
||||||
|
tracing::info!(id = %server.id, "local server advertises tool support");
|
||||||
|
} else {
|
||||||
|
rt.detected_tools.lock().unwrap().remove(&server.id);
|
||||||
|
}
|
||||||
rt.log(format!(
|
rt.log(format!(
|
||||||
"local CMF server ready — '{}' at http://{}:{}/v1",
|
"local CMF server ready — '{}' at http://{}:{}/v1",
|
||||||
server.id, cfg.local_host, server.port
|
server.id, cfg.local_host, server.port
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ pub struct CmfServer {
|
|||||||
/// Disable Multi-Token Prediction speculative decoding (CMF_MTP=0).
|
/// Disable Multi-Token Prediction speculative decoding (CMF_MTP=0).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub skip_mtp: bool,
|
pub skip_mtp: bool,
|
||||||
|
/// Manual capability override (e.g. ["tools"]). Usually unnecessary: the
|
||||||
|
/// gateway auto-detects tool support from the server's healthz handshake.
|
||||||
|
#[serde(default)]
|
||||||
|
pub caps: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CMF-format model factory: import a HuggingFace model, convert it to a
|
/// CMF-format model factory: import a HuggingFace model, convert it to a
|
||||||
@@ -229,6 +233,7 @@ impl CmfCfg {
|
|||||||
o1_window: None,
|
o1_window: None,
|
||||||
o1_sink: None,
|
o1_sink: None,
|
||||||
skip_mtp: false,
|
skip_mtp: false,
|
||||||
|
caps: Vec::new(),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
|||||||
+15
-5
@@ -183,6 +183,9 @@ impl Pipeline {
|
|||||||
&self,
|
&self,
|
||||||
req: &ChatRequest,
|
req: &ChatRequest,
|
||||||
live: &crate::state::Live,
|
live: &crate::state::Live,
|
||||||
|
// Tool support detected via the local servers' healthz handshake —
|
||||||
|
// treated as equivalent to declared caps when filtering candidates.
|
||||||
|
detected_tools: &std::collections::HashSet<String>,
|
||||||
) -> Result<(RouteDecision, String, Vec<String>)> {
|
) -> Result<(RouteDecision, String, Vec<String>)> {
|
||||||
// 1. Extract text for classification
|
// 1. Extract text for classification
|
||||||
let text = self.extract_text(
|
let text = self.extract_text(
|
||||||
@@ -274,10 +277,13 @@ impl Pipeline {
|
|||||||
|
|
||||||
// Filter candidates by capabilities (e.g. tools support)
|
// Filter candidates by capabilities (e.g. tools support)
|
||||||
let needs_tools = !req.tools.is_empty();
|
let needs_tools = !req.tools.is_empty();
|
||||||
|
let has_tools = |id: &str, prov: &std::sync::Arc<dyn crate::providers::Provider>| {
|
||||||
|
prov.caps().tools || detected_tools.contains(id)
|
||||||
|
};
|
||||||
let mut final_candidates = Vec::new();
|
let mut final_candidates = Vec::new();
|
||||||
for model_id in candidates {
|
for model_id in candidates {
|
||||||
if let Some(prov) = live.registry.get(&model_id) {
|
if let Some(prov) = live.registry.get(&model_id) {
|
||||||
if needs_tools && !prov.caps().tools {
|
if needs_tools && !has_tools(&model_id, &prov) {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Candidate '{}' skipped: lacks support for requested tools",
|
"Candidate '{}' skipped: lacks support for requested tools",
|
||||||
model_id
|
model_id
|
||||||
@@ -306,7 +312,7 @@ impl Pipeline {
|
|||||||
if final_candidates.is_empty() && live.cfg.cmf.manage_server {
|
if final_candidates.is_empty() && live.cfg.cmf.manage_server {
|
||||||
for id in live.local_candidates() {
|
for id in live.local_candidates() {
|
||||||
if let Some(prov) = live.registry.get(&id) {
|
if let Some(prov) = live.registry.get(&id) {
|
||||||
if needs_tools && !prov.caps().tools {
|
if needs_tools && !has_tools(&id, &prov) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final_candidates.push(id);
|
final_candidates.push(id);
|
||||||
@@ -336,7 +342,7 @@ impl Pipeline {
|
|||||||
}
|
}
|
||||||
for id in widened {
|
for id in widened {
|
||||||
if let Some(prov) = live.registry.get(&id) {
|
if let Some(prov) = live.registry.get(&id) {
|
||||||
if needs_tools && !prov.caps().tools {
|
if needs_tools && !has_tools(&id, &prov) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final_candidates.push(id);
|
final_candidates.push(id);
|
||||||
@@ -360,7 +366,9 @@ impl Pipeline {
|
|||||||
|
|
||||||
async fn run_inner(&self, req: ChatRequest, state: &SharedState) -> Result<ChatResponse> {
|
async fn run_inner(&self, req: ChatRequest, state: &SharedState) -> Result<ChatResponse> {
|
||||||
let live = state.live();
|
let live = state.live();
|
||||||
let (decision, route_source, mut final_candidates) = self.resolve(&req, &live).await?;
|
let detected_tools = state.cmf.detected_tools.lock().unwrap().clone();
|
||||||
|
let (decision, route_source, mut final_candidates) =
|
||||||
|
self.resolve(&req, &live, &detected_tools).await?;
|
||||||
|
|
||||||
// Serving-when-promoted (gated): a promoted task-label is served by the
|
// Serving-when-promoted (gated): a promoted task-label is served by the
|
||||||
// local model FIRST (failover to cloud on error). Per-request VETO uses
|
// local model FIRST (failover to cloud on error). Per-request VETO uses
|
||||||
@@ -507,7 +515,9 @@ impl Pipeline {
|
|||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let live = state.live();
|
let live = state.live();
|
||||||
let (decision, route_source, final_candidates) = match self.resolve(&req, &live).await {
|
let detected_tools = state.cmf.detected_tools.lock().unwrap().clone();
|
||||||
|
let (decision, route_source, final_candidates) =
|
||||||
|
match self.resolve(&req, &live, &detected_tools).await {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
record_error(state, &account, &protocol, &directive, started, &e);
|
record_error(state, &account, &protocol, &directive, started, &e);
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ impl Registry {
|
|||||||
price_out: 0.0,
|
price_out: 0.0,
|
||||||
kind: "chat".into(),
|
kind: "chat".into(),
|
||||||
api_key_env: None,
|
api_key_env: None,
|
||||||
caps: Vec::new(),
|
caps: s.caps.clone(),
|
||||||
temperature: s.temperature,
|
temperature: s.temperature,
|
||||||
top_p: s.top_p,
|
top_p: s.top_p,
|
||||||
max_tokens: s.max_tokens,
|
max_tokens: s.max_tokens,
|
||||||
|
|||||||
Reference in New Issue
Block a user