diff --git a/src/harness/mod.rs b/src/harness/mod.rs index a6bb933..69d72f9 100644 --- a/src/harness/mod.rs +++ b/src/harness/mod.rs @@ -47,7 +47,7 @@ pub use message::{ContentBlock, Message}; pub use model::{ CapabilitySet, Modalities, ModelProfile, ModelRequest, ModelResponse, ModelStatus, ModelStream, ModelStreamItem, ProviderError, ResponseFormat, StreamAccumulator, ToolChoice, - collect_model_stream, + collect_model_stream, context_window_for_model_id, }; pub use no_progress::{ DEFAULT_IDENTICAL_HALT_THRESHOLD, NoProgress, NoProgressTracker, ToolAttempt, diff --git a/src/harness/model/mod.rs b/src/harness/model/mod.rs index f427da0..d38b7d9 100644 --- a/src/harness/model/mod.rs +++ b/src/harness/model/mod.rs @@ -27,6 +27,82 @@ use crate::harness::usage::Usage; pub use types::*; +/// How a context-window pattern is matched against a model id. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ContextPatternMatch { + /// Pattern may appear anywhere in the lowercased model id. + Substring, + /// Pattern must be a complete segment delimited by common provider/id + /// separators. This avoids false positives for short model ids such as + /// `o1` and `o3`. + Segment, +} + +/// Generic context-window hints for common provider model families. +/// +/// These are deliberately provider-neutral fallbacks, not a pricing catalog. +/// Hosts should prefer authoritative provider/catalog metadata when available +/// and use this only when a raw model id needs a conservative pre-dispatch +/// budget. +/// +/// Order matters: lookup returns the first matching entry, so more-specific +/// substrings such as `gpt-4.1` and `gpt-4-turbo` must stay before broader +/// patterns such as `gpt-4` that would otherwise shadow them. +const MODEL_CONTEXT_PATTERNS: &[(&str, ContextPatternMatch, u64)] = &[ + ("claude-haiku-4.5", ContextPatternMatch::Substring, 200_000), + ("claude-haiku-4", ContextPatternMatch::Substring, 200_000), + ("claude-haiku", ContextPatternMatch::Substring, 200_000), + ("claude-sonnet-4", ContextPatternMatch::Substring, 200_000), + ("claude-opus-4", ContextPatternMatch::Substring, 200_000), + ("claude-3-5-sonnet", ContextPatternMatch::Substring, 200_000), + ("claude-3-5-haiku", ContextPatternMatch::Substring, 200_000), + ("claude-3-opus", ContextPatternMatch::Substring, 200_000), + ("gpt-4.1", ContextPatternMatch::Substring, 1_047_576), + ("gpt-4o", ContextPatternMatch::Substring, 128_000), + ("gpt-4-turbo", ContextPatternMatch::Substring, 128_000), + ("gpt-4", ContextPatternMatch::Substring, 128_000), + ("gpt-3.5", ContextPatternMatch::Substring, 16_385), + ("o1", ContextPatternMatch::Segment, 200_000), + ("o3", ContextPatternMatch::Segment, 200_000), + ("deepseek", ContextPatternMatch::Substring, 128_000), + ("gemma3", ContextPatternMatch::Substring, 8_192), + ("gemma", ContextPatternMatch::Substring, 8_192), + ("llama-3", ContextPatternMatch::Substring, 128_000), + ("llama3", ContextPatternMatch::Substring, 128_000), +]; + +fn matches_context_pattern(lower: &str, pattern: &str, mode: ContextPatternMatch) -> bool { + match mode { + ContextPatternMatch::Substring => lower.contains(pattern), + ContextPatternMatch::Segment => { + let model_name = lower.rsplit(['/', ':']).next().unwrap_or(lower); + model_name + .split(['-', '_', '.']) + .next() + .is_some_and(|segment| segment == pattern) + } + } +} + +/// Returns a generic context-window hint for a raw provider model id. +/// +/// Returns `None` for unknown ids rather than guessing. Hosts with product tier +/// aliases, local runtime profiles, or authoritative provider catalogs should +/// check those first and use this helper as a last generic fallback. +pub fn context_window_for_model_id(model: &str) -> Option { + let normalized = model.trim(); + if normalized.is_empty() { + return None; + } + + let lower = normalized.to_ascii_lowercase(); + MODEL_CONTEXT_PATTERNS + .iter() + .find_map(|(pattern, mode, window)| { + matches_context_pattern(&lower, pattern, *mode).then_some(*window) + }) +} + impl std::fmt::Display for ProviderError { /// Renders the same human-readable shape real provider adapters used to /// build by hand before flattening it into a plain diff --git a/src/harness/model/test.rs b/src/harness/model/test.rs index e506b21..9195f95 100644 --- a/src/harness/model/test.rs +++ b/src/harness/model/test.rs @@ -100,6 +100,43 @@ fn lifecycle_helpers_gate_retired_and_deprecated_models() { assert!(retired.is_deprecated()); } +#[test] +fn context_window_patterns_cover_common_provider_families() { + assert_eq!(context_window_for_model_id("gpt-4.1"), Some(1_047_576)); + assert_eq!( + context_window_for_model_id("openai/gpt-4o-mini"), + Some(128_000) + ); + assert_eq!( + context_window_for_model_id("github_copilot/claude-haiku-4.5"), + Some(200_000) + ); + assert_eq!(context_window_for_model_id("deepseek-chat"), Some(128_000)); + assert_eq!(context_window_for_model_id("gemma3:4b"), Some(8_192)); + assert_eq!(context_window_for_model_id("llama3:8b"), Some(128_000)); + assert_eq!(context_window_for_model_id("totally-unknown-model"), None); + assert_eq!(context_window_for_model_id(" "), None); +} + +#[test] +fn o1_o3_context_patterns_require_segment_boundaries() { + assert_eq!(context_window_for_model_id("o1"), Some(200_000)); + assert_eq!(context_window_for_model_id("o1-mini"), Some(200_000)); + assert_eq!(context_window_for_model_id("o3-mini"), Some(200_000)); + assert_eq!( + context_window_for_model_id("openai/o1-preview"), + Some(200_000) + ); + + assert_eq!(context_window_for_model_id("solo1-7b"), None); + assert_eq!(context_window_for_model_id("proto3-chat"), None); + assert_eq!(context_window_for_model_id("octo3thing"), None); + assert_eq!( + context_window_for_model_id("ollama/mistral-for-o1-benchmark"), + None + ); +} + #[test] fn cacheable_prefix_ids_in_order() { let req = ModelRequest::new(vec![]).with_cache_segments(vec![