use crate::access::AccessMode; use crate::cli::Cli; use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; pub const DEFAULT_PROVIDER_ID: &str = "fireworks"; pub const DEFAULT_PROVIDER_NAME: &str = "Fireworks"; pub const DEFAULT_PROVIDER_KIND: &str = "openai-compatible"; pub const DEFAULT_MODEL: &str = "accounts/fireworks/models/qwen3p7-plus"; pub const DEFAULT_BASE_URL: &str = "https://api.fireworks.ai/inference/v1"; pub const DEFAULT_API_KEY_ENV: &str = "FIREWORKS_API_KEY"; #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ConfigFile { #[serde(skip_serializing_if = "Option::is_none")] pub default_provider: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_model: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_reasoning_effort: Option, // Deprecated compatibility fields accepted from older config.json files. #[serde(skip_serializing_if = "Option::is_none")] pub provider: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, #[serde(skip_serializing_if = "Option::is_none")] pub base_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub api_key_env: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_access_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] pub context_message_limit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model_tool_result_limit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ui_tool_result_limit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub show_reasoning: Option, #[serde(skip_serializing_if = "Option::is_none")] pub confirm_destructive_operations: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProvidersFile { pub providers: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ProviderDefinition { pub id: String, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, pub kind: String, pub base_url: String, pub api_key: String, #[serde(skip_serializing_if = "Option::is_none")] pub default_model: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub models: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ModelsFile { pub models: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ModelDefinition { pub id: String, pub provider: String, #[serde(skip_serializing_if = "Option::is_none")] pub display_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub context_length: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_output_tokens: Option, #[serde(default = "default_true")] pub supports_tools: bool, #[serde(default = "default_true")] pub supports_streaming: bool, #[serde(default)] pub reasoning: ReasoningMetadata, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ReasoningMetadata { #[serde(default = "default_true")] pub supported: bool, #[serde(default)] pub required: bool, #[serde(default = "default_reasoning_effort")] pub default_effort: ReasoningEffort, #[serde(default)] pub request_format: ReasoningRequestFormat, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ReasoningEffort { Off, Low, Medium, High, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ReasoningRequestFormat { ReasoningEffort, ReasoningObject, } #[derive(Debug, Clone)] pub struct ResolvedProviderConfig { pub id: String, pub name: Option, pub kind: String, pub base_url: String, /// Either a literal API key or an env-var reference like "$FIREWORKS_API_KEY". pub api_key: String, pub default_model: Option, pub models: Vec, } #[derive(Debug, Clone)] pub struct Config { pub provider_id: String, pub model: String, pub reasoning_effort: ReasoningEffort, pub active_provider: ResolvedProviderConfig, pub model_metadata: Option, pub default_access_mode: AccessMode, pub context_message_limit: usize, pub model_tool_result_limit: usize, pub ui_tool_result_limit: usize, pub show_reasoning: bool, pub confirm_destructive_operations: bool, pub root: PathBuf, pub docs_dir: PathBuf, } #[derive(Debug, Clone, Default)] pub struct ConfigOverrides { pub model: Option, pub base_url: Option, pub api_key_env: Option, pub access_mode: Option, } impl ConfigOverrides { pub fn from_cli(cli: &Cli) -> Self { let access_mode = if cli.readonly { Some(AccessMode::ReadOnly) } else if cli.workspace_edit { Some(AccessMode::WorkspaceEdit) } else if cli.full_access { Some(AccessMode::FullAccess) } else { None }; Self { model: cli.model.clone(), base_url: cli.base_url.clone(), api_key_env: cli.api_key_env.clone(), access_mode, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ApiKeyReference { Env(String), Literal, } impl Default for ReasoningMetadata { fn default() -> Self { Self { supported: true, required: false, default_effort: ReasoningEffort::Medium, request_format: ReasoningRequestFormat::ReasoningEffort, } } } impl Default for ReasoningRequestFormat { fn default() -> Self { Self::ReasoningEffort } } impl std::fmt::Display for ReasoningEffort { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { ReasoningEffort::Off => "off", ReasoningEffort::Low => "low", ReasoningEffort::Medium => "medium", ReasoningEffort::High => "high", }) } } impl ReasoningEffort { pub fn default_for_model(model: Option<&ModelDefinition>) -> Self { let Some(model) = model else { return Self::Off; }; if !model.reasoning.supported { return Self::Off; } if model.reasoning.required && model.reasoning.default_effort == Self::Off { Self::Medium } else { model.reasoning.default_effort } } pub fn next_for_model(self, model: Option<&ModelDefinition>) -> Self { let Some(model) = model else { return Self::Off; }; if !model.reasoning.supported { return Self::Off; } let required = model.reasoning.required; match (self, required) { (Self::Off, _) => Self::Low, (Self::Low, _) => Self::Medium, (Self::Medium, _) => Self::High, (Self::High, true) => Self::Low, (Self::High, false) => Self::Off, } } pub fn clamp_for_model(self, model: Option<&ModelDefinition>) -> Self { let Some(model) = model else { return Self::Off; }; if !model.reasoning.supported { return Self::Off; } if model.reasoning.required && self == Self::Off { return Self::default_for_model(Some(model)); } self } pub fn request_value(self) -> Option<&'static str> { match self { Self::Off => None, Self::Low => Some("low"), Self::Medium => Some("medium"), Self::High => Some("high"), } } } fn default_reasoning_effort() -> ReasoningEffort { ReasoningEffort::Medium } impl Default for Config { fn default() -> Self { let root = cass_root(); let docs_dir = root.join("docs"); let active_provider = default_provider_definition().into_resolved(); Self { provider_id: DEFAULT_PROVIDER_ID.to_string(), model: DEFAULT_MODEL.to_string(), reasoning_effort: ReasoningEffort::Medium, active_provider, model_metadata: Some(default_model_definition()), default_access_mode: AccessMode::ReadOnly, context_message_limit: 80, model_tool_result_limit: 24_000, ui_tool_result_limit: 4_000, show_reasoning: false, confirm_destructive_operations: false, root, docs_dir, } } } pub fn cass_root() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(".cass") } pub fn config_path(root: &Path) -> PathBuf { root.join("config.json") } pub fn providers_path(root: &Path) -> PathBuf { root.join("providers.json") } pub fn models_path(root: &Path) -> PathBuf { root.join("models.json") } impl Config { pub fn load(cli: &Cli) -> Result { Self::load_with_overrides(cass_root(), ConfigOverrides::from_cli(cli)) } pub fn load_with_overrides(root: PathBuf, overrides: ConfigOverrides) -> Result { fs::create_dir_all(root.join("conversations")) .with_context(|| format!("creating {}", root.join("conversations").display()))?; let docs_dir = crate::docs::install(&root)?; Self::load_from_root_with_docs_and_overrides(root, docs_dir, overrides) } pub fn load_from_root(root: PathBuf, cli: &Cli) -> Result { Self::load_with_overrides(root, ConfigOverrides::from_cli(cli)) } pub fn load_from_root_with_docs(root: PathBuf, docs_dir: PathBuf, cli: &Cli) -> Result { Self::load_from_root_with_docs_and_overrides(root, docs_dir, ConfigOverrides::from_cli(cli)) } pub fn load_from_root_with_docs_and_overrides( root: PathBuf, docs_dir: PathBuf, overrides: ConfigOverrides, ) -> Result { fs::create_dir_all(&root).with_context(|| format!("creating {}", root.display()))?; let providers = load_or_create_default_provider_registry(&root)?; let models = load_or_create_default_model_registry(&root)?; let file = load_config_file(&root)?; let mut cfg = Config { root: root.clone(), docs_dir, ..Config::default() }; if let Some(file) = &file { if let Some(v) = file.default_access_mode { cfg.default_access_mode = v; } if let Some(v) = file.context_message_limit { cfg.context_message_limit = v; } if let Some(v) = file.model_tool_result_limit { cfg.model_tool_result_limit = v; } if let Some(v) = file.ui_tool_result_limit { cfg.ui_tool_result_limit = v; } if let Some(v) = file.show_reasoning { cfg.show_reasoning = v; } if let Some(v) = file.confirm_destructive_operations { cfg.confirm_destructive_operations = v; } } if let Some(access_mode) = overrides.access_mode { cfg.default_access_mode = access_mode; } let requested_model = requested_model(file.as_ref(), &overrides); let provider_id_from_config = requested_provider_id(file.as_ref(), &providers); let legacy = legacy_provider_override(file.as_ref(), &overrides); let mut provider = resolve_provider( requested_model.as_deref().unwrap_or(DEFAULT_MODEL), provider_id_from_config.as_deref(), file.as_ref().and_then(|f| f.provider.as_deref()), legacy.as_ref(), &providers, &models, )?; if let Some(base_url) = &overrides.base_url { provider.base_url = base_url.clone(); } if let Some(api_key_env) = &overrides.api_key_env { provider.api_key = format!("${api_key_env}"); } let model = requested_model .or_else(|| provider.default_model.clone()) .unwrap_or_else(|| DEFAULT_MODEL.to_string()); let metadata = find_model_for_provider(&models, &provider.id, &model).cloned(); // Use the persisted reasoning effort if present, otherwise the model default. let reasoning_effort = file .as_ref() .and_then(|f| f.default_reasoning_effort) .map(|e| e.clamp_for_model(metadata.as_ref())) .unwrap_or_else(|| ReasoningEffort::default_for_model(metadata.as_ref())); cfg.provider_id = provider.id.clone(); cfg.model = model; cfg.reasoning_effort = reasoning_effort; cfg.active_provider = provider; cfg.model_metadata = metadata; Ok(cfg) } pub fn conversations_dir(&self) -> PathBuf { self.root.join("conversations") } pub fn global_path(&self) -> PathBuf { self.root.join("global.md") } pub fn docs_dir(&self) -> PathBuf { self.docs_dir.clone() } pub fn resolved_api_key(&self) -> Result { resolve_api_key(&self.active_provider.api_key) } } impl ProviderDefinition { pub fn into_resolved(self) -> ResolvedProviderConfig { ResolvedProviderConfig { id: self.id, name: self.name, kind: self.kind, base_url: self.base_url, api_key: self.api_key, default_model: self.default_model, models: self.models, } } pub fn to_resolved(&self) -> ResolvedProviderConfig { self.clone().into_resolved() } } pub fn load_config_file(root: &Path) -> Result> { let path = config_path(root); if !path.exists() { return Ok(None); } let text = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; let file: ConfigFile = serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?; Ok(Some(file)) } /// Persist the last-used model and reasoning effort into `config.json` so the /// next session starts with the same values. pub fn save_last_used(root: &Path, model: &str, reasoning_effort: ReasoningEffort) -> Result<()> { let path = config_path(root); let mut file = load_config_file(root)?.unwrap_or_default(); file.default_model = Some(model.to_string()); file.default_reasoning_effort = Some(reasoning_effort); write_json_pretty(&path, &file) } pub fn load_or_create_default_provider_registry(root: &Path) -> Result { fs::create_dir_all(root).with_context(|| format!("creating {}", root.display()))?; let path = providers_path(root); if !path.exists() { let file = ProvidersFile { providers: vec![default_provider_definition()], }; write_json_pretty(&path, &file)?; return Ok(file); } let text = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display())) } pub fn load_or_create_default_model_registry(root: &Path) -> Result { fs::create_dir_all(root).with_context(|| format!("creating {}", root.display()))?; let path = models_path(root); if !path.exists() { let file = ModelsFile { models: vec![default_model_definition()], }; write_json_pretty(&path, &file)?; return Ok(file); } let text = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display())) } pub fn default_provider_definition() -> ProviderDefinition { ProviderDefinition { id: DEFAULT_PROVIDER_ID.to_string(), name: Some(DEFAULT_PROVIDER_NAME.to_string()), kind: DEFAULT_PROVIDER_KIND.to_string(), base_url: DEFAULT_BASE_URL.to_string(), api_key: format!("${DEFAULT_API_KEY_ENV}"), default_model: Some(DEFAULT_MODEL.to_string()), models: vec![DEFAULT_MODEL.to_string()], } } pub fn default_model_definition() -> ModelDefinition { ModelDefinition { id: DEFAULT_MODEL.to_string(), provider: DEFAULT_PROVIDER_ID.to_string(), display_name: Some("Qwen 3p7 Plus".to_string()), context_length: Some(262_144), max_output_tokens: Some(32_768), supports_tools: true, supports_streaming: true, reasoning: ReasoningMetadata::default(), } } pub fn api_key_reference(spec: &str) -> Result { if let Some(name) = spec.strip_prefix('$') { if name.is_empty() { bail!("API key env-var reference must include a variable name, e.g. \"$FIREWORKS_API_KEY\""); } return Ok(ApiKeyReference::Env(name.to_string())); } if spec.is_empty() { bail!("literal API key must not be empty"); } Ok(ApiKeyReference::Literal) } pub fn resolve_api_key(spec: &str) -> Result { match api_key_reference(spec)? { ApiKeyReference::Env(name) => { let value = std::env::var(&name) .with_context(|| format!("missing API key environment variable `{name}`"))?; if value.is_empty() { bail!("API key environment variable `{name}` is empty"); } Ok(value) } ApiKeyReference::Literal => Ok(spec.to_string()), } } pub fn redact_api_key_for_display(spec: &str) -> String { match api_key_reference(spec) { Ok(ApiKeyReference::Env(name)) => format!("${name}"), Ok(ApiKeyReference::Literal) => "".to_string(), Err(_) => "".to_string(), } } pub fn validate_registries( config_file: Option<&ConfigFile>, providers: &ProvidersFile, models: &ModelsFile, ) -> ValidationSummary { let mut out = ValidationSummary::default(); let mut provider_ids = BTreeSet::new(); let mut provider_counts = BTreeMap::::new(); for provider in &providers.providers { *provider_counts.entry(provider.id.clone()).or_default() += 1; if provider.id.trim().is_empty() { out.errors .push("providers.json: provider id must not be empty".into()); } if provider.kind.trim().is_empty() { out.errors.push(format!( "providers.json: provider `{}` kind must not be empty", provider.id )); } else if provider.kind != DEFAULT_PROVIDER_KIND { out.errors.push(format!( "providers.json: provider `{}` uses unsupported kind `{}`", provider.id, provider.kind )); } if provider.base_url.trim().is_empty() { out.errors.push(format!( "providers.json: provider `{}` base_url must not be empty", provider.id )); } else if reqwest::Url::parse(&provider.base_url).is_err() { out.errors.push(format!( "providers.json: provider `{}` base_url is not a valid URL", provider.id )); } if let Err(err) = api_key_reference(&provider.api_key) { out.errors.push(format!( "providers.json: provider `{}` has invalid api_key: {err}", provider.id )); } if let Some(model) = &provider.default_model { if model.trim().is_empty() { out.errors.push(format!( "providers.json: provider `{}` default_model must not be empty", provider.id )); } } for model in &provider.models { if model.trim().is_empty() { out.errors.push(format!( "providers.json: provider `{}` models entries must not be empty", provider.id )); } } provider_ids.insert(provider.id.clone()); } for (id, count) in provider_counts { if count > 1 { out.errors .push(format!("providers.json: duplicate provider id `{id}`")); } } let mut model_counts = BTreeMap::<(String, String), usize>::new(); for model in &models.models { *model_counts .entry((model.provider.clone(), model.id.clone())) .or_default() += 1; if model.id.trim().is_empty() { out.errors .push("models.json: model id must not be empty".into()); } if model.provider.trim().is_empty() { out.errors.push(format!( "models.json: model `{}` provider must not be empty", model.id )); } else if !provider_ids.contains(&model.provider) { out.errors.push(format!( "models.json: model `{}` references unknown provider `{}`", model.id, model.provider )); } if matches!(model.context_length, Some(0)) { out.errors.push(format!( "models.json: model `{}` context_length must be positive", model.id )); } if matches!(model.max_output_tokens, Some(0)) { out.errors.push(format!( "models.json: model `{}` max_output_tokens must be positive", model.id )); } if model.reasoning.required && !model.reasoning.supported { out.errors.push(format!( "models.json: model `{}` cannot require reasoning when reasoning is unsupported", model.id )); } if model.reasoning.required && model.reasoning.default_effort == ReasoningEffort::Off { out.errors.push(format!( "models.json: model `{}` reasoning default_effort cannot be `off` when reasoning is required", model.id )); } } for ((provider, id), count) in model_counts { if count > 1 { out.errors.push(format!( "models.json: duplicate model `{id}` for provider `{provider}`" )); } } for provider in &providers.providers { if let Some(default_model) = &provider.default_model { if find_model_for_provider(models, &provider.id, default_model).is_none() { out.warnings.push(format!( "providers.json: provider `{}` default_model `{}` has no matching models.json entry", provider.id, default_model )); } } for model in &provider.models { if find_model_for_provider(models, &provider.id, model).is_none() { out.warnings.push(format!( "providers.json: provider `{}` model `{}` has no matching models.json entry", provider.id, model )); } } } if let Some(file) = config_file { if file.provider.is_some() { out.warnings.push( "config.json: `provider` is deprecated; use `default_provider` or infer provider from `default_model`".into(), ); } if file.model.is_some() { out.warnings .push("config.json: `model` is deprecated; use `default_model`".into()); } if file.base_url.is_some() || file.api_key_env.is_some() { out.warnings.push( "config.json: `base_url` and `api_key_env` are deprecated; move provider connection details to providers.json".into(), ); } if let Some(default_provider) = &file.default_provider { if !provider_ids.contains(default_provider) { out.errors.push(format!( "config.json: default_provider `{default_provider}` does not exist in providers.json" )); } } } out } #[derive(Debug, Default, Clone)] pub struct ValidationSummary { pub warnings: Vec, pub errors: Vec, } pub fn find_model_for_provider<'a>( models: &'a ModelsFile, provider_id: &str, model_id: &str, ) -> Option<&'a ModelDefinition> { models .models .iter() .find(|m| m.provider == provider_id && m.id == model_id) } fn requested_model(file: Option<&ConfigFile>, overrides: &ConfigOverrides) -> Option { overrides.model.clone().or_else(|| { file.and_then(|f| { f.default_model .clone() .or_else(|| f.model.clone()) .filter(|m| !m.trim().is_empty()) }) }) } fn requested_provider_id(file: Option<&ConfigFile>, providers: &ProvidersFile) -> Option { let file = file?; if let Some(default_provider) = &file.default_provider { return Some(default_provider.clone()); } let legacy_provider = file.provider.as_ref()?; if providers.providers.iter().any(|p| p.id == *legacy_provider) { Some(legacy_provider.clone()) } else { None } } #[derive(Debug, Clone)] struct LegacyProviderOverride { base_url: Option, api_key: Option, } fn legacy_provider_override( file: Option<&ConfigFile>, overrides: &ConfigOverrides, ) -> Option { let base_url = overrides .base_url .clone() .or_else(|| file.and_then(|f| f.base_url.clone())); let api_key = overrides .api_key_env .as_ref() .map(|env| format!("${env}")) .or_else(|| file.and_then(|f| f.api_key_env.as_ref().map(|env| format!("${env}")))); if base_url.is_some() || api_key.is_some() { Some(LegacyProviderOverride { base_url, api_key }) } else { None } } fn resolve_provider( model: &str, explicit_provider_id: Option<&str>, legacy_provider_name: Option<&str>, legacy: Option<&LegacyProviderOverride>, providers: &ProvidersFile, models: &ModelsFile, ) -> Result { if let Some(id) = explicit_provider_id { if let Some(provider) = providers.providers.iter().find(|p| p.id == id) { return Ok(provider.to_resolved()); } if let Some(legacy) = legacy { return Ok(legacy_resolved_provider(id, legacy)); } bail!("configured provider `{id}` does not exist in providers.json"); } if let Some(legacy) = legacy { let id = legacy_provider_name.unwrap_or("legacy-openai-compatible"); return Ok(legacy_resolved_provider(id, legacy)); } if let Some(provider_id) = unique_provider_for_model(model, providers, models)? { if let Some(provider) = providers.providers.iter().find(|p| p.id == provider_id) { return Ok(provider.to_resolved()); } bail!("model `{model}` references unknown provider `{provider_id}` in models.json"); } if let Some(provider) = providers .providers .iter() .find(|p| p.id == DEFAULT_PROVIDER_ID) { return Ok(provider.to_resolved()); } if providers.providers.len() == 1 { return Ok(providers.providers[0].to_resolved()); } bail!("could not resolve active provider; set config.json `default_provider` or choose a model with a unique provider in models.json") } fn unique_provider_for_model( model: &str, providers: &ProvidersFile, models: &ModelsFile, ) -> Result> { let mut ids = BTreeSet::new(); for model_def in &models.models { if model_def.id == model { ids.insert(model_def.provider.clone()); } } for provider in &providers.providers { if provider.default_model.as_deref() == Some(model) || provider.models.iter().any(|candidate| candidate == model) { ids.insert(provider.id.clone()); } } match ids.len() { 0 => Ok(None), 1 => Ok(ids.into_iter().next()), _ => bail!( "model `{model}` is configured for multiple providers; set config.json `default_provider`" ), } } fn legacy_resolved_provider(id: &str, legacy: &LegacyProviderOverride) -> ResolvedProviderConfig { ResolvedProviderConfig { id: id.to_string(), name: Some("Legacy OpenAI-compatible provider".to_string()), kind: DEFAULT_PROVIDER_KIND.to_string(), base_url: legacy .base_url .clone() .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()), api_key: legacy .api_key .clone() .unwrap_or_else(|| format!("${DEFAULT_API_KEY_ENV}")), default_model: Some(DEFAULT_MODEL.to_string()), models: vec![DEFAULT_MODEL.to_string()], } } fn write_json_pretty(path: &Path, value: &T) -> Result<()> { let text = serde_json::to_string_pretty(value)?; fs::write(path, format!("{text}\n")).with_context(|| format!("writing {}", path.display())) } fn default_true() -> bool { true }