mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] [1/4] Add recommended plugin endpoint cache (#28399)
Summary - Add authenticated parsing for `/ps/plugins/suggested?scope=GLOBAL`, including remote plugin and connector app identities. - Validate, deduplicate, sort, and cap endpoint candidates before caching them by backend and account identity. - Deduplicate concurrent cache misses and warm recommendations from the existing remote-installed-plugin refresh path used at startup and after account changes. - Keep endpoint results model-invisible in this PR; failures and responses without `enabled: true` resolve to legacy mode. Stack - 1/3. Follow-up: #28400 generalizes plugin suggestion presentation without activating endpoint recommendations. - Final activation: #27704. Validation - `just test -p codex-core-plugins recommended_plugins` - `just fix -p codex-core-plugins` - `just fmt` - `git diff --check`
This commit is contained in:
committed by
GitHub
Unverified
parent
bd2a786326
commit
7e735b59ce
@@ -173,7 +173,7 @@ impl AccountRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
async fn maybe_refresh_plugin_caches_for_current_config(
|
||||
config_manager: &ConfigManager,
|
||||
thread_manager: &Arc<ThreadManager>,
|
||||
auth: Option<CodexAuth>,
|
||||
@@ -181,6 +181,9 @@ impl AccountRequestProcessor {
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
.set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode));
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
.clear_recommended_plugins_cache();
|
||||
|
||||
match config_manager
|
||||
.load_latest_config(/*fallback_cwd*/ None)
|
||||
@@ -191,7 +194,7 @@ impl AccountRequestProcessor {
|
||||
let refresh_config_manager = config_manager.clone();
|
||||
thread_manager
|
||||
.plugins_manager()
|
||||
.maybe_start_remote_installed_plugins_cache_refresh(
|
||||
.maybe_start_remote_plugin_caches_refresh(
|
||||
&config.plugins_config_input(),
|
||||
auth,
|
||||
Some(Arc::new(move || {
|
||||
@@ -618,7 +621,7 @@ impl AccountRequestProcessor {
|
||||
}
|
||||
|
||||
async fn send_login_success_notifications(&self, login_id: Option<Uuid>) {
|
||||
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
Self::maybe_refresh_plugin_caches_for_current_config(
|
||||
&self.config_manager,
|
||||
&self.thread_manager,
|
||||
self.auth_manager.auth_cached(),
|
||||
@@ -671,7 +674,7 @@ impl AccountRequestProcessor {
|
||||
.await;
|
||||
|
||||
let auth = auth_manager.auth_cached();
|
||||
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
Self::maybe_refresh_plugin_caches_for_current_config(
|
||||
&config_manager,
|
||||
&thread_manager,
|
||||
auth.clone(),
|
||||
@@ -703,7 +706,7 @@ impl AccountRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
Self::maybe_refresh_remote_installed_plugins_cache_for_current_config(
|
||||
Self::maybe_refresh_plugin_caches_for_current_config(
|
||||
&self.config_manager,
|
||||
&self.thread_manager,
|
||||
self.auth_manager.auth_cached(),
|
||||
|
||||
@@ -54,3 +54,5 @@ pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarket
|
||||
pub use provider::ExecutorPluginProvider;
|
||||
pub use provider::ExecutorPluginProviderError;
|
||||
pub use provider::ResolvedExecutorPlugin;
|
||||
pub use remote::RecommendedPlugin;
|
||||
pub use remote::RecommendedPluginsMode;
|
||||
|
||||
@@ -38,6 +38,7 @@ use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
|
||||
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
|
||||
use crate::marketplace_upgrade::configured_git_marketplace_names;
|
||||
use crate::marketplace_upgrade::upgrade_configured_git_marketplaces;
|
||||
use crate::remote::RecommendedPluginsMode;
|
||||
use crate::remote::RemoteInstalledPlugin;
|
||||
use crate::remote::RemotePluginCatalogError;
|
||||
use crate::remote::RemotePluginServiceConfig;
|
||||
@@ -80,6 +81,7 @@ use std::sync::RwLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::OnceCell;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::instrument;
|
||||
use tracing::warn;
|
||||
@@ -120,6 +122,11 @@ struct FeaturedPluginIdsCacheKey {
|
||||
is_workspace_account: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Hash, PartialEq, Eq)]
|
||||
struct RecommendedPluginsCacheKey {
|
||||
chatgpt_base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedFeaturedPluginIds {
|
||||
key: FeaturedPluginIdsCacheKey,
|
||||
@@ -209,6 +216,12 @@ fn featured_plugin_ids_cache_key(
|
||||
}
|
||||
}
|
||||
|
||||
fn recommended_plugins_cache_key(config: &PluginsConfigInput) -> RecommendedPluginsCacheKey {
|
||||
RecommendedPluginsCacheKey {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallRequest {
|
||||
pub plugin_name: String,
|
||||
@@ -318,6 +331,9 @@ pub struct PluginsManager {
|
||||
codex_home: PathBuf,
|
||||
store: PluginStore,
|
||||
featured_plugin_ids_cache: RwLock<Option<CachedFeaturedPluginIds>>,
|
||||
recommended_plugins_cache: RwLock<HashMap<RecommendedPluginsCacheKey, RecommendedPluginsMode>>,
|
||||
recommended_plugins_refreshes:
|
||||
RwLock<HashMap<RecommendedPluginsCacheKey, Arc<OnceCell<RecommendedPluginsMode>>>>,
|
||||
configured_marketplace_upgrade_state: RwLock<ConfiguredMarketplaceUpgradeState>,
|
||||
non_curated_cache_refresh_state: RwLock<NonCuratedCacheRefreshState>,
|
||||
// Keep the cache auth-independent so auth changes only need to resolve capabilities again.
|
||||
@@ -371,6 +387,8 @@ impl PluginsManager {
|
||||
codex_home: codex_home.clone(),
|
||||
store: PluginStore::new(codex_home),
|
||||
featured_plugin_ids_cache: RwLock::new(None),
|
||||
recommended_plugins_cache: RwLock::new(HashMap::new()),
|
||||
recommended_plugins_refreshes: RwLock::new(HashMap::new()),
|
||||
configured_marketplace_upgrade_state: RwLock::new(
|
||||
ConfiguredMarketplaceUpgradeState::default(),
|
||||
),
|
||||
@@ -499,6 +517,19 @@ impl PluginsManager {
|
||||
*featured_plugin_ids_cache = None;
|
||||
}
|
||||
|
||||
pub fn clear_recommended_plugins_cache(&self) {
|
||||
let mut refreshes = match self.recommended_plugins_refreshes.write() {
|
||||
Ok(refreshes) => refreshes,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
refreshes.clear();
|
||||
let mut cache = match self.recommended_plugins_cache.write() {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
fn clear_loaded_plugins_cache(&self) {
|
||||
let mut cache = match self.loaded_plugins_cache.write() {
|
||||
Ok(cache) => cache,
|
||||
@@ -700,7 +731,7 @@ impl PluginsManager {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn maybe_start_remote_installed_plugins_cache_refresh(
|
||||
pub fn maybe_start_remote_plugin_caches_refresh(
|
||||
self: &Arc<Self>,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<CodexAuth>,
|
||||
@@ -708,10 +739,18 @@ impl PluginsManager {
|
||||
) {
|
||||
self.maybe_start_remote_installed_plugins_cache_refresh_with_notify(
|
||||
config,
|
||||
auth,
|
||||
auth.clone(),
|
||||
RemoteInstalledPluginsCacheRefreshNotify::IfCacheChanged,
|
||||
on_effective_plugins_changed,
|
||||
);
|
||||
|
||||
let manager = Arc::clone(self);
|
||||
let config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
manager
|
||||
.recommended_plugins_mode_for_config(&config, auth.as_ref())
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
|
||||
@@ -805,7 +844,7 @@ impl PluginsManager {
|
||||
if options.refresh_global_remote_catalog_cache {
|
||||
self.maybe_start_global_remote_catalog_cache_refresh(config, auth.clone());
|
||||
}
|
||||
self.maybe_start_remote_installed_plugins_cache_refresh(
|
||||
self.maybe_start_remote_plugin_caches_refresh(
|
||||
config,
|
||||
auth.clone(),
|
||||
on_effective_plugins_changed.clone(),
|
||||
@@ -888,6 +927,87 @@ impl PluginsManager {
|
||||
Ok(featured_plugin_ids)
|
||||
}
|
||||
|
||||
pub async fn recommended_plugins_mode_for_config(
|
||||
&self,
|
||||
config: &PluginsConfigInput,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> RecommendedPluginsMode {
|
||||
if !config.plugins_enabled
|
||||
|| !config.remote_plugin_enabled
|
||||
|| !auth.is_some_and(CodexAuth::uses_codex_backend)
|
||||
{
|
||||
return RecommendedPluginsMode::Legacy;
|
||||
}
|
||||
|
||||
let cache_key = recommended_plugins_cache_key(config);
|
||||
if let Some(cached) = self.cached_recommended_plugins_mode(&cache_key) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let refresh = {
|
||||
let mut refreshes = match self.recommended_plugins_refreshes.write() {
|
||||
Ok(refreshes) => refreshes,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
if let Some(cached) = self.cached_recommended_plugins_mode(&cache_key) {
|
||||
return cached;
|
||||
}
|
||||
refreshes
|
||||
.entry(cache_key.clone())
|
||||
.or_insert_with(|| Arc::new(OnceCell::new()))
|
||||
.clone()
|
||||
};
|
||||
|
||||
let mode = refresh
|
||||
.get_or_init(|| async {
|
||||
match crate::remote::fetch_recommended_plugins(
|
||||
&remote_plugin_service_config(config),
|
||||
auth,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mode) => {
|
||||
let mut cache = match self.recommended_plugins_cache.write() {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
cache.insert(cache_key.clone(), mode.clone());
|
||||
mode
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to load recommended plugins");
|
||||
RecommendedPluginsMode::Legacy
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.clone();
|
||||
|
||||
let mut refreshes = match self.recommended_plugins_refreshes.write() {
|
||||
Ok(refreshes) => refreshes,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
if refreshes
|
||||
.get(&cache_key)
|
||||
.is_some_and(|current| Arc::ptr_eq(current, &refresh))
|
||||
{
|
||||
refreshes.remove(&cache_key);
|
||||
}
|
||||
|
||||
mode
|
||||
}
|
||||
|
||||
fn cached_recommended_plugins_mode(
|
||||
&self,
|
||||
cache_key: &RecommendedPluginsCacheKey,
|
||||
) -> Option<RecommendedPluginsMode> {
|
||||
let cache = match self.recommended_plugins_cache.read() {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
cache.get(cache_key).cloned()
|
||||
}
|
||||
|
||||
pub async fn install_plugin(
|
||||
&self,
|
||||
request: PluginInstallRequest,
|
||||
@@ -1420,7 +1540,7 @@ impl PluginsManager {
|
||||
let on_effective_plugins_changed = on_effective_plugins_changed.clone();
|
||||
tokio::spawn(async move {
|
||||
let auth = auth_manager_for_remote_sync.auth().await;
|
||||
manager.maybe_start_remote_installed_plugins_cache_refresh(
|
||||
manager.maybe_start_remote_plugin_caches_refresh(
|
||||
&config_for_remote_sync,
|
||||
auth.clone(),
|
||||
on_effective_plugins_changed.clone(),
|
||||
@@ -1453,12 +1573,12 @@ impl PluginsManager {
|
||||
}
|
||||
});
|
||||
|
||||
let config = config.clone();
|
||||
let config_for_featured_plugins = config.clone();
|
||||
let manager = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let auth = auth_manager.auth().await;
|
||||
if let Err(err) = manager
|
||||
.featured_plugin_ids_for_config(&config, auth.as_ref())
|
||||
.featured_plugin_ids_for_config(&config_for_featured_plugins, auth.as_ref())
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::marketplace::MarketplacePluginInstallPolicy;
|
||||
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||
use crate::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME;
|
||||
use crate::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME;
|
||||
use crate::remote::RecommendedPlugin;
|
||||
use crate::remote::RemoteInstalledPlugin;
|
||||
use crate::startup_sync::curated_plugins_repo_path;
|
||||
use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
|
||||
@@ -40,6 +41,7 @@ use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use toml::Value;
|
||||
use wiremock::Mock;
|
||||
@@ -3703,6 +3705,236 @@ plugins = true
|
||||
assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_plugin_caches_refresh_warms_recommended_plugins_cache() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
remote_plugin = true
|
||||
"#,
|
||||
);
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/suggested"))
|
||||
.and(query_param("scope", "GLOBAL"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"enabled": true,
|
||||
"plugins": []
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut config = load_config(tmp.path(), tmp.path()).await;
|
||||
config.chatgpt_base_url = server.uri();
|
||||
let manager = std::sync::Arc::new(PluginsManager::new(tmp.path().to_path_buf()));
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let cache_key = recommended_plugins_cache_key(&config);
|
||||
|
||||
manager.maybe_start_remote_plugin_caches_refresh(
|
||||
&config,
|
||||
Some(auth.clone()),
|
||||
/*on_effective_plugins_changed*/ None,
|
||||
);
|
||||
|
||||
let mode = tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
if let Some(mode) = manager.cached_recommended_plugins_mode(&cache_key) {
|
||||
break mode;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("recommended plugins cache should be warmed");
|
||||
assert_eq!(
|
||||
mode,
|
||||
RecommendedPluginsMode::Endpoint {
|
||||
plugins: Vec::new()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
manager
|
||||
.recommended_plugins_mode_for_config(&config, Some(&auth))
|
||||
.await,
|
||||
mode
|
||||
);
|
||||
manager.clear_recommended_plugins_cache();
|
||||
assert_eq!(manager.cached_recommended_plugins_mode(&cache_key), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recommended_plugins_mode_deduplicates_concurrent_cache_misses() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
remote_plugin = true
|
||||
"#,
|
||||
);
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/suggested"))
|
||||
.and(query_param("scope", "GLOBAL"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.and(header("OAI-Product-Sku", "codex"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(serde_json::json!({
|
||||
"enabled": true,
|
||||
"plugins": [
|
||||
{
|
||||
"id": "plugin_slack",
|
||||
"name": "slack",
|
||||
"release": {
|
||||
"display_name": "Slack",
|
||||
"app_ids": ["connector_slack"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "plugin_github",
|
||||
"name": "github",
|
||||
"release": {"display_name": "GitHub"}
|
||||
}
|
||||
]
|
||||
}))
|
||||
.set_delay(Duration::from_millis(100)),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut config = load_config(tmp.path(), tmp.path()).await;
|
||||
config.chatgpt_base_url = server.uri();
|
||||
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let expected = RecommendedPluginsMode::Endpoint {
|
||||
plugins: vec![
|
||||
RecommendedPlugin {
|
||||
config_id: "github@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: "plugin_github".to_string(),
|
||||
display_name: "GitHub".to_string(),
|
||||
app_connector_ids: Vec::new(),
|
||||
},
|
||||
RecommendedPlugin {
|
||||
config_id: "slack@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: "plugin_slack".to_string(),
|
||||
display_name: "Slack".to_string(),
|
||||
app_connector_ids: vec!["connector_slack".to_string()],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let (left, right) = tokio::join!(
|
||||
manager.recommended_plugins_mode_for_config(&config, Some(&auth)),
|
||||
manager.recommended_plugins_mode_for_config(&config, Some(&auth)),
|
||||
);
|
||||
assert_eq!((left, right), (expected.clone(), expected.clone()));
|
||||
assert_eq!(
|
||||
manager
|
||||
.recommended_plugins_mode_for_config(&config, Some(&auth))
|
||||
.await,
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recommended_plugins_mode_caches_explicit_false() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
remote_plugin = true
|
||||
"#,
|
||||
);
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/suggested"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"enabled": false,
|
||||
"plugins": []
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut config = load_config(tmp.path(), tmp.path()).await;
|
||||
config.chatgpt_base_url = server.uri();
|
||||
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
assert_eq!(
|
||||
manager
|
||||
.recommended_plugins_mode_for_config(&config, Some(&auth))
|
||||
.await,
|
||||
RecommendedPluginsMode::Legacy
|
||||
);
|
||||
assert_eq!(
|
||||
manager
|
||||
.recommended_plugins_mode_for_config(&config, Some(&auth))
|
||||
.await,
|
||||
RecommendedPluginsMode::Legacy
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recommended_plugins_mode_retries_after_fetch_failure() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
remote_plugin = true
|
||||
"#,
|
||||
);
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/suggested"))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut config = load_config(tmp.path(), tmp.path()).await;
|
||||
config.chatgpt_base_url = server.uri();
|
||||
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
assert_eq!(
|
||||
manager
|
||||
.recommended_plugins_mode_for_config(&config, Some(&auth))
|
||||
.await,
|
||||
RecommendedPluginsMode::Legacy
|
||||
);
|
||||
|
||||
server.reset().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/suggested"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"enabled": true,
|
||||
"plugins": []
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
manager
|
||||
.recommended_plugins_mode_for_config(&config, Some(&auth))
|
||||
.await,
|
||||
RecommendedPluginsMode::Endpoint {
|
||||
plugins: Vec::new()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_curated_plugin_cache_replaces_existing_local_version_with_short_sha_version() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -79,7 +79,11 @@ const OPENAI_CURATED_REMOTE_COLLECTION_KEY: &str = "vertical";
|
||||
const OAI_PRODUCT_SKU_HEADER: &str = "OAI-Product-Sku";
|
||||
const CODEX_PRODUCT_SKU: &str = "codex";
|
||||
const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const RECOMMENDED_PLUGINS_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
|
||||
const MAX_RECOMMENDED_PLUGINS: usize = 50;
|
||||
const MAX_RECOMMENDED_PLUGIN_NAME_LEN: usize = 64;
|
||||
const MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN: usize = 64;
|
||||
const MAX_REMOTE_DEFAULT_PROMPT_COUNT: usize = 3;
|
||||
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
||||
@@ -237,6 +241,20 @@ pub struct RemoteDiscoverablePlugin {
|
||||
pub availability: PluginAvailability,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecommendedPlugin {
|
||||
pub config_id: String,
|
||||
pub remote_plugin_id: String,
|
||||
pub display_name: String,
|
||||
pub app_connector_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RecommendedPluginsMode {
|
||||
Legacy,
|
||||
Endpoint { plugins: Vec<RecommendedPlugin> },
|
||||
}
|
||||
|
||||
pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool {
|
||||
!plugin_id.is_empty()
|
||||
&& plugin_id
|
||||
@@ -568,6 +586,32 @@ struct RemotePluginListResponse {
|
||||
pagination: RemotePluginPagination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RecommendedPluginsResponse {
|
||||
#[serde(default)]
|
||||
enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
plugins: Vec<RecommendedPluginItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RecommendedPluginItem {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
status: Option<PluginAvailability>,
|
||||
#[serde(default)]
|
||||
installation_policy: Option<PluginInstallPolicy>,
|
||||
release: RecommendedPluginRelease,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RecommendedPluginRelease {
|
||||
display_name: String,
|
||||
#[serde(default)]
|
||||
app_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemotePluginInstalledResponse {
|
||||
plugins: Vec<RemotePluginInstalledItem>,
|
||||
@@ -761,6 +805,86 @@ pub async fn fetch_and_cache_global_remote_plugin_catalog(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fetch_recommended_plugins(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<RecommendedPluginsMode, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/ps/plugins/suggested");
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.get(&url), auth)?
|
||||
.timeout(RECOMMENDED_PLUGINS_TIMEOUT)
|
||||
.query(&[("scope", "GLOBAL")]);
|
||||
let response: RecommendedPluginsResponse = send_and_decode(request, &url).await?;
|
||||
Ok(recommended_plugins_mode(response))
|
||||
}
|
||||
|
||||
fn recommended_plugins_mode(response: RecommendedPluginsResponse) -> RecommendedPluginsMode {
|
||||
if response.enabled != Some(true) {
|
||||
return RecommendedPluginsMode::Legacy;
|
||||
}
|
||||
|
||||
let mut plugins = BTreeMap::new();
|
||||
for plugin in response.plugins {
|
||||
if !is_valid_remote_plugin_id(&plugin.id)
|
||||
|| plugin.name.chars().count() > MAX_RECOMMENDED_PLUGIN_NAME_LEN
|
||||
|| plugin
|
||||
.status
|
||||
.is_some_and(|status| status != PluginAvailability::Available)
|
||||
|| plugin
|
||||
.installation_policy
|
||||
.is_some_and(|policy| policy != PluginInstallPolicy::Available)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let plugin_id = match PluginId::new(
|
||||
plugin.name.clone(),
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(),
|
||||
) {
|
||||
Ok(plugin_id) => plugin_id,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
plugin_name = plugin.name,
|
||||
error = %err,
|
||||
"ignoring invalid recommended plugin"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let RecommendedPluginRelease {
|
||||
display_name,
|
||||
app_ids,
|
||||
} = plugin.release;
|
||||
let display_name = non_empty_string(Some(&display_name))
|
||||
.unwrap_or_else(|| plugin.name.clone())
|
||||
.chars()
|
||||
.take(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN)
|
||||
.collect();
|
||||
let mut seen_app_ids = HashSet::new();
|
||||
let app_connector_ids = app_ids
|
||||
.into_iter()
|
||||
.filter(|app_id| !app_id.is_empty() && seen_app_ids.insert(app_id.clone()))
|
||||
.collect();
|
||||
let config_id = plugin_id.as_key();
|
||||
plugins
|
||||
.entry(config_id.clone())
|
||||
.or_insert(RecommendedPlugin {
|
||||
config_id,
|
||||
remote_plugin_id: plugin.id,
|
||||
display_name,
|
||||
app_connector_ids,
|
||||
});
|
||||
}
|
||||
|
||||
RecommendedPluginsMode::Endpoint {
|
||||
plugins: plugins
|
||||
.into_values()
|
||||
.take(MAX_RECOMMENDED_PLUGINS)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_cached_global_remote_plugin_catalog(
|
||||
codex_home: &Path,
|
||||
config: &RemotePluginServiceConfig,
|
||||
|
||||
@@ -76,3 +76,206 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem {
|
||||
},
|
||||
}
|
||||
}
|
||||
fn item(name: &str, display_name: &str) -> RecommendedPluginItem {
|
||||
RecommendedPluginItem {
|
||||
id: format!("plugin_{name}"),
|
||||
name: name.to_string(),
|
||||
status: None,
|
||||
installation_policy: None,
|
||||
release: RecommendedPluginRelease {
|
||||
display_name: display_name.to_string(),
|
||||
app_ids: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommended_plugins_enabled_flag_selects_endpoint_or_legacy_mode() {
|
||||
let disabled: RecommendedPluginsResponse = serde_json::from_value(serde_json::json!({
|
||||
"enabled": false,
|
||||
"plugins": [{"id": "plugin_github", "name": "github", "release": {"display_name": "GitHub"}}]
|
||||
}))
|
||||
.expect("response should deserialize");
|
||||
assert_eq!(
|
||||
recommended_plugins_mode(disabled),
|
||||
RecommendedPluginsMode::Legacy
|
||||
);
|
||||
|
||||
for response in [
|
||||
serde_json::json!({"plugins": []}),
|
||||
serde_json::json!({"enabled": null, "plugins": []}),
|
||||
] {
|
||||
let response: RecommendedPluginsResponse =
|
||||
serde_json::from_value(response).expect("response should deserialize");
|
||||
assert_eq!(
|
||||
recommended_plugins_mode(response),
|
||||
RecommendedPluginsMode::Legacy
|
||||
);
|
||||
}
|
||||
|
||||
let enabled: RecommendedPluginsResponse = serde_json::from_value(serde_json::json!({
|
||||
"enabled": true,
|
||||
"plugins": []
|
||||
}))
|
||||
.expect("response should deserialize");
|
||||
assert_eq!(
|
||||
recommended_plugins_mode(enabled),
|
||||
RecommendedPluginsMode::Endpoint {
|
||||
plugins: Vec::new()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommended_plugins_require_remote_install_identity() {
|
||||
let response = serde_json::from_value::<RecommendedPluginsResponse>(serde_json::json!({
|
||||
"enabled": true,
|
||||
"plugins": [{
|
||||
"name": "github",
|
||||
"release": {"display_name": "GitHub"}
|
||||
}]
|
||||
}));
|
||||
|
||||
assert!(response.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommended_plugins_are_validated_deduplicated_sorted_and_capped() {
|
||||
let mut plugins = (0..=52)
|
||||
.rev()
|
||||
.map(|index| item(&format!("plugin-{index:02}"), &format!("Plugin {index:02}")))
|
||||
.collect::<Vec<_>>();
|
||||
plugins.push(item("plugin-00", "Duplicate"));
|
||||
plugins.push(item("not/a/plugin", "Invalid"));
|
||||
plugins.push(RecommendedPluginItem {
|
||||
id: "plugin_disabled".to_string(),
|
||||
name: "disabled".to_string(),
|
||||
status: Some(PluginAvailability::DisabledByAdmin),
|
||||
installation_policy: Some(PluginInstallPolicy::Available),
|
||||
release: RecommendedPluginRelease {
|
||||
display_name: "Disabled".to_string(),
|
||||
app_ids: Vec::new(),
|
||||
},
|
||||
});
|
||||
plugins.push(RecommendedPluginItem {
|
||||
id: "plugin_not_available".to_string(),
|
||||
name: "not-available".to_string(),
|
||||
status: Some(PluginAvailability::Available),
|
||||
installation_policy: Some(PluginInstallPolicy::NotAvailable),
|
||||
release: RecommendedPluginRelease {
|
||||
display_name: "Not Available".to_string(),
|
||||
app_ids: Vec::new(),
|
||||
},
|
||||
});
|
||||
|
||||
let mode = recommended_plugins_mode(RecommendedPluginsResponse {
|
||||
enabled: Some(true),
|
||||
plugins,
|
||||
});
|
||||
let RecommendedPluginsMode::Endpoint { plugins } = mode else {
|
||||
panic!("expected endpoint mode");
|
||||
};
|
||||
|
||||
assert_eq!(plugins.len(), MAX_RECOMMENDED_PLUGINS);
|
||||
assert_eq!(
|
||||
plugins.first(),
|
||||
Some(&RecommendedPlugin {
|
||||
config_id: "plugin-00@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: "plugin_plugin-00".to_string(),
|
||||
display_name: "Plugin 00".to_string(),
|
||||
app_connector_ids: Vec::new(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
plugins.last(),
|
||||
Some(&RecommendedPlugin {
|
||||
config_id: "plugin-49@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: "plugin_plugin-49".to_string(),
|
||||
display_name: "Plugin 49".to_string(),
|
||||
app_connector_ids: Vec::new(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommended_plugins_bound_model_visible_fields() {
|
||||
let overlong_name = "n".repeat(MAX_RECOMMENDED_PLUGIN_NAME_LEN + 1);
|
||||
let overlong_display_name = "D".repeat(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN + 1);
|
||||
let mode = recommended_plugins_mode(RecommendedPluginsResponse {
|
||||
enabled: Some(true),
|
||||
plugins: vec![
|
||||
item(&overlong_name, "Ignored"),
|
||||
item("bounded", &overlong_display_name),
|
||||
],
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
mode,
|
||||
RecommendedPluginsMode::Endpoint {
|
||||
plugins: vec![RecommendedPlugin {
|
||||
config_id: "bounded@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: "plugin_bounded".to_string(),
|
||||
display_name: "D".repeat(MAX_RECOMMENDED_PLUGIN_DISPLAY_NAME_LEN),
|
||||
app_connector_ids: Vec::new(),
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommended_plugins_preserve_install_identity_and_normalize_app_ids() {
|
||||
let mode = recommended_plugins_mode(RecommendedPluginsResponse {
|
||||
enabled: Some(true),
|
||||
plugins: vec![RecommendedPluginItem {
|
||||
id: "plugin_connector_sample".to_string(),
|
||||
name: "sample".to_string(),
|
||||
status: Some(PluginAvailability::Available),
|
||||
installation_policy: Some(PluginInstallPolicy::Available),
|
||||
release: RecommendedPluginRelease {
|
||||
display_name: "Sample".to_string(),
|
||||
app_ids: vec![
|
||||
"connector_one".to_string(),
|
||||
String::new(),
|
||||
"connector_two".to_string(),
|
||||
"connector_one".to_string(),
|
||||
],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
mode,
|
||||
RecommendedPluginsMode::Endpoint {
|
||||
plugins: vec![RecommendedPlugin {
|
||||
config_id: "sample@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: "plugin_connector_sample".to_string(),
|
||||
display_name: "Sample".to_string(),
|
||||
app_connector_ids: vec!["connector_one".to_string(), "connector_two".to_string(),],
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommended_plugins_ignore_invalid_remote_plugin_ids() {
|
||||
let mode = recommended_plugins_mode(RecommendedPluginsResponse {
|
||||
enabled: Some(true),
|
||||
plugins: vec![RecommendedPluginItem {
|
||||
id: "not/a/plugin".to_string(),
|
||||
name: "sample".to_string(),
|
||||
status: None,
|
||||
installation_policy: None,
|
||||
release: RecommendedPluginRelease {
|
||||
display_name: "Sample".to_string(),
|
||||
app_ids: Vec::new(),
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
mode,
|
||||
RecommendedPluginsMode::Endpoint {
|
||||
plugins: Vec::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user