Skip to main content

lean_ctx/core/context_kernel/
client_profile.rs

1//! Client capability and efficiency profiles.
2
3use 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/// MCP capabilities advertised by a client.
12#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13pub struct McpFeatures {
14    /// Maximum number of tools the client can expose; zero means unspecified.
15    pub tool_limit: usize,
16    /// Whether the client supports MCP elicitation.
17    pub supports_elicitation: bool,
18    /// Whether the client supports MCP sampling.
19    pub supports_sampling: bool,
20}
21
22/// Limits applied to the tool catalog sent to a client.
23#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
24pub struct ToolBudget {
25    /// Maximum number of tools to expose.
26    pub max_tools: usize,
27    /// Maximum combined size of tool schemas, in tokens.
28    pub max_schema_tokens: usize,
29}
30
31impl ToolBudget {
32    /// Returns a practical tool budget for clients with no explicit limits.
33    #[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/// Client properties used to adapt context and tool delivery.
43#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub struct ClientEfficiencyProfile {
45    /// Stable client identifier.
46    pub client_id: String,
47    /// Degree of control available over the client's context.
48    pub coverage: CoverageClass,
49    /// MCP capabilities supported by the client.
50    pub mcp_features: McpFeatures,
51    /// Limits for tool metadata delivered to the client.
52    pub tool_budget: ToolBudget,
53    /// Model family reported by the client, when known.
54    pub model_family: Option<String>,
55    /// Maximum model context window, in tokens.
56    pub context_window: usize,
57    /// Whether the client supports streamed responses.
58    pub supports_streaming: bool,
59    /// Whether the client supports reusable cached context.
60    pub supports_caching: bool,
61    /// Target end-to-end latency budget, in milliseconds.
62    pub latency_budget_ms: u64,
63}
64
65/// Fluent builder for [`ClientEfficiencyProfile`].
66#[derive(Debug, Clone)]
67pub struct ProfileBuilder {
68    profile: ClientEfficiencyProfile,
69}
70
71impl ProfileBuilder {
72    /// Creates a builder with conservative, production-ready defaults.
73    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    /// Sets the client's coverage class.
90    #[must_use]
91    pub fn coverage(mut self, coverage: CoverageClass) -> Self {
92        self.profile.coverage = coverage;
93        self
94    }
95
96    /// Sets the model family reported by the client.
97    #[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    /// Sets the model context window in tokens.
104    #[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    /// Sets the tool catalog budget.
111    #[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    /// Sets supported MCP features.
118    #[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    /// Sets whether response streaming is supported.
125    #[must_use]
126    pub fn streaming(mut self, supports_streaming: bool) -> Self {
127        self.profile.supports_streaming = supports_streaming;
128        self
129    }
130
131    /// Sets whether reusable context caching is supported.
132    #[must_use]
133    pub fn caching(mut self, supports_caching: bool) -> Self {
134        self.profile.supports_caching = supports_caching;
135        self
136    }
137
138    /// Sets the target end-to-end latency budget in milliseconds.
139    #[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    /// Builds the client profile.
146    #[must_use]
147    pub fn build(self) -> ClientEfficiencyProfile {
148        self.profile
149    }
150}
151
152/// Detects a client profile from case-insensitive transport headers.
153#[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/// Merges non-default override fields into a base profile.
171#[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}