diff --git a/crates/jcode-base/src/config.rs b/crates/jcode-base/src/config.rs index 19fe4417b..376caee52 100644 --- a/crates/jcode-base/src/config.rs +++ b/crates/jcode-base/src/config.rs @@ -158,8 +158,15 @@ struct ConfigCache { static CONFIG_CACHE: LazyLock> = LazyLock::new(|| { let fingerprint = ConfigCacheFingerprint::current(); + let config = leak_config(Config::load()); + // Populate the context-limit cache from named provider configs on initial + // load so that both the server and TUI processes have correct context + // limits from the very start. Read from the loaded config directly to + // avoid a deadlock (config() would try to read CONFIG_CACHE which is + // still being initialized). + populate_context_limits_from_config_ref(config); RwLock::new(ConfigCache { - config: leak_config(Config::load()), + config, fingerprint, last_checked: Instant::now(), force_reload: false, @@ -170,6 +177,27 @@ fn leak_config(config: Config) -> &'static Config { Box::leak(Box::new(config)) } +/// Populate the context-limit cache from a config reference directly. +/// Used during initial CONFIG_CACHE init to avoid a deadlock from calling +/// config() inside the LazyLock initializer. +fn populate_context_limits_from_config_ref(cfg: &Config) { + let mut limits = std::collections::HashMap::new(); + for (_name, provider_cfg) in &cfg.providers { + for model in &provider_cfg.models { + let id = model.id.trim(); + if id.is_empty() { + continue; + } + if let Some(limit) = model.context_window.or(provider_cfg.context_window) { + limits.insert(id.to_ascii_lowercase(), limit); + } + } + } + if !limits.is_empty() { + crate::provider::populate_context_limits(limits); + } +} + /// Get the global config instance. /// /// The returned reference is backed by a reloadable process cache. Calls check @@ -217,6 +245,10 @@ pub fn config() -> &'static Config { if let Some(reason) = reload_reason { crate::logging::info(&format!("CONFIG_RELOAD {}", reason)); notify_config_reloaded(); + // Populate the context-limit cache from named provider configs so that + // both the server and TUI processes have correct context limits even + // before the provider instance is created. + crate::provider::populate_context_limits_from_config(); } config diff --git a/crates/jcode-base/src/provider/mod.rs b/crates/jcode-base/src/provider/mod.rs index 65b49289e..93529e188 100644 --- a/crates/jcode-base/src/provider/mod.rs +++ b/crates/jcode-base/src/provider/mod.rs @@ -245,7 +245,7 @@ pub use self::models::{ model_availability_for_account, model_unavailability_detail_for_account, note_openai_model_catalog_refresh_attempt, persist_anthropic_model_catalog, persist_openai_model_catalog, populate_account_models, populate_anthropic_models, - populate_context_limits, provider_for_model, provider_for_model_with_hint, + populate_context_limits, populate_context_limits_from_config, provider_for_model, provider_for_model_with_hint, provider_unavailability_detail_for_account, record_model_unavailable_for_account, record_provider_unavailable_for_account, refresh_openai_model_catalog_in_background, resolve_model_capabilities, should_refresh_anthropic_model_catalog, diff --git a/crates/jcode-base/src/provider/models.rs b/crates/jcode-base/src/provider/models.rs index 3d7144656..7c8a90e7d 100644 --- a/crates/jcode-base/src/provider/models.rs +++ b/crates/jcode-base/src/provider/models.rs @@ -440,6 +440,28 @@ pub fn populate_context_limits(models: HashMap) { } } +/// Populate the context limit cache from named provider model configs in the +/// user's config file. Called early so both the server and TUI processes have +/// the cache populated even before the provider instance is created. +pub fn populate_context_limits_from_config() { + let cfg = crate::config::config(); + let mut limits = HashMap::new(); + for (_name, provider_cfg) in &cfg.providers { + for model in &provider_cfg.models { + let id = model.id.trim(); + if id.is_empty() { + continue; + } + if let Some(limit) = model.context_window.or(provider_cfg.context_window) { + limits.insert(id.to_ascii_lowercase(), limit); + } + } + } + if !limits.is_empty() { + populate_context_limits(limits); + } +} + /// Populate the account-available model list (called once at startup from the Codex API). pub fn populate_account_models(slugs: Vec) { populate_account_models_for_scope(¤t_openai_account_scope(), slugs); diff --git a/crates/jcode-base/src/provider/openrouter.rs b/crates/jcode-base/src/provider/openrouter.rs index d30432f56..48b07d37b 100644 --- a/crates/jcode-base/src/provider/openrouter.rs +++ b/crates/jcode-base/src/provider/openrouter.rs @@ -1118,6 +1118,9 @@ impl OpenRouterProvider { .filter(|id| !id.is_empty()) .map(ToString::to_string) .collect::>(); + // Build per-model context limits. Per-model context_window takes + // precedence; provider-level context_window serves as the default + // for models that don't specify their own. let static_context_limits = profile .models .iter() @@ -1126,11 +1129,17 @@ impl OpenRouterProvider { if id.is_empty() { return None; } - model - .context_window - .map(|limit| (id.to_ascii_lowercase(), limit)) + let limit = model.context_window.or(profile.context_window); + limit.map(|limit| (id.to_ascii_lowercase(), limit)) }) .collect::>(); + // Populate the global context-limit cache so that resolution paths + // outside this provider instance (e.g. TUI info widget, compaction + // budget) also see user-configured limits, even when the upstream + // endpoint does not expose GET /v1/models. + if !static_context_limits.is_empty() { + crate::provider::populate_context_limits(static_context_limits.clone()); + } Ok(Self { client: crate::provider::shared_http_client(), model: Arc::new(RwLock::new(model)), diff --git a/crates/jcode-base/src/provider/openrouter_tests.rs b/crates/jcode-base/src/provider/openrouter_tests.rs index 1284f6589..ba3feddb6 100644 --- a/crates/jcode-base/src/provider/openrouter_tests.rs +++ b/crates/jcode-base/src/provider/openrouter_tests.rs @@ -1224,6 +1224,76 @@ fn named_openai_compatible_model_context_window_overrides_default() { assert_eq!(provider.context_window(), 512_000); } +#[test] +fn named_openai_compatible_context_window_populates_global_cache() { + let _lock = ENV_LOCK.lock().unwrap(); + let _namespace = EnvVarGuard::remove("JCODE_OPENROUTER_CACHE_NAMESPACE"); + let mut config = crate::config::NamedProviderConfig { + base_url: "https://compat.example.test/v1".to_string(), + api_key: Some("test".to_string()), + default_model: Some("mimo-v2.5-pro".to_string()), + models: vec![crate::config::NamedProviderModelConfig { + id: "mimo-v2.5-pro".to_string(), + context_window: Some(128_000), + input: Vec::new(), + }], + ..Default::default() + }; + config.model_catalog = false; + + let _provider = + OpenRouterProvider::new_named_openai_compatible("custom", &config).expect("provider"); + + // The global context limit cache should now contain the user-configured limit, + // so codepaths that don't have access to the provider instance (e.g. TUI + // info widget, compaction budget) still resolve the correct limit instead of + // falling back to DEFAULT_CONTEXT_LIMIT (200k). + assert_eq!( + crate::provider::context_limit_for_model_with_provider( + "mimo-v2.5-pro", + Some("openrouter"), + ), + Some(128_000) + ); + // Also accessible without a provider hint via the cache. + assert_eq!( + crate::provider::context_limit_for_model("mimo-v2.5-pro"), + Some(128_000) + ); +} + +#[test] +fn named_openai_compatible_provider_level_context_window_fallback() { + let _lock = ENV_LOCK.lock().unwrap(); + let _namespace = EnvVarGuard::remove("JCODE_OPENROUTER_CACHE_NAMESPACE"); + // Matches the user's config pattern: context_window at provider level, + // model entry with no context_window of its own. + let mut config = crate::config::NamedProviderConfig { + base_url: "https://opengateway.example.test/v1".to_string(), + api_key: Some("test".to_string()), + default_model: Some("mimo-v2.5-pro".to_string()), + context_window: Some(1_000_000), + models: vec![crate::config::NamedProviderModelConfig { + id: "mimo-v2.5-pro".to_string(), + context_window: None, + input: Vec::new(), + }], + ..Default::default() + }; + config.model_catalog = false; + + let provider = + OpenRouterProvider::new_named_openai_compatible("opengateway", &config).expect("provider"); + + // Provider instance should use provider-level context_window. + assert_eq!(provider.context_window(), 1_000_000); + // Global cache should also be populated. + assert_eq!( + crate::provider::context_limit_for_model("mimo-v2.5-pro"), + Some(1_000_000) + ); +} + #[test] fn named_openai_compatible_loads_api_key_from_env_file() { let _lock = ENV_LOCK.lock().unwrap(); diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index ef9be3063..be20ead3e 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -315,6 +315,15 @@ pub struct NamedProviderConfig { pub model_catalog: bool, #[serde(default)] pub allow_provider_pinning: bool, + #[serde( + default, + alias = "context_limit", + alias = "context-length", + alias = "context-window", + alias = "context_length", + skip_serializing_if = "Option::is_none" + )] + pub context_window: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub models: Vec, } @@ -335,6 +344,7 @@ impl Default for NamedProviderConfig { provider_routing: false, model_catalog: false, allow_provider_pinning: false, + context_window: None, models: Vec::new(), } } diff --git a/src/cli/commands/provider_setup.rs b/src/cli/commands/provider_setup.rs index 442571b53..a8253bba2 100644 --- a/src/cli/commands/provider_setup.rs +++ b/src/cli/commands/provider_setup.rs @@ -175,6 +175,7 @@ pub(crate) fn configure_provider_profile( provider_routing: options.provider_routing, model_catalog: options.model_catalog, allow_provider_pinning: options.provider_routing, + context_window: options.context_window, models: vec![NamedProviderModelConfig { id: model.clone(), context_window: options.context_window,