1use std::collections::hash_map::DefaultHasher;
7use std::hash::{Hash, Hasher};
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use super::support::ratio_of;
13
14pub const TOKEN_BYTES_DIVISOR: usize = 3;
18
19pub const TOKEN_ITEM_OVERHEAD: usize = 16;
24
25pub const TARGET_BUDGET_RATIO: f64 = 0.80;
30
31pub const AUTO_COMPACTION_RATIO: f64 = 0.92;
34
35pub const PROVIDER_OVERHEAD_TOKENS: u64 = 1_024;
40
41pub const FALLBACK_CONTEXT_WINDOW: u64 = 32_768;
43
44pub const FALLBACK_MAX_COMPLETION_TOKENS: u64 = 4_096;
46
47pub const FALLBACK_RECOMMENDED_COMPLETION_TOKENS: u64 = 4_096;
50
51#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ContextItemKind {
58 Harness,
61 ProjectInstruction,
63 PinnedFile,
65 Skill,
67 Transcript,
69 Summary,
71 ToolArchive,
73}
74
75impl ContextItemKind {
76 pub fn label(&self) -> &'static str {
78 match self {
79 ContextItemKind::Harness => "harness",
80 ContextItemKind::ProjectInstruction => "project_instruction",
81 ContextItemKind::PinnedFile => "pinned_file",
82 ContextItemKind::Skill => "skill",
83 ContextItemKind::Transcript => "transcript",
84 ContextItemKind::Summary => "summary",
85 ContextItemKind::ToolArchive => "tool_archive",
86 }
87 }
88}
89
90#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum ContextVisibility {
97 Visible,
99 Pinned,
101 SummaryOnly,
103 Archived,
105 Candidate,
107 Dropped,
109 Blocked,
111}
112
113impl ContextVisibility {
114 pub fn is_rendered(&self) -> bool {
116 matches!(self, ContextVisibility::Visible | ContextVisibility::Pinned)
117 }
118
119 pub fn label(&self) -> &'static str {
121 match self {
122 ContextVisibility::Visible => "visible",
123 ContextVisibility::Pinned => "pinned",
124 ContextVisibility::SummaryOnly => "summary_only",
125 ContextVisibility::Archived => "archived",
126 ContextVisibility::Candidate => "candidate",
127 ContextVisibility::Dropped => "dropped",
128 ContextVisibility::Blocked => "blocked",
129 }
130 }
131
132 pub fn reason(&self, base: &str) -> String {
134 match self {
135 ContextVisibility::Visible => base.to_string(),
136 ContextVisibility::Pinned => format!("{base}: pinned"),
137 ContextVisibility::SummaryOnly => format!("{base}: summary-only"),
138 ContextVisibility::Archived => format!("{base}: archived"),
139 ContextVisibility::Candidate => format!("{base}: candidate (not selected)"),
140 ContextVisibility::Dropped => format!("{base}: dropped"),
141 ContextVisibility::Blocked => format!("{base}: blocked (oversized)"),
142 }
143 }
144}
145
146#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum ModelLimitSource {
154 Fallback = 0,
156 Static = 1,
158 LiveMetadata = 2,
160 UserOverride = 3,
162}
163
164impl ModelLimitSource {
165 pub fn label(&self) -> &'static str {
167 match self {
168 ModelLimitSource::Fallback => "fallback",
169 ModelLimitSource::Static => "static",
170 ModelLimitSource::LiveMetadata => "live-metadata",
171 ModelLimitSource::UserOverride => "user-override",
172 }
173 }
174}
175
176#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum ModelLimitConfidence {
180 Conservative,
182 ProviderReported,
184 Exact,
186 UserSupplied,
188}
189
190impl ModelLimitConfidence {
191 pub fn label(&self) -> &'static str {
193 match self {
194 ModelLimitConfidence::Conservative => "conservative",
195 ModelLimitConfidence::ProviderReported => "provider-reported",
196 ModelLimitConfidence::Exact => "exact",
197 ModelLimitConfidence::UserSupplied => "user-supplied",
198 }
199 }
200}
201
202#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum DiagnosticSeverity {
206 Info,
208 Warning,
210 Error,
212}
213
214impl DiagnosticSeverity {
215 pub fn label(&self) -> &'static str {
217 match self {
218 DiagnosticSeverity::Info => "info",
219 DiagnosticSeverity::Warning => "warning",
220 DiagnosticSeverity::Error => "error",
221 }
222 }
223}
224
225#[derive(Clone, Debug, Eq, PartialEq)]
230pub struct ModelContextLimits {
231 pub provider: String,
233 pub model: String,
235 pub context_window: u64,
237 pub max_completion_tokens: u64,
239 pub recommended_completion_tokens: u64,
241 pub source: ModelLimitSource,
243 pub confidence: ModelLimitConfidence,
245}
246
247impl ModelContextLimits {
248 pub fn available_input_budget(&self) -> u64 {
255 let reserved_completion = self.recommended_completion_tokens.max(self.max_completion_tokens);
256 self.context_window
257 .saturating_sub(reserved_completion)
258 .saturating_sub(PROVIDER_OVERHEAD_TOKENS)
259 }
260
261 pub fn target_budget(&self) -> u64 {
263 ratio_of(self.available_input_budget(), TARGET_BUDGET_RATIO)
264 }
265
266 pub fn auto_compaction_threshold(&self) -> u64 {
269 ratio_of(self.available_input_budget(), AUTO_COMPACTION_RATIO)
270 }
271
272 pub fn resolve(
278 provider: &str, model: &str, override_entry: Option<ModelLimitOverride>, live: Option<&LiveModelMetadata>,
279 ) -> (ModelContextLimits, Vec<ContextDiagnostic>) {
280 let mut diagnostics = Vec::new();
281
282 if let Some(entry) = override_entry {
283 match entry.validate() {
284 Ok(()) => {
285 return (
286 ModelContextLimits {
287 provider: provider.to_string(),
288 model: model.to_string(),
289 context_window: entry.context_window,
290 max_completion_tokens: entry.max_completion_tokens,
291 recommended_completion_tokens: entry.recommended_completion_tokens,
292 source: ModelLimitSource::UserOverride,
293 confidence: ModelLimitConfidence::UserSupplied,
294 },
295 diagnostics,
296 );
297 }
298 Err(reason) => {
299 diagnostics.push(ContextDiagnostic::invalid_model_override(provider, model, &reason));
300 }
301 }
302 }
303
304 if let Some(live) = live
305 && let Some(limits) = live.to_limits(provider, model)
306 {
307 return (limits, diagnostics);
308 }
309
310 if let Some(static_limits) = static_provider_limits(provider, model) {
311 return (static_limits, diagnostics);
312 }
313
314 diagnostics.push(ContextDiagnostic::fallback_model_limits(provider, model));
315 (
316 ModelContextLimits {
317 provider: provider.to_string(),
318 model: model.to_string(),
319 context_window: FALLBACK_CONTEXT_WINDOW,
320 max_completion_tokens: FALLBACK_MAX_COMPLETION_TOKENS,
321 recommended_completion_tokens: FALLBACK_RECOMMENDED_COMPLETION_TOKENS,
322 source: ModelLimitSource::Fallback,
323 confidence: ModelLimitConfidence::Conservative,
324 },
325 diagnostics,
326 )
327 }
328}
329
330#[derive(Clone, Debug, Default, PartialEq, Eq)]
339pub struct LiveModelMetadata {
340 pub context_window: Option<u64>,
342 pub max_completion_tokens: Option<u64>,
344 pub recommended_completion_tokens: Option<u64>,
346}
347
348impl LiveModelMetadata {
349 pub fn new(context_window: u64, max_completion_tokens: u64, recommended_completion_tokens: u64) -> Self {
351 Self {
352 context_window: Some(context_window),
353 max_completion_tokens: Some(max_completion_tokens),
354 recommended_completion_tokens: Some(recommended_completion_tokens),
355 }
356 }
357
358 fn to_limits(&self, provider: &str, model: &str) -> Option<ModelContextLimits> {
362 let context_window = self.context_window?;
363 let max_completion_tokens = self.max_completion_tokens?;
364 let recommended = self
365 .recommended_completion_tokens
366 .unwrap_or_else(|| max_completion_tokens.min(context_window / 2));
367 if context_window == 0 || max_completion_tokens == 0 {
368 return None;
369 }
370 Some(ModelContextLimits {
371 provider: provider.to_string(),
372 model: model.to_string(),
373 context_window,
374 max_completion_tokens,
375 recommended_completion_tokens: recommended,
376 source: ModelLimitSource::LiveMetadata,
377 confidence: ModelLimitConfidence::Exact,
378 })
379 }
380}
381
382#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
385#[serde(default, deny_unknown_fields)]
386pub struct ModelLimitOverride {
387 pub context_window: u64,
389 pub max_completion_tokens: u64,
391 pub recommended_completion_tokens: u64,
393}
394
395impl ModelLimitOverride {
396 pub fn validate(&self) -> Result<(), String> {
402 if self.context_window == 0 {
403 return Err("context_window must be a positive integer".to_string());
404 }
405 if self.max_completion_tokens == 0 {
406 return Err("max_completion_tokens must be a positive integer".to_string());
407 }
408 if self.recommended_completion_tokens == 0 {
409 return Err("recommended_completion_tokens must be a positive integer".to_string());
410 }
411 if self.recommended_completion_tokens > self.max_completion_tokens {
412 return Err(format!(
413 "recommended_completion_tokens ({}) must not exceed max_completion_tokens ({})",
414 self.recommended_completion_tokens, self.max_completion_tokens
415 ));
416 }
417 if self.max_completion_tokens >= self.context_window {
418 return Err(format!(
419 "max_completion_tokens ({}) must be less than context_window ({})",
420 self.max_completion_tokens, self.context_window
421 ));
422 }
423 Ok(())
424 }
425}
426
427#[derive(Clone, Debug, Eq, PartialEq)]
429pub struct ContextItem {
430 pub id: String,
432 pub kind: ContextItemKind,
434 pub label: String,
436 pub source_path: Option<PathBuf>,
438 pub scope: String,
440 pub content_hash: Option<u64>,
442 pub artifact_handle: Option<String>,
444 pub byte_count: usize,
446 pub content: Option<String>,
454 pub token_estimate: usize,
456 pub visibility: ContextVisibility,
458 pub reason_code: String,
460 pub reason: String,
462}
463
464impl ContextItem {
465 pub fn summary(&self) -> String {
467 format!(
468 "{} {} {} {} est. tokens [{}]",
469 self.id,
470 self.kind.label(),
471 self.visibility.label(),
472 self.token_estimate,
473 self.label,
474 )
475 }
476}
477
478#[derive(Clone, Debug, Eq, PartialEq)]
480pub struct ContextBudget {
481 pub limits: ModelContextLimits,
483 pub available_input: u64,
485 pub target: u64,
487 pub auto_compaction_threshold: u64,
489 pub used: u64,
491}
492
493impl ContextBudget {
494 pub fn from_limits(limits: ModelContextLimits, items: &[ContextItem]) -> Self {
496 let available_input = limits.available_input_budget();
497 let target = limits.target_budget();
498 let auto_compaction_threshold = limits.auto_compaction_threshold();
499 let used = items
500 .iter()
501 .filter(|item| item.visibility.is_rendered())
502 .map(|item| item.token_estimate as u64)
503 .sum();
504 ContextBudget { limits, available_input, target, auto_compaction_threshold, used }
505 }
506
507 pub fn exceeds_target(&self) -> bool {
509 self.used > self.target
510 }
511
512 pub fn exceeds_auto_compaction(&self) -> bool {
514 self.used > self.auto_compaction_threshold
515 }
516
517 pub fn remaining_to_target(&self) -> u64 {
519 self.target.saturating_sub(self.used)
520 }
521}
522
523#[derive(Clone, Debug, Eq, PartialEq)]
525pub struct ContextDiagnostic {
526 pub severity: DiagnosticSeverity,
528 pub code: String,
530 pub message: String,
532}
533
534impl ContextDiagnostic {
535 pub fn fallback_model_limits(provider: &str, model: &str) -> Self {
537 ContextDiagnostic {
538 severity: DiagnosticSeverity::Warning,
539 code: "fallback_model_limits".to_string(),
540 message: format!("no model metadata for {provider}/{model}; using conservative fallback context window"),
541 }
542 }
543
544 pub fn invalid_model_override(provider: &str, model: &str, reason: &str) -> Self {
546 ContextDiagnostic {
547 severity: DiagnosticSeverity::Error,
548 code: "invalid_model_override".to_string(),
549 message: format!("model_limits override for {provider}/{model} rejected: {reason}"),
550 }
551 }
552
553 pub fn summary(&self) -> String {
555 format!("{} {} {}", self.severity.label(), self.code, self.message)
556 }
557}
558
559#[derive(Clone, Debug, Eq, PartialEq)]
562pub struct ContextLedger {
563 pub items: Vec<ContextItem>,
565 pub budget: ContextBudget,
567 pub diagnostics: Vec<ContextDiagnostic>,
569}
570
571impl ContextLedger {
572 pub fn rendered(&self) -> Vec<&ContextItem> {
574 self.items.iter().filter(|item| item.visibility.is_rendered()).collect()
575 }
576
577 pub fn counts(&self) -> ContextCounts {
579 let mut counts = ContextCounts::default();
580 for item in &self.items {
581 match item.visibility {
582 ContextVisibility::Visible => counts.visible += 1,
583 ContextVisibility::Pinned => counts.pinned += 1,
584 ContextVisibility::SummaryOnly => counts.summary_only += 1,
585 ContextVisibility::Archived => counts.archived += 1,
586 ContextVisibility::Candidate => counts.candidate += 1,
587 ContextVisibility::Dropped => counts.dropped += 1,
588 ContextVisibility::Blocked => counts.blocked += 1,
589 }
590 }
591 counts
592 }
593
594 pub fn find(&self, id: &str) -> Option<&ContextItem> {
596 self.items.iter().find(|item| item.id == id)
597 }
598}
599
600#[derive(Clone, Debug, Default, Eq, PartialEq)]
602pub struct ContextCounts {
603 pub visible: usize,
605 pub pinned: usize,
607 pub summary_only: usize,
609 pub archived: usize,
611 pub candidate: usize,
613 pub dropped: usize,
615 pub blocked: usize,
617}
618
619pub fn estimate_tokens(bytes: usize) -> usize {
624 bytes.div_ceil(TOKEN_BYTES_DIVISOR) + TOKEN_ITEM_OVERHEAD
625}
626
627pub fn item_id_for_path(kind: &ContextItemKind, path: &Path) -> String {
632 let mut hasher = DefaultHasher::new();
633 kind.label().hash(&mut hasher);
634 path.hash(&mut hasher);
635 format!("ctx_{}_{:016x}", kind.label(), hasher.finish())
636}
637
638pub fn item_id_for_session_range(kind: &ContextItemKind, session_id: &str, start: u64, end: u64) -> String {
645 let mut hasher = DefaultHasher::new();
646 kind.label().hash(&mut hasher);
647 session_id.hash(&mut hasher);
648 start.hash(&mut hasher);
649 end.hash(&mut hasher);
650 format!("ctx_{}_{:016x}", kind.label(), hasher.finish())
651}
652
653pub fn render_ledger_summary(ledger: &ContextLedger) -> String {
657 let counts = ledger.counts();
658 let mut parts = vec![format!("{} visible", counts.visible)];
659 if counts.pinned > 0 {
660 parts.push(format!("{} pinned", counts.pinned));
661 }
662 if counts.archived > 0 {
663 parts.push(format!("{} archived", counts.archived));
664 }
665 if counts.summary_only > 0 {
666 parts.push(format!("{} summary", counts.summary_only));
667 }
668 if counts.blocked > 0 {
669 parts.push(format!("{} blocked", counts.blocked));
670 }
671 parts.push(format!("{} est. tokens", compact_token_count(ledger.budget.used)));
672 format!("context {}", parts.join(" · "))
673}
674
675pub fn render_model_dashboard(ledger: &ContextLedger) -> String {
682 let mut out = String::new();
683 out.push_str("<context_dashboard>\n");
684 out.push_str(" <budget>\n");
685 element(
686 &mut out,
687 4,
688 "available_input",
689 &compact_token_count(ledger.budget.available_input),
690 );
691 element(&mut out, 4, "target", &compact_token_count(ledger.budget.target));
692 element(
693 &mut out,
694 4,
695 "auto_compaction_threshold",
696 &compact_token_count(ledger.budget.auto_compaction_threshold),
697 );
698 element(&mut out, 4, "used", &compact_token_count(ledger.budget.used));
699 element(
700 &mut out,
701 4,
702 "exceeds_target",
703 &ledger.budget.exceeds_target().to_string(),
704 );
705 element(
706 &mut out,
707 4,
708 "exceeds_auto_compaction",
709 &ledger.budget.exceeds_auto_compaction().to_string(),
710 );
711 element(&mut out, 4, "limit_source", ledger.budget.limits.source.label());
712 element(&mut out, 4, "limit_confidence", ledger.budget.limits.confidence.label());
713 out.push_str(" </budget>\n");
714
715 out.push_str(" <items>\n");
716 for item in &ledger.items {
717 out.push_str(" <item>\n");
718 element(&mut out, 6, "id", &item.id);
719 element(&mut out, 6, "kind", item.kind.label());
720 element(&mut out, 6, "visibility", item.visibility.label());
721 element(&mut out, 6, "tokens", &item.token_estimate.to_string());
722 element(&mut out, 6, "label", &item.label);
723 if let Some(handle) = &item.artifact_handle {
724 element(&mut out, 6, "recovery_handle", handle);
725 }
726 out.push_str(" </item>\n");
727 }
728 out.push_str(" </items>\n");
729
730 if !ledger.diagnostics.is_empty() {
731 out.push_str(" <diagnostics>\n");
732 for diagnostic in &ledger.diagnostics {
733 element(&mut out, 4, "diagnostic", &diagnostic.summary());
734 }
735 out.push_str(" </diagnostics>\n");
736 }
737 out.push_str("</context_dashboard>");
738 out
739}
740
741fn static_provider_limits(provider: &str, model: &str) -> Option<ModelContextLimits> {
747 let (context_window, max_completion, recommended) = match provider {
748 "umans" => (200_000, 32_768, 8_192),
749 "opencode-go" => (200_000, 32_768, 8_192),
750 "opencode-zen" => (200_000, 32_768, 8_192),
751 "chatgpt-codex" => (200_000, 32_768, 8_192),
752 _ => return None,
753 };
754 Some(ModelContextLimits {
755 provider: provider.to_string(),
756 model: model.to_string(),
757 context_window,
758 max_completion_tokens: max_completion,
759 recommended_completion_tokens: recommended,
760 source: ModelLimitSource::Static,
761 confidence: ModelLimitConfidence::ProviderReported,
762 })
763}
764
765fn compact_token_count(tokens: u64) -> String {
767 const K: u64 = 1_000;
768 const M: u64 = K * 1_000;
769 if tokens >= M && tokens.is_multiple_of(M) {
770 format!("{}M", tokens / M)
771 } else if tokens >= K && tokens.is_multiple_of(K) {
772 format!("{}k", tokens / K)
773 } else {
774 tokens.to_string()
775 }
776}
777
778fn element(out: &mut String, indent: usize, name: &str, value: &str) {
780 let pad = " ".repeat(indent);
781 out.push_str(&format!("{pad}<{name}>{value}</{name}>\n"));
782}