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
1 change: 1 addition & 0 deletions crates/jcode-base/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions crates/jcode-base/src/config/default_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions crates/jcode-base/src/config/env_overrides.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions crates/jcode-base/src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions crates/jcode-base/src/provider/model_catalog_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
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,
Expand Down Expand Up @@ -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();
Expand Down
38 changes: 37 additions & 1 deletion crates/jcode-base/src/provider/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, PersistedModelCatalogScope>,
Expand All @@ -42,6 +47,10 @@ struct PersistedModelCatalogScope {
context_limits: HashMap<String, usize>,
#[serde(default)]
reasoning_efforts: HashMap<String, Vec<String>>,
#[serde(default)]
cyber_access_programs: HashMap<String, Vec<String>>,
#[serde(default)]
schema_version: u32,
observed_at_unix_secs: u64,
}

Expand Down Expand Up @@ -428,6 +437,7 @@ fn persist_scoped_model_catalog(
models: &[String],
context_limits: &HashMap<String, usize>,
reasoning_efforts: &HashMap<String, Vec<String>>,
cyber_access_programs: &HashMap<String, Vec<String>>,
observed_at: SystemTime,
) {
if models.is_empty() {
Expand All @@ -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),
},
);
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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<HashMap<String, Vec<String>>> {
cached_openai_cyber_access_programs_for_scope(&current_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<HashMap<String, Vec<String>>> {
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
Expand All @@ -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(),
);
}
Expand All @@ -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(),
);
}
Expand Down
52 changes: 52 additions & 0 deletions crates/jcode-base/src/provider/models_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ pub struct OpenAIModelCatalog {
pub context_limits: HashMap<String, usize>,
/// Ordered reasoning-effort values advertised by each Codex model.
pub reasoning_efforts: HashMap<String, Vec<String>>,
/// Cyber access programs each Codex model accepts in
/// `access_programs.cyber` (for example `standard`, `daybreak_blue`).
pub cyber_access_programs: HashMap<String, Vec<String>>,
}

#[derive(Debug, Clone, Default)]
Expand Down Expand Up @@ -90,6 +93,7 @@ pub(crate) fn parse_openai_model_catalog(data: &serde_json::Value) -> OpenAIMode
let mut available: HashSet<String> = HashSet::new();
let mut limits: HashMap<String, usize> = HashMap::new();
let mut reasoning_efforts: HashMap<String, Vec<String>> = HashMap::new();
let mut cyber_access_programs: HashMap<String, Vec<String>> = HashMap::new();

for model in models.into_iter().flatten() {
let Some(slug) = model
Expand All @@ -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<String> = 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"))
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -330,13 +351,44 @@ pub async fn fetch_openai_api_key_model_catalog(api_key: &str) -> Result<OpenAIM
available_models,
context_limits: HashMap::new(),
reasoning_efforts: HashMap::new(),
cyber_access_programs: HashMap::new(),
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn openai_catalog_parses_cyber_access_programs() {
let catalog = parse_openai_model_catalog(&serde_json::json!({
"models": [
{ "slug": "gpt-6-sol",
"available_access_programs": { "cyber": ["standard", "daybreak_blue"] } },
{ "slug": "gpt-6-astra",
"available_access_programs": { "cyber": ["standard"] } },
{ "slug": "gpt-daybreak-blue-latest",
"available_access_programs": { "cyber": ["daybreak_blue"] } },
{ "slug": "no-programs" },
{ "slug": "empty-programs", "available_access_programs": { "cyber": [] } }
]
}));
assert_eq!(
catalog.cyber_access_programs["gpt-6-sol"],
vec!["standard".to_string(), "daybreak_blue".to_string()]
);
assert_eq!(
catalog.cyber_access_programs["gpt-6-astra"],
vec!["standard".to_string()]
);
assert_eq!(
catalog.cyber_access_programs["gpt-daybreak-blue-latest"],
vec!["daybreak_blue".to_string()]
);
assert!(!catalog.cyber_access_programs.contains_key("no-programs"));
assert!(!catalog.cyber_access_programs.contains_key("empty-programs"));
}

#[test]
fn openai_catalog_parses_string_and_object_reasoning_efforts() {
let catalog = parse_openai_model_catalog(&serde_json::json!({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ fn test_openai_model_catalog_hydrates_from_disk_cache() {
context_limits: [("openai-disk-only-model".to_string(), 424_242)]
.into_iter()
.collect(),
cyber_access_programs: HashMap::new(),
reasoning_efforts: [(
"openai-disk-only-model".to_string(),
vec!["low".to_string(), "max".to_string()],
Expand Down
6 changes: 6 additions & 0 deletions crates/jcode-config-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,11 @@ pub struct ProviderConfig {
pub openai_native_compaction_mode: String,
/// Token threshold at which OpenAI auto native compaction should trigger.
pub openai_native_compaction_threshold_tokens: usize,
/// Cyber access program to request on OpenAI OAuth (ChatGPT/Codex) routes,
/// e.g. `daybreak_blue`. Sent as `access_programs.cyber` only to models whose
/// catalog entry lists the program. Requires an account granted that program.
/// Overridable via `JCODE_OPENAI_CYBER_ACCESS_PROGRAM`.
pub openai_cyber_access_program: Option<String>,
/// 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.
Expand Down Expand Up @@ -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,
Expand Down
Loading