Skip to main content

magi_code/
config.rs

1mod custom_provider_config;
2mod hooks;
3mod paths;
4pub(crate) mod release_notes;
5mod settings;
6mod settings_storage;
7
8use crate::thinking::ThinkingLevel;
9pub(crate) use settings::DEFAULT_TUI_SUBAGENT_CARD_ROWS;
10pub(crate) use settings::{HeaderPanel, PanelLayoutSettings, set_panel_layout};
11use std::{collections::BTreeMap, env, fmt, io::IsTerminal};
12
13use crate::auth::{
14    AuthState, ProviderCredential, codex_credential_from_store, read_auth,
15    resolve_provider_credential,
16};
17pub use custom_provider_config::{
18    CustomProviderConfig, CustomProviderFastMode, CustomProviderHeaderValue,
19    CustomReasoningProtocol,
20};
21pub(crate) use custom_provider_config::{
22    derive_custom_provider_id, looks_like_secret_value, make_custom_provider_config,
23    normalize_custom_provider_base_url, normalized_extra_models, validate_custom_provider_id,
24    validate_optional_env_var_name,
25};
26pub use hooks::{
27    HookDefinition, HookFailurePolicy, HookPayloadMode, HookSettings, InjectedContentSettings,
28    InjectedContentStyle,
29};
30pub use paths::McPaths;
31#[cfg(test)]
32pub(crate) use settings::ensure_settings_schema_files;
33#[cfg(test)]
34pub(crate) use settings::load_config_with_settings;
35pub(crate) use settings::set_selected_model;
36pub use settings::{
37    AnthropicCacheTtl, AstGrepToolSettings, AutoCompactionSettings, BashProtectionFailurePolicy,
38    BashProtectionLevel, BashProtectionSettings, BashToolSettings, CodeModeToolSettings,
39    CompactionSettings, CompactionTimingSettings, CompletionVerificationFailurePolicy,
40    CompletionVerificationSettings, FastSettings, FindToolSettings, GrepToolSettings,
41    HashEditToolSettings, HerdrSettings, HumanizeProtectionFailurePolicy,
42    HumanizeProtectionSettings, InstructionsSettings, IntegrationsSettings, JevSettings,
43    ListFilesToolSettings, LspServerConfig, LspServersSettings, LspSettings, McpHttpServerConfig,
44    McpOAuthConfig, McpServerConfig, McpServersSettings, McpStdioServerConfig, ModelsSettings,
45    OpenAiCodexSettings, OpenAiResponsesSettings, ProgressiveSessionMaintenanceSettings,
46    PromptInjectionFailurePolicy, PromptInjectionProtectionLevel,
47    PromptInjectionProtectionSettings, ProviderStreamSettings, ReadToolSettings,
48    SelectedModelSettings, SessionTitleSettings, Settings, SideAgentSettings,
49    SkillSuggestionSettings, SkillsSettings, SubagentsSettings, SubagentsToolSettings,
50    SummarizerSettings, TextVerbosity, ToolSettings, TuiSettings, ViewImageToolSettings,
51    ViewImageVisionModelSettings, WriteToolSettings,
52};
53pub(crate) use settings::{AppearanceSettings, AutoCompactionLimit, set_appearance_theme};
54pub(crate) use settings::{
55    CompactionConfig, SessionTitleConfig, clamp_subagent_max_depth, validate_mcp_http_url_field,
56    validate_mcp_server_name, validate_view_image_identifier, validate_view_image_max_image_bytes,
57};
58pub(crate) use settings::{
59    DEFAULT_MCP_TIMEOUT_SECONDS, DEFAULT_SESSION_RETENTION_DAYS, SettingsListKind, SettingsScope,
60};
61pub(crate) use settings::{DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES, MAX_VIEW_IMAGE_MAX_IMAGE_BYTES};
62pub(crate) use settings::{
63    disabled_skill_names_from_settings, disabled_subagent_profile_names_from_settings,
64    disabled_tool_names_from_settings, enabled_model_ids_from_settings,
65    load_startup_config_with_settings,
66};
67pub(crate) use settings::{
68    fast_mode_enabled, list_names_for_modal_scope, read_settings, remove_custom_provider,
69    set_fast_mode, set_mcp_server_enabled, set_model_enabled_for_scope, set_selected_primary_agent,
70    set_skill_disabled_for_scope, set_subagent_profile_disabled, set_tool_disabled,
71    toggle_fast_mode, upsert_custom_provider,
72};
73pub(crate) use settings::{load_settings_editor, save_settings_editor, set_code_mode_tool_enabled};
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
76pub(crate) enum ColorChoice {
77    #[default]
78    Auto,
79    Always,
80    Never,
81}
82
83#[derive(Debug, Clone, Default, PartialEq, Eq)]
84pub(crate) struct CliConfigOverrides {
85    pub(crate) provider: Option<String>,
86    pub(crate) model: Option<String>,
87    pub(crate) api_key: Option<String>,
88    pub(crate) color: Option<ColorChoice>,
89}
90
91#[derive(Clone, PartialEq, Eq)]
92pub(crate) struct EffectiveConfig {
93    pub(crate) provider: Option<String>,
94    pub(crate) model: Option<String>,
95    pub(crate) no_color: bool,
96    pub(crate) file_autocomplete_respects_gitignore: bool,
97    pub(crate) custom_providers: BTreeMap<String, CustomProviderConfig>,
98    pub(crate) thinking_level: ThinkingLevel,
99    pub(crate) auth: Option<ProviderCredential>,
100    pub(crate) paths: McPaths,
101}
102
103impl fmt::Debug for EffectiveConfig {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        f.debug_struct("EffectiveConfig")
106            .field("provider", &self.provider)
107            .field("model", &self.model)
108            .field("no_color", &self.no_color)
109            .field(
110                "file_autocomplete_respects_gitignore",
111                &self.file_autocomplete_respects_gitignore,
112            )
113            .field("custom_providers", &self.custom_providers)
114            .field("auth", &self.auth)
115            .field("thinking_level", &self.thinking_level)
116            .field("paths", &self.paths)
117            .finish()
118    }
119}
120
121impl EffectiveConfig {
122    pub(crate) fn selected_provider_model(settings: &Settings) -> (Option<String>, Option<String>) {
123        (
124            env::var("MC_PROVIDER")
125                .ok()
126                .or_else(|| settings.selected_model.provider.clone()),
127            env::var("MC_MODEL")
128                .ok()
129                .or_else(|| settings.selected_model.model.clone()),
130        )
131    }
132
133    pub(crate) fn from_loaded_settings(
134        paths: McPaths,
135        cli: CliConfigOverrides,
136        settings: Settings,
137    ) -> anyhow::Result<Self> {
138        let (color_enabled, _) = resolve_output_style(&settings, cli.color);
139        let (provider, model) = Self::selected_provider_model(&settings);
140        let provider = cli.provider.or(provider);
141        let model = cli.model.or(model);
142        let provider_id = provider
143            .as_deref()
144            .unwrap_or(crate::providers::OPENAI_CODEX_PROVIDER);
145        reject_retired_provider_selection(provider_id, model.as_deref().unwrap_or(""))?;
146        let auth_file = read_auth(&paths)?;
147        let auth = resolve_provider_credential(
148            provider_id,
149            &auth_file,
150            cli.api_key,
151            &settings.custom_providers,
152        )?;
153        Ok(Self {
154            provider,
155            model,
156            no_color: !color_enabled,
157            file_autocomplete_respects_gitignore: settings.file_autocomplete_respects_gitignore,
158            custom_providers: settings.custom_providers,
159            thinking_level: settings.selected_model.thinking_level.unwrap_or_default(),
160            auth,
161            paths,
162        })
163    }
164
165    pub(crate) fn auth_state(&self) -> AuthState {
166        AuthState::for_provider_with_custom(
167            self.provider_id(),
168            self.auth.as_ref(),
169            &self.custom_providers,
170        )
171    }
172
173    pub(crate) fn require_auth(&self) -> Result<ProviderCredential, ConfigError> {
174        self.auth_state()
175            .credential()
176            .cloned()
177            .ok_or_else(|| self.missing_auth_error())
178    }
179
180    pub(crate) fn resolve_provider_auth_for_runtime(&self) -> anyhow::Result<ProviderCredential> {
181        self.resolve_provider_auth_for_runtime_with(codex_credential_from_store)
182    }
183
184    fn resolve_provider_auth_for_runtime_with(
185        &self,
186        prepare_codex_auth: impl FnOnce(&McPaths) -> anyhow::Result<ProviderCredential>,
187    ) -> anyhow::Result<ProviderCredential> {
188        if self.provider_id() == crate::providers::ANTHROPIC_PROVIDER {
189            // Long-lived configs must not reuse a stored key after logout or replacement.
190            return resolve_provider_credential(
191                self.provider_id(),
192                &read_auth(&self.paths)?,
193                None,
194                &self.custom_providers,
195            )?
196            .ok_or_else(|| self.missing_auth_error().into());
197        }
198
199        if self.provider_id() == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
200            return resolve_provider_credential(
201                self.provider_id(),
202                &read_auth(&self.paths)?,
203                None,
204                &self.custom_providers,
205            )?
206            .ok_or_else(|| self.missing_auth_error().into());
207        }
208        if self.provider_id() != crate::providers::OPENAI_CODEX_PROVIDER {
209            return self.require_auth().map_err(Into::into);
210        }
211
212        // ProviderCredential intentionally omits expiry, so Codex runtime auth must come from
213        // the current store and be classified/refreshed before provider construction.
214        prepare_codex_auth(&self.paths)
215    }
216
217    pub(crate) fn missing_auth_error(&self) -> ConfigError {
218        ConfigError::missing_auth_for_custom_providers(
219            self.provider_id(),
220            &self.custom_providers,
221            &self.paths.auth_file,
222        )
223    }
224
225    pub(crate) fn provider_id(&self) -> &str {
226        self.provider
227            .as_deref()
228            .unwrap_or(crate::providers::OPENAI_CODEX_PROVIDER)
229    }
230}
231
232pub(crate) fn reject_retired_provider_selection(provider: &str, model: &str) -> anyhow::Result<()> {
233    if provider != "claude-code" {
234        return Ok(());
235    }
236    let model = if model.trim().is_empty() {
237        "<model>"
238    } else {
239        model
240    };
241    anyhow::bail!(
242        "stale provider selection 'claude-code/{model}': the Claude Code provider was removed; select 'anthropic/{model}' instead, then set ANTHROPIC_API_KEY or configure a provider-keyed 'anthropic' API key; Claude Code OAuth/subscription credentials are not reused"
243    );
244}
245
246pub(crate) fn load_effective_provider_selection(
247    paths: &McPaths,
248    provider: &str,
249    model: &str,
250) -> anyhow::Result<EffectiveConfig> {
251    load_effective_provider_selection_with_settings(paths, provider, model, &read_settings(paths)?)
252}
253
254pub(crate) fn load_effective_provider_selection_with_settings(
255    paths: &McPaths,
256    provider: &str,
257    model: &str,
258    settings: &Settings,
259) -> anyhow::Result<EffectiveConfig> {
260    reject_retired_provider_selection(provider, model)?;
261    let auth_file = read_auth(paths)?;
262    let auth = resolve_provider_credential(provider, &auth_file, None, &settings.custom_providers)?;
263    let (color_enabled, _) = resolve_output_style(settings, None);
264    Ok(EffectiveConfig {
265        provider: Some(provider.to_string()),
266        model: Some(model.to_string()),
267        no_color: !color_enabled,
268        file_autocomplete_respects_gitignore: settings.file_autocomplete_respects_gitignore,
269        custom_providers: settings.custom_providers.clone(),
270        thinking_level: settings.selected_model.thinking_level.unwrap_or_default(),
271        auth,
272        paths: paths.clone(),
273    })
274}
275
276impl ConfigError {
277    pub(crate) fn missing_auth_for_custom_providers(
278        provider: &str,
279        custom_providers: &BTreeMap<String, CustomProviderConfig>,
280        auth_file: &std::path::Path,
281    ) -> Self {
282        missing_auth_error(provider, custom_providers.get(provider), auth_file)
283    }
284}
285
286fn missing_auth_error(
287    provider: &str,
288    custom: Option<&CustomProviderConfig>,
289    auth_file: &std::path::Path,
290) -> ConfigError {
291    let auth_path = auth_file.display();
292    let message = if let Some(custom) = custom {
293        match &custom.api_key_env_var {
294            Some(env_var) => format!(
295                "missing auth: custom provider '{provider}' is configured but environment variable {env_var} is missing or empty"
296            ),
297            None => format!(
298                "missing auth: custom provider '{provider}' is configured for no-auth but could not be prepared"
299            ),
300        }
301    } else if provider == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
302        "Claude subscription unavailable: on Unix, install Claude Code and run `claude auth login`; API keys are not used".to_string()
303    } else if provider == crate::providers::OPENAI_CODEX_PROVIDER {
304        format!(
305            "missing auth: missing OAuth auth or expired OAuth credentials without refresh for provider 'openai-codex'; needs re-login with /login openai-codex; --api-key, MC_API_KEY, and OPENAI_API_KEY are unsupported for openai-codex; OAuth auth includes access token and accountId in {auth_path}"
306        )
307    } else if provider == crate::providers::ANTHROPIC_PROVIDER {
308        format!(
309            "missing auth: provider 'anthropic' requires an Anthropic API key; set ANTHROPIC_API_KEY or configure provider-keyed API-key auth in {auth_path}; OPENAI_API_KEY, MC_API_KEY, and --api-key are not used for Anthropic"
310        )
311    } else {
312        format!(
313            "missing auth for provider '{provider}'; configure provider-keyed auth in {auth_path}"
314        )
315    };
316    ConfigError::MissingAuth {
317        provider: provider.to_string(),
318        message,
319    }
320}
321
322fn resolve_output_style(settings: &Settings, cli_color: Option<ColorChoice>) -> (bool, bool) {
323    let stdout_is_tty = std::io::stdout().is_terminal();
324    resolve_output_style_for_stdout(settings, cli_color, stdout_is_tty)
325}
326
327pub(crate) fn resolve_output_style_for_stdout(
328    settings: &Settings,
329    cli_color: Option<ColorChoice>,
330    stdout_is_tty: bool,
331) -> (bool, bool) {
332    let policy = crate::appearance::resolve_color_policy_from_env(
333        settings.no_color,
334        cli_color,
335        stdout_is_tty,
336        env::var_os("NO_COLOR").is_some(),
337        env::var("COLORTERM").ok().as_deref(),
338        env::var("TERM").ok().as_deref(),
339    );
340    (policy.color_enabled, policy.unicode_enabled)
341}
342#[derive(Debug, thiserror::Error, PartialEq, Eq)]
343pub(crate) enum ConfigError {
344    #[error("{message}")]
345    MissingAuth { provider: String, message: String },
346}
347
348pub(crate) fn load_context_budget(
349    config: &EffectiveConfig,
350) -> anyhow::Result<crate::context::ContextBudget> {
351    let settings = read_settings(&config.paths)?;
352    let mut budget = settings.context.unwrap_or_default();
353    let provider = config.provider_id();
354    let model = config
355        .model
356        .as_deref()
357        .unwrap_or_else(|| crate::providers::default_model_for_provider(provider));
358    if let Some(context_window) =
359        crate::model_catalog::cached_model_context_window(&config.paths, provider, model)
360    {
361        budget.max_tokens = context_window;
362    }
363    budget.apply_model_limits(provider, model);
364    Ok(budget)
365}
366#[cfg(test)]
367pub(crate) use settings::write_settings;