1use std::collections::BTreeMap;
4use std::collections::HashMap;
5use std::path::Path;
6
7use crate::HooksToml;
8use crate::permissions_toml::PermissionsToml;
9use crate::profile_toml::ConfigProfile;
10use crate::types::AnalyticsConfigToml;
11use crate::types::ApprovalsReviewer;
12use crate::types::AppsConfigToml;
13use crate::types::AuthCredentialsStoreMode;
14use crate::types::FeedbackConfigToml;
15use crate::types::History;
16use crate::types::MarketplaceConfig;
17use crate::types::McpServerConfig;
18use crate::types::MemoriesToml;
19use crate::types::Notice;
20use crate::types::OAuthCredentialsStoreMode;
21use crate::types::OtelConfigToml;
22use crate::types::PluginConfig;
23use crate::types::SandboxWorkspaceWrite;
24use crate::types::ShellEnvironmentPolicyToml;
25use crate::types::SkillsConfig;
26use crate::types::ToolSuggestConfig;
27use crate::types::Tui;
28use crate::types::UriBasedFileOpener;
29use crate::types::WindowsToml;
30use codex_features::FeaturesToml;
31use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID;
32use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
33use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
34use codex_model_provider_info::ModelProviderInfo;
35use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR;
36use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID;
37use codex_model_provider_info::OPENAI_PROVIDER_ID;
38use codex_protocol::config_types::AutoCompactTokenLimitScope;
39use codex_protocol::config_types::ForcedLoginMethod;
40use codex_protocol::config_types::Personality;
41use codex_protocol::config_types::ReasoningSummary;
42use codex_protocol::config_types::SandboxMode;
43use codex_protocol::config_types::TrustLevel;
44use codex_protocol::config_types::Verbosity;
45use codex_protocol::config_types::WebSearchMode;
46use codex_protocol::config_types::WebSearchToolConfig;
47use codex_protocol::config_types::WindowsSandboxLevel;
48use codex_protocol::models::PermissionProfile;
49use codex_protocol::openai_models::ReasoningEffort;
50use codex_protocol::permissions::NetworkSandboxPolicy;
51use codex_protocol::protocol::AskForApproval;
52use codex_utils_absolute_path::AbsolutePathBuf;
53use codex_utils_path::normalize_for_path_comparison;
54use schemars::JsonSchema;
55use serde::Deserialize;
56use serde::Deserializer;
57use serde::Serialize;
58use serde::de::Error as SerdeError;
59use serde_json::Value as JsonValue;
60
61const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [
62 AMAZON_BEDROCK_PROVIDER_ID,
63 OPENAI_PROVIDER_ID,
64 OLLAMA_OSS_PROVIDER_ID,
65 LMSTUDIO_OSS_PROVIDER_ID,
66];
67
68pub const DEFAULT_PROJECT_DOC_MAX_BYTES: usize = 32 * 1024;
69
70fn default_history() -> Option<History> {
71 Some(History::default())
72}
73
74const fn default_project_doc_max_bytes() -> Option<usize> {
75 Some(DEFAULT_PROJECT_DOC_MAX_BYTES)
76}
77
78fn default_project_doc_fallback_filenames() -> Option<Vec<String>> {
79 Some(Vec::new())
80}
81
82const fn default_hide_agent_reasoning() -> Option<bool> {
83 Some(false)
84}
85
86const fn default_true() -> bool {
87 true
88}
89
90#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema)]
92#[serde(untagged)]
93pub enum ForcedChatgptWorkspaceIds {
94 Single(String),
95 Multiple(Vec<String>),
96}
97
98impl ForcedChatgptWorkspaceIds {
99 pub fn into_vec(self) -> Vec<String> {
100 match self {
101 Self::Single(value) => vec![value],
102 Self::Multiple(values) => values,
103 }
104 }
105}
106
107impl<'de> Deserialize<'de> for ForcedChatgptWorkspaceIds {
108 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
109 where
110 D: Deserializer<'de>,
111 {
112 #[derive(Deserialize)]
113 #[serde(untagged)]
114 enum Repr {
115 Single(String),
116 Multiple(Vec<String>),
117 }
118
119 match Repr::deserialize(deserializer)? {
120 Repr::Single(value) if value.contains(',') => Err(D::Error::custom(
121 "forced_chatgpt_workspace_id must be a single workspace ID string or a TOML list \
122of strings; comma-separated strings are not supported. Use \
123`forced_chatgpt_workspace_id = [\"123e4567-e89b-42d3-a456-426614174000\", \
124\"123e4567-e89b-42d3-a456-426614174001\"]` instead.",
125 )),
126 Repr::Single(value) => Ok(Self::Single(value)),
127 Repr::Multiple(values) => Ok(Self::Multiple(values)),
128 }
129 }
130}
131
132#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
134#[schemars(deny_unknown_fields)]
135pub struct OrchestratorToml {
136 pub skills: Option<OrchestratorFeatureToml>,
137 pub mcp: Option<OrchestratorFeatureToml>,
138}
139
140#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
142#[schemars(deny_unknown_fields)]
143pub struct OrchestratorFeatureToml {
144 pub enabled: Option<bool>,
145}
146
147#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
149#[schemars(deny_unknown_fields)]
150pub struct ConfigToml {
151 pub model: Option<String>,
153 pub review_model: Option<String>,
155
156 pub model_provider: Option<String>,
158
159 pub model_context_window: Option<i64>,
161
162 pub model_auto_compact_token_limit: Option<i64>,
164
165 pub model_auto_compact_token_limit_scope: Option<AutoCompactTokenLimitScope>,
168
169 pub approval_policy: Option<AskForApproval>,
171
172 pub approvals_reviewer: Option<ApprovalsReviewer>,
176
177 #[serde(default)]
179 pub auto_review: Option<AutoReviewToml>,
180
181 #[serde(default)]
182 pub shell_environment_policy: ShellEnvironmentPolicyToml,
183
184 pub allow_login_shell: Option<bool>,
193
194 pub sandbox_mode: Option<SandboxMode>,
196
197 pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
199
200 pub default_permissions: Option<String>,
204
205 #[serde(default)]
207 pub permissions: Option<PermissionsToml>,
208
209 #[serde(default)]
211 pub notify: Option<Vec<String>>,
212
213 pub instructions: Option<String>,
215
216 #[serde(default)]
218 pub developer_instructions: Option<String>,
219
220 pub include_permissions_instructions: Option<bool>,
222
223 pub include_apps_instructions: Option<bool>,
225
226 pub include_collaboration_mode_instructions: Option<bool>,
228
229 pub include_environment_context: Option<bool>,
231
232 pub model_instructions_file: Option<AbsolutePathBuf>,
237
238 pub compact_prompt: Option<String>,
240
241 #[serde(default)]
243 pub forced_chatgpt_workspace_id: Option<ForcedChatgptWorkspaceIds>,
244
245 #[serde(default)]
247 pub forced_login_method: Option<ForcedLoginMethod>,
248
249 #[serde(default)]
254 pub cli_auth_credentials_store: Option<AuthCredentialsStoreMode>,
255
256 #[serde(default)]
258 #[schemars(schema_with = "crate::schema::mcp_servers_schema")]
260 pub mcp_servers: HashMap<String, McpServerConfig>,
261
262 #[serde(default)]
268 pub mcp_oauth_credentials_store: Option<OAuthCredentialsStoreMode>,
269
270 pub mcp_oauth_callback_port: Option<u16>,
273
274 pub mcp_oauth_callback_url: Option<String>,
279
280 #[serde(default, deserialize_with = "deserialize_model_providers")]
283 pub model_providers: HashMap<String, ModelProviderInfo>,
284
285 #[serde(default = "default_project_doc_max_bytes")]
287 pub project_doc_max_bytes: Option<usize>,
288
289 #[serde(default = "default_project_doc_fallback_filenames")]
291 pub project_doc_fallback_filenames: Option<Vec<String>>,
292
293 pub tool_output_token_limit: Option<usize>,
295
296 pub background_terminal_max_timeout: Option<u64>,
299
300 #[schemars(skip)]
302 pub js_repl_node_path: Option<AbsolutePathBuf>,
303
304 #[schemars(skip)]
306 pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,
307
308 pub profile: Option<String>,
310
311 #[serde(default)]
313 pub profiles: HashMap<String, ConfigProfile>,
314
315 #[serde(default = "default_history")]
317 pub history: Option<History>,
318
319 pub sqlite_home: Option<AbsolutePathBuf>,
322
323 pub log_dir: Option<AbsolutePathBuf>,
327
328 pub debug: Option<DebugToml>,
330
331 pub file_opener: Option<UriBasedFileOpener>,
334
335 pub tui: Option<Tui>,
337
338 #[serde(default = "default_hide_agent_reasoning")]
341 pub hide_agent_reasoning: Option<bool>,
342
343 pub show_raw_agent_reasoning: Option<bool>,
346
347 pub model_reasoning_effort: Option<ReasoningEffort>,
348 pub plan_mode_reasoning_effort: Option<ReasoningEffort>,
349 pub model_reasoning_summary: Option<ReasoningSummary>,
350 pub model_verbosity: Option<Verbosity>,
352
353 pub model_catalog_json: Option<AbsolutePathBuf>,
356
357 pub personality: Option<Personality>,
359
360 pub service_tier: Option<String>,
363
364 pub chatgpt_base_url: Option<String>,
366
367 pub apps_mcp_product_sku: Option<String>,
369
370 pub orchestrator: Option<OrchestratorToml>,
372
373 pub openai_base_url: Option<String>,
375
376 #[serde(default)]
378 pub audio: Option<RealtimeAudioToml>,
379
380 pub experimental_realtime_ws_base_url: Option<String>,
385 pub experimental_realtime_webrtc_call_base_url: Option<String>,
389 pub experimental_realtime_ws_model: Option<String>,
392 #[serde(default)]
395 pub realtime: Option<RealtimeToml>,
396 pub experimental_realtime_ws_backend_prompt: Option<String>,
400 pub experimental_realtime_ws_startup_context: Option<String>,
404 pub experimental_realtime_start_instructions: Option<String>,
408
409 pub experimental_thread_config_endpoint: Option<String>,
412
413 #[schemars(skip)]
416 pub experimental_thread_store_endpoint: Option<String>,
417
418 pub experimental_thread_store: Option<ThreadStoreToml>,
420 pub projects: Option<HashMap<String, ProjectConfig>>,
421
422 pub web_search: Option<WebSearchMode>,
424
425 pub tools: Option<ToolsToml>,
427
428 pub tool_suggest: Option<ToolSuggestConfig>,
430
431 pub agents: Option<AgentsToml>,
433
434 pub memories: Option<MemoriesToml>,
436
437 pub skills: Option<SkillsConfig>,
439
440 pub hooks: Option<HooksToml>,
442
443 #[serde(default)]
445 pub plugins: HashMap<String, PluginConfig>,
446
447 #[serde(default)]
449 pub marketplaces: HashMap<String, MarketplaceConfig>,
450
451 #[serde(default)]
453 #[schemars(schema_with = "crate::schema::features_schema")]
455 pub features: Option<FeaturesToml>,
456
457 pub suppress_unstable_features_warning: Option<bool>,
459
460 #[serde(default)]
463 pub ghost_snapshot: Option<GhostSnapshotToml>,
464
465 #[serde(default)]
468 pub project_root_markers: Option<Vec<String>>,
469
470 pub check_for_update_on_startup: Option<bool>,
474
475 pub disable_paste_burst: Option<bool>,
479
480 pub analytics: Option<AnalyticsConfigToml>,
483
484 pub feedback: Option<FeedbackConfigToml>,
487
488 #[serde(default)]
490 pub apps: Option<AppsConfigToml>,
491
492 #[serde(default)]
494 pub desktop: Option<HashMap<String, JsonValue>>,
495
496 pub otel: Option<OtelConfigToml>,
498
499 #[serde(default)]
501 pub windows: Option<WindowsToml>,
502
503 pub notice: Option<Notice>,
506
507 pub experimental_compact_prompt_file: Option<AbsolutePathBuf>,
508 pub experimental_use_unified_exec_tool: Option<bool>,
509 pub oss_provider: Option<String>,
511}
512
513#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
514#[schemars(deny_unknown_fields)]
515pub struct ConfigLockfileToml {
516 pub version: u32,
517 pub codex_version: String,
518
519 pub config: ConfigToml,
521}
522
523#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
524#[schemars(deny_unknown_fields)]
525pub struct DebugToml {
526 pub config_lockfile: Option<DebugConfigLockToml>,
527}
528
529#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
530#[schemars(deny_unknown_fields)]
531pub struct DebugConfigLockToml {
532 pub export_dir: Option<AbsolutePathBuf>,
534
535 pub load_path: Option<AbsolutePathBuf>,
537
538 pub allow_codex_version_mismatch: Option<bool>,
540
541 pub save_fields_resolved_from_model_catalog: Option<bool>,
543}
544
545#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
546#[serde(tag = "type", rename_all = "snake_case")]
547pub enum ThreadStoreToml {
548 Local {},
549 #[schemars(skip)]
550 InMemory {
551 id: String,
552 },
553}
554
555#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
556pub struct AutoReviewToml {
557 pub policy: Option<String>,
559}
560
561#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
562#[schemars(deny_unknown_fields)]
563pub struct ProjectConfig {
564 pub trust_level: Option<TrustLevel>,
565}
566
567impl ProjectConfig {
568 pub fn is_trusted(&self) -> bool {
569 matches!(self.trust_level, Some(TrustLevel::Trusted))
570 }
571
572 pub fn is_untrusted(&self) -> bool {
573 matches!(self.trust_level, Some(TrustLevel::Untrusted))
574 }
575}
576
577#[derive(Debug, Clone, Default, PartialEq, Eq)]
578pub struct RealtimeAudioConfig {
579 pub microphone: Option<String>,
580 pub speaker: Option<String>,
581}
582
583#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
584#[serde(rename_all = "snake_case")]
585pub enum RealtimeWsMode {
586 #[default]
587 Conversational,
588 Transcription,
589}
590
591#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
592#[serde(rename_all = "snake_case")]
593pub enum RealtimeTransport {
594 #[default]
595 #[serde(rename = "webrtc")]
596 WebRtc,
597 Websocket,
598}
599
600pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion;
601pub use codex_protocol::protocol::RealtimeVoice;
602
603#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
604#[schemars(deny_unknown_fields)]
605pub struct RealtimeConfig {
606 pub version: RealtimeWsVersion,
607 #[serde(rename = "type")]
608 pub session_type: RealtimeWsMode,
609 pub transport: RealtimeTransport,
610 pub voice: Option<RealtimeVoice>,
611}
612
613#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
614#[schemars(deny_unknown_fields)]
615pub struct RealtimeToml {
616 pub version: Option<RealtimeWsVersion>,
617 #[serde(rename = "type")]
618 pub session_type: Option<RealtimeWsMode>,
619 pub transport: Option<RealtimeTransport>,
620 pub voice: Option<RealtimeVoice>,
621}
622
623#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
624#[schemars(deny_unknown_fields)]
625pub struct RealtimeAudioToml {
626 pub microphone: Option<String>,
627 pub speaker: Option<String>,
628}
629
630#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
631#[schemars(deny_unknown_fields)]
632pub struct ToolsToml {
633 #[serde(
634 default,
635 deserialize_with = "deserialize_optional_web_search_tool_config"
636 )]
637 pub web_search: Option<WebSearchToolConfig>,
638 pub experimental_request_user_input: Option<ExperimentalRequestUserInput>,
639}
640
641#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
642#[schemars(deny_unknown_fields)]
643pub struct ExperimentalRequestUserInput {
644 #[serde(default = "default_true")]
645 pub enabled: bool,
646}
647
648#[derive(Deserialize)]
649#[serde(untagged)]
650enum WebSearchToolConfigInput {
651 Enabled(bool),
652 Config(WebSearchToolConfig),
653}
654
655fn deserialize_optional_web_search_tool_config<'de, D>(
656 deserializer: D,
657) -> Result<Option<WebSearchToolConfig>, D::Error>
658where
659 D: Deserializer<'de>,
660{
661 let value = Option::<WebSearchToolConfigInput>::deserialize(deserializer)?;
662
663 Ok(match value {
664 None => None,
665 Some(WebSearchToolConfigInput::Enabled(enabled)) => {
666 let _ = enabled;
667 None
668 }
669 Some(WebSearchToolConfigInput::Config(config)) => Some(config),
670 })
671}
672
673#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
674#[schemars(deny_unknown_fields)]
675pub struct AgentsToml {
676 pub enabled: Option<bool>,
679 #[serde(alias = "max_threads")]
682 #[schemars(range(min = 1))]
683 pub max_concurrent_threads_per_session: Option<usize>,
684 pub max_depth: Option<i32>,
686 pub default_subagent_model: Option<String>,
688 pub default_subagent_reasoning_effort: Option<ReasoningEffort>,
690 #[schemars(skip)]
692 pub job_max_runtime_seconds: Option<u64>,
693 pub interrupt_message: Option<bool>,
696
697 #[serde(default, flatten)]
707 pub roles: BTreeMap<String, AgentRoleToml>,
708}
709
710#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
711#[schemars(deny_unknown_fields)]
712pub struct AgentRoleToml {
713 pub description: Option<String>,
716
717 pub config_file: Option<AbsolutePathBuf>,
720
721 pub nickname_candidates: Option<Vec<String>>,
723}
724
725#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
726#[schemars(deny_unknown_fields)]
727pub struct GhostSnapshotToml {
728 #[serde(alias = "ignore_untracked_files_over_bytes")]
730 pub ignore_large_untracked_files: Option<i64>,
731 #[serde(alias = "large_untracked_dir_warning_threshold")]
733 pub ignore_large_untracked_dirs: Option<i64>,
734 pub disable_warnings: Option<bool>,
736}
737
738impl ConfigToml {
739 pub async fn derive_permission_profile(
745 &self,
746 sandbox_mode_override: Option<SandboxMode>,
747 windows_sandbox_level: WindowsSandboxLevel,
748 active_project: Option<&ProjectConfig>,
749 permission_profile_constraint: Option<&crate::Constrained<PermissionProfile>>,
750 ) -> PermissionProfile {
751 let configured_sandbox_mode = sandbox_mode_override.or(self.sandbox_mode);
752 let resolved_sandbox_mode = configured_sandbox_mode
753 .or_else(|| {
754 active_project
758 .filter(|project| project.is_trusted() || project.is_untrusted())
759 .map(|_| {
760 if cfg!(target_os = "windows")
761 && windows_sandbox_level == WindowsSandboxLevel::Disabled
762 {
763 SandboxMode::ReadOnly
764 } else {
765 SandboxMode::WorkspaceWrite
766 }
767 })
768 })
769 .unwrap_or_default();
770 let effective_sandbox_mode = if cfg!(target_os = "windows")
771 && windows_sandbox_level == WindowsSandboxLevel::Disabled
773 && matches!(resolved_sandbox_mode, SandboxMode::WorkspaceWrite)
774 {
775 SandboxMode::ReadOnly
776 } else {
777 resolved_sandbox_mode
778 };
779
780 let permission_profile = match effective_sandbox_mode {
781 SandboxMode::ReadOnly => PermissionProfile::read_only(),
782 SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() {
783 Some(SandboxWorkspaceWrite {
784 writable_roots,
785 network_access,
786 exclude_tmpdir_env_var,
787 exclude_slash_tmp,
788 }) => {
789 let network_policy = if *network_access {
790 NetworkSandboxPolicy::Enabled
791 } else {
792 NetworkSandboxPolicy::Restricted
793 };
794 PermissionProfile::workspace_write_with(
795 writable_roots,
796 network_policy,
797 *exclude_tmpdir_env_var,
798 *exclude_slash_tmp,
799 )
800 }
801 None => PermissionProfile::workspace_write(),
802 },
803 SandboxMode::DangerFullAccess => PermissionProfile::Disabled,
804 };
805 if configured_sandbox_mode.is_none()
806 && let Some(constraint) = permission_profile_constraint
807 && let Err(err) = constraint.can_set(&permission_profile)
808 {
809 tracing::warn!(
810 error = %err,
811 "default sandbox policy is disallowed by requirements; falling back to required default"
812 );
813 PermissionProfile::read_only()
814 } else {
815 permission_profile
816 }
817 }
818
819 pub fn get_active_project(
823 &self,
824 resolved_cwd: &Path,
825 repo_root: Option<&Path>,
826 ) -> Option<ProjectConfig> {
827 let projects = self.projects.as_ref()?;
828
829 for normalized_cwd in normalized_project_lookup_keys(resolved_cwd) {
830 if let Some(project_config) = project_config_for_lookup_key(projects, &normalized_cwd) {
831 return Some(project_config);
832 }
833 }
834
835 if let Some(repo_root) = repo_root {
836 for normalized_repo_root in normalized_project_lookup_keys(repo_root) {
837 if let Some(project_config_for_root) =
838 project_config_for_lookup_key(projects, &normalized_repo_root)
839 {
840 return Some(project_config_for_root);
841 }
842 }
843 }
844
845 None
846 }
847}
848
849fn normalized_project_lookup_keys(path: &Path) -> Vec<String> {
853 let normalized_path = normalize_project_lookup_key(path.to_string_lossy().to_string());
854 let normalized_canonical_path = normalize_project_lookup_key(
855 normalize_for_path_comparison(path)
856 .unwrap_or_else(|_| path.to_path_buf())
857 .to_string_lossy()
858 .to_string(),
859 );
860 if normalized_path == normalized_canonical_path {
861 vec![normalized_canonical_path]
862 } else {
863 vec![normalized_canonical_path, normalized_path]
864 }
865}
866
867fn normalize_project_lookup_key(key: String) -> String {
868 if cfg!(windows) {
869 key.to_ascii_lowercase()
870 } else {
871 key
872 }
873}
874
875fn project_config_for_lookup_key(
876 projects: &HashMap<String, ProjectConfig>,
877 lookup_key: &str,
878) -> Option<ProjectConfig> {
879 if let Some(project_config) = projects.get(lookup_key) {
880 return Some(project_config.clone());
881 }
882
883 let mut normalized_matches: Vec<_> = projects
884 .iter()
885 .filter(|(key, _)| normalize_project_lookup_key((*key).clone()) == lookup_key)
886 .collect();
887 normalized_matches.sort_by_key(|(key, _)| *key);
888 normalized_matches
889 .first()
890 .map(|(_, project_config)| (**project_config).clone())
891}
892
893pub fn validate_reserved_model_provider_ids(
894 model_providers: &HashMap<String, ModelProviderInfo>,
895) -> Result<(), String> {
896 let mut conflicts = model_providers
897 .keys()
898 .filter(|key| {
899 key.as_str() != AMAZON_BEDROCK_PROVIDER_ID
900 && RESERVED_MODEL_PROVIDER_IDS.contains(&key.as_str())
901 })
902 .map(|key| format!("`{key}`"))
903 .collect::<Vec<_>>();
904 conflicts.sort_unstable();
905 if conflicts.is_empty() {
906 Ok(())
907 } else {
908 Err(format!(
909 "model_providers contains reserved built-in provider IDs: {}. \
910Built-in providers cannot be overridden. Rename your custom provider (for example, `openai-custom`).",
911 conflicts.join(", ")
912 ))
913 }
914}
915
916pub fn validate_model_providers(
917 model_providers: &HashMap<String, ModelProviderInfo>,
918) -> Result<(), String> {
919 validate_reserved_model_provider_ids(model_providers)?;
920 for (key, provider) in model_providers {
921 if key != AMAZON_BEDROCK_PROVIDER_ID {
922 if provider.aws.is_some() {
923 return Err(format!(
924 "model_providers.{key}: provider aws is only supported for `{AMAZON_BEDROCK_PROVIDER_ID}`"
925 ));
926 }
927 if provider.name.trim().is_empty() {
928 return Err(format!(
929 "model_providers.{key}: provider name must not be empty"
930 ));
931 }
932 }
933 provider
934 .validate()
935 .map_err(|message| format!("model_providers.{key}: {message}"))?;
936 }
937 Ok(())
938}
939
940fn deserialize_model_providers<'de, D>(
941 deserializer: D,
942) -> Result<HashMap<String, ModelProviderInfo>, D::Error>
943where
944 D: serde::Deserializer<'de>,
945{
946 let model_providers = HashMap::<String, ModelProviderInfo>::deserialize(deserializer)?;
947 validate_model_providers(&model_providers).map_err(serde::de::Error::custom)?;
948 Ok(model_providers)
949}
950
951pub fn validate_oss_provider(provider: &str) -> std::io::Result<()> {
952 match provider {
953 LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => Ok(()),
954 LEGACY_OLLAMA_CHAT_PROVIDER_ID => Err(std::io::Error::new(
955 std::io::ErrorKind::InvalidInput,
956 OLLAMA_CHAT_PROVIDER_REMOVED_ERROR,
957 )),
958 _ => Err(std::io::Error::new(
959 std::io::ErrorKind::InvalidInput,
960 format!(
961 "Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}"
962 ),
963 )),
964 }
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970 use pretty_assertions::assert_eq;
971
972 const WORKSPACE_ID_A: &str = "123e4567-e89b-42d3-a456-426614174000";
973 const WORKSPACE_ID_B: &str = "123e4567-e89b-42d3-a456-426614174001";
974
975 #[test]
976 fn forced_chatgpt_workspace_id_accepts_single_string() {
977 let config: ConfigToml = toml::from_str(&format!(
978 r#"forced_chatgpt_workspace_id = "{WORKSPACE_ID_A}""#
979 ))
980 .expect("single workspace id should deserialize");
981
982 assert_eq!(
983 config
984 .forced_chatgpt_workspace_id
985 .expect("workspace id should be set")
986 .into_vec(),
987 vec![WORKSPACE_ID_A.to_string()]
988 );
989 }
990
991 #[test]
992 fn forced_chatgpt_workspace_id_accepts_string_list() {
993 let config: ConfigToml = toml::from_str(&format!(
994 r#"forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"]"#
995 ))
996 .expect("workspace id list should deserialize");
997
998 assert_eq!(
999 config
1000 .forced_chatgpt_workspace_id
1001 .expect("workspace ids should be set")
1002 .into_vec(),
1003 vec![WORKSPACE_ID_A.to_string(), WORKSPACE_ID_B.to_string()]
1004 );
1005 }
1006
1007 #[test]
1008 fn forced_chatgpt_workspace_id_rejects_comma_separated_string() {
1009 let err = toml::from_str::<ConfigToml>(&format!(
1010 r#"forced_chatgpt_workspace_id = "{WORKSPACE_ID_A},{WORKSPACE_ID_B}""#
1011 ))
1012 .expect_err("comma-separated string should be rejected");
1013
1014 let message = err.to_string();
1015 assert!(message.contains("TOML list of strings"));
1016 assert!(message.contains("comma-separated strings are not supported"));
1017 }
1018
1019 #[test]
1020 fn amazon_bedrock_auth_command_must_not_be_empty() {
1021 let err = toml::from_str::<ConfigToml>(
1022 r#"
1023[model_providers.amazon-bedrock.auth]
1024command = " "
1025"#,
1026 )
1027 .expect_err("empty Amazon Bedrock auth command should be rejected");
1028
1029 assert!(
1030 err.to_string().contains(
1031 "model_providers.amazon-bedrock: provider auth.command must not be empty"
1032 )
1033 );
1034 }
1035}