mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Scope model catalog caches to the current provider and auth identity (#43906)
## Why Switching providers or authentication could reuse a previous identity's model catalog, including its default service tier. An in-flight refresh could also overwrite a newer account's catalog after cache storage completed. ## What changed - Require a matching provider and auth identity for disk and in-memory catalog reuse. Treat legacy entries without an identity as cache misses and fall back to bundled metadata when the in-memory identity no longer matches. - Recheck identity after fetching and storing a catalog before publishing it. - Renew cache freshness only when the client version, identity, and ETag match the stored entry. - Update cache fixtures to include the configured provider and auth identity. ## Testing Add regression coverage for provider and auth switches, credential rotation, legacy and mismatched cache entries, conditional TTL renewal, and an account switch during cache storage. Verify that API-key requests do not inherit a ChatGPT catalog's default `service_tier`, while explicit tier selection still works. GitOrigin-RevId: e15d7ffff238d951d7a1c6a68381cbb479994146
This commit is contained in:
Generated
+1
-1
@@ -427,7 +427,7 @@ dependencies = [
|
||||
"codex-exec-server",
|
||||
"codex-features",
|
||||
"codex-login",
|
||||
"codex-models-manager",
|
||||
"codex-model-provider",
|
||||
"codex-protocol",
|
||||
"codex-utils-cargo-bin",
|
||||
"core_test_support",
|
||||
|
||||
@@ -21,7 +21,7 @@ codex-core = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-features = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-models-manager = { workspace = true }
|
||||
codex-model-provider = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use codex_core::test_support::all_model_presets;
|
||||
use codex_models_manager::client_version_to_whole;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
@@ -10,7 +7,6 @@ use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
use codex_protocol::openai_models::TruncationPolicyConfig;
|
||||
use codex_protocol::openai_models::default_input_modalities;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
/// Convert a ModelPreset to ModelInfo for cache storage.
|
||||
@@ -85,7 +81,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
|
||||
/// This prevents ModelsManager from making network requests to refresh models.
|
||||
/// The cache will be treated as fresh (within TTL) and used instead of fetching from the network.
|
||||
/// Uses bundled-catalog-derived presets, converted to ModelInfo format.
|
||||
pub fn write_models_cache(codex_home: &Path) -> std::io::Result<()> {
|
||||
pub async fn write_models_cache(codex_home: &Path) -> std::io::Result<()> {
|
||||
// Get a stable bundled-catalog-derived preset list and filter for picker-visible entries.
|
||||
let presets: Vec<&ModelPreset> = all_model_presets()
|
||||
.iter()
|
||||
@@ -103,24 +99,33 @@ pub fn write_models_cache(codex_home: &Path) -> std::io::Result<()> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
write_models_cache_with_models(codex_home, models)
|
||||
write_models_cache_with_models(codex_home, models).await
|
||||
}
|
||||
|
||||
/// Write a models_cache.json file with specific models.
|
||||
/// Useful when tests need specific models to be available.
|
||||
pub fn write_models_cache_with_models(
|
||||
pub async fn write_models_cache_with_models(
|
||||
codex_home: &Path,
|
||||
models: Vec<ModelInfo>,
|
||||
) -> std::io::Result<()> {
|
||||
let config = codex_core::config::ConfigBuilder::default()
|
||||
.loader_overrides(codex_config::LoaderOverrides::without_managed_config_for_tests())
|
||||
.codex_home(codex_home.to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
let auth = codex_login::CodexAuth::from_auth_storage(
|
||||
codex_home,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
Some(&config.chatgpt_base_url),
|
||||
config.auth_keyring_backend_kind(),
|
||||
&codex_login::test_support::transport_default_auth_route_config(),
|
||||
)
|
||||
.await?;
|
||||
let cache = codex_model_provider::test_support::models_cache_entry(
|
||||
&config.model_provider,
|
||||
auth.as_ref(),
|
||||
models,
|
||||
);
|
||||
let cache_path = codex_home.join("models_cache.json");
|
||||
// DateTime<Utc> serializes to RFC3339 format by default with serde
|
||||
let fetched_at: DateTime<Utc> = Utc::now();
|
||||
let client_version = client_version_to_whole();
|
||||
let cache = json!({
|
||||
"fetched_at": fetched_at,
|
||||
"etag": null,
|
||||
"client_version": client_version,
|
||||
"models": models
|
||||
});
|
||||
std::fs::write(cache_path, serde_json::to_string_pretty(&cache)?)
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ supports_websockets = false
|
||||
"#
|
||||
))
|
||||
.write(codex_home.path())?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
|
||||
@@ -416,7 +416,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
let access_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
@@ -499,7 +499,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> {
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
let access_token = encode_id_token(
|
||||
&ChatGptIdTokenClaims::new()
|
||||
@@ -611,7 +611,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> {
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
let success_sse = responses::sse(vec![
|
||||
responses::ev_response_created("resp-turn"),
|
||||
@@ -726,7 +726,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> {
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
let unauthorized = ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "message": "unauthorized" }
|
||||
@@ -837,7 +837,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> {
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
let unauthorized = ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "message": "unauthorized" }
|
||||
@@ -953,7 +953,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> {
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
let unauthorized = ResponseTemplate::new(401).set_body_json(json!({
|
||||
"error": { "message": "unauthorized" }
|
||||
@@ -2003,7 +2003,7 @@ async fn login_account_chatgpt_device_code_returns_error_when_disabled() -> Resu
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
mock_device_code_usercode_failure(&mock_server, /*status*/ 404).await;
|
||||
|
||||
let issuer = mock_server.uri();
|
||||
@@ -2059,7 +2059,7 @@ async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()>
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
mock_device_code_usercode(&mock_server, /*interval_seconds*/ 0).await;
|
||||
mock_device_code_token_success(&mock_server).await;
|
||||
@@ -2139,7 +2139,7 @@ async fn login_account_chatgpt_device_code_failure_notifies_without_account_upda
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
mock_device_code_usercode(&mock_server, /*interval_seconds*/ 0).await;
|
||||
mock_device_code_token_failure(&mock_server, /*status*/ 500).await;
|
||||
@@ -2210,7 +2210,7 @@ async fn login_account_chatgpt_device_code_can_be_cancelled() -> Result<()> {
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
mock_device_code_usercode(&mock_server, /*interval_seconds*/ 1).await;
|
||||
mock_device_code_token_failure(&mock_server, /*status*/ 404).await;
|
||||
|
||||
@@ -56,7 +56,6 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand
|
||||
.await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
create_chatgpt_websocket_config(
|
||||
codex_home.path(),
|
||||
&websocket_server.uri().replacen("ws://", "http://", 1),
|
||||
@@ -66,6 +65,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand
|
||||
ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
|
||||
@@ -601,7 +601,7 @@ async fn start_function_dynamic_tool_call(call_id: &str) -> Result<PendingDynami
|
||||
let mut model_info =
|
||||
codex_core::test_support::construct_model_info_offline("mock-model", &config);
|
||||
model_info.input_modalities.push(InputModality::Audio);
|
||||
write_models_cache_with_models(codex_home.path(), vec![model_info])?;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model_info]).await?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
|
||||
@@ -127,7 +127,7 @@ async fn model_guardian_policy_scores_code_mode_cells(
|
||||
shell: Some(GuardianReviewMode::Adaptive),
|
||||
..Default::default()
|
||||
});
|
||||
write_models_cache_with_models(codex_home.path(), vec![model])?;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model]).await?;
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized_with_timeout(TIMEOUT)
|
||||
|
||||
@@ -76,7 +76,7 @@ async fn model_guardian_policy_controls_cua(
|
||||
computer_use: Some(computer_use),
|
||||
..Default::default()
|
||||
});
|
||||
write_models_cache_with_models(codex_home.path(), vec![model])?;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model]).await?;
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized_with_timeout(TIMEOUT)
|
||||
|
||||
@@ -284,7 +284,7 @@ async fn inline_review_delegate_runs_strict_guardian_assessment() -> Result<()>
|
||||
let mut model = codex_core::test_support::construct_model_info_offline(MODEL, &config);
|
||||
// Strict MCP approvals must work under the inline delegate's `never` policy.
|
||||
model.node_repl_auto_review_required = true;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model])?;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model]).await?;
|
||||
let mut app = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized_with_timeout(TIMEOUT)
|
||||
|
||||
@@ -131,7 +131,7 @@ async fn resumed_thread_does_not_wait_for_guardian_websocket_warmup() -> Result<
|
||||
let config = load_default_config_for_test(&codex_home).await;
|
||||
let mut model = codex_core::test_support::construct_model_info_offline(MODEL, &config);
|
||||
model.node_repl_auto_review_required = true;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model])?;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model]).await?;
|
||||
let thread_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-01-05T12-00-00",
|
||||
@@ -833,7 +833,7 @@ async fn guardian_v2_routes_scoped_tool_approvals(
|
||||
let config = load_default_config_for_test(&codex_home).await;
|
||||
let mut model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config);
|
||||
model_info.node_repl_auto_review_required = true;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model_info])?;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model_info]).await?;
|
||||
}
|
||||
let original_thread_id = match lifecycle {
|
||||
ThreadLifecycle::New
|
||||
@@ -2042,7 +2042,7 @@ async fn first_cua_review_does_not_wait_for_initial_score(
|
||||
let config = load_default_config_for_test(&codex_home).await;
|
||||
let mut model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config);
|
||||
model_info.node_repl_auto_review_required = true;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model_info])?;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model_info]).await?;
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized_with_timeout(TIMEOUT)
|
||||
@@ -2128,7 +2128,7 @@ async fn user_approval_skips_async_guardian_without_changing_other_modes() -> Re
|
||||
let config = load_default_config_for_test(&codex_home).await;
|
||||
let mut model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config);
|
||||
model_info.node_repl_auto_review_required = true;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model_info])?;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model_info]).await?;
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized_with_timeout(TIMEOUT)
|
||||
|
||||
@@ -314,12 +314,12 @@ async fn guardians_retain_evidence_after_compaction_and_discard_it_after_rollbac
|
||||
info
|
||||
})
|
||||
.collect();
|
||||
write_models_cache_with_models(codex_home.path(), models)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
write_models_cache_with_models(codex_home.path(), models).await?;
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
|
||||
@@ -77,7 +77,7 @@ async fn computer_use_scoring_follows_model_review_requirement(
|
||||
let mut reviewed_model =
|
||||
codex_core::test_support::construct_model_info_offline(REVIEWED_MODEL, &config);
|
||||
reviewed_model.node_repl_auto_review_required = true;
|
||||
write_models_cache_with_models(codex_home.path(), vec![ordinary_model, reviewed_model])?;
|
||||
write_models_cache_with_models(codex_home.path(), vec![ordinary_model, reviewed_model]).await?;
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized_with_timeout(TIMEOUT)
|
||||
|
||||
@@ -60,7 +60,7 @@ fn model_from_preset(preset: &ModelPreset) -> Model {
|
||||
.collect(),
|
||||
default_reasoning_effort: preset.default_reasoning_effort.clone(),
|
||||
input_modalities: preset.input_modalities.clone(),
|
||||
// `write_models_cache()` round-trips through a simplified ModelInfo fixture that does not
|
||||
// `write_models_cache().await` round-trips through a simplified ModelInfo fixture that does not
|
||||
// preserve personality placeholders in base instructions, so app-server list results from
|
||||
// cache report `supports_personality = false`.
|
||||
// todo(sayan): fix, maybe make roundtrip use ModelInfo only
|
||||
@@ -101,7 +101,7 @@ fn expected_visible_models() -> Vec<Model> {
|
||||
#[tokio::test]
|
||||
async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
@@ -131,7 +131,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
|
||||
#[tokio::test]
|
||||
async fn list_models_includes_hidden_models() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
@@ -286,7 +286,7 @@ openai_base_url = "{server_uri}/v1"
|
||||
#[tokio::test]
|
||||
async fn list_models_pagination_works() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
@@ -332,7 +332,7 @@ async fn list_models_pagination_works() -> Result<()> {
|
||||
#[tokio::test]
|
||||
async fn list_models_rejects_invalid_cursor() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.without_auto_env()
|
||||
|
||||
@@ -198,7 +198,7 @@ async fn spawned_subagents_apply_configured_developer_instruction_precedence(
|
||||
config
|
||||
.with_extra_config(&feature_config)
|
||||
.write(codex_home.path())?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized()
|
||||
@@ -379,7 +379,7 @@ async fn compacted_full_history_fork_replaces_parent_developer_instructions() ->
|
||||
"[features.multi_agent_v2]\nenabled = true\nsubagent_developer_instructions = {CHILD_INSTRUCTIONS:?}"
|
||||
))
|
||||
.write(codex_home.path())?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
@@ -641,7 +641,7 @@ async fn cold_resume_preserves_effective_developer_instructions_for_worker(
|
||||
))
|
||||
.with_extra_config(&feature_config)
|
||||
.write(codex_home.path())?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
let (thread_id, child_resume_params, baseline) = {
|
||||
let mut app_server = TestAppServer::builder()
|
||||
|
||||
@@ -48,7 +48,7 @@ async fn account_switch_reloads_telemetry_collectors_and_preserves_trace_context
|
||||
.email(INITIAL_EMAIL),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
let mut app_server = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
|
||||
@@ -27,7 +27,7 @@ enum ModelTransport {
|
||||
Websocket,
|
||||
}
|
||||
|
||||
fn write_provider_config(
|
||||
async fn write_provider_config(
|
||||
codex_home: &Path,
|
||||
model_base_url: &str,
|
||||
transport: ModelTransport,
|
||||
@@ -60,7 +60,7 @@ supports_websockets = {supports_websockets}
|
||||
"#
|
||||
);
|
||||
std::fs::write(codex_home.join("config.toml"), config)?;
|
||||
write_models_cache(codex_home)?;
|
||||
write_models_cache(codex_home).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ async fn managed_residency_overrides_provider_headers(transport: ModelTransport)
|
||||
};
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
write_provider_config(codex_home.path(), &model_base_url, transport)?;
|
||||
write_provider_config(codex_home.path(), &model_base_url, transport).await?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("requirements.toml"),
|
||||
"enforce_residency = \"us\"\n",
|
||||
|
||||
@@ -47,7 +47,7 @@ async fn fork_before_first_turn_preserves_model_selected_multi_agent_version(
|
||||
let config = load_default_config_for_test(&codex_home).await;
|
||||
let mut model = codex_core::test_support::construct_model_info_offline("mock-model", &config);
|
||||
model.multi_agent_version = Some(MultiAgentVersion::V2);
|
||||
write_models_cache_with_models(codex_home.path(), vec![model])?;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model]).await?;
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build_initialized()
|
||||
|
||||
@@ -63,7 +63,7 @@ async fn thread_revert_preserves_model_selected_multi_agent_version(restart: boo
|
||||
let config = load_default_config_for_test(&codex_home).await;
|
||||
let mut model = codex_core::test_support::construct_model_info_offline("mock-model", &config);
|
||||
model.multi_agent_version = Some(MultiAgentVersion::V2);
|
||||
write_models_cache_with_models(codex_home.path(), vec![model])?;
|
||||
write_models_cache_with_models(codex_home.path(), vec![model]).await?;
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
.build()
|
||||
|
||||
@@ -47,7 +47,7 @@ async fn thread_settings_update_emits_notification_and_updates_future_turns() ->
|
||||
.await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
let (model_id, service_tier_id) = service_tier_model_and_tier_id()?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
@@ -289,7 +289,7 @@ async fn thread_settings_update_null_service_tier_uses_default() -> Result<()> {
|
||||
.await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
let (model_id, service_tier_id) = service_tier_model_and_tier_id()?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
|
||||
@@ -113,7 +113,7 @@ metrics_exporter = {{ otlp-http = {{ endpoint = "{}/metrics", protocol = "json"
|
||||
.chatgpt_user_id("user-a"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
write_models_cache(home.path())?;
|
||||
write_models_cache(home.path()).await?;
|
||||
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
|
||||
let config = Arc::new(
|
||||
ConfigBuilder::default()
|
||||
|
||||
@@ -777,7 +777,7 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills(
|
||||
MockResponsesConfig::new(&server.uri())
|
||||
.enable_feature(Feature::Personality)
|
||||
.write(codex_home.path())?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
let cache_path = codex_home.path().join("models_cache.json");
|
||||
let mut cache: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&cache_path)?)?;
|
||||
@@ -874,7 +874,7 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> {
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
let service_tier_model = all_model_presets()
|
||||
.iter()
|
||||
.find(|preset| preset.show_in_picker && !preset.service_tiers.is_empty())
|
||||
@@ -998,7 +998,7 @@ async fn turn_start_emits_raw_response_completed_with_upstream_usage(
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
@@ -3978,7 +3978,7 @@ async fn turn_start_streams_apply_patch_change_updates_v2() -> Result<()> {
|
||||
.disable_feature(Feature::RemoteModels)
|
||||
.disable_feature(Feature::ShellSnapshot)
|
||||
.write(&codex_home)?;
|
||||
write_models_cache(&codex_home)?;
|
||||
write_models_cache(&codex_home).await?;
|
||||
let cache_path = codex_home.join("models_cache.json");
|
||||
let mut cache: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&cache_path)?)?;
|
||||
@@ -4368,7 +4368,7 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected(
|
||||
.enable_feature(Feature::Goals)
|
||||
.with_root_config(&format!("chatgpt_base_url = \"{}\"", server.uri()))
|
||||
.write(codex_home.path())?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
mount_analytics_capture(&server, codex_home.path()).await?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
|
||||
@@ -50,7 +50,7 @@ async fn fresh_context_subagent_inherits_disabled_view_image_and_mcp_tools() ->
|
||||
"[mcp_servers.{TEST_SERVER_NAME}]\nurl = \"{mcp_server_url}/mcp\"\n\n[features.multi_agent_v2]\nenabled = true"
|
||||
))
|
||||
.write(codex_home.path())?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
write_models_cache(codex_home.path()).await?;
|
||||
|
||||
let mut mcp = TestAppServer::builder()
|
||||
.with_codex_home(codex_home.path())
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Integration coverage for model catalogs supplied through the public cache interface.
|
||||
|
||||
use codex_core::TurnInputRequest;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
@@ -5,10 +7,13 @@ use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use chrono::Utc;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_model_provider_info::built_in_model_providers;
|
||||
use codex_models_manager::cache::ModelsCache;
|
||||
use codex_models_manager::cache::ModelsCacheEntry;
|
||||
use codex_models_manager::cache::ModelsCacheError;
|
||||
@@ -20,9 +25,11 @@ use codex_models_manager::manager::OpenAiModelsManager;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_models_manager::model_info::model_info_from_slug;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::error::Result as CoreResult;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -36,12 +43,23 @@ use core_test_support::submit_thread_settings;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StoreGate {
|
||||
entered: oneshot::Sender<()>,
|
||||
release: oneshot::Receiver<()>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestModelsCache {
|
||||
entry: Option<ModelsCacheEntry>,
|
||||
load_error: bool,
|
||||
stored_entries: Mutex<Vec<ModelsCacheEntry>>,
|
||||
store_gate: Mutex<Option<StoreGate>>,
|
||||
}
|
||||
|
||||
impl ModelsCache for TestModelsCache {
|
||||
@@ -53,7 +71,13 @@ impl ModelsCache for TestModelsCache {
|
||||
if self.load_error {
|
||||
Err(ModelsCacheError::new("test load failure"))
|
||||
} else {
|
||||
Ok(self.entry.clone())
|
||||
Ok(self
|
||||
.stored_entries
|
||||
.lock()
|
||||
.expect("stored entries lock should not be poisoned")
|
||||
.last()
|
||||
.cloned()
|
||||
.or_else(|| self.entry.clone()))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -67,6 +91,15 @@ impl ModelsCache for TestModelsCache {
|
||||
.lock()
|
||||
.expect("stored entries lock should not be poisoned")
|
||||
.push(entry.clone());
|
||||
let gate = self
|
||||
.store_gate
|
||||
.lock()
|
||||
.expect("store gate lock should not be poisoned")
|
||||
.take();
|
||||
if let Some(StoreGate { entered, release }) = gate {
|
||||
let _ = entered.send(());
|
||||
let _ = release.await;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -74,6 +107,8 @@ impl ModelsCache for TestModelsCache {
|
||||
fn refresh_ttl<'a>(
|
||||
&'a self,
|
||||
_client_version: &'a str,
|
||||
_identity: &'a str,
|
||||
_etag: &'a str,
|
||||
) -> ModelsCacheFuture<'a, Result<(), ModelsCacheError>> {
|
||||
Box::pin(async move {
|
||||
let mut entry = self
|
||||
@@ -227,6 +262,7 @@ async fn injected_cache_hit_drives_agent_model_selection() -> Result<()> {
|
||||
}),
|
||||
load_error: false,
|
||||
stored_entries: Mutex::new(Vec::new()),
|
||||
store_gate: Mutex::new(None),
|
||||
});
|
||||
let endpoint = TestModelsEndpoint::new(Vec::new());
|
||||
|
||||
@@ -243,6 +279,7 @@ async fn injected_cache_error_falls_back_for_agent_model_selection() -> Result<(
|
||||
entry: None,
|
||||
load_error: true,
|
||||
stored_entries: Mutex::new(Vec::new()),
|
||||
store_gate: Mutex::new(None),
|
||||
});
|
||||
let endpoint = TestModelsEndpoint::new(vec![remote_model(model_slug)]);
|
||||
|
||||
@@ -258,3 +295,133 @@ async fn injected_cache_error_falls_back_for_agent_model_selection() -> Result<(
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn account_switch_during_cache_store_preserves_new_catalog_for_next_turn() -> Result<()> {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let initial_auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let auth = AuthManager::from_auth_for_testing_with_home(
|
||||
initial_auth.clone(),
|
||||
home.path().to_path_buf(),
|
||||
);
|
||||
let mut provider_info = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone();
|
||||
provider_info.base_url = Some(format!("{}/v1", server.uri()));
|
||||
provider_info.supports_websockets = false;
|
||||
let cache = Arc::new(TestModelsCache {
|
||||
entry: None,
|
||||
load_error: false,
|
||||
stored_entries: Mutex::new(Vec::new()),
|
||||
store_gate: Mutex::new(None),
|
||||
});
|
||||
let manager = create_model_provider(provider_info.clone(), Some(auth.clone()))
|
||||
.models_manager_with_cache(/*config_model_catalog*/ None, cache.clone());
|
||||
let mut old_model = remote_model("gpt-5.4");
|
||||
old_model.default_reasoning_summary = ReasoningSummary::Concise;
|
||||
let initial = responses::mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![old_model.clone()],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let test = test_codex()
|
||||
.with_home(home.clone())
|
||||
.with_auth(initial_auth.clone())
|
||||
.with_model("gpt-5.4")
|
||||
.with_models_manager(manager.clone())
|
||||
.with_config(move |config| {
|
||||
config.model_provider = provider_info;
|
||||
config.model_reasoning_summary = None;
|
||||
})
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
assert_eq!(initial.requests().len(), 1);
|
||||
|
||||
// Pause after the old catalog has been stored, before its refresh can publish it.
|
||||
let (store_entered, entered) = oneshot::channel();
|
||||
let (release_store, release) = oneshot::channel();
|
||||
*cache.store_gate.lock().unwrap() = Some(StoreGate {
|
||||
entered: store_entered,
|
||||
release,
|
||||
});
|
||||
let old_request = responses::mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![old_model],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let old_refresh = tokio::spawn({
|
||||
let manager = manager.clone();
|
||||
async move {
|
||||
manager
|
||||
.list_models(
|
||||
RefreshStrategy::Online,
|
||||
codex_core::test_support::default_http_client_factory(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
timeout(Duration::from_secs(10), entered).await??;
|
||||
|
||||
let mut tokens = initial_auth.get_token_data()?;
|
||||
tokens.id_token.raw_jwt = format!(
|
||||
"e30.{}.signature",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(br#"{"email":"second@example.com"}"#)
|
||||
);
|
||||
tokens.account_id = Some("second-account".into());
|
||||
tokens.access_token = "second-account-token".into();
|
||||
std::fs::write(
|
||||
home.path().join("auth.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"auth_mode": "chatgpt", "tokens": tokens, "last_refresh": Utc::now(),
|
||||
}))?,
|
||||
)?;
|
||||
auth.reload().await;
|
||||
test.thread_manager.auth_manager().reload().await;
|
||||
|
||||
let mut new_model = remote_model("gpt-5.4");
|
||||
new_model.default_reasoning_summary = ReasoningSummary::Detailed;
|
||||
let new_request = responses::mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![new_model.clone()],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
manager
|
||||
.list_models(
|
||||
RefreshStrategy::Online,
|
||||
codex_core::test_support::default_http_client_factory(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(manager.get_remote_models().await, vec![new_model.clone()]);
|
||||
release_store.send(()).unwrap();
|
||||
timeout(Duration::from_secs(10), old_refresh).await??;
|
||||
assert_eq!(manager.get_remote_models().await, vec![new_model]);
|
||||
for (mock, account) in [(old_request, "account_id"), (new_request, "second-account")] {
|
||||
let requests = mock.requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].headers["chatgpt-account-id"], account);
|
||||
}
|
||||
|
||||
let response = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("next-turn"),
|
||||
ev_completed("next-turn"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
test.submit_turn("use the new account's model defaults")
|
||||
.await?;
|
||||
let request = response.single_request();
|
||||
assert_eq!(
|
||||
request.header("chatgpt-account-id").as_deref(),
|
||||
Some("second-account")
|
||||
);
|
||||
assert_eq!(request.body_json()["reasoning"]["summary"], "detailed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ mod model_overrides;
|
||||
mod model_runtime_selectors;
|
||||
mod model_switching;
|
||||
mod model_visible_layout;
|
||||
mod models_cache_auth;
|
||||
mod models_cache_ttl;
|
||||
mod models_etag_responses;
|
||||
mod multi_agent_mode;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Catalogs must follow auth and provider changes without leaking default billing tiers.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_login::AuthCredentialsStoreMode;
|
||||
use codex_login::AuthKeyringBackendKind;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::login_with_api_key;
|
||||
use codex_models_manager::bundled_models_response;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn auth_and_provider_switches_do_not_reuse_chatgpt_catalog() -> Result<()> {
|
||||
let server = wiremock::MockServer::start().await;
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let mut model = bundled_models_response()?
|
||||
.models
|
||||
.into_iter()
|
||||
.find(|model| model.slug == "gpt-5.4")
|
||||
.unwrap();
|
||||
model.visibility = ModelVisibility::List;
|
||||
model.default_service_tier = Some("priority".into());
|
||||
let models_mock = responses::mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
models: vec![model.clone()],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let chatgpt = test_codex()
|
||||
.with_home(home.clone())
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_model("gpt-5.4")
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
assert!(home.path().join("models_cache.json").exists());
|
||||
assert_eq!(
|
||||
chatgpt
|
||||
.thread_manager
|
||||
.get_models_manager()
|
||||
.get_remote_models()
|
||||
.await,
|
||||
vec![model.clone()]
|
||||
);
|
||||
login_with_api_key(
|
||||
home.path(),
|
||||
"api-key",
|
||||
AuthCredentialsStoreMode::File,
|
||||
AuthKeyringBackendKind::default(),
|
||||
)?;
|
||||
assert!(chatgpt.thread_manager.auth_manager().reload().await);
|
||||
let manager = chatgpt.thread_manager.get_models_manager();
|
||||
let bundled = bundled_models_response()?.models;
|
||||
assert_eq!(manager.get_remote_models().await, bundled);
|
||||
assert_eq!(
|
||||
manager
|
||||
.raw_model_catalog(
|
||||
RefreshStrategy::Offline,
|
||||
codex_core::test_support::default_http_client_factory()
|
||||
)
|
||||
.await
|
||||
.models,
|
||||
bundled
|
||||
);
|
||||
drop(chatgpt);
|
||||
|
||||
let api = test_codex()
|
||||
.with_home(home.clone())
|
||||
.with_auth(CodexAuth::from_api_key("api-key"))
|
||||
.with_model("gpt-5.4")
|
||||
.build_with_auto_env(&server)
|
||||
.await?;
|
||||
let ordinary = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("ordinary"),
|
||||
ev_completed("ordinary"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
api.submit_turn("hello").await?;
|
||||
assert_eq!(
|
||||
ordinary.single_request().body_json().get("service_tier"),
|
||||
None
|
||||
);
|
||||
|
||||
let explicit = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("explicit"),
|
||||
ev_completed("explicit"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
api.submit_turn_with_service_tier("hello again", Some("priority"))
|
||||
.await?;
|
||||
assert_eq!(
|
||||
explicit.single_request().body_json()["service_tier"],
|
||||
"priority"
|
||||
);
|
||||
assert_eq!(models_mock.requests().len(), 1);
|
||||
drop(api);
|
||||
|
||||
let other_server = wiremock::MockServer::start().await;
|
||||
model.default_service_tier = None;
|
||||
model.display_name = "Second provider model".into();
|
||||
let other_models = responses::mount_models_once(
|
||||
&other_server,
|
||||
ModelsResponse {
|
||||
models: vec![model.clone()],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let other = test_codex()
|
||||
.with_home(home)
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_model("gpt-5.4")
|
||||
.with_config(|config| {
|
||||
config.model_provider_id = "second".into();
|
||||
config.model_provider.name = "Second".into();
|
||||
})
|
||||
.build_with_auto_env(&other_server)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
other
|
||||
.thread_manager
|
||||
.get_models_manager()
|
||||
.get_remote_models()
|
||||
.await,
|
||||
vec![model]
|
||||
);
|
||||
assert_eq!(other_models.requests().len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
@@ -289,9 +289,19 @@ async fn uses_cache_when_version_matches() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let identity = codex_model_provider::test_support::models_cache_entry(
|
||||
&codex_model_provider_info::ModelProviderInfo::create_openai_provider(Some(format!(
|
||||
"{}/v1",
|
||||
server.uri()
|
||||
))),
|
||||
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
|
||||
Vec::new(),
|
||||
)
|
||||
.identity;
|
||||
builder = builder
|
||||
.with_pre_build_hook(move |home| {
|
||||
let mut cache = serde_json::to_value(ModelsCache {
|
||||
identity,
|
||||
fetched_at: Utc::now(),
|
||||
etag: None,
|
||||
client_version: Some(client_version_to_whole()),
|
||||
@@ -354,9 +364,19 @@ async fn refreshes_when_cache_version_missing() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let identity = codex_model_provider::test_support::models_cache_entry(
|
||||
&codex_model_provider_info::ModelProviderInfo::create_openai_provider(Some(format!(
|
||||
"{}/v1",
|
||||
server.uri()
|
||||
))),
|
||||
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
|
||||
Vec::new(),
|
||||
)
|
||||
.identity;
|
||||
builder = builder
|
||||
.with_pre_build_hook(move |home| {
|
||||
let cache = ModelsCache {
|
||||
identity,
|
||||
fetched_at: Utc::now(),
|
||||
etag: None,
|
||||
client_version: None,
|
||||
@@ -404,10 +424,20 @@ async fn refreshes_when_cache_version_differs() -> Result<()> {
|
||||
}
|
||||
|
||||
let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
let identity = codex_model_provider::test_support::models_cache_entry(
|
||||
&codex_model_provider_info::ModelProviderInfo::create_openai_provider(Some(format!(
|
||||
"{}/v1",
|
||||
server.uri()
|
||||
))),
|
||||
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
|
||||
Vec::new(),
|
||||
)
|
||||
.identity;
|
||||
builder = builder
|
||||
.with_pre_build_hook(move |home| {
|
||||
let client_version = client_version_to_whole();
|
||||
let cache = ModelsCache {
|
||||
identity,
|
||||
fetched_at: Utc::now(),
|
||||
etag: None,
|
||||
client_version: Some(format!("{client_version}-diff")),
|
||||
@@ -471,6 +501,8 @@ fn write_cache_sync(path: &Path, cache: &ModelsCache) -> Result<()> {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ModelsCache {
|
||||
#[serde(default)]
|
||||
identity: Option<String>,
|
||||
fetched_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
etag: Option<String>,
|
||||
|
||||
@@ -5,6 +5,7 @@ mod models_endpoint;
|
||||
mod models_identity;
|
||||
mod provider;
|
||||
mod shared_state;
|
||||
pub mod test_support;
|
||||
|
||||
pub use amazon_bedrock::is_supported_amazon_bedrock_region;
|
||||
pub use auth::AgentIdentitySessionFallback;
|
||||
|
||||
@@ -456,4 +456,104 @@ mod tests {
|
||||
Some(&"eu".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RotatingAuth(std::sync::atomic::AtomicUsize);
|
||||
|
||||
impl codex_login::ExternalAuth for RotatingAuth {
|
||||
fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
|
||||
Box::pin(async move {
|
||||
let generation = self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(CodexAuth::from_api_key(&format!("token-{generation}")))
|
||||
})
|
||||
}
|
||||
|
||||
fn refresh(
|
||||
&self,
|
||||
_context: codex_login::ExternalAuthRefreshContext,
|
||||
) -> codex_login::ExternalAuthFuture<'_, CodexAuth> {
|
||||
self.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn command_auth_refresh_fetches_a_catalog_for_the_current_credentials() {
|
||||
use codex_models_manager::manager::ModelsManager;
|
||||
use codex_models_manager::manager::OpenAiModelsManager;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let auth = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("initial"));
|
||||
auth.set_external_auth(Arc::new(RotatingAuth(std::sync::atomic::AtomicUsize::new(
|
||||
0,
|
||||
))))
|
||||
.await
|
||||
.unwrap();
|
||||
let model = codex_protocol::openai_models::ModelInfo {
|
||||
used_fallback_model_metadata: false,
|
||||
..codex_models_manager::model_info::model_info_from_slug("command-auth-model")
|
||||
};
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/models"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(ModelsResponse {
|
||||
models: vec![model.clone()],
|
||||
}))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut provider = provider_info_with_command_auth();
|
||||
provider.base_url = Some(server.uri());
|
||||
// Keep this test independent of the residency override exercised in parallel.
|
||||
provider.http_headers = Some(std::collections::HashMap::from([(
|
||||
RESIDENCY_HEADER_NAME.to_string(),
|
||||
"us".into(),
|
||||
)]));
|
||||
let manager = OpenAiModelsManager::new_without_cache(
|
||||
Arc::new(OpenAiModelsEndpoint::new(provider, Some(auth.clone()))),
|
||||
Some(auth.clone()),
|
||||
);
|
||||
let catalog = manager
|
||||
.raw_model_catalog(
|
||||
RefreshStrategy::OnlineIfUncached,
|
||||
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
catalog
|
||||
.models
|
||||
.iter()
|
||||
.find(|candidate| candidate.slug == model.slug),
|
||||
Some(&model)
|
||||
);
|
||||
auth.auth().await;
|
||||
let bundled = codex_models_manager::bundled_models_response().unwrap();
|
||||
assert_eq!(manager.get_remote_models().await, bundled.models);
|
||||
assert_eq!(manager.try_get_remote_models().unwrap(), bundled.models);
|
||||
assert_eq!(
|
||||
manager
|
||||
.raw_model_catalog(
|
||||
RefreshStrategy::Offline,
|
||||
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||
)
|
||||
.await,
|
||||
bundled
|
||||
);
|
||||
assert_eq!(
|
||||
manager
|
||||
.raw_model_catalog(
|
||||
RefreshStrategy::OnlineIfUncached,
|
||||
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||
)
|
||||
.await,
|
||||
catalog
|
||||
);
|
||||
let requests = server.received_requests().await.unwrap();
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.map(|request| request.headers["authorization"].to_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["Bearer token-2", "Bearer token-6"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Fixtures for integration tests that seed the provider's model cache.
|
||||
|
||||
use codex_login::CodexAuth;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_models_manager::cache::ModelsCacheEntry;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
|
||||
/// Constructs a cache fixture matching the supplied test provider and authentication.
|
||||
pub fn models_cache_entry(
|
||||
provider_info: &ModelProviderInfo,
|
||||
auth: Option<&CodexAuth>,
|
||||
models: Vec<ModelInfo>,
|
||||
) -> ModelsCacheEntry {
|
||||
ModelsCacheEntry {
|
||||
fetched_at: std::time::SystemTime::now().into(),
|
||||
etag: None,
|
||||
client_version: Some(codex_models_manager::client_version_to_whole()),
|
||||
identity: crate::models_identity::identity(provider_info, auth).ok(),
|
||||
models,
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ use tracing::info;
|
||||
///
|
||||
/// Implementations own cache freshness and lookup partitioning. [`ModelsCache::load`] must not
|
||||
/// return stale entries or entries for a different client version. A shared backend must also keep
|
||||
/// catalogs for different providers and tenants separate, typically through configuration captured
|
||||
/// by the implementation.
|
||||
/// catalogs for different providers and tenants separate. The manager also validates the entry
|
||||
/// identity against the current endpoint before using it.
|
||||
///
|
||||
/// Cache failures are non-fatal. The models manager logs them and falls back to the configured
|
||||
/// models endpoint.
|
||||
@@ -42,16 +42,18 @@ pub trait ModelsCache: fmt::Debug + Send + Sync {
|
||||
entry: &'a ModelsCacheEntry,
|
||||
) -> ModelsCacheFuture<'a, Result<(), ModelsCacheError>>;
|
||||
|
||||
/// Extends the freshness of the entry for `client_version` without changing its catalog.
|
||||
/// Extends freshness only if the stored version, identity, and ETag match.
|
||||
///
|
||||
/// The models manager calls this after the endpoint confirms that the cached ETag is still
|
||||
/// current. Implementations must preserve the models, ETag, and client version. This operation
|
||||
/// current. Implementations must preserve the models, identity, ETag, and client version. This operation
|
||||
/// cannot be expressed in terms of [`ModelsCache::load`], because `load` intentionally omits
|
||||
/// expired entries. File-backed implementations can read and revalidate the stored entry
|
||||
/// directly, while backends with native TTL support can extend the entry in place.
|
||||
fn refresh_ttl<'a>(
|
||||
&'a self,
|
||||
client_version: &'a str,
|
||||
identity: &'a str,
|
||||
etag: &'a str,
|
||||
) -> ModelsCacheFuture<'a, Result<(), ModelsCacheError>>;
|
||||
}
|
||||
|
||||
@@ -71,7 +73,7 @@ pub struct ModelsCacheEntry {
|
||||
/// The models manager rejects entries whose value is absent or differs from its current version.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub client_version: Option<String>,
|
||||
/// Opaque provider and auth identity associated with this catalog.
|
||||
/// Opaque provider and auth identity. Unscoped legacy entries are cache misses.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identity: Option<String>,
|
||||
/// Models returned by the catalog endpoint.
|
||||
@@ -154,14 +156,20 @@ impl ModelsCache for FileModelsCache {
|
||||
|
||||
fn refresh_ttl<'a>(
|
||||
&'a self,
|
||||
_client_version: &'a str,
|
||||
client_version: &'a str,
|
||||
identity: &'a str,
|
||||
etag: &'a str,
|
||||
) -> ModelsCacheFuture<'a, Result<(), ModelsCacheError>> {
|
||||
Box::pin(async move {
|
||||
let mut entry = load_file(&self.cache_path)
|
||||
.await
|
||||
.map_err(cache_error)?
|
||||
.ok_or_else(|| ModelsCacheError::new("cache not found"))?;
|
||||
if entry.is_fresh(self.cache_ttl / 2) {
|
||||
if entry.client_version.as_deref() != Some(client_version)
|
||||
|| entry.identity.as_deref() != Some(identity)
|
||||
|| entry.etag.as_deref() != Some(etag)
|
||||
|| entry.is_fresh(self.cache_ttl / 2)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
entry.fetched_at = Utc::now();
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Coverage for cache identity validation and conditional freshness updates.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn mismatched_and_legacy_cache_entries_fetch_the_current_catalog() {
|
||||
for identity in [None, Some("other-provider-or-account".to_string())] {
|
||||
let stale = remote_model("stale", "Stale", /*priority*/ 0);
|
||||
let current = remote_model("current", "Current", /*priority*/ 0);
|
||||
let cache = TestModelsCache::with_entry(ModelsCacheEntry {
|
||||
fetched_at: Utc::now(),
|
||||
etag: Some("old-etag".into()),
|
||||
client_version: Some(crate::client_version_to_whole()),
|
||||
identity,
|
||||
models: vec![stale],
|
||||
});
|
||||
let endpoint = TestModelsEndpoint::new(vec![vec![current.clone()]]);
|
||||
let manager = OpenAiModelsManager::new_with_cache(
|
||||
cache.clone(),
|
||||
endpoint.clone(),
|
||||
Some(AuthManager::from_auth_for_testing(
|
||||
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
)),
|
||||
);
|
||||
assert_eq!(
|
||||
manager
|
||||
.raw_model_catalog(
|
||||
RefreshStrategy::OnlineIfUncached,
|
||||
DEFAULT_HTTP_CLIENT_FACTORY
|
||||
)
|
||||
.await
|
||||
.models,
|
||||
vec![current.clone()]
|
||||
);
|
||||
assert_eq!(endpoint.fetch_count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matching_etag_cannot_renew_a_different_identity_or_catalog() {
|
||||
let home = tempdir().unwrap();
|
||||
let cache = FileModelsCache::new(home.path().join(MODEL_CACHE_FILE), DEFAULT_MODEL_CACHE_TTL);
|
||||
let expected = ModelsCacheEntry {
|
||||
fetched_at: Utc::now() - chrono::Duration::hours(1),
|
||||
etag: Some("etag".into()),
|
||||
client_version: Some(crate::client_version_to_whole()),
|
||||
identity: Some("first-account".into()),
|
||||
models: vec![remote_model("model", "Model", /*priority*/ 0)],
|
||||
};
|
||||
for (key, etag, version) in [
|
||||
("other-account", "etag", crate::client_version_to_whole()),
|
||||
(
|
||||
"first-account",
|
||||
"new-etag",
|
||||
crate::client_version_to_whole(),
|
||||
),
|
||||
("first-account", "etag", "other-version".into()),
|
||||
] {
|
||||
let stored = ModelsCacheEntry {
|
||||
identity: Some(key.into()),
|
||||
etag: Some(etag.into()),
|
||||
client_version: Some(version),
|
||||
..expected.clone()
|
||||
};
|
||||
cache.store(&stored).await.unwrap();
|
||||
cache
|
||||
.refresh_ttl(
|
||||
expected.client_version.as_deref().unwrap(),
|
||||
expected.identity.as_deref().unwrap(),
|
||||
expected.etag.as_deref().unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let actual: ModelsCacheEntry = serde_json::from_slice(
|
||||
&tokio::fs::read(home.path().join(MODEL_CACHE_FILE))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(actual, stored);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ pub trait ModelsEndpointClient: fmt::Debug + Send + Sync {
|
||||
/// Opaque identity of the current provider and credentials, without resolving auth.
|
||||
///
|
||||
/// Must change when account, email, plan, provider, or API credentials change.
|
||||
/// Return `None` when identity is unavailable.
|
||||
/// Return `None` when identity is unavailable; cached catalog reuse is then disabled.
|
||||
fn identity(&self) -> Option<String>;
|
||||
|
||||
/// Returns whether this provider can authenticate command-scoped requests.
|
||||
@@ -232,8 +232,7 @@ pub type SharedModelsManager = Arc<dyn ModelsManager>;
|
||||
/// OpenAI-compatible model manager backed by bundled models, cache, and `/models`.
|
||||
#[derive(Debug)]
|
||||
pub struct OpenAiModelsManager {
|
||||
remote_models: RwLock<Vec<ModelInfo>>,
|
||||
etag: RwLock<Option<String>>,
|
||||
remote_models: RwLock<ModelsCacheEntry>,
|
||||
cache: Option<Arc<dyn ModelsCache>>,
|
||||
endpoint_client: SharedModelsEndpointClient,
|
||||
auth_manager: Option<Arc<AuthManager>>,
|
||||
@@ -291,8 +290,13 @@ impl OpenAiModelsManager {
|
||||
) -> Self {
|
||||
let remote_models = load_remote_models_from_file().unwrap_or_default();
|
||||
Self {
|
||||
remote_models: RwLock::new(remote_models),
|
||||
etag: RwLock::new(None),
|
||||
remote_models: RwLock::new(ModelsCacheEntry {
|
||||
fetched_at: Utc::now(),
|
||||
etag: None,
|
||||
client_version: Some(crate::client_version_to_whole()),
|
||||
identity: endpoint_client.identity(),
|
||||
models: remote_models,
|
||||
}),
|
||||
cache,
|
||||
endpoint_client,
|
||||
auth_manager,
|
||||
@@ -324,11 +328,25 @@ impl ModelsManager for OpenAiModelsManager {
|
||||
}
|
||||
|
||||
fn get_remote_models(&self) -> ModelsManagerFuture<'_, Vec<ModelInfo>> {
|
||||
Box::pin(async move { self.remote_models.read().await.clone() })
|
||||
Box::pin(async move {
|
||||
let entry = self.remote_models.read().await;
|
||||
if entry.identity.is_some() && entry.identity == self.endpoint_client.identity() {
|
||||
entry.models.clone()
|
||||
} else {
|
||||
load_remote_models_from_file().unwrap_or_default()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn try_get_remote_models(&self) -> Result<Vec<ModelInfo>, TryLockError> {
|
||||
Ok(self.remote_models.try_read()?.clone())
|
||||
let entry = self.remote_models.try_read()?;
|
||||
Ok(
|
||||
if entry.identity.is_some() && entry.identity == self.endpoint_client.identity() {
|
||||
entry.models.clone()
|
||||
} else {
|
||||
load_remote_models_from_file().unwrap_or_default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn auth_manager(&self) -> Option<&AuthManager> {
|
||||
@@ -370,10 +388,18 @@ impl OpenAiModelsManager {
|
||||
}
|
||||
|
||||
async fn refresh_if_new_etag(&self, etag: String, http_client_factory: HttpClientFactory) {
|
||||
let current_etag = self.get_etag().await;
|
||||
if current_etag.clone().is_some() && current_etag.as_deref() == Some(etag.as_str()) {
|
||||
let (identity, current_etag) = {
|
||||
let entry = self.remote_models.read().await;
|
||||
(entry.identity.clone(), entry.etag.clone())
|
||||
};
|
||||
if let Some(identity) = identity
|
||||
&& Some(&identity) == self.endpoint_client.identity().as_ref()
|
||||
&& current_etag.as_deref() == Some(etag.as_str())
|
||||
{
|
||||
if let Some(cache) = self.cache.as_ref()
|
||||
&& let Err(err) = cache.refresh_ttl(&crate::client_version_to_whole()).await
|
||||
&& let Err(err) = cache
|
||||
.refresh_ttl(&crate::client_version_to_whole(), &identity, &etag)
|
||||
.await
|
||||
{
|
||||
error!("failed to renew cache TTL: {err}");
|
||||
}
|
||||
@@ -402,26 +428,18 @@ impl OpenAiModelsManager {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match refresh_strategy {
|
||||
RefreshStrategy::Offline => {
|
||||
// Only try to load from cache, never fetch
|
||||
self.try_load_cache().await;
|
||||
Ok(())
|
||||
}
|
||||
RefreshStrategy::OnlineIfUncached => {
|
||||
// Try cache first, fall back to online if unavailable
|
||||
if self.try_load_cache().await {
|
||||
info!("models cache: using cached models for OnlineIfUncached");
|
||||
return Ok(());
|
||||
}
|
||||
info!("models cache: cache miss, fetching remote models");
|
||||
self.fetch_and_update_models(http_client_factory).await
|
||||
}
|
||||
RefreshStrategy::Online => {
|
||||
// Always fetch from network
|
||||
self.fetch_and_update_models(http_client_factory).await
|
||||
}
|
||||
RefreshStrategy::Online => self.fetch_and_update_models(http_client_factory).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,20 +456,22 @@ impl OpenAiModelsManager {
|
||||
.endpoint_client
|
||||
.list_models(&client_version, http_client_factory.clone())
|
||||
.await?;
|
||||
self.apply_remote_models(models.clone()).await;
|
||||
*self.etag.write().await = etag.clone();
|
||||
if let Some(cache) = self.cache.as_ref() {
|
||||
let entry = ModelsCacheEntry {
|
||||
fetched_at: Utc::now(),
|
||||
etag,
|
||||
client_version: Some(client_version),
|
||||
identity: Some(identity),
|
||||
models,
|
||||
};
|
||||
if let Err(err) = cache.store(&entry).await {
|
||||
error!("failed to write models cache: {err}");
|
||||
}
|
||||
if Some(&identity) != self.endpoint_client.identity().as_ref() {
|
||||
return Ok(());
|
||||
}
|
||||
let entry = ModelsCacheEntry {
|
||||
fetched_at: Utc::now(),
|
||||
etag,
|
||||
client_version: Some(client_version),
|
||||
identity: Some(identity),
|
||||
models,
|
||||
};
|
||||
if let Some(cache) = self.cache.as_ref()
|
||||
&& let Err(err) = cache.store(&entry).await
|
||||
{
|
||||
error!("failed to write models cache: {err}");
|
||||
}
|
||||
self.apply_remote_models(entry).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -459,40 +479,38 @@ impl OpenAiModelsManager {
|
||||
self.endpoint_client.uses_codex_backend().await || self.endpoint_client.has_command_auth()
|
||||
}
|
||||
|
||||
async fn get_etag(&self) -> Option<String> {
|
||||
self.etag.read().await.clone()
|
||||
}
|
||||
|
||||
/// Replace the cached remote models and rebuild the derived presets list.
|
||||
async fn apply_remote_models(&self, models: Vec<ModelInfo>) {
|
||||
// Use the remote models list as the source of truth if it contains at least one
|
||||
// non-hidden model and the user is using ChatGPT auth.
|
||||
let should_use_remote_models_only = !models.is_empty()
|
||||
&& models
|
||||
.iter()
|
||||
.any(|model| model.visibility == ModelVisibility::List)
|
||||
/// Publish only while the request identity still matches, including after async storage.
|
||||
async fn apply_remote_models(&self, mut entry: ModelsCacheEntry) -> bool {
|
||||
let mut current = self.remote_models.write().await;
|
||||
if entry.identity != self.endpoint_client.identity() {
|
||||
return false;
|
||||
}
|
||||
// Visible ChatGPT catalogs are authoritative; other catalogs augment bundled metadata.
|
||||
let remote_only = entry
|
||||
.models
|
||||
.iter()
|
||||
.any(|model| model.visibility == ModelVisibility::List)
|
||||
&& self.auth_manager.as_ref().is_some_and(|auth_manager| {
|
||||
auth_manager
|
||||
.auth_mode()
|
||||
.is_some_and(AuthMode::has_chatgpt_account)
|
||||
});
|
||||
if should_use_remote_models_only {
|
||||
*self.remote_models.write().await = models;
|
||||
return;
|
||||
}
|
||||
|
||||
let mut existing_models = load_remote_models_from_file().unwrap_or_default();
|
||||
for model in models {
|
||||
if let Some(existing_index) = existing_models
|
||||
.iter()
|
||||
.position(|existing| existing.slug == model.slug)
|
||||
{
|
||||
existing_models[existing_index] = model;
|
||||
} else {
|
||||
existing_models.push(model);
|
||||
if !remote_only {
|
||||
let mut models = load_remote_models_from_file().unwrap_or_default();
|
||||
for model in entry.models {
|
||||
if let Some(index) = models
|
||||
.iter()
|
||||
.position(|existing| existing.slug == model.slug)
|
||||
{
|
||||
models[index] = model;
|
||||
} else {
|
||||
models.push(model);
|
||||
}
|
||||
}
|
||||
entry.models = models;
|
||||
}
|
||||
*self.remote_models.write().await = existing_models;
|
||||
*current = entry;
|
||||
true
|
||||
}
|
||||
|
||||
/// Attempt to satisfy the refresh from the cache when it matches the provider and TTL.
|
||||
@@ -504,8 +522,9 @@ impl OpenAiModelsManager {
|
||||
codex_otel::start_global_timer("codex.remote_models.load_cache.duration_ms", &[]);
|
||||
let client_version = crate::client_version_to_whole();
|
||||
info!(client_version, "models cache: evaluating cache eligibility");
|
||||
// TODO(celia-oai): Include provider identity in cache eligibility so switching
|
||||
// providers does not reuse a fresh models_cache.json entry from another provider.
|
||||
let Some(identity) = self.endpoint_client.identity() else {
|
||||
return false;
|
||||
};
|
||||
let cache_entry = match cache.load(&client_version).await {
|
||||
Ok(Some(cache_entry)) => cache_entry,
|
||||
Ok(None) => {
|
||||
@@ -525,15 +544,13 @@ impl OpenAiModelsManager {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
let models = cache_entry.models.clone();
|
||||
*self.etag.write().await = cache_entry.etag.clone();
|
||||
self.apply_remote_models(models.clone()).await;
|
||||
info!(
|
||||
models_count = models.len(),
|
||||
etag = ?cache_entry.etag,
|
||||
"models cache: cache entry applied"
|
||||
);
|
||||
true
|
||||
if cache_entry.identity.as_ref() != Some(&identity)
|
||||
|| self.endpoint_client.identity().as_ref() != Some(&identity)
|
||||
{
|
||||
info!("models cache: provider or auth identity mismatch");
|
||||
return false;
|
||||
}
|
||||
self.apply_remote_models(cache_entry).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[path = "cache_identity_tests.rs"]
|
||||
mod cache_identity_tests;
|
||||
|
||||
#[path = "model_info_overrides_tests.rs"]
|
||||
mod model_info_overrides_tests;
|
||||
|
||||
@@ -164,6 +167,8 @@ impl ModelsCache for TestModelsCache {
|
||||
fn refresh_ttl<'a>(
|
||||
&'a self,
|
||||
_client_version: &'a str,
|
||||
_identity: &'a str,
|
||||
_etag: &'a str,
|
||||
) -> ModelsCacheFuture<'a, Result<(), ModelsCacheError>> {
|
||||
Box::pin(async move {
|
||||
let refreshed = {
|
||||
@@ -394,7 +399,11 @@ async fn file_cache_refresh_ttl_renews_expired_entry_without_serving_it_stale()
|
||||
);
|
||||
|
||||
cache
|
||||
.refresh_ttl(&client_version)
|
||||
.refresh_ttl(
|
||||
&client_version,
|
||||
entry.identity.as_deref().unwrap(),
|
||||
entry.etag.as_deref().unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("TTL refresh succeeds");
|
||||
|
||||
|
||||
@@ -358,7 +358,6 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_shows_chatgpt_plan_without_email() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
write_models_cache(temp_home.path()).expect("write models cache");
|
||||
let mut config = test_config(&temp_home).await;
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
config.model_provider_id = "openai".to_string();
|
||||
@@ -371,6 +370,9 @@ async fn status_snapshot_shows_chatgpt_plan_without_email() {
|
||||
AuthCredentialsStoreMode::File,
|
||||
)
|
||||
.expect("write email-less ChatGPT auth");
|
||||
write_models_cache(temp_home.path())
|
||||
.await
|
||||
.expect("write models cache");
|
||||
let mut app_server = crate::start_embedded_app_server_for_picker(&config)
|
||||
.await
|
||||
.expect("start embedded app server");
|
||||
|
||||
Reference in New Issue
Block a user