All checks were successful
CI / Format (push) Successful in 35s
CI / CUDA type-check (push) Successful in 1m38s
CI / Clippy (push) Successful in 2m18s
CI / Test (push) Successful in 4m54s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The router's /v1/models is now the deduped union of every reachable cortex's catalogue, so an opencode client doing discovery against the router resolves the whole federation without knowing about operators or cortexes (resolves #61's "Router/discovery contract"). To preserve per-model limit/cost, the topology poller now retains each cortex's full `cortex_core::node::CortexModelEntry` (was distilled to a {loaded, feasible} bool). `entry_feasible()` replaces the dropped field; dispatch (#73) and `cortexes_serving` use it — no routing behaviour change. `catalogue.rs::aggregate_models`: - Dedupe by model id; a model served by >=1 reachable cortex appears once. - Merge availability: `loaded` OR across operators; only feasible (loaded-or-cold-loadable) entries surface — a catalogue-only model no neuron can host is hidden. - Re-tier to operator names: `feasible_on` becomes the cortexes that can serve it and `locations` the operators it's loaded on (node = cortex name), so the federation view doesn't leak each operator's neuron names or per-device VRAM. - Conflict resolution: `limit` → tightest (smallest context, so a client never overflows the most-constrained operator); `cost` → cheapest (the federation "from" price). Richer range/region policy couples to #68, noted as follow-up. Tests: 4 unit (dedupe+merge, unreachable excluded, infeasible hidden, tightest-limit+cheapest-cost) + 1 end-to-end (two mock cortexes overlapping on a model → GET /v1/models over HTTP asserts the merged union). dispatch/topology suites updated for the entry-storage change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6o3ddqmYNh9kzdwq6eowh
244 lines
8.8 KiB
Rust
244 lines
8.8 KiB
Rust
//! Federation catalogue (#75) — the router's aggregate `/v1/models`.
|
|
//!
|
|
//! Presents the **deduped union** of every reachable cortex's `/v1/models`
|
|
//! as the router's own catalogue, so an opencode client doing discovery
|
|
//! against the router resolves the whole federation without knowing about
|
|
//! operators or cortexes (resolves #61's "Router/discovery contract").
|
|
//!
|
|
//! Re-tiering: the fractal design is neuron ← cortex ← router. At the
|
|
//! router tier the "nodes" are **cortexes**, so the merged entry's
|
|
//! `feasible_on` / `locations` are rewritten to **operator names**, not the
|
|
//! neuron names a cortex reports. That keeps the federation view honest
|
|
//! ("served by these operators") without leaking each operator's internal
|
|
//! topology (neuron names, per-device VRAM) to end users.
|
|
//!
|
|
//! Conflict resolution when operators advertise the same model with
|
|
//! different enrichment:
|
|
//! - **`limit`** → the *tightest* (smallest `context`), so a client never
|
|
//! overflows the most-constrained operator that might serve it (same rule
|
|
//! cortex uses across its neurons).
|
|
//! - **`cost`** → the *cheapest* (lowest input, then output), the
|
|
//! federation "from" price. Richer policy (a range, region/price-aware
|
|
//! selection) couples to #68 and is left as a follow-up.
|
|
|
|
use crate::state::{CortexTopology, entry_feasible};
|
|
use cortex_core::harness::{ModelCost, ModelLimit};
|
|
use cortex_core::node::{CortexModelEntry, ModelLocation, ModelStatus};
|
|
use std::collections::HashMap;
|
|
|
|
/// Build the federation catalogue: the deduped union of every reachable
|
|
/// cortex's serveable models, merged across operators and sorted by id.
|
|
pub fn aggregate_models(topology: &HashMap<String, CortexTopology>) -> Vec<CortexModelEntry> {
|
|
// Iterate cortexes in name order so `feasible_on` / `locations` and the
|
|
// limit/cost tie-breaks are deterministic regardless of map ordering.
|
|
let mut cortexes: Vec<(&String, &CortexTopology)> = topology.iter().collect();
|
|
cortexes.sort_by(|a, b| a.0.cmp(b.0));
|
|
|
|
let mut merged: HashMap<String, CortexModelEntry> = HashMap::new();
|
|
for (cortex_name, t) in cortexes {
|
|
if !t.reachable {
|
|
continue;
|
|
}
|
|
for entry in t.models.values() {
|
|
// Only surface models the cortex can actually serve — a
|
|
// catalogue-only entry no neuron can host shouldn't appear in
|
|
// the federation view.
|
|
if !entry_feasible(entry) {
|
|
continue;
|
|
}
|
|
merged
|
|
.entry(entry.id.clone())
|
|
.and_modify(|acc| merge_into(acc, cortex_name, entry))
|
|
.or_insert_with(|| router_entry(cortex_name, entry));
|
|
}
|
|
}
|
|
|
|
let mut out: Vec<CortexModelEntry> = merged.into_values().collect();
|
|
out.sort_by(|a, b| a.id.cmp(&b.id));
|
|
out
|
|
}
|
|
|
|
/// Seed a federation entry from the first cortex that serves the model,
|
|
/// re-tiering `feasible_on` / `locations` to the operator name.
|
|
fn router_entry(cortex: &str, e: &CortexModelEntry) -> CortexModelEntry {
|
|
CortexModelEntry {
|
|
id: e.id.clone(),
|
|
object: "model".into(),
|
|
created: e.created,
|
|
owned_by: e.owned_by.clone(),
|
|
loaded: e.loaded,
|
|
feasible_on: vec![cortex.to_string()],
|
|
locations: loaded_location(cortex, e),
|
|
capabilities: e.capabilities.clone(),
|
|
limit: e.limit.clone(),
|
|
cost: e.cost.clone(),
|
|
tool_call: e.tool_call,
|
|
reasoning: e.reasoning,
|
|
}
|
|
}
|
|
|
|
/// Fold another cortex's view of the same model into the merged entry.
|
|
fn merge_into(acc: &mut CortexModelEntry, cortex: &str, e: &CortexModelEntry) {
|
|
acc.loaded |= e.loaded;
|
|
acc.feasible_on.push(cortex.to_string());
|
|
acc.locations.extend(loaded_location(cortex, e));
|
|
for cap in &e.capabilities {
|
|
if !acc.capabilities.contains(cap) {
|
|
acc.capabilities.push(cap.clone());
|
|
}
|
|
}
|
|
acc.tool_call |= e.tool_call;
|
|
acc.reasoning |= e.reasoning;
|
|
acc.limit = tightest_limit(acc.limit.take(), e.limit.clone());
|
|
acc.cost = cheapest_cost(acc.cost.take(), e.cost.clone());
|
|
}
|
|
|
|
/// A single cortex-tier location when the model is loaded at that operator;
|
|
/// empty when only cold-loadable. Neuron-level VRAM is deliberately dropped.
|
|
fn loaded_location(cortex: &str, e: &CortexModelEntry) -> Vec<ModelLocation> {
|
|
if e.loaded {
|
|
vec![ModelLocation {
|
|
node: cortex.to_string(),
|
|
status: ModelStatus::Loaded,
|
|
vram_estimate_mb: None,
|
|
}]
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
/// Smaller `context` wins — never advertise more headroom than the
|
|
/// most-constrained operator can honour.
|
|
fn tightest_limit(a: Option<ModelLimit>, b: Option<ModelLimit>) -> Option<ModelLimit> {
|
|
match (a, b) {
|
|
(None, x) | (x, None) => x,
|
|
(Some(a), Some(b)) => Some(if b.context < a.context { b } else { a }),
|
|
}
|
|
}
|
|
|
|
/// Cheapest by (input, output) price — the federation "from" price.
|
|
fn cheapest_cost(a: Option<ModelCost>, b: Option<ModelCost>) -> Option<ModelCost> {
|
|
match (a, b) {
|
|
(None, x) | (x, None) => x,
|
|
(Some(a), Some(b)) => Some(if (b.input, b.output) < (a.input, a.output) {
|
|
b
|
|
} else {
|
|
a
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::state::CortexTopology;
|
|
|
|
fn entry(id: &str, loaded: bool, feasible: bool) -> CortexModelEntry {
|
|
CortexModelEntry {
|
|
id: id.into(),
|
|
object: "model".into(),
|
|
created: 0,
|
|
owned_by: "helexa".into(),
|
|
loaded,
|
|
feasible_on: if feasible || loaded {
|
|
vec!["some-neuron".into()]
|
|
} else {
|
|
vec![]
|
|
},
|
|
locations: vec![],
|
|
capabilities: vec![],
|
|
limit: None,
|
|
cost: None,
|
|
tool_call: false,
|
|
reasoning: false,
|
|
}
|
|
}
|
|
|
|
fn cortex(reachable: bool, entries: Vec<CortexModelEntry>) -> CortexTopology {
|
|
CortexTopology {
|
|
reachable,
|
|
consecutive_failures: 0,
|
|
last_poll: None,
|
|
healthy_nodes: 1,
|
|
total_nodes: 1,
|
|
models: entries.into_iter().map(|e| (e.id.clone(), e)).collect(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dedupes_and_merges_availability_across_cortexes() {
|
|
let mut topo = HashMap::new();
|
|
// c-a: model loaded. c-b: same model only cold-loadable.
|
|
topo.insert("c-a".into(), cortex(true, vec![entry("m", true, true)]));
|
|
topo.insert("c-b".into(), cortex(true, vec![entry("m", false, true)]));
|
|
|
|
let out = aggregate_models(&topo);
|
|
assert_eq!(out.len(), 1, "duplicate model id collapses to one");
|
|
let m = &out[0];
|
|
assert!(m.loaded, "loaded somewhere → loaded");
|
|
// feasible_on re-tiered to operator names, both present, sorted.
|
|
assert_eq!(m.feasible_on, vec!["c-a".to_string(), "c-b".to_string()]);
|
|
// Only the loaded operator contributes a location, named by operator.
|
|
assert_eq!(m.locations.len(), 1);
|
|
assert_eq!(m.locations[0].node, "c-a");
|
|
assert_eq!(m.locations[0].vram_estimate_mb, None);
|
|
}
|
|
|
|
#[test]
|
|
fn unreachable_cortex_is_excluded() {
|
|
let mut topo = HashMap::new();
|
|
topo.insert("up".into(), cortex(true, vec![entry("m", true, true)]));
|
|
topo.insert(
|
|
"down".into(),
|
|
cortex(false, vec![entry("other", true, true)]),
|
|
);
|
|
let out = aggregate_models(&topo);
|
|
assert_eq!(out.len(), 1);
|
|
assert_eq!(out[0].id, "m");
|
|
}
|
|
|
|
#[test]
|
|
fn catalogue_only_infeasible_entries_are_hidden() {
|
|
let mut topo = HashMap::new();
|
|
topo.insert("c".into(), cortex(true, vec![entry("ghost", false, false)]));
|
|
assert!(aggregate_models(&topo).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_tightest_limit_and_cheapest_cost() {
|
|
let mut a = entry("m", true, true);
|
|
a.limit = Some(ModelLimit {
|
|
context: 32_768,
|
|
input: None,
|
|
output: 4096,
|
|
});
|
|
a.cost = Some(ModelCost {
|
|
input: 0.50,
|
|
output: 1.50,
|
|
cache_read: None,
|
|
cache_write: None,
|
|
});
|
|
let mut b = entry("m", true, true);
|
|
b.limit = Some(ModelLimit {
|
|
context: 16_384, // tighter
|
|
input: None,
|
|
output: 4096,
|
|
});
|
|
b.cost = Some(ModelCost {
|
|
input: 0.20, // cheaper
|
|
output: 0.80,
|
|
cache_read: None,
|
|
cache_write: None,
|
|
});
|
|
|
|
let mut topo = HashMap::new();
|
|
topo.insert("c-a".into(), cortex(true, vec![a]));
|
|
topo.insert("c-b".into(), cortex(true, vec![b]));
|
|
|
|
let out = aggregate_models(&topo);
|
|
assert_eq!(out.len(), 1);
|
|
assert_eq!(out[0].limit.as_ref().unwrap().context, 16_384);
|
|
assert_eq!(out[0].cost.as_ref().unwrap().input, 0.20);
|
|
}
|
|
}
|