1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
//! §Fase 68.c — pure model resolution (the capability resolver).
//!
//! Given a step's declared `requires_context: N` (the §68.b grammar) and the
//! resolved backend's model catalog (the §68.a `ModelCap` list), pick the
//! concrete model that satisfies the requirement — or fail closed. The doctrine
//! (Kivi brief #36):
//!
//! - **smallest-model-that-fits** (D68.2): the cheapest model whose context
//! window is `>= requires_context`. The adopter declares the floor; the
//! resolver optimises above it (a bigger model wastes money).
//! - **fail closed, never downgrade** (D68.3): when nothing satisfies, return
//! `NoModelSatisfies` — NEVER a too-small model that 400s in production. This
//! is the §36.b "no silent stub" discipline, for models.
//! - **`None` requirement = backend default** (D68.4, back-compat): a step with
//! no `requires_context:` resolves to the empty model string, which the
//! `ChatRequest` layer reads as "use the backend's `default_model()`" — every
//! pre-§68 flow is byte-identical.
//!
//! Pure + total + no I/O/env/clock (the `backend_resolution.rs` mould): the
//! caller passes the catalog, so the enterprise §68.h per-tenant catalog reuses
//! this exact function with different data — identical behaviour, governed source.
use crate::backends::model_catalog::ModelCap;
/// Why a model was chosen (the closed observability catalog).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelResolutionReason {
/// A `requires_context:` selected the smallest satisfying model.
RequirementSatisfied,
/// No requirement was declared → the backend default (empty model string).
BackendDefault,
}
impl ModelResolutionReason {
/// Stable wire/observability slug.
pub fn as_slug(self) -> &'static str {
match self {
ModelResolutionReason::RequirementSatisfied => "requirement_satisfied",
ModelResolutionReason::BackendDefault => "backend_default",
}
}
}
/// A resolved model + the rung that chose it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedModel {
/// The provider-native model id, OR the empty string meaning "let the backend
/// use its `default_model()`" (the back-compat path for a no-requirement step).
pub model: String,
pub reason: ModelResolutionReason,
}
/// The honest-failure outcome (D68.3): the step needs more context than any model
/// in the resolved backend's catalog can serve.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoModelSatisfies {
/// The context window the step declared it needs.
pub requires_context: u32,
/// The largest window any catalog model offers (`None` when the catalog is
/// empty — a custom/unknown backend whose models must be declared per-tenant).
pub largest_available: Option<u32>,
}
impl std::fmt::Display for NoModelSatisfies {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.largest_available {
Some(max) => write!(
f,
"no model satisfies the step's `requires_context: {}` — the resolved \
backend's largest context window is {max}. Lower the requirement, or \
configure a larger-context backend/model for this deployment.",
self.requires_context
),
None => write!(
f,
"no model satisfies the step's `requires_context: {}` — the resolved \
backend has no known model catalog (a custom backend). Declare the \
model's context window for this tenant, or use a canonical backend.",
self.requires_context
),
}
}
}
impl std::error::Error for NoModelSatisfies {}
/// §D68.2/3/4 — resolve the model for a step against a backend's catalog.
///
/// `models` is the resolved backend's `ModelCap` list (canonical §68.a or the
/// enterprise per-tenant catalog), assumed smallest-window-first (the §68.a
/// invariant — verified by the catalog's own ordering test). `requires_context`
/// is the step's declared need (`None` → no requirement).
pub fn resolve_model(
models: &[ModelCap],
requires_context: Option<u32>,
) -> Result<ResolvedModel, NoModelSatisfies> {
let need = match requires_context {
// D68.4 — no requirement: the backend default (empty model string is the
// `ChatRequest` "use default" sentinel). Byte-identical to every pre-§68 flow.
None => {
return Ok(ResolvedModel {
model: String::new(),
reason: ModelResolutionReason::BackendDefault,
})
}
Some(n) => n,
};
// D68.2 — the smallest model whose window fits. The catalog is smallest-first,
// so the first fit IS the smallest fit (a single forward scan).
if let Some(m) = models.iter().find(|m| m.context_window >= need) {
return Ok(ResolvedModel {
model: m.name.to_string(),
reason: ModelResolutionReason::RequirementSatisfied,
});
}
// D68.3 — fail closed. Never pick a too-small model.
Err(NoModelSatisfies {
requires_context: need,
largest_available: models.iter().map(|m| m.context_window).max(),
})
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::backends::model_catalog::models_for;
#[test]
fn none_requirement_resolves_to_the_backend_default_empty_string() {
// Back-compat (D68.4): no requirement → empty model → backend default.
let r = resolve_model(models_for("kimi"), None).unwrap();
assert_eq!(r.model, "");
assert_eq!(r.reason, ModelResolutionReason::BackendDefault);
}
#[test]
fn kimi_16k_requirement_picks_the_smallest_that_fits_32k() {
// The brief-#36 case: 16000 tokens against kimi's 8k/32k/128k ladder →
// moonshot-v1-32k (8k too small, 32k fits and is cheaper than 128k).
let r = resolve_model(models_for("kimi"), Some(16_000)).unwrap();
assert_eq!(r.model, "moonshot-v1-32k");
assert_eq!(r.reason, ModelResolutionReason::RequirementSatisfied);
}
#[test]
fn exact_boundary_fits_the_equal_window_model() {
// `>=` — a requirement equal to a window is satisfied by that model.
let r = resolve_model(models_for("kimi"), Some(8_192)).unwrap();
assert_eq!(r.model, "moonshot-v1-8k");
let r = resolve_model(models_for("kimi"), Some(32_768)).unwrap();
assert_eq!(r.model, "moonshot-v1-32k");
}
#[test]
fn just_over_a_boundary_steps_up_to_the_next_model() {
let r = resolve_model(models_for("kimi"), Some(8_193)).unwrap();
assert_eq!(r.model, "moonshot-v1-32k");
let r = resolve_model(models_for("kimi"), Some(32_769)).unwrap();
assert_eq!(r.model, "moonshot-v1-128k");
}
#[test]
fn unsatisfiable_requirement_fails_closed_never_downgrades() {
// 200k > kimi's largest (128k) → honest failure, NOT moonshot-v1-128k.
let err = resolve_model(models_for("kimi"), Some(200_000)).unwrap_err();
assert_eq!(err.requires_context, 200_000);
assert_eq!(err.largest_available, Some(131_072));
assert!(err.to_string().contains("131072"));
}
#[test]
fn empty_catalog_with_a_requirement_fails_closed_with_no_largest() {
// A custom backend (no catalog) cannot prove satisfaction → fail closed.
let err = resolve_model(&[], Some(4_096)).unwrap_err();
assert_eq!(err.largest_available, None);
assert!(err.to_string().contains("custom backend"));
}
#[test]
fn empty_catalog_with_no_requirement_still_resolves_to_default() {
// A custom backend with NO declared requirement is fine — backend default.
let r = resolve_model(&[], None).unwrap();
assert_eq!(r.model, "");
assert_eq!(r.reason, ModelResolutionReason::BackendDefault);
}
#[test]
fn large_context_backend_satisfies_from_its_single_default() {
// gemini-2.5-flash (1M) satisfies a big requirement from its one entry.
let r = resolve_model(models_for("gemini"), Some(500_000)).unwrap();
assert_eq!(r.model, "gemini-2.5-flash");
assert_eq!(r.reason, ModelResolutionReason::RequirementSatisfied);
}
}