From 91cdfe1142b4278952c4ea0a40f19aab57f9d293 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Tue, 26 May 2026 16:06:24 -0300 Subject: [PATCH 1/3] fix: populate global context-limit cache from named provider model config (#262) Named OpenAI-compatible providers that don't expose GET /v1/models fall back to DEFAULT_CONTEXT_LIMIT (200k) even when the user has configured explicit context_window values in their provider config. Populate CONTEXT_LIMIT_CACHE from NamedProviderModelConfig.context_window values during provider init, so all resolution paths (TUI info widget, compaction budget, remote model switching) see the user-configured limits instead of falling back to 200k. --- crates/jcode-base/src/provider/openrouter.rs | 7 ++++ .../src/provider/openrouter_tests.rs | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/crates/jcode-base/src/provider/openrouter.rs b/crates/jcode-base/src/provider/openrouter.rs index d30432f56..9b6d345e3 100644 --- a/crates/jcode-base/src/provider/openrouter.rs +++ b/crates/jcode-base/src/provider/openrouter.rs @@ -1131,6 +1131,13 @@ impl OpenRouterProvider { .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..3e109da55 100644 --- a/crates/jcode-base/src/provider/openrouter_tests.rs +++ b/crates/jcode-base/src/provider/openrouter_tests.rs @@ -1224,6 +1224,44 @@ 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_loads_api_key_from_env_file() { let _lock = ENV_LOCK.lock().unwrap(); From 38f18930c7077884931939e09cc6c031e37c55e7 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Tue, 26 May 2026 16:58:40 -0300 Subject: [PATCH 2/3] feat: support provider-level context_window as fallback for all models (#262) NamedProviderConfig now accepts a context_window field at the provider level. When a model entry doesn't specify its own context_window, the provider-level value is used as fallback. This matches the common config pattern where all models served by a gateway share the same context limit. [providers.opengateway] context_window = 1000000 default_model = "mimo-v2.5-pro" [[providers.opengateway.models]] id = "mimo-v2.5-pro" # inherits context_window from provider level --- crates/jcode-base/src/provider/openrouter.rs | 8 +++-- .../src/provider/openrouter_tests.rs | 32 +++++++++++++++++++ crates/jcode-config-types/src/lib.rs | 10 ++++++ src/cli/commands/provider_setup.rs | 1 + 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/jcode-base/src/provider/openrouter.rs b/crates/jcode-base/src/provider/openrouter.rs index 9b6d345e3..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,9 +1129,8 @@ 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 diff --git a/crates/jcode-base/src/provider/openrouter_tests.rs b/crates/jcode-base/src/provider/openrouter_tests.rs index 3e109da55..ba3feddb6 100644 --- a/crates/jcode-base/src/provider/openrouter_tests.rs +++ b/crates/jcode-base/src/provider/openrouter_tests.rs @@ -1262,6 +1262,38 @@ fn named_openai_compatible_context_window_populates_global_cache() { ); } +#[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, From 7f18942b0f90b730fd8493aca2d0548870d53149 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Tue, 26 May 2026 17:20:25 -0300 Subject: [PATCH 3/3] fix: populate context-limit cache from config on initial load (#262) The TUI and server run in separate processes. The CONTEXT_LIMIT_CACHE was only populated in the server process during provider init. The TUI process had an empty cache, causing context_limit_for_model_with_provider to return None and fall back to DEFAULT_CONTEXT_LIMIT (200k). Add populate_context_limits_from_config() which reads all named provider model configs and populates the cache. Call it during config initialization (both initial load and reload) so every process has the cache populated from the start. Use a direct config-ref variant during LazyLock init to avoid a deadlock. --- crates/jcode-base/src/config.rs | 34 +++++++++++++++++++++++- crates/jcode-base/src/provider/mod.rs | 2 +- crates/jcode-base/src/provider/models.rs | 22 +++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) 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);