mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Enforce current provider requirements for the model catalog (#46917)
## Why The app server retains its startup model provider, but managed provider requirements can change while it is running. Catalog reads and background refreshes must check those requirements even when models are cached. ## What changed - Gate `model/list` and background refreshes through a shared catalog that validates the startup provider against current requirements. - Return JSON-RPC error `-32600` with restart guidance when the provider no longer complies, and skip background requests to that endpoint. Requirement load failures also block catalog access. - Log only the error kind for blocked refreshes to avoid exposing credentials in parser diagnostics. ## Testing Add coverage for cached catalog access after provider selection or definition changes, invalid requirements, cloud configuration load failures, and recovery when requirements allow the retained provider again. Extend refresh tests to cover startup provider overrides, blocked endpoint requests, and credential-safe logging. GitOrigin-RevId: 03127bba46f1f59f2a8b1dc37391eab75f9be23c
This commit is contained in:
@@ -1,3 +1,12 @@
|
||||
# Model catalog provider requirements
|
||||
|
||||
`model/list` and periodic model catalog refreshes check the startup provider against
|
||||
current managed provider requirements before using the catalog. If that provider no longer
|
||||
complies, `model/list` returns JSON-RPC error `-32600` asking the client to restart Codex,
|
||||
and background refreshes skip the old endpoint. Requirement load failures also block these
|
||||
operations. Checks apply even when the catalog is cached. Existing startup provider selection
|
||||
and caching behavior remain in effect while the provider satisfies current requirements.
|
||||
|
||||
# MCP App UI
|
||||
|
||||
`mcpToolCall.mcpAppUi` records the invoked descriptor's `resourceUri`
|
||||
|
||||
@@ -120,6 +120,7 @@ mod image_url;
|
||||
pub mod in_process;
|
||||
mod mcp_refresh;
|
||||
mod message_processor;
|
||||
mod model_catalog;
|
||||
mod models;
|
||||
mod models_refresh_worker;
|
||||
mod notification_media;
|
||||
|
||||
@@ -370,9 +370,12 @@ impl MessageProcessor {
|
||||
None => manager,
|
||||
}
|
||||
});
|
||||
let models_manager = thread_manager.get_models_manager();
|
||||
let models_refresh_worker =
|
||||
crate::models_refresh_worker::spawn(&models_manager, config.http_client_factory());
|
||||
let model_catalog = Arc::new(crate::model_catalog::ModelCatalog::new(
|
||||
config_manager.clone(),
|
||||
Arc::clone(&config),
|
||||
thread_manager.get_models_manager(),
|
||||
));
|
||||
let models_refresh_worker = crate::models_refresh_worker::spawn(&model_catalog);
|
||||
let turn_cost_worker =
|
||||
TurnCostWorker::spawn(Arc::clone(&config), Arc::clone(&auth_manager));
|
||||
thread_manager
|
||||
@@ -424,6 +427,7 @@ impl MessageProcessor {
|
||||
Arc::clone(&thread_manager),
|
||||
Arc::clone(&config),
|
||||
config_manager.clone(),
|
||||
model_catalog,
|
||||
);
|
||||
let command_exec_processor = CommandExecRequestProcessor::new(
|
||||
arg0_paths.clone(),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Gates access to the retained startup model catalog on current managed provider requirements.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
|
||||
use crate::config_manager::ConfigManager;
|
||||
|
||||
/// Checks the retained catalog route before model/list and background refreshes.
|
||||
pub(crate) struct ModelCatalog {
|
||||
config_manager: ConfigManager,
|
||||
config: Arc<Config>,
|
||||
models_manager: SharedModelsManager,
|
||||
}
|
||||
|
||||
impl ModelCatalog {
|
||||
pub(crate) fn new(
|
||||
config_manager: ConfigManager,
|
||||
config: Arc<Config>,
|
||||
models_manager: SharedModelsManager,
|
||||
) -> Self {
|
||||
Self {
|
||||
config_manager,
|
||||
config,
|
||||
models_manager,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn list_models(
|
||||
&self,
|
||||
refresh_strategy: RefreshStrategy,
|
||||
) -> std::io::Result<Vec<ModelPreset>> {
|
||||
// Check before consulting even a warm cache, just as turn admission checks
|
||||
// the retained session route before using it.
|
||||
self.config_manager
|
||||
.check_thread_model_provider(&self.config)
|
||||
.await?;
|
||||
Ok(self
|
||||
.models_manager
|
||||
.list_models(refresh_strategy, self.config.http_client_factory())
|
||||
.await)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_app_server_protocol::Model;
|
||||
use codex_app_server_protocol::ModelServiceTier;
|
||||
use codex_app_server_protocol::ModelUpgradeInfo;
|
||||
use codex_app_server_protocol::ReasoningEffortOption;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
|
||||
pub async fn supported_models(
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
include_hidden: bool,
|
||||
http_client_factory: HttpClientFactory,
|
||||
) -> Vec<Model> {
|
||||
thread_manager
|
||||
.list_models(RefreshStrategy::OnlineIfUncached, http_client_factory)
|
||||
.await
|
||||
pub fn supported_models(models: Vec<ModelPreset>, include_hidden: bool) -> Vec<Model> {
|
||||
models
|
||||
.into_iter()
|
||||
.filter(|preset| include_hidden || preset.show_in_picker)
|
||||
.map(model_from_preset)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_models_manager::manager::SharedModelsManager;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::model_catalog::ModelCatalog;
|
||||
|
||||
const MODELS_REFRESH_INTERVAL: Duration = Duration::from_secs(4 * 60 + 30);
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -27,19 +27,15 @@ impl Drop for ModelsRefreshWorker {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn spawn(
|
||||
models_manager: &SharedModelsManager,
|
||||
http_client_factory: HttpClientFactory,
|
||||
) -> ModelsRefreshWorker {
|
||||
spawn_with_interval(models_manager, http_client_factory, MODELS_REFRESH_INTERVAL)
|
||||
pub(crate) fn spawn(model_catalog: &Arc<ModelCatalog>) -> ModelsRefreshWorker {
|
||||
spawn_with_interval(model_catalog, MODELS_REFRESH_INTERVAL)
|
||||
}
|
||||
|
||||
fn spawn_with_interval(
|
||||
models_manager: &SharedModelsManager,
|
||||
http_client_factory: HttpClientFactory,
|
||||
model_catalog: &Arc<ModelCatalog>,
|
||||
refresh_interval: Duration,
|
||||
) -> ModelsRefreshWorker {
|
||||
let models_manager = Arc::downgrade(models_manager);
|
||||
let model_catalog = Arc::downgrade(model_catalog);
|
||||
let shutdown = CancellationToken::new();
|
||||
let worker_shutdown = shutdown.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
@@ -47,13 +43,14 @@ fn spawn_with_interval(
|
||||
if worker_shutdown.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
let Some(models_manager) = models_manager.upgrade() else {
|
||||
let Some(model_catalog) = model_catalog.upgrade() else {
|
||||
break;
|
||||
};
|
||||
models_manager
|
||||
.list_models(RefreshStrategy::Online, http_client_factory.clone())
|
||||
.await;
|
||||
drop(models_manager);
|
||||
if let Err(err) = model_catalog.list_models(RefreshStrategy::Online).await {
|
||||
// Parser diagnostics can include provider credentials from the source TOML.
|
||||
tracing::warn!(error_kind = ?err.kind(), "model catalog refresh blocked by provider requirements");
|
||||
}
|
||||
drop(model_catalog);
|
||||
|
||||
tokio::select! {
|
||||
_ = worker_shutdown.cancelled() => break,
|
||||
|
||||
@@ -3,8 +3,14 @@ use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_config::CloudConfigBundle;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::test_support::CloudConfigBundleFixture;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_feedback::CodexFeedback;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_http_client::OutboundProxyPolicy;
|
||||
use codex_models_manager::manager::ModelsEndpointClient;
|
||||
use codex_models_manager::manager::ModelsEndpointFuture;
|
||||
use codex_models_manager::manager::ModelsEndpointResponse;
|
||||
@@ -15,6 +21,9 @@ use codex_protocol::error::Result as CoreResult;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -85,16 +94,36 @@ impl ModelsEndpointClient for TestModelsEndpoint {
|
||||
async fn refreshes_immediately_periodically_and_stops_when_dropped() {
|
||||
let codex_home = tempdir().expect("temp dir");
|
||||
let endpoint = TestModelsEndpoint::new();
|
||||
let requirements_path = codex_home.path().join("requirements.toml");
|
||||
std::fs::write(&requirements_path, "model_provider = 'ollama'").unwrap();
|
||||
let config_manager = crate::config_manager::ConfigManager::new_for_tests(
|
||||
codex_home.path().to_path_buf(),
|
||||
Vec::new(),
|
||||
LoaderOverrides::with_managed_config_path_for_tests(
|
||||
codex_home.path().join("managed_config.toml"),
|
||||
),
|
||||
CloudConfigBundleLoader::default(),
|
||||
);
|
||||
// --oss selects the provider through typed overrides, outside ConfigManager's raw -c flags.
|
||||
let config = Arc::new(
|
||||
ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
model_provider: Some("ollama".to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let models_manager: SharedModelsManager = Arc::new(OpenAiModelsManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
endpoint.clone(),
|
||||
/*auth_manager*/ None,
|
||||
));
|
||||
let worker = spawn_with_interval(
|
||||
&models_manager,
|
||||
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||
Duration::from_millis(10),
|
||||
);
|
||||
let catalog = Arc::new(ModelCatalog::new(config_manager, config, models_manager));
|
||||
let worker = spawn_with_interval(&catalog, Duration::from_millis(/*millis*/ 10));
|
||||
|
||||
endpoint.wait_for_fetch_count(/*expected*/ 2).await;
|
||||
drop(worker);
|
||||
@@ -102,4 +131,101 @@ async fn refreshes_immediately_periodically_and_stops_when_dropped() {
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
|
||||
assert_eq!(endpoint.fetch_count.load(Ordering::SeqCst), 2);
|
||||
|
||||
// Reloading config would select the newly required provider. The catalog must
|
||||
// keep validating the typed startup selection, even when only reading its cache.
|
||||
std::fs::write(&requirements_path, "model_provider = 'openai'").unwrap();
|
||||
let error = catalog
|
||||
.list_models(RefreshStrategy::Offline)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
assert!(
|
||||
error
|
||||
.get_ref()
|
||||
.unwrap()
|
||||
.is::<crate::config_manager::ModelProviderRequirementsChanged>()
|
||||
);
|
||||
assert_eq!(endpoint.fetch_count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_refresh_blocks_changed_and_invalid_requirements_then_recovers()
|
||||
-> anyhow::Result<()> {
|
||||
let home = tempdir()?;
|
||||
let config = Arc::new(
|
||||
crate::config_manager::ConfigManager::without_managed_config_for_tests(
|
||||
home.path().to_path_buf(),
|
||||
)
|
||||
.load_non_project_config()
|
||||
.await?,
|
||||
);
|
||||
let (checks_tx, mut checks_rx) = mpsc::unbounded_channel();
|
||||
let loader = CloudConfigBundleLoader::from_getter(move || {
|
||||
let (reply_tx, reply_rx) = oneshot::channel::<Option<CloudConfigBundle>>();
|
||||
checks_tx
|
||||
.send(reply_tx)
|
||||
.expect("requirements check receiver");
|
||||
async move { Ok(reply_rx.await.expect("requirements check response")) }
|
||||
});
|
||||
let config_manager = crate::config_manager::ConfigManager::new_for_tests(
|
||||
home.path().to_path_buf(),
|
||||
Vec::new(),
|
||||
LoaderOverrides::without_managed_config_for_tests(),
|
||||
loader,
|
||||
);
|
||||
let endpoint = TestModelsEndpoint::new();
|
||||
let models_manager: SharedModelsManager = Arc::new(OpenAiModelsManager::new(
|
||||
home.path().to_path_buf(),
|
||||
endpoint.clone(),
|
||||
/*auth_manager*/ None,
|
||||
));
|
||||
let catalog = Arc::new(ModelCatalog::new(config_manager, config, models_manager));
|
||||
let feedback = CodexFeedback::new();
|
||||
// This test uses Tokio's current-thread runtime, so spawned refreshes share the subscriber.
|
||||
let _subscriber_guard = tracing::subscriber::set_default(
|
||||
tracing_subscriber::registry().with(feedback.logger_layer()),
|
||||
);
|
||||
let worker = spawn_with_interval(&catalog, Duration::from_millis(/*millis*/ 10));
|
||||
tokio::time::timeout(Duration::from_secs(/*secs*/ 10), async {
|
||||
for expected in 1..=2 {
|
||||
checks_rx.recv().await.unwrap().send(/*t*/ None).unwrap();
|
||||
endpoint.wait_for_fetch_count(expected).await;
|
||||
}
|
||||
|
||||
// Let the in-flight request finish, then control each refresh's requirements.
|
||||
endpoint.release_second_fetch.notify_one();
|
||||
let mut next_check = checks_rx.recv().await.unwrap();
|
||||
for requirements in [
|
||||
"model_provider = 'other'",
|
||||
"model_provider = []",
|
||||
"[model_providers.gateway]\nexperimental_bearer_token = 'private-provider-token-marker' trailing",
|
||||
] {
|
||||
next_check
|
||||
.send(Some(
|
||||
CloudConfigBundleFixture::enterprise_requirement(requirements).into_bundle(),
|
||||
))
|
||||
.unwrap();
|
||||
// Reaching the next check proves the previous refresh completed. Keep
|
||||
// this check blocked while asserting that the previous one did not fetch.
|
||||
next_check = checks_rx.recv().await.unwrap();
|
||||
assert_eq!(endpoint.fetch_count.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
next_check.send(/*t*/ None).unwrap();
|
||||
endpoint.wait_for_fetch_count(/*expected*/ 3).await;
|
||||
})
|
||||
.await?;
|
||||
drop(worker);
|
||||
let logs = String::from_utf8(
|
||||
feedback
|
||||
.snapshot(/*session_id*/ None)
|
||||
.log_attachment(/*logs_override*/ None)
|
||||
.buffer,
|
||||
)?;
|
||||
assert!(logs.contains("model catalog refresh blocked by provider requirements"));
|
||||
assert!(logs.contains("error_kind=PermissionDenied"));
|
||||
assert!(logs.contains("error_kind=InvalidData"));
|
||||
assert!(!logs.contains("private-provider-token-marker"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::model_catalog::ModelCatalog;
|
||||
use codex_config::ConfigPathContext;
|
||||
use codex_core::config::permission_profile_catalog;
|
||||
use codex_hooks::HookListEntryHandler;
|
||||
@@ -14,6 +15,7 @@ pub(crate) struct CatalogRequestProcessor {
|
||||
pub(super) thread_manager: Arc<ThreadManager>,
|
||||
pub(super) config: Arc<Config>,
|
||||
pub(super) config_manager: ConfigManager,
|
||||
model_catalog: Arc<ModelCatalog>,
|
||||
}
|
||||
|
||||
const SKILLS_LIST_CWD_CONCURRENCY: usize = 5;
|
||||
@@ -124,6 +126,7 @@ impl CatalogRequestProcessor {
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
config: Arc<Config>,
|
||||
config_manager: ConfigManager,
|
||||
model_catalog: Arc<ModelCatalog>,
|
||||
) -> Self {
|
||||
Self {
|
||||
outgoing,
|
||||
@@ -131,6 +134,7 @@ impl CatalogRequestProcessor {
|
||||
thread_manager,
|
||||
config,
|
||||
config_manager,
|
||||
model_catalog,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,13 +178,9 @@ impl CatalogRequestProcessor {
|
||||
&self,
|
||||
params: ModelListParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
Self::list_models(
|
||||
self.thread_manager.clone(),
|
||||
self.config.http_client_factory(),
|
||||
params,
|
||||
)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
self.list_models(params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn experimental_feature_list(
|
||||
@@ -245,8 +245,7 @@ impl CatalogRequestProcessor {
|
||||
}
|
||||
|
||||
async fn list_models(
|
||||
thread_manager: Arc<ThreadManager>,
|
||||
http_client_factory: codex_http_client::HttpClientFactory,
|
||||
&self,
|
||||
params: ModelListParams,
|
||||
) -> Result<ModelListResponse, JSONRPCErrorError> {
|
||||
let ModelListParams {
|
||||
@@ -254,12 +253,12 @@ impl CatalogRequestProcessor {
|
||||
cursor,
|
||||
include_hidden,
|
||||
} = params;
|
||||
let models = supported_models(
|
||||
thread_manager,
|
||||
include_hidden.unwrap_or(false),
|
||||
http_client_factory,
|
||||
)
|
||||
.await;
|
||||
let presets = self
|
||||
.model_catalog
|
||||
.list_models(codex_models_manager::manager::RefreshStrategy::OnlineIfUncached)
|
||||
.await
|
||||
.map_err(|err| config_load_error(&err))?;
|
||||
let models = supported_models(presets, include_hidden.unwrap_or(false));
|
||||
let total = models.len();
|
||||
|
||||
if total == 0 {
|
||||
|
||||
@@ -74,6 +74,7 @@ mod memory_reset;
|
||||
mod misalignment_policy;
|
||||
mod model_auto_review;
|
||||
mod model_list;
|
||||
mod model_list_requirements_tests;
|
||||
mod model_provider_capabilities_read;
|
||||
#[path = "model_provider_enforcement_tests.rs"]
|
||||
mod model_provider_enforcement;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Exercises live catalog routing through the public model/list RPC.
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ModelListParams;
|
||||
use codex_app_server_protocol::ModelListResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use core_test_support::responses::mount_models_once;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use test_case::test_case;
|
||||
use wiremock::MockServer;
|
||||
|
||||
fn catalog(slug: &str) -> Result<ModelsResponse> {
|
||||
Ok(ModelsResponse {
|
||||
models: vec![serde_json::from_value::<ModelInfo>(json!({
|
||||
"slug": slug,
|
||||
"display_name": slug,
|
||||
"supported_reasoning_levels": [],
|
||||
"shell_type": "shell_command",
|
||||
"visibility": "list",
|
||||
"supported_in_api": true,
|
||||
"priority": 0,
|
||||
"support_verbosity": false,
|
||||
"truncation_policy": {"mode": "bytes", "limit": 10000},
|
||||
"experimental_supported_tools": []
|
||||
}))?],
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_models(server: &mut TestAppServer) -> Result<ModelListResponse> {
|
||||
server
|
||||
.request(|request_id| ClientRequest::ModelList {
|
||||
request_id,
|
||||
params: ModelListParams::default(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[test_case("other"; "selection_changes")]
|
||||
#[test_case("gateway"; "definition_changes")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn model_list_blocks_noncompliant_cached_provider_until_requirements_allow_it(
|
||||
required_selection: &str,
|
||||
) -> Result<()> {
|
||||
let gateway = MockServer::start().await;
|
||||
let other = MockServer::start().await;
|
||||
mount_models_once(&gateway, catalog("gateway-model")?).await;
|
||||
mount_models_once(&other, catalog("other-model")?).await;
|
||||
let home = TempDir::new()?;
|
||||
let providers = format!(
|
||||
r#"
|
||||
[model_providers.gateway]
|
||||
name = "Gateway"
|
||||
base_url = "{}/v1"
|
||||
requires_openai_auth = true
|
||||
[model_providers.other]
|
||||
name = "Other"
|
||||
base_url = "{}/v1"
|
||||
requires_openai_auth = true
|
||||
"#,
|
||||
gateway.uri(),
|
||||
other.uri(),
|
||||
);
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!("model_provider = 'gateway'\n{providers}"),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-access-token").plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
let requirements_path = home.path().join("requirements.toml");
|
||||
std::fs::write(
|
||||
&requirements_path,
|
||||
format!("model_provider = 'gateway'\n{providers}"),
|
||||
)?;
|
||||
let mut server = TestAppServer::builder()
|
||||
.with_codex_home(home.path())
|
||||
.with_env_overrides(&[("OPENAI_API_KEY", None)])
|
||||
.build_initialized()
|
||||
.await?;
|
||||
|
||||
// Let the startup refresh populate the cache before exercising cached RPCs.
|
||||
tokio::time::timeout(Duration::from_secs(/*secs*/ 10), async {
|
||||
loop {
|
||||
if let Ok(bytes) = tokio::fs::read(home.path().join("models_cache.json")).await
|
||||
&& let Ok(cache) = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
&& cache["models"][0]["slug"] == "gateway-model"
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
let initial = list_models(&mut server).await?;
|
||||
assert_eq!(
|
||||
initial
|
||||
.data
|
||||
.iter()
|
||||
.map(|model| model.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["gateway-model"]
|
||||
);
|
||||
assert_eq!(list_models(&mut server).await?, initial);
|
||||
assert_eq!(
|
||||
gateway
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording enabled")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
other
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording enabled")
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
let updated_providers = if required_selection == "gateway" {
|
||||
providers.replace(&gateway.uri(), &other.uri())
|
||||
} else {
|
||||
providers.clone()
|
||||
};
|
||||
let updated = format!("model_provider = '{required_selection}'\n{updated_providers}");
|
||||
std::fs::write(&requirements_path, &updated)?;
|
||||
let id = server
|
||||
.send_raw_request("model/list", Some(json!({})))
|
||||
.await?;
|
||||
let error = server
|
||||
.read_stream_until_error_message(RequestId::Integer(id))
|
||||
.await?;
|
||||
assert_eq!(error.error, codex_app_server_protocol::JSONRPCErrorError {
|
||||
code: -32600,
|
||||
message: "failed to load configuration: Your organization's required model provider settings changed. Restart Codex to apply them; this request was not sent".to_string(),
|
||||
data: None,
|
||||
});
|
||||
assert_eq!(
|
||||
gateway
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording enabled")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
other
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording enabled")
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// A warm catalog must not hide a policy load failure or make any stale request.
|
||||
std::fs::write(&requirements_path, "model_provider = []")?;
|
||||
let id = server
|
||||
.send_raw_request("model/list", Some(json!({})))
|
||||
.await?;
|
||||
let error = server
|
||||
.read_stream_until_error_message(RequestId::Integer(id))
|
||||
.await?;
|
||||
assert_eq!(error.error.code, -32600);
|
||||
assert!(error.error.message.contains("failed to load configuration"));
|
||||
assert_eq!(
|
||||
gateway
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording enabled")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
other
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording enabled")
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// Removing the requirement makes the retained provider permissible again.
|
||||
std::fs::remove_file(requirements_path)?;
|
||||
assert_eq!(list_models(&mut server).await?, initial);
|
||||
assert_eq!(
|
||||
gateway
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording enabled")
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -52,6 +52,7 @@ use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
use test_case::test_case;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::time::timeout;
|
||||
@@ -1416,8 +1417,12 @@ async fn thread_start_does_not_wait_for_optional_http_mcp_auth_discovery() -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case("thread/start"; "thread_start")]
|
||||
#[test_case("model/list"; "model_list")]
|
||||
#[tokio::test]
|
||||
async fn thread_start_surfaces_cloud_config_bundle_load_errors() -> Result<()> {
|
||||
async fn config_requests_surface_cloud_config_bundle_load_errors(
|
||||
request_method: &str,
|
||||
) -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/wham/config/bundle"))
|
||||
@@ -1468,9 +1473,13 @@ async fn thread_start_surfaces_cloud_config_bundle_load_errors() -> Result<()> {
|
||||
.build_initialized()
|
||||
.await?;
|
||||
|
||||
let req_id = mcp
|
||||
.send_thread_start_request_with_auto_env(ThreadStartParams::default())
|
||||
.await?;
|
||||
let req_id = if request_method == "thread/start" {
|
||||
mcp.send_thread_start_request_with_auto_env(ThreadStartParams::default())
|
||||
.await?
|
||||
} else {
|
||||
mcp.send_raw_request(request_method, Some(json!({})))
|
||||
.await?
|
||||
};
|
||||
|
||||
let err: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
|
||||
Reference in New Issue
Block a user