mirror of
https://github.com/openai/codex.git
synced 2026-09-09 15:58:47 +00:00
Discover TUI collaboration modes from the app server (#42401)
## What changed - Fetch `collaborationMode/list` during TUI startup and reconnect, and use the returned catalog for visible collaboration-mode selection. - Keep collaboration-mode discovery optional with a two-second timeout so an unavailable, invalid, or unsupported response does not block bootstrap. - Preserve discovered modes when the model catalog refreshes, clear restored developer-instruction overrides when selecting a server mode, and retain a `/plan` draft when plan mode is unavailable. ## Testing - Cover optional discovery failures and catalog refresh on reconnect. - Cover server mode selection, model refreshes, restored settings, and unavailable plan mode. GitOrigin-RevId: a6e35c14aab2e38526606164091ac64c500151bb
This commit is contained in:
@@ -258,7 +258,10 @@ impl App {
|
||||
)));
|
||||
self.file_search =
|
||||
FileSearchManager::new(self.config.cwd.to_path_buf(), self.app_event_tx.clone());
|
||||
self.model_catalog = Arc::new(ModelCatalog::new(bootstrap.available_models));
|
||||
self.model_catalog = Arc::new(
|
||||
ModelCatalog::new(bootstrap.available_models)
|
||||
.with_collaboration_modes(bootstrap.collaboration_modes),
|
||||
);
|
||||
self.pending_app_server_requests.clear();
|
||||
self.pending_primary_events.clear();
|
||||
self.pending_plugin_enabled_writes.clear();
|
||||
|
||||
@@ -212,7 +212,10 @@ impl App {
|
||||
{
|
||||
tracing::warn!(%error, "TUI task delegation is unavailable without its MCP server");
|
||||
}
|
||||
let model_catalog = Arc::new(ModelCatalog::new(available_models.clone()));
|
||||
let model_catalog = Arc::new(
|
||||
ModelCatalog::new(available_models.clone())
|
||||
.with_collaboration_modes(bootstrap.collaboration_modes),
|
||||
);
|
||||
let feedback_audience = bootstrap.feedback_audience;
|
||||
let auth_mode = bootstrap.auth_mode;
|
||||
let has_chatgpt_account = bootstrap.has_chatgpt_account;
|
||||
|
||||
@@ -53,6 +53,7 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
|
||||
/*developer_instructions*/ None,
|
||||
);
|
||||
let expected_submitted_mode = expected_mode.clone();
|
||||
assert!(!app.model_catalog.collaboration_modes.is_empty());
|
||||
if edit_offline {
|
||||
app.chat_widget
|
||||
.restore_user_message_to_composer("unacknowledged prompt".into());
|
||||
@@ -260,6 +261,13 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() ->
|
||||
app.chat_widget.effective_collaboration_mode(),
|
||||
expected_mode
|
||||
);
|
||||
assert!(app.model_catalog.collaboration_modes.is_empty());
|
||||
assert!(
|
||||
app.chat_widget
|
||||
.model_catalog()
|
||||
.collaboration_modes
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
if edit_offline {
|
||||
|
||||
@@ -269,6 +269,7 @@ where
|
||||
Some(json!({"result": {"account": null, "requiresOpenaiAuth": false}}))
|
||||
}
|
||||
"model/list" => Some(json!({"result": {"data": [], "nextCursor": null}})),
|
||||
"collaborationMode/list" => Some(json!({"result": {"data": []}})),
|
||||
"configRequirements/read" => Some(json!({"result": {"requirements": null}})),
|
||||
_ => respond(request).await,
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ pub(super) fn configure_reserve_catalog(app: &mut App) {
|
||||
let mut high = original.supported_reasoning_efforts[0].clone();
|
||||
high.effort = ReasoningEffortConfig::High;
|
||||
original.supported_reasoning_efforts.push(high);
|
||||
app.model_catalog = Arc::new(ModelCatalog::new(models));
|
||||
Arc::make_mut(&mut app.model_catalog).models = models;
|
||||
let mut tui = crate::tui::test_support::make_test_tui().unwrap();
|
||||
let mut init = app.chatwidget_init_for_forked_or_resumed_thread(
|
||||
&mut tui,
|
||||
|
||||
@@ -8,6 +8,10 @@ mod history;
|
||||
mod models;
|
||||
mod rollout_history;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "app_server_session/collaboration_catalog_tests.rs"]
|
||||
mod collaboration_catalog_tests;
|
||||
|
||||
pub(crate) use history::HISTORY_ITEM_PAGE_LIMIT;
|
||||
pub(crate) use history::HISTORY_ITEM_SCAN_LIMIT;
|
||||
pub(crate) use history::HistoryHydrationScope;
|
||||
@@ -290,6 +294,7 @@ pub(crate) struct AppServerBootstrap {
|
||||
pub(crate) feedback_audience: FeedbackAudience,
|
||||
pub(crate) has_chatgpt_account: bool,
|
||||
pub(crate) available_models: Vec<ModelPreset>,
|
||||
pub(crate) collaboration_modes: Vec<codex_protocol::config_types::CollaborationModeMask>,
|
||||
}
|
||||
|
||||
pub(crate) struct AppServerSession {
|
||||
@@ -560,7 +565,7 @@ impl AppServerSession {
|
||||
// requirements together so an uncached model fetch can overlap both config requests.
|
||||
let model_request_id = self.next_request_id();
|
||||
let requirements_request_id = self.next_request_id();
|
||||
let (models, requirements) = tokio::try_join!(
|
||||
let (models, requirements, collaboration_modes) = tokio::try_join!(
|
||||
async {
|
||||
self.client
|
||||
.request_typed::<ModelListResponse>(ClientRequest::ModelList {
|
||||
@@ -592,6 +597,7 @@ impl AppServerSession {
|
||||
)
|
||||
})
|
||||
},
|
||||
async { Ok(crate::collaboration_modes::list(self.request_handle()).await) },
|
||||
)?;
|
||||
self.managed_new_thread_defaults = requirements
|
||||
.requirements
|
||||
@@ -669,6 +675,7 @@ impl AppServerSession {
|
||||
feedback_audience,
|
||||
has_chatgpt_account,
|
||||
available_models,
|
||||
collaboration_modes,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Optional catalog discovery uses the public RPC and never prevents ordinary bootstrap.
|
||||
|
||||
use super::*;
|
||||
use crate::legacy_core::config::ConfigBuilder;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
#[tokio::test]
|
||||
async fn collaboration_catalog_is_optional_and_refetched_on_bootstrap() -> Result<()> {
|
||||
for initial_reply in [
|
||||
Some(json!({"error": {"code": -32601, "message": "Method not found"}})),
|
||||
Some(json!({"error": {"code": -32600, "message": "Experimental API unavailable"}})),
|
||||
Some(json!({"error": {"code": -32603, "message": "Discovery failed"}})),
|
||||
Some(json!({"result": {"data": []}})),
|
||||
Some(json!({"result": {"data": "invalid"}})),
|
||||
None,
|
||||
] {
|
||||
let codex_home = tempfile::tempdir()?;
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config.model = Some("task-model".into());
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
let mut socket = tokio_tungstenite::accept_async(stream).await?;
|
||||
let mut catalog_requests = 0;
|
||||
while let Some(Ok(Message::Text(text))) = socket.next().await {
|
||||
let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else {
|
||||
continue;
|
||||
};
|
||||
let mut reply = match request.method.as_str() {
|
||||
"initialize" => json!({"result": {"userAgent": "catalog-test/1.0.0"}}),
|
||||
"account/read" => {
|
||||
json!({"result": {"account": null, "requiresOpenaiAuth": false}})
|
||||
}
|
||||
"model/list" => json!({"result": {"data": [], "nextCursor": null}}),
|
||||
"configRequirements/read" => json!({"result": {"requirements": null}}),
|
||||
"collaborationMode/list" => {
|
||||
assert_eq!(request.params, Some(json!({})));
|
||||
catalog_requests += 1;
|
||||
if catalog_requests == 1 {
|
||||
let Some(reply) = initial_reply.clone() else {
|
||||
continue;
|
||||
};
|
||||
reply
|
||||
} else {
|
||||
json!({"result": {"data": [{"name": "Server plan", "mode": "plan", "model": "server-model", "reasoning_effort": "high"}]}})
|
||||
}
|
||||
}
|
||||
method => panic!("unexpected request: {method}"),
|
||||
};
|
||||
reply["id"] = json!(request.id);
|
||||
socket.send(Message::Text(reply.to_string().into())).await?;
|
||||
}
|
||||
Ok::<_, color_eyre::Report>(catalog_requests)
|
||||
});
|
||||
let mut session = AppServerSession::new(
|
||||
crate::connect_remote_app_server(endpoint).await?,
|
||||
ThreadParamsMode::Remote,
|
||||
);
|
||||
let initial = session.bootstrap(&config).await?;
|
||||
assert_eq!(
|
||||
(initial.default_model, initial.collaboration_modes),
|
||||
("task-model".into(), vec![])
|
||||
);
|
||||
let refreshed = session.bootstrap(&config).await?;
|
||||
assert_eq!(
|
||||
refreshed.collaboration_modes,
|
||||
vec![CollaborationModeMask {
|
||||
name: "Server plan".into(),
|
||||
mode: Some(ModeKind::Plan),
|
||||
model: Some("server-model".into()),
|
||||
reasoning_effort: Some(Some(ReasoningEffort::High)),
|
||||
developer_instructions: Some(None),
|
||||
}]
|
||||
);
|
||||
session.shutdown().await?;
|
||||
assert_eq!(server.await??, 2);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -36,7 +36,7 @@ impl ChatWidget {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.model_catalog = Arc::new(ModelCatalog::new(presets));
|
||||
Arc::make_mut(&mut self.model_catalog).models = presets;
|
||||
self.refresh_effective_service_tier();
|
||||
self.refresh_model_dependent_surfaces();
|
||||
true
|
||||
|
||||
@@ -801,9 +801,7 @@ impl ChatWidget {
|
||||
});
|
||||
}
|
||||
SlashCommand::Plan if !trimmed.is_empty() => {
|
||||
if !self.apply_plan_slash_command() {
|
||||
return;
|
||||
}
|
||||
let plan_available = self.apply_plan_slash_command();
|
||||
let mut user_message = self.prepared_inline_user_message(
|
||||
args,
|
||||
text_elements,
|
||||
@@ -812,7 +810,8 @@ impl ChatWidget {
|
||||
mention_bindings,
|
||||
source,
|
||||
);
|
||||
if !self.is_session_configured()
|
||||
if !plan_available
|
||||
|| !self.is_session_configured()
|
||||
|| self.current_model().trim().is_empty()
|
||||
|| (!self.current_model_supports_images()
|
||||
&& (!user_message.local_images.is_empty()
|
||||
@@ -825,6 +824,10 @@ impl ChatWidget {
|
||||
element.byte_range.end += PLAN_PREFIX.len();
|
||||
}
|
||||
}
|
||||
if !plan_available {
|
||||
self.restore_user_message_to_composer(user_message);
|
||||
return;
|
||||
}
|
||||
if self.is_session_configured() {
|
||||
self.reasoning_buffer.clear();
|
||||
self.reasoning_header = None;
|
||||
|
||||
@@ -235,6 +235,8 @@ mod app_server;
|
||||
mod approval_requests;
|
||||
#[path = "tests/backend_banners_tests.rs"]
|
||||
mod backend_banners_tests;
|
||||
#[path = "tests/collaboration_catalog_tests.rs"]
|
||||
mod collaboration_catalog_tests;
|
||||
#[path = "tests/compaction_tests.rs"]
|
||||
mod compaction_tests;
|
||||
mod composer_submission;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Server choices are independent of model refresh and restored task settings.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn collaboration_catalog_selection_clears_restored_prompt_and_survives_model_refresh() {
|
||||
let (mut chat, _events, _ops) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
let restored = CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model: "gpt-5.2".into(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::Low),
|
||||
developer_instructions: Some("stale restored prompt".into()),
|
||||
},
|
||||
};
|
||||
chat.set_effective_collaboration_mode(restored.clone());
|
||||
let plan = CollaborationModeMask {
|
||||
name: "Server plan".into(),
|
||||
mode: Some(ModeKind::Plan),
|
||||
model: None,
|
||||
reasoning_effort: Some(Some(ReasoningEffortConfig::High)),
|
||||
developer_instructions: Some(None),
|
||||
};
|
||||
Arc::make_mut(&mut chat.model_catalog).collaboration_modes = vec![plan.clone()];
|
||||
let mut models = chat.model_catalog.try_list_models().unwrap();
|
||||
models[0].description = "Refreshed model description".into();
|
||||
let request_id = uuid::Uuid::new_v4();
|
||||
chat.model_popup_request_id = Some(request_id);
|
||||
assert!(chat.on_models_loaded(request_id, Ok(models)));
|
||||
assert_eq!(chat.model_catalog.collaboration_modes, vec![plan]);
|
||||
assert_eq!(chat.effective_collaboration_mode(), restored);
|
||||
assert!(collaboration_modes::default_mode_mask(&chat.model_catalog).is_none());
|
||||
|
||||
chat.cycle_collaboration_mode();
|
||||
assert_eq!(
|
||||
chat.effective_collaboration_mode(),
|
||||
CollaborationMode {
|
||||
mode: ModeKind::Plan,
|
||||
settings: Settings {
|
||||
model: "gpt-5.2".into(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::High),
|
||||
developer_instructions: None,
|
||||
},
|
||||
}
|
||||
);
|
||||
chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Ultra));
|
||||
chat.cycle_collaboration_mode();
|
||||
assert_eq!(
|
||||
chat.effective_reasoning_effort(),
|
||||
Some(ReasoningEffortConfig::Ultra)
|
||||
);
|
||||
chat.set_plan_mode_reasoning_effort(/*effort*/ None);
|
||||
assert_eq!(
|
||||
chat.effective_reasoning_effort(),
|
||||
Some(ReasoningEffortConfig::High)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn collaboration_catalog_unavailable_preserves_task_and_plan_input() {
|
||||
let (mut chat, _events, _ops) = make_chatwidget_manual(Some("gpt-5.2")).await;
|
||||
Arc::make_mut(&mut chat.model_catalog)
|
||||
.collaboration_modes
|
||||
.clear();
|
||||
for mode in [ModeKind::Default, ModeKind::Plan] {
|
||||
let restored = CollaborationMode {
|
||||
mode,
|
||||
settings: Settings {
|
||||
model: "task-model".into(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::Ultra),
|
||||
developer_instructions: Some("restored prompt".into()),
|
||||
},
|
||||
};
|
||||
chat.set_effective_collaboration_mode(restored.clone());
|
||||
chat.cycle_collaboration_mode();
|
||||
assert_eq!(chat.effective_collaboration_mode(), restored);
|
||||
}
|
||||
chat.set_effective_collaboration_mode(CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model: "task-model".into(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::Low),
|
||||
developer_instructions: None,
|
||||
},
|
||||
});
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.bottom_pane
|
||||
.set_composer_text("/plan keep my draft".into(), Vec::new(), Vec::new());
|
||||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "/plan keep my draft");
|
||||
assert_eq!(chat.active_mode_kind(), ModeKind::Default);
|
||||
}
|
||||
@@ -140,9 +140,12 @@ pub(super) fn test_session_telemetry(config: &Config, model: &str) -> SessionTel
|
||||
}
|
||||
|
||||
pub(super) fn test_model_catalog(_config: &Config) -> Arc<ModelCatalog> {
|
||||
Arc::new(ModelCatalog::new(
|
||||
crate::test_support::TEST_MODEL_PRESETS.clone(),
|
||||
))
|
||||
Arc::new(
|
||||
ModelCatalog::new(crate::test_support::TEST_MODEL_PRESETS.clone())
|
||||
.with_collaboration_modes(
|
||||
codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// --- Helpers for tests that need direct construction and event draining ---
|
||||
@@ -309,7 +312,7 @@ pub(crate) fn set_fast_mode_test_catalog(chat: &mut ChatWidget) {
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
|
||||
chat.model_catalog = Arc::new(ModelCatalog::new(models));
|
||||
Arc::make_mut(&mut chat.model_catalog).models = models;
|
||||
}
|
||||
|
||||
pub(crate) async fn make_chatwidget_manual_with_sender() -> (
|
||||
|
||||
@@ -1483,7 +1483,7 @@ async fn plan_slash_command_with_hidden_shell_paste_rejected_image_remains_liter
|
||||
.expect("current model")
|
||||
.input_modalities
|
||||
.retain(|modality| *modality != InputModality::Image);
|
||||
chat.model_catalog = Arc::new(ModelCatalog::new(models));
|
||||
Arc::make_mut(&mut chat.model_catalog).models = models;
|
||||
let payload = paste_hidden_plan_shell_payload(&mut chat);
|
||||
chat.set_remote_image_urls(vec!["https://example.com/image.png".to_string()]);
|
||||
|
||||
@@ -1558,7 +1558,7 @@ async fn rejected_initial_image_does_not_submit_later_queued_prompt() {
|
||||
.expect("current model")
|
||||
.input_modalities
|
||||
.retain(|modality| *modality != InputModality::Image);
|
||||
chat.model_catalog = Arc::new(ModelCatalog::new(models));
|
||||
Arc::make_mut(&mut chat.model_catalog).models = models;
|
||||
let mut initial_message = UserMessage::from("initial prompt");
|
||||
initial_message.remote_image_urls = vec!["https://example.com/image.png".to_string()];
|
||||
chat.initial_user_message = Some(initial_message);
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
|
||||
//! Server collaboration-mode discovery and TUI-visible selection. Missing discovery adds no presets.
|
||||
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::CollaborationModeListParams;
|
||||
use codex_app_server_protocol::CollaborationModeListResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::model_catalog::ModelCatalog;
|
||||
|
||||
fn filtered_presets(_model_catalog: &ModelCatalog) -> Vec<CollaborationModeMask> {
|
||||
builtin_collaboration_mode_presets()
|
||||
.into_iter()
|
||||
fn filtered_presets(model_catalog: &ModelCatalog) -> Vec<CollaborationModeMask> {
|
||||
model_catalog
|
||||
.collaboration_modes
|
||||
.iter()
|
||||
.filter(|mask| mask.mode.is_some_and(ModeKind::is_tui_visible))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -56,3 +65,39 @@ pub(crate) fn default_mode_mask(model_catalog: &ModelCatalog) -> Option<Collabor
|
||||
pub(crate) fn plan_mask(model_catalog: &ModelCatalog) -> Option<CollaborationModeMask> {
|
||||
mask_for_kind(model_catalog, ModeKind::Plan)
|
||||
}
|
||||
|
||||
/// Discovery is optional even on servers that accept the rest of TUI bootstrap.
|
||||
pub(crate) async fn list(request_handle: AppServerRequestHandle) -> Vec<CollaborationModeMask> {
|
||||
let response = tokio::time::timeout(
|
||||
Duration::from_secs(/*secs*/ 2),
|
||||
request_handle.request_typed::<CollaborationModeListResponse>(
|
||||
ClientRequest::CollaborationModeList {
|
||||
request_id: RequestId::String(format!(
|
||||
"collaboration-mode-list-{}",
|
||||
uuid::Uuid::new_v4()
|
||||
)),
|
||||
params: CollaborationModeListParams::default(),
|
||||
},
|
||||
),
|
||||
)
|
||||
.await;
|
||||
match response {
|
||||
Ok(Ok(response)) => response
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|mask| CollaborationModeMask {
|
||||
name: mask.name,
|
||||
mode: mask.mode,
|
||||
model: mask.model,
|
||||
reasoning_effort: mask.reasoning_effort,
|
||||
// The RPC intentionally omits prompts. Clear restored overrides so the server
|
||||
// supplies its current instructions when this mode is selected.
|
||||
developer_instructions: Some(None),
|
||||
})
|
||||
.collect(),
|
||||
_ => {
|
||||
tracing::warn!("optional collaborationMode/list discovery unavailable");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! TUI model inventory and user-facing labels; wire requests retain their original model slugs.
|
||||
//! TUI model and collaboration inventories; refreshing models preserves the server mode catalog.
|
||||
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use std::convert::Infallible;
|
||||
|
||||
@@ -16,12 +17,21 @@ pub(crate) fn model_display_name(model: &str) -> &str {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ModelCatalog {
|
||||
models: Vec<ModelPreset>,
|
||||
pub(crate) models: Vec<ModelPreset>,
|
||||
pub(crate) collaboration_modes: Vec<CollaborationModeMask>,
|
||||
}
|
||||
|
||||
impl ModelCatalog {
|
||||
pub(crate) fn new(models: Vec<ModelPreset>) -> Self {
|
||||
Self { models }
|
||||
Self {
|
||||
models,
|
||||
collaboration_modes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_collaboration_modes(mut self, modes: Vec<CollaborationModeMask>) -> Self {
|
||||
self.collaboration_modes = modes;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn try_list_models(&self) -> Result<Vec<ModelPreset>, Infallible> {
|
||||
|
||||
Reference in New Issue
Block a user