lean_ctx/core/context_kernel/
client_profile.rs1use super::coverage_class::CoverageClass;
4
5const DEFAULT_CLIENT_ID: &str = "unknown";
6const DEFAULT_CONTEXT_WINDOW: usize = 128_000;
7const DEFAULT_MAX_TOOLS: usize = 64;
8const DEFAULT_MAX_SCHEMA_TOKENS: usize = 16_384;
9const DEFAULT_LATENCY_BUDGET_MS: u64 = 30_000;
10
11#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13pub struct McpFeatures {
14 pub tool_limit: usize,
16 pub supports_elicitation: bool,
18 pub supports_sampling: bool,
20}
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24pub struct ToolBudget {
25 pub max_tools: usize,
27 pub max_schema_tokens: usize,
29}
30
31impl ToolBudget {
32 #[must_use]
34 pub const fn new() -> Self {
35 Self {
36 max_tools: DEFAULT_MAX_TOOLS,
37 max_schema_tokens: DEFAULT_MAX_SCHEMA_TOKENS,
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub struct ClientEfficiencyProfile {
45 pub client_id: String,
47 pub coverage: CoverageClass,
49 pub mcp_features: McpFeatures,
51 pub tool_budget: ToolBudget,
53 pub model_family: Option<String>,
55 pub context_window: usize,
57 pub supports_streaming: bool,
59 pub supports_caching: bool,
61 pub latency_budget_ms: u64,
63}
64
65#[derive(Debug, Clone)]
67pub struct ProfileBuilder {
68 profile: ClientEfficiencyProfile,
69}
70
71impl ProfileBuilder {
72 pub fn new(client_id: impl Into<String>) -> Self {
74 Self {
75 profile: ClientEfficiencyProfile {
76 client_id: client_id.into(),
77 coverage: CoverageClass::default(),
78 mcp_features: McpFeatures::default(),
79 tool_budget: ToolBudget::new(),
80 model_family: None,
81 context_window: DEFAULT_CONTEXT_WINDOW,
82 supports_streaming: false,
83 supports_caching: false,
84 latency_budget_ms: DEFAULT_LATENCY_BUDGET_MS,
85 },
86 }
87 }
88
89 #[must_use]
91 pub fn coverage(mut self, coverage: CoverageClass) -> Self {
92 self.profile.coverage = coverage;
93 self
94 }
95
96 #[must_use]
98 pub fn model_family(mut self, model_family: impl Into<String>) -> Self {
99 self.profile.model_family = Some(model_family.into());
100 self
101 }
102
103 #[must_use]
105 pub fn context_window(mut self, context_window: usize) -> Self {
106 self.profile.context_window = context_window;
107 self
108 }
109
110 #[must_use]
112 pub fn tool_budget(mut self, tool_budget: ToolBudget) -> Self {
113 self.profile.tool_budget = tool_budget;
114 self
115 }
116
117 #[must_use]
119 pub fn mcp_features(mut self, mcp_features: McpFeatures) -> Self {
120 self.profile.mcp_features = mcp_features;
121 self
122 }
123
124 #[must_use]
126 pub fn streaming(mut self, supports_streaming: bool) -> Self {
127 self.profile.supports_streaming = supports_streaming;
128 self
129 }
130
131 #[must_use]
133 pub fn caching(mut self, supports_caching: bool) -> Self {
134 self.profile.supports_caching = supports_caching;
135 self
136 }
137
138 #[must_use]
140 pub fn latency_ms(mut self, latency_budget_ms: u64) -> Self {
141 self.profile.latency_budget_ms = latency_budget_ms;
142 self
143 }
144
145 #[must_use]
147 pub fn build(self) -> ClientEfficiencyProfile {
148 self.profile
149 }
150}
151
152#[must_use]
154pub fn detect_from_headers(headers: &[(String, String)]) -> ClientEfficiencyProfile {
155 let mut builder = ProfileBuilder::new(DEFAULT_CLIENT_ID);
156 for (name, value) in headers {
157 if name.eq_ignore_ascii_case("x-client-id") {
158 builder.profile.client_id.clone_from(value);
159 } else if name.eq_ignore_ascii_case("x-model-family") {
160 builder.profile.model_family = Some(value.clone());
161 } else if name.eq_ignore_ascii_case("x-context-window")
162 && let Ok(context_window) = value.parse::<usize>()
163 {
164 builder.profile.context_window = context_window;
165 }
166 }
167 builder.build()
168}
169
170#[must_use]
172pub fn merge_profiles(
173 base: &ClientEfficiencyProfile,
174 override_: &ClientEfficiencyProfile,
175) -> ClientEfficiencyProfile {
176 let mut merged = base.clone();
177 if !override_.client_id.is_empty() {
178 merged.client_id.clone_from(&override_.client_id);
179 }
180 if override_.coverage != CoverageClass::default() {
181 merged.coverage = override_.coverage;
182 }
183 if override_.mcp_features.tool_limit != 0 {
184 merged.mcp_features.tool_limit = override_.mcp_features.tool_limit;
185 }
186 if override_.mcp_features.supports_elicitation {
187 merged.mcp_features.supports_elicitation = true;
188 }
189 if override_.mcp_features.supports_sampling {
190 merged.mcp_features.supports_sampling = true;
191 }
192 if override_.tool_budget.max_tools != 0 {
193 merged.tool_budget.max_tools = override_.tool_budget.max_tools;
194 }
195 if override_.tool_budget.max_schema_tokens != 0 {
196 merged.tool_budget.max_schema_tokens = override_.tool_budget.max_schema_tokens;
197 }
198 if let Some(model_family) = &override_.model_family {
199 merged.model_family = Some(model_family.clone());
200 }
201 if override_.context_window != 0 {
202 merged.context_window = override_.context_window;
203 }
204 if override_.supports_streaming {
205 merged.supports_streaming = true;
206 }
207 if override_.supports_caching {
208 merged.supports_caching = true;
209 }
210 if override_.latency_budget_ms != 0 {
211 merged.latency_budget_ms = override_.latency_budget_ms;
212 }
213 merged
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 #[test]
220 fn builder_defaults() {
221 let profile = ProfileBuilder::new("test").build();
222 assert_eq!(profile.client_id, "test");
223 assert_eq!(profile.coverage, CoverageClass::default());
224 assert!(profile.context_window > 0);
225 assert!(profile.tool_budget.max_tools > 0);
226 }
227 #[test]
228 fn builder_chain() {
229 let features = McpFeatures {
230 tool_limit: 12,
231 supports_elicitation: true,
232 supports_sampling: true,
233 };
234 let budget = ToolBudget {
235 max_tools: 10,
236 max_schema_tokens: 2_048,
237 };
238 let profile = ProfileBuilder::new("client")
239 .coverage(CoverageClass::FullInline)
240 .model_family("gpt")
241 .context_window(32_000)
242 .tool_budget(budget.clone())
243 .mcp_features(features.clone())
244 .streaming(true)
245 .caching(true)
246 .latency_ms(900)
247 .build();
248 assert_eq!(profile.coverage, CoverageClass::FullInline);
249 assert_eq!(profile.model_family.as_deref(), Some("gpt"));
250 assert_eq!(profile.context_window, 32_000);
251 assert_eq!(profile.tool_budget, budget);
252 assert_eq!(profile.mcp_features, features);
253 assert!(profile.supports_streaming);
254 assert!(profile.supports_caching);
255 assert_eq!(profile.latency_budget_ms, 900);
256 }
257 #[test]
258 fn detect_from_empty_headers() {
259 assert_eq!(detect_from_headers(&[]).client_id, DEFAULT_CLIENT_ID);
260 }
261 #[test]
262 fn detect_from_headers_with_client_id() {
263 let headers = vec![("X-Client-Id".to_owned(), "codex".to_owned())];
264 assert_eq!(detect_from_headers(&headers).client_id, "codex");
265 }
266 #[test]
267 fn merge_overrides_non_default() {
268 let base = ProfileBuilder::new("base")
269 .model_family("base-model")
270 .context_window(8_000)
271 .latency_ms(2_000)
272 .build();
273 let override_ = ProfileBuilder::new("override")
274 .coverage(CoverageClass::FullInline)
275 .model_family("new-model")
276 .context_window(16_000)
277 .latency_ms(500)
278 .build();
279 let merged = merge_profiles(&base, &override_);
280 assert_eq!(merged.client_id, "override");
281 assert_eq!(merged.coverage, CoverageClass::FullInline);
282 assert_eq!(merged.model_family.as_deref(), Some("new-model"));
283 assert_eq!(merged.context_window, 16_000);
284 assert_eq!(merged.latency_budget_ms, 500);
285 }
286 #[test]
287 fn serde_roundtrip() {
288 let profile = ProfileBuilder::new("serde")
289 .coverage(CoverageClass::ObserveOnly)
290 .model_family("family")
291 .context_window(64_000)
292 .streaming(false)
293 .latency_ms(700)
294 .build();
295 let json = serde_json::to_string(&profile).expect("profile must serialize");
296 let decoded: ClientEfficiencyProfile =
297 serde_json::from_str(&json).expect("profile must deserialize");
298 assert_eq!(decoded, profile);
299 }
300}