Merge pull request 'feat(cortex): re-expose flat max_model_len / max_input_tokens / max_output_tokens on /v1/models (#78)' (#107) from feat/78-max-model-len into main
Some checks failed
build-prerelease / Lint (fmt + clippy) (push) Blocked by required conditions
build-prerelease / Build neuron-blackwell (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Test (push) Has been cancelled
build-prerelease / Build cortex binary (push) Has been cancelled
build-prerelease / Build helexa-bench binary (push) Has been cancelled
build-prerelease / Build helexa-upstream binary (push) Has been cancelled
build-prerelease / Build neuron-ampere (push) Has been cancelled
build-prerelease / Build neuron-ada (push) Has been cancelled
build-prerelease / Package cortex RPM (push) Has been cancelled
build-prerelease / Package helexa-bench RPM (push) Has been cancelled
build-prerelease / Package helexa-upstream RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled

This commit was merged in pull request #107.
This commit is contained in:
2026-07-01 20:58:08 +00:00
5 changed files with 118 additions and 8 deletions

View File

@@ -147,6 +147,39 @@ pub struct CortexModelEntry {
/// `true` when any neuron reports this model supports reasoning tokens. /// `true` when any neuron reports this model supports reasoning tokens.
#[serde(default)] #[serde(default)]
pub reasoning: bool, pub reasoning: bool,
// ── Flat ecosystem context-window fields (issue #78) ──────
// Duplicates of `limit` under the flat, vLLM-convention key names
// (`max_model_len` et al.) that OpenAI-ecosystem clients (Hermes
// Agent, vLLM tooling) probe for — they cannot see `limit.context`.
// Additive: `limit` stays the opencode-oriented source of truth.
// Derived, never set directly — call [`sync_flat_limit`] after the
// final `limit` value is known. Omitted (not `0`) when the window
// is unknown; absent-vs-zero is load-bearing, as with `cost`.
//
// [`sync_flat_limit`]: CortexModelEntry::sync_flat_limit
/// Served max-seq-len in tokens — mirrors `limit.context`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_model_len: Option<usize>,
/// Usable input budget — mirrors `limit.input` when present.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_input_tokens: Option<usize>,
/// Maximum generation tokens — mirrors `limit.output`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output_tokens: Option<usize>,
}
impl CortexModelEntry {
/// Re-derive the flat ecosystem fields (#78) from `limit`.
///
/// Must run after the final `limit` is known (post merge/tightening),
/// immediately before serialization. Fully overwrites: a `None` limit
/// clears the flat fields, so stale values can't survive a merge that
/// dropped the limit.
pub fn sync_flat_limit(&mut self) {
self.max_model_len = self.limit.as_ref().map(|l| l.context);
self.max_input_tokens = self.limit.as_ref().and_then(|l| l.input);
self.max_output_tokens = self.limit.as_ref().map(|l| l.output);
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -581,6 +581,11 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
// Runtime-detected — will be OR-ed in Pass 2 from neuron data. // Runtime-detected — will be OR-ed in Pass 2 from neuron data.
tool_call: false, tool_call: false,
reasoning: false, reasoning: false,
// Flat #78 fields are derived from `limit` in the final
// sync pass, once merging is done.
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
}, },
); );
} }
@@ -638,6 +643,9 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
cost: None, cost: None,
tool_call: entry.tool_call, tool_call: entry.tool_call,
reasoning: entry.reasoning, reasoning: entry.reasoning,
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
}); });
} }
} }
@@ -694,6 +702,9 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
cost: None, cost: None,
tool_call: false, tool_call: false,
reasoning: false, reasoning: false,
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
}); });
} }
} }
@@ -728,11 +739,24 @@ async fn list_models(State(fleet): State<Arc<CortexState>>) -> Json<Value> {
cost: target_entry.cost.clone(), cost: target_entry.cost.clone(),
tool_call: target_entry.tool_call, tool_call: target_entry.tool_call,
reasoning: target_entry.reasoning, reasoning: target_entry.reasoning,
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
}, },
); );
} }
let data: Vec<Value> = entries.values().map(|e| json!(e)).collect(); // Final pass: derive the flat ecosystem context-window fields (#78)
// from each entry's now-settled `limit`, so vLLM-convention clients
// (Hermes Agent et al.) can read the window without knowing helexa's
// `limit` schema.
let data: Vec<Value> = entries
.values_mut()
.map(|e| {
e.sync_flat_limit();
json!(e)
})
.collect();
Json(json!({ Json(json!({
"object": "list", "object": "list",
"data": data, "data": data,

View File

@@ -8,8 +8,11 @@
//! - `cost` from the catalogue profile (operator-set pricing). //! - `cost` from the catalogue profile (operator-set pricing).
//! - `tool_call` / `reasoning` from the neuron's runtime detection (OR-ed in) //! - `tool_call` / `reasoning` from the neuron's runtime detection (OR-ed in)
//! //!
//! Also a regression guard for the removal of `max_model_len` — the misnamed, //! Also asserts the flat, vLLM-convention duplicates (`max_model_len`,
//! unconsumed vLLM-ism that this contract replaces. //! `max_input_tokens`, `max_output_tokens`) mirror `limit` (#78): the
//! earlier removal of `max_model_len` as "unconsumed" was wrong — Hermes
//! Agent (and the wider OpenAI client ecosystem) probes those flat keys
//! and cannot see `limit.context`.
use cortex_core::config::{ use cortex_core::config::{
EvictionSettings, EvictionStrategy, GatewayConfig, GatewaySettings, NeuronEndpoint, EvictionSettings, EvictionStrategy, GatewayConfig, GatewaySettings, NeuronEndpoint,
@@ -85,6 +88,21 @@ capabilities = ["text"]
}), }),
}, },
); );
// A model with no derivable limit: the flat #78 fields must be
// OMITTED (absent-vs-zero is load-bearing), never 0 or a guess.
node.models.insert(
"no-limit-model".into(),
ModelEntry {
id: "no-limit-model".into(),
status: ModelStatus::Loaded,
last_accessed: None,
vram_estimate_mb: None,
capabilities: vec!["text".into()],
tool_call: false,
reasoning: false,
limit: None,
},
);
} }
let app = cortex_gateway::build_app(Arc::clone(&fleet)); let app = cortex_gateway::build_app(Arc::clone(&fleet));
@@ -123,11 +141,26 @@ capabilities = ["text"]
assert_eq!(entry["tool_call"], true); assert_eq!(entry["tool_call"], true);
assert_eq!(entry["reasoning"], true); assert_eq!(entry["reasoning"], true);
// Regression guard: the removed, unconsumed vLLM-ism must not reappear. // Flat ecosystem duplicates (#78) mirror the advertised `limit` so
assert!( // vLLM-convention probes (Hermes Agent) auto-detect the window.
entry.get("max_model_len").is_none(), assert_eq!(entry["max_model_len"], 49152);
"max_model_len was removed; /v1/models must not advertise it" assert_eq!(entry["max_input_tokens"], 40960);
); assert_eq!(entry["max_output_tokens"], 8192);
// No limit → flat fields omitted entirely, never 0 or a guess.
let unknown = body["data"]
.as_array()
.unwrap()
.iter()
.find(|m| m["id"] == "no-limit-model")
.expect("no-limit-model present in /v1/models");
assert!(unknown.get("limit").is_none());
for key in ["max_model_len", "max_input_tokens", "max_output_tokens"] {
assert!(
unknown.get(key).is_none(),
"{key} must be omitted when the window is unknown"
);
}
let _ = std::fs::remove_file(&cat_path); let _ = std::fs::remove_file(&cat_path);
} }

View File

@@ -55,6 +55,12 @@ pub fn aggregate_models(topology: &HashMap<String, CortexTopology>) -> Vec<Corte
let mut out: Vec<CortexModelEntry> = merged.into_values().collect(); let mut out: Vec<CortexModelEntry> = merged.into_values().collect();
out.sort_by(|a, b| a.id.cmp(&b.id)); out.sort_by(|a, b| a.id.cmp(&b.id));
// Re-derive the flat ecosystem fields (#78) from the merged (tightest)
// limit — the values deserialized from each cortex are per-operator and
// may not match the federation-wide merge.
for e in &mut out {
e.sync_flat_limit();
}
out out
} }
@@ -74,6 +80,10 @@ fn router_entry(cortex: &str, e: &CortexModelEntry) -> CortexModelEntry {
cost: e.cost.clone(), cost: e.cost.clone(),
tool_call: e.tool_call, tool_call: e.tool_call,
reasoning: e.reasoning, reasoning: e.reasoning,
// Derived from `limit` by the final sync pass in aggregate_models.
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
} }
} }
@@ -151,6 +161,9 @@ mod tests {
cost: None, cost: None,
tool_call: false, tool_call: false,
reasoning: false, reasoning: false,
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
} }
} }
@@ -239,5 +252,9 @@ mod tests {
assert_eq!(out.len(), 1); assert_eq!(out.len(), 1);
assert_eq!(out[0].limit.as_ref().unwrap().context, 16_384); assert_eq!(out[0].limit.as_ref().unwrap().context, 16_384);
assert_eq!(out[0].cost.as_ref().unwrap().input, 0.20); assert_eq!(out[0].cost.as_ref().unwrap().input, 0.20);
// Flat #78 fields re-derived from the merged (tightest) limit.
assert_eq!(out[0].max_model_len, Some(16_384));
assert_eq!(out[0].max_input_tokens, None);
assert_eq!(out[0].max_output_tokens, Some(4096));
} }
} }

View File

@@ -39,6 +39,9 @@ fn model_entry(loaded: bool, feasible: bool) -> CortexModelEntry {
cost: None, cost: None,
tool_call: false, tool_call: false,
reasoning: false, reasoning: false,
max_model_len: None,
max_input_tokens: None,
max_output_tokens: None,
} }
} }