Skip to main content

magi_code/config/
mod.rs

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