diff --git a/crates/jcode-base/src/config.rs b/crates/jcode-base/src/config.rs index af5f0bec5a..82cabcbcfc 100644 --- a/crates/jcode-base/src/config.rs +++ b/crates/jcode-base/src/config.rs @@ -190,6 +190,7 @@ const CONFIG_ENV_KEYS: &[&str] = &[ "JCODE_NTFY_TOPIC", "JCODE_OPENAI_NATIVE_COMPACTION_MODE", "JCODE_OPENAI_NATIVE_COMPACTION_THRESHOLD_TOKENS", + "JCODE_OPENAI_CYBER_ACCESS_PROGRAM", "JCODE_OPENAI_REASONING_EFFORT", "JCODE_OPENAI_SERVICE_TIER", "JCODE_OPENAI_TRANSPORT", diff --git a/crates/jcode-base/src/config/default_file.rs b/crates/jcode-base/src/config/default_file.rs index affbe3183d..a0c69a0f15 100644 --- a/crates/jcode-base/src/config/default_file.rs +++ b/crates/jcode-base/src/config/default_file.rs @@ -441,6 +441,9 @@ openai_reasoning_effort = "low" # Defaults to `priority` to match Codex /fast behavior for OpenAI OAuth # (higher speed, higher usage). Set to "off" (or "standard") to disable. openai_service_tier = "priority" +# Cyber access program for OpenAI OAuth, sent only to models whose catalog +# lists it (for example Daybreak Blue on GPT-5.6/6 Sol). Needs an approved account. +# openai_cyber_access_program = "daybreak_blue" # Preserve provider-native reasoning/thinking for future-turn context when supported. # Applies to OpenRouter, Anthropic, and OpenAI native reasoning replay. Display is separate. preserve_reasoning_context = true diff --git a/crates/jcode-base/src/config/env_overrides.rs b/crates/jcode-base/src/config/env_overrides.rs index eb1b0ecb9f..5bd30ac45a 100644 --- a/crates/jcode-base/src/config/env_overrides.rs +++ b/crates/jcode-base/src/config/env_overrides.rs @@ -872,6 +872,13 @@ impl Config { } } } + if let Ok(v) = std::env::var("JCODE_OPENAI_CYBER_ACCESS_PROGRAM") { + let trimmed = v.trim().to_ascii_lowercase(); + self.provider.openai_cyber_access_program = match trimmed.as_str() { + "" | "off" | "none" => None, + _ => Some(trimmed), + }; + } if let Ok(v) = std::env::var("JCODE_PRESERVE_REASONING_CONTEXT") { if let Some(parsed) = parse_env_bool(&v) { self.provider.preserve_reasoning_context = parsed; diff --git a/crates/jcode-base/src/provider/mod.rs b/crates/jcode-base/src/provider/mod.rs index ded7a6c315..a3c4fae957 100644 --- a/crates/jcode-base/src/provider/mod.rs +++ b/crates/jcode-base/src/provider/mod.rs @@ -329,9 +329,9 @@ pub use self::models::{ ModelCatalogHttpStatus, OpenAIModelCatalog, anthropic_catalog_scope_for_route, begin_anthropic_model_catalog_refresh, begin_anthropic_model_catalog_refresh_for_scope, begin_openai_model_catalog_refresh, cached_anthropic_model_ids, - cached_anthropic_model_ids_for_scope, cached_context_limit_for_model, cached_openai_model_ids, - cached_openai_reasoning_efforts, clear_all_model_unavailability_for_account, - clear_all_provider_unavailability_for_account, + cached_anthropic_model_ids_for_scope, cached_context_limit_for_model, + cached_openai_cyber_access_programs, cached_openai_cyber_access_programs_for_scope, cached_openai_model_ids, cached_openai_reasoning_efforts, + clear_all_model_unavailability_for_account, clear_all_provider_unavailability_for_account, clear_claude_provider_unavailability_for_account_label, clear_model_unavailable_for_account, clear_openai_provider_unavailability_for_account_label, clear_provider_unavailable_for_account, context_limit_for_model, context_limit_for_model_with_provider, fetch_anthropic_model_catalog, diff --git a/crates/jcode-base/src/provider/model_catalog_service.rs b/crates/jcode-base/src/provider/model_catalog_service.rs index 543bbe43aa..ea66c4819d 100644 --- a/crates/jcode-base/src/provider/model_catalog_service.rs +++ b/crates/jcode-base/src/provider/model_catalog_service.rs @@ -104,6 +104,21 @@ impl ModelCatalogService { self.replace_scope_models_inner(scope, models, observed_at, fetched_at) } + /// Hydrate from a snapshot that must not count as fresh (for example one + /// written before the persisted schema gained fields), keeping its real + /// `observed_at` for display while letting the next refresh run at once. + pub(crate) fn hydrate_scope_models_from_stale_snapshot( + &self, + scope: &str, + models: HashSet, + observed_at: SystemTime, + ) -> bool { + let fetched_at = Instant::now() + .checked_sub(self.cache_ttl + Duration::from_secs(1)) + .unwrap_or_else(Instant::now); + self.replace_scope_models_inner(scope, models, observed_at, fetched_at) + } + fn replace_scope_models_inner( &self, scope: &str, @@ -393,6 +408,24 @@ mod tests { assert!(!service.should_refresh("default")); } + #[test] + fn hydrating_old_schema_snapshot_forces_refresh_but_keeps_observed_at() { + let service = service(); + let models = HashSet::from(["gpt-5.5".to_string()]); + + // Even a seconds-old snapshot must refresh when its schema is outdated. + let observed_at = SystemTime::now() - Duration::from_secs(5); + assert!(service.hydrate_scope_models_from_stale_snapshot("default", models, observed_at)); + + assert_eq!( + service.model_ids("default"), + Some(vec!["gpt-5.5".to_string()]) + ); + assert!(!service.is_fresh("default")); + assert!(service.should_refresh("default")); + assert_eq!(service.observed_at("default"), Some(observed_at)); + } + #[test] fn empty_model_sets_do_not_replace_existing_catalog() { let service = service(); diff --git a/crates/jcode-base/src/provider/models.rs b/crates/jcode-base/src/provider/models.rs index d3f0631c1a..f06484e733 100644 --- a/crates/jcode-base/src/provider/models.rs +++ b/crates/jcode-base/src/provider/models.rs @@ -30,6 +30,11 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const OPENAI_MODEL_CATALOG_CACHE_FILE: &str = "openai_model_catalog_cache.json"; const ANTHROPIC_MODEL_CATALOG_CACHE_FILE: &str = "anthropic_model_catalog_cache.json"; +/// Bump when a persisted catalog scope gains a field parsed from the live +/// catalog. Older snapshots still hydrate for display, but are treated as stale +/// so the next refresh fetches the new fields instead of waiting out the TTL. +const MODEL_CATALOG_SCHEMA_VERSION: u32 = 2; + #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct PersistedModelCatalogStore { scopes: HashMap, @@ -42,6 +47,10 @@ struct PersistedModelCatalogScope { context_limits: HashMap, #[serde(default)] reasoning_efforts: HashMap>, + #[serde(default)] + cyber_access_programs: HashMap>, + #[serde(default)] + schema_version: u32, observed_at_unix_secs: u64, } @@ -428,6 +437,7 @@ fn persist_scoped_model_catalog( models: &[String], context_limits: &HashMap, reasoning_efforts: &HashMap>, + cyber_access_programs: &HashMap>, observed_at: SystemTime, ) { if models.is_empty() { @@ -447,6 +457,8 @@ fn persist_scoped_model_catalog( models: models.to_vec(), context_limits: context_limits.clone(), reasoning_efforts: reasoning_efforts.clone(), + cyber_access_programs: cyber_access_programs.clone(), + schema_version: MODEL_CATALOG_SCHEMA_VERSION, observed_at_unix_secs: observed_at_unix_secs(observed_at), }, ); @@ -476,7 +488,13 @@ fn hydrate_catalog_cache_from_disk( } let observed_at = system_time_from_unix_secs(persisted.observed_at_unix_secs); - service.hydrate_scope_models_from_snapshot(scope, normalized, observed_at); + // A snapshot from an older schema lacks newer catalog fields. Hydrate it as + // stale so a live refresh fetches them promptly instead of after the TTL. + if persisted.schema_version < MODEL_CATALOG_SCHEMA_VERSION { + service.hydrate_scope_models_from_stale_snapshot(scope, normalized, observed_at); + } else { + service.hydrate_scope_models_from_snapshot(scope, normalized, observed_at); + } if !persisted.context_limits.is_empty() { populate_context_limits(persisted.context_limits.clone()); } @@ -524,6 +542,22 @@ pub fn cached_openai_reasoning_efforts_for_scope( (!efforts.is_empty()).then_some(efforts) } +/// Return the cyber access programs each OpenAI model accepts for the active +/// account, from its scoped disk snapshot. +pub fn cached_openai_cyber_access_programs() -> Option>> { + cached_openai_cyber_access_programs_for_scope(¤t_openai_account_scope()) +} + +/// Return the cyber access programs each OpenAI model accepts for `scope`, +/// from that scope's disk snapshot. +pub fn cached_openai_cyber_access_programs_for_scope( + scope: &str, +) -> Option>> { + let store = load_persisted_model_catalog_store(OPENAI_MODEL_CATALOG_CACHE_FILE)?; + let programs = store.scopes.get(scope)?.cyber_access_programs.clone(); + (!programs.is_empty()).then_some(programs) +} + /// Test-only: clear the process-global in-memory model catalogs. The catalog /// services are statics shared by every test in the process; a test that /// hydrates a scope (directly or via `persist_*` + `cached_*`) otherwise leaks @@ -545,6 +579,7 @@ pub fn persist_openai_model_catalog_for_scope(scope: &str, catalog: &OpenAIModel &catalog.available_models, &catalog.context_limits, &catalog.reasoning_efforts, + &catalog.cyber_access_programs, SystemTime::now(), ); } @@ -560,6 +595,7 @@ pub fn persist_anthropic_model_catalog_for_scope(scope: &str, catalog: &Anthropi &catalog.available_models, &catalog.context_limits, &HashMap::new(), + &HashMap::new(), SystemTime::now(), ); } diff --git a/crates/jcode-base/src/provider/models_catalog.rs b/crates/jcode-base/src/provider/models_catalog.rs index df0bddf419..600abeb267 100644 --- a/crates/jcode-base/src/provider/models_catalog.rs +++ b/crates/jcode-base/src/provider/models_catalog.rs @@ -29,6 +29,9 @@ pub struct OpenAIModelCatalog { pub context_limits: HashMap, /// Ordered reasoning-effort values advertised by each Codex model. pub reasoning_efforts: HashMap>, + /// Cyber access programs each Codex model accepts in + /// `access_programs.cyber` (for example `standard`, `daybreak_blue`). + pub cyber_access_programs: HashMap>, } #[derive(Debug, Clone, Default)] @@ -90,6 +93,7 @@ pub(crate) fn parse_openai_model_catalog(data: &serde_json::Value) -> OpenAIMode let mut available: HashSet = HashSet::new(); let mut limits: HashMap = HashMap::new(); let mut reasoning_efforts: HashMap> = HashMap::new(); + let mut cyber_access_programs: HashMap> = HashMap::new(); for model in models.into_iter().flatten() { let Some(slug) = model @@ -116,6 +120,22 @@ pub(crate) fn parse_openai_model_catalog(data: &serde_json::Value) -> OpenAIMode limits.insert(slug.clone(), ctx as usize); } + if let Some(programs) = model + .get("available_access_programs") + .and_then(|value| value.get("cyber")) + .and_then(|value| value.as_array()) + { + let programs: Vec = programs + .iter() + .filter_map(|value| value.as_str()) + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect(); + if !programs.is_empty() { + cyber_access_programs.insert(slug.clone(), programs); + } + } + if let Some(values) = model .get("supported_reasoning_levels") .or_else(|| model.get("supported_reasoning_efforts")) @@ -151,6 +171,7 @@ pub(crate) fn parse_openai_model_catalog(data: &serde_json::Value) -> OpenAIMode available_models, context_limits: limits, reasoning_efforts, + cyber_access_programs, } } @@ -330,6 +351,7 @@ pub async fn fetch_openai_api_key_model_catalog(api_key: &str) -> Result Result, /// Preserve provider-native reasoning/thinking items for future-turn context when supported. pub preserve_reasoning_context: bool, /// How to handle cross-provider failover when the same input would be resent elsewhere. @@ -1316,6 +1321,7 @@ impl Default for ProviderConfig { openai_service_tier: Some("priority".to_string()), openai_native_compaction_mode: "auto".to_string(), openai_native_compaction_threshold_tokens: 200_000, + openai_cyber_access_program: None, preserve_reasoning_context: true, cross_provider_failover: CrossProviderFailoverMode::Countdown, same_provider_account_failover: true, diff --git a/crates/jcode-provider-openai-runtime/src/lib.rs b/crates/jcode-provider-openai-runtime/src/lib.rs index 5a7902d0d3..4f687d271c 100644 --- a/crates/jcode-provider-openai-runtime/src/lib.rs +++ b/crates/jcode-provider-openai-runtime/src/lib.rs @@ -87,6 +87,20 @@ fn model_supports_image_generation(model_id: &str) -> bool { !model_id.to_ascii_lowercase().contains("codex") } +/// Pick the cyber access program for a request. Returns the requested program +/// only when the model's catalog entry lists it, so unsupported models (and +/// models without catalog data) keep sending no `access_programs` field. +fn select_cyber_access_program(requested: &str, supported: Option<&[String]>) -> Option { + let requested = requested.trim().to_ascii_lowercase(); + if requested.is_empty() { + return None; + } + supported? + .iter() + .any(|program| program.eq_ignore_ascii_case(&requested)) + .then_some(requested) +} + /// Maximum number of retries for transient errors const MAX_RETRIES: u32 = 3; @@ -710,6 +724,10 @@ pub struct OpenAIProvider { max_output_tokens: Option, reasoning_effort: Arc>>, model_reasoning_efforts: Arc>>>, + /// Cyber access programs each model accepts, from the Codex catalog. + model_cyber_access_programs: Arc>>>, + /// Requested cyber access program (`provider.openai_cyber_access_program`). + cyber_access_program: Option, service_tier: Arc>>, native_compaction_mode: OpenAINativeCompactionMode, native_compaction_threshold_tokens: usize, @@ -838,6 +856,15 @@ impl OpenAIProvider { let model_reasoning_efforts = jcode_base::provider::cached_openai_reasoning_efforts_for_scope(&catalog_scope) .unwrap_or_default(); + let model_cyber_access_programs = + jcode_base::provider::cached_openai_cyber_access_programs_for_scope(&catalog_scope) + .unwrap_or_default(); + let cyber_access_program = jcode_base::config::config() + .provider + .openai_cyber_access_program + .as_deref() + .map(|program| program.trim().to_ascii_lowercase()) + .filter(|program| !program.is_empty() && program != "off" && program != "none"); let provider = Self { client: jcode_provider_core::shared_http_client(), @@ -850,6 +877,8 @@ impl OpenAIProvider { max_output_tokens, reasoning_effort: Arc::new(StdRwLock::new(reasoning_effort)), model_reasoning_efforts: Arc::new(StdRwLock::new(model_reasoning_efforts)), + model_cyber_access_programs: Arc::new(StdRwLock::new(model_cyber_access_programs)), + cyber_access_program, service_tier: Arc::new(StdRwLock::new(service_tier)), native_compaction_mode, native_compaction_threshold_tokens, @@ -954,9 +983,28 @@ impl OpenAIProvider { Ok(mut efforts) => *efforts = cached, Err(poisoned) => *poisoned.into_inner() = cached, } + let programs = + jcode_base::provider::cached_openai_cyber_access_programs().unwrap_or_default(); + match self.model_cyber_access_programs.write() { + Ok(mut guard) => *guard = programs, + Err(poisoned) => *poisoned.into_inner() = programs, + } self.revalidate_reasoning_effort(); } + /// The cyber access program to send for `model_id`, if the user requested one + /// and the model's catalog entry lists it. Models without catalog data, or + /// that do not list the program (for example Astra for Daybreak Blue), get + /// no `access_programs` field so their requests are unchanged. + fn cyber_access_program_for_model(&self, model_id: &str) -> Option { + let requested = self.cyber_access_program.as_deref()?; + let programs = self + .model_cyber_access_programs + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + select_cyber_access_program(requested, programs.get(model_id).map(Vec::as_slice)) + } + pub(crate) fn credential_mode_snapshot(&self) -> OpenAICredentialMode { self.credential_mode .try_read() @@ -1303,7 +1351,7 @@ impl OpenAIProvider { .unwrap_or_else(|poisoned| poisoned.into_inner().clone()); let native_compaction_threshold = self.native_compaction_threshold_for_context_window(self.context_window()); - Self::build_response_request( + let mut request = Self::build_response_request( model_id, system.to_string(), input, @@ -1315,7 +1363,11 @@ impl OpenAIProvider { self.prompt_cache_key.as_deref(), self.prompt_cache_retention.as_deref(), native_compaction_threshold, - ) + ); + if is_chatgpt_mode && let Some(program) = self.cyber_access_program_for_model(model_id) { + request["access_programs"] = serde_json::json!({ "cyber": program }); + } + request } #[expect( diff --git a/crates/jcode-provider-openai-runtime/src/openai_provider_impl.rs b/crates/jcode-provider-openai-runtime/src/openai_provider_impl.rs index 7c282d001b..a6ddbf5fd9 100644 --- a/crates/jcode-provider-openai-runtime/src/openai_provider_impl.rs +++ b/crates/jcode-provider-openai-runtime/src/openai_provider_impl.rs @@ -954,6 +954,10 @@ impl Provider for OpenAIProvider { Ok(mut efforts) => *efforts = catalog.reasoning_efforts.clone(), Err(poisoned) => *poisoned.into_inner() = catalog.reasoning_efforts.clone(), } + match self.model_cyber_access_programs.write() { + Ok(mut programs) => *programs = catalog.cyber_access_programs.clone(), + Err(poisoned) => *poisoned.into_inner() = catalog.cyber_access_programs.clone(), + } self.revalidate_reasoning_effort(); jcode_base::provider::persist_openai_model_catalog_for_scope(&scope, &catalog); if !catalog.context_limits.is_empty() { @@ -1231,6 +1235,15 @@ impl Provider for OpenAIProvider { .unwrap_or_else(|p| p.into_inner()) .clone(), )), + // Per-account catalog metadata, refreshed beside the efforts map: + // a fork pinned to another account must not write into the parent's. + model_cyber_access_programs: Arc::new(StdRwLock::new( + self.model_cyber_access_programs + .read() + .unwrap_or_else(|p| p.into_inner()) + .clone(), + )), + cyber_access_program: self.cyber_access_program.clone(), service_tier: Arc::new(StdRwLock::new(self.service_tier())), native_compaction_mode: self.native_compaction_mode, native_compaction_threshold_tokens: self.native_compaction_threshold_tokens, diff --git a/crates/jcode-provider-openai-runtime/src/openai_stream_runtime.rs b/crates/jcode-provider-openai-runtime/src/openai_stream_runtime.rs index a6838496e8..f774286cb3 100644 --- a/crates/jcode-provider-openai-runtime/src/openai_stream_runtime.rs +++ b/crates/jcode-provider-openai-runtime/src/openai_stream_runtime.rs @@ -901,6 +901,9 @@ async fn continue_persistent_ws_locked( if let Some(prompt_cache_retention) = request.get("prompt_cache_retention") { continuation_request["prompt_cache_retention"] = prompt_cache_retention.clone(); } + if let Some(access_programs) = request.get("access_programs") { + continuation_request["access_programs"] = access_programs.clone(); + } continuation_request["store"] = serde_json::json!(false); continuation_request["parallel_tool_calls"] = serde_json::json!(false); diff --git a/crates/jcode-provider-openai-runtime/src/openai_tests/payloads.rs b/crates/jcode-provider-openai-runtime/src/openai_tests/payloads.rs index 40c86398b4..aad25f77e5 100644 --- a/crates/jcode-provider-openai-runtime/src/openai_tests/payloads.rs +++ b/crates/jcode-provider-openai-runtime/src/openai_tests/payloads.rs @@ -1,3 +1,44 @@ +#[test] +fn test_base_chatgpt_request_has_no_access_programs() { + let request = OpenAIProvider::build_response_request( + "gpt-5.4", + "system".to_string(), + &[], + &[], + true, + None, + None, + None, + None, + None, + None, + ); + assert!( + request.get("access_programs").is_none(), + "the base request never carries access_programs" + ); +} + +#[test] +fn test_cyber_access_program_sent_only_to_models_that_list_it() { + let sol = vec!["standard".to_string(), "daybreak_blue".to_string()]; + let astra = vec!["standard".to_string()]; + assert_eq!( + select_cyber_access_program("daybreak_blue", Some(&sol)), + Some("daybreak_blue".to_string()) + ); + assert_eq!( + select_cyber_access_program(" Daybreak_Blue ", Some(&sol)), + Some("daybreak_blue".to_string()) + ); + assert_eq!( + select_cyber_access_program("daybreak_blue", Some(&astra)), + None + ); + assert_eq!(select_cyber_access_program("daybreak_blue", None), None); + assert_eq!(select_cyber_access_program("", Some(&sol)), None); +} + #[test] fn test_build_response_request_includes_stream_for_http() { let request = OpenAIProvider::build_response_request(