Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion crates/jcode-base/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,15 @@ struct ConfigCache {

static CONFIG_CACHE: LazyLock<RwLock<ConfigCache>> = 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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/jcode-base/src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions crates/jcode-base/src/provider/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,28 @@ pub fn populate_context_limits(models: HashMap<String, usize>) {
}
}

/// 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<String>) {
populate_account_models_for_scope(&current_openai_account_scope(), slugs);
Expand Down
15 changes: 12 additions & 3 deletions crates/jcode-base/src/provider/openrouter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,9 @@ impl OpenRouterProvider {
.filter(|id| !id.is_empty())
.map(ToString::to_string)
.collect::<Vec<_>>();
// 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()
Expand All @@ -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::<HashMap<_, _>>();
// 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)),
Expand Down
70 changes: 70 additions & 0 deletions crates/jcode-base/src/provider/openrouter_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 10 additions & 0 deletions crates/jcode-config-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<NamedProviderModelConfig>,
}
Expand All @@ -335,6 +344,7 @@ impl Default for NamedProviderConfig {
provider_routing: false,
model_catalog: false,
allow_provider_pinning: false,
context_window: None,
models: Vec::new(),
}
}
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/provider_setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down