use std::collections::HashMap; use std::path::PathBuf; use derive_setters::Setters; use fake::Dummy; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::reader::ConfigReader; use crate::writer::ConfigWriter; use crate::{ AutoDumpFormat, Compact, Decimal, HttpConfig, ModelConfig, ReasoningConfig, RetryConfig, Update, }; /// Wire protocol a provider uses for chat completions. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)] pub enum ProviderResponseType { OpenAI, OpenAIResponses, Anthropic, Bedrock, Google, OpenCode, } /// Category of a provider. #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, Dummy)] #[serde(rename_all = "snake_case")] pub enum ProviderTypeEntry { /// LLM provider for chat completions. #[default] Llm, /// Context engine provider for code indexing and search. ContextEngine, } /// Authentication method supported by a provider. /// /// Only the simple (non-OAuth) methods are available here; providers that /// require OAuth device or authorization-code flows must be configured via the /// file-based `provider.json` override instead. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)] #[serde(rename_all = "snake_case")] pub enum ProviderAuthMethod { ApiKey, GoogleAdc, } /// A URL parameter variable for a provider, used to substitute template /// variables in URL strings. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)] #[serde(rename_all = "snake_case")] pub struct ProviderUrlParam { /// The environment variable name used as the template variable key. pub name: String, /// Optional preset values for this parameter shown as suggestions in the /// UI. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub options: Vec, /// Whether this parameter is optional. When `true`, the parameter may be /// left blank without causing an error. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub optional: bool, } /// Source of models for a provider: either a URL to fetch them from or a /// static list defined inline. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)] #[serde(untagged)] pub enum ModelListConfig { /// URL template used to fetch the model list dynamically. Url(String), /// A static list of models defined directly in the configuration. Hardcoded(Vec), } /// A single provider entry defined inline in `forge.toml`. /// /// Inline providers are merged with the built-in provider list; entries with /// the same `id` override the corresponding built-in entry field-by-field, /// while entries with a new `id` are appended to the list. #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)] #[serde(rename_all = "snake_case")] pub struct ProviderEntry { /// Unique provider identifier used in model paths (e.g. `"my_provider"`). pub id: String, /// Environment variable holding the API key for this provider. #[serde(default, skip_serializing_if = "Option::is_none")] pub api_key_var: Option, /// URL template for chat completions; may contain `{{VAR}}` placeholders /// that are substituted from the credential's url params. pub url: String, /// Model source: either a URL template for dynamic discovery or a static /// list of models defined inline. #[serde(default, skip_serializing_if = "Option::is_none")] pub models: Option, /// Wire protocol used by this provider. #[serde(default, skip_serializing_if = "Option::is_none")] pub response_type: Option, /// Environment variables whose values are substituted into `{{VAR}}` /// placeholders in the `url` and `models` templates. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub url_param_vars: Vec, /// Additional HTTP headers sent with every request to this provider. #[serde(default, skip_serializing_if = "Option::is_none")] pub custom_headers: Option>, /// Provider category; defaults to `llm` when omitted. #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_type: Option, /// Authentication methods supported by this provider; defaults to /// `["api_key"]` when omitted. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub auth_methods: Vec, } /// Top-level Forge configuration merged from all sources (defaults, file, /// environment). #[derive(Default, Debug, Setters, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)] #[serde(rename_all = "snake_case")] #[setters(strip_option)] pub struct ForgeConfig { /// Retry settings applied at the system level to all IO operations. #[serde(default, skip_serializing_if = "Option::is_none")] pub retry: Option, /// Maximum number of lines returned by a single file search operation. #[serde(default)] pub max_search_lines: usize, /// Maximum number of bytes returned by a single file search operation. #[serde(default)] pub max_search_result_bytes: usize, /// Maximum number of characters returned from a URL fetch. #[serde(default)] pub max_fetch_chars: usize, /// Maximum number of lines captured from the leading portion of shell /// command output. #[serde(default)] pub max_stdout_prefix_lines: usize, /// Maximum number of lines captured from the trailing portion of shell /// command output. #[serde(default)] pub max_stdout_suffix_lines: usize, /// Maximum number of characters per line in shell command output. #[serde(default)] pub max_stdout_line_chars: usize, /// Maximum number of characters per line when reading a file. #[serde(default)] pub max_line_chars: usize, /// Maximum number of lines read from a file in a single operation. #[serde(default)] pub max_read_lines: u64, /// Maximum number of files read in a single batch operation. #[serde(default)] pub max_file_read_batch_size: usize, /// HTTP client settings including proxy, TLS, and timeout configuration. #[serde(default, skip_serializing_if = "Option::is_none")] pub http: Option, /// Maximum file size in bytes permitted for read operations. #[serde(default)] pub max_file_size_bytes: u64, /// Maximum image file size in bytes permitted for read operations. #[serde(default)] pub max_image_size_bytes: u64, /// Maximum time in seconds a single tool call may run before being /// cancelled. #[serde(default)] pub tool_timeout_secs: u64, /// Whether to automatically open HTML dump files in the browser after /// creation. #[serde(default)] pub auto_open_dump: bool, /// Directory where debug request files are written; disabled when absent. #[serde(default, skip_serializing_if = "Option::is_none")] pub debug_requests: Option, /// Path to the conversation history file; defaults to the global history /// location when absent. #[serde(default, skip_serializing_if = "Option::is_none")] pub custom_history_path: Option, /// Maximum number of conversations shown in the conversation list. #[serde(default)] pub max_conversations: usize, /// Maximum number of candidate results returned from the initial semantic /// search vector query. #[serde(default)] pub max_sem_search_results: usize, /// Number of top results retained after re-ranking in semantic search. #[serde(default)] pub sem_search_top_k: usize, /// Base URL of the Forge services API used for semantic search and /// indexing. #[serde(default)] #[dummy(expr = "\"https://api.forgecode.dev/api\".to_string()")] pub services_url: String, /// Maximum number of file extensions included in the agent system prompt. #[serde(default)] pub max_extensions: usize, /// Format used when automatically creating a session dump after task /// completion; disabled when absent. #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_dump: Option, /// Maximum number of files read concurrently during batch operations. #[serde(default)] pub max_parallel_file_reads: usize, /// Time-to-live in seconds for the cached model API list. #[serde(default)] pub model_cache_ttl_secs: u64, /// Default model and provider configuration used when not overridden by /// individual agents. #[serde(default, skip_serializing_if = "Option::is_none")] pub session: Option, /// Model and provider configuration used for commit message generation. #[serde(default, skip_serializing_if = "Option::is_none")] pub commit: Option, /// Whether `forge commit` should override `GIT_COMMITTER_NAME` and /// `GIT_COMMITTER_EMAIL` with the Forge identity. Defaults to `true` via /// the embedded `.forge.toml` defaults. #[serde(default)] pub use_forge_committer: bool, /// Maximum number of recent commits included as context for commit message /// generation. #[serde(default)] pub max_commit_count: usize, /// Model and provider configuration used for shell command suggestion /// generation. #[serde(default, skip_serializing_if = "Option::is_none")] pub suggest: Option, // --- Workflow fields --- /// Configuration for automatic Forge updates. #[serde(default, skip_serializing_if = "Option::is_none")] pub updates: Option, /// Output randomness for all agents; lower values are deterministic, higher /// values are creative (0.0–2.0). #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, /// Nucleus sampling threshold for all agents; limits token selection to the /// top cumulative probability mass (0.0–1.0). #[serde(default, skip_serializing_if = "Option::is_none")] pub top_p: Option, /// Top-k vocabulary cutoff for all agents; restricts sampling to the k /// highest-probability tokens (1–1000). #[serde(default, skip_serializing_if = "Option::is_none")] pub top_k: Option, /// Maximum tokens the model may generate per response for all agents /// (1–100,000). #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tokens: Option, /// Maximum tool failures per turn before the orchestrator forces /// completion. #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tool_failure_per_turn: Option, /// Maximum number of requests that can be made in a single turn. #[serde(default, skip_serializing_if = "Option::is_none")] pub max_requests_per_turn: Option, /// Context compaction settings applied to all agents; falls back to each /// agent's individual setting when absent. #[serde(default, skip_serializing_if = "Option::is_none")] pub compact: Option, /// Whether restricted mode is active; when enabled, tool execution requires /// explicit permission grants. #[serde(default)] pub restricted: bool, /// Whether tool use is supported in the current environment; when false, /// all tool calls are disabled. #[serde(default)] pub tool_supported: bool, /// Reasoning configuration applied to all agents; controls effort level, /// token budget, and visibility of the model's thinking process. #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning: Option, /// Additional provider definitions merged with the built-in provider list. /// /// Entries with an `id` matching a built-in provider override its fields; /// entries with a new `id` are appended and become available for model /// selection. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub providers: Vec, /// Currency symbol displayed in the shell rprompt next to the session cost /// (e.g. `"$"`, `"€"`, `"₹"`). Defaults to `"$"`. #[serde(default)] pub currency_symbol: String, /// Conversion rate applied to costs before display in the shell rprompt. /// The raw USD cost is multiplied by this value, allowing costs to be shown /// in a local currency. Defaults to `1.0` (no conversion). #[serde(default)] pub currency_conversion_rate: Decimal, /// Enables the pending todos hook that checks for incomplete todo items /// when a task ends and reminds the LLM about them. #[serde(default)] pub verify_todos: bool, /// Switches patch replacement fallback from the legacy fuzzy-search range /// lookup to the newer text-patch gRPC API. /// Defaults to `false` so patching continues to use the legacy fallback /// behavior unless explicitly enabled in `forge.toml`. #[serde(default)] pub use_text_patch_fallback: bool, /// Whether the deep research agent is available. /// /// When set to `true`, the Sage agent is added to the agent list and /// the `:sage` app command is enabled. Defaults to `false`. #[serde(default)] pub research_subagent: bool, /// Enables subagent support via the task tool; when true the forge agent /// gains access to the `task` tool for delegating work to specialised /// sub-agents, and the `sage` research-only agent tool is removed. /// When false the `task` tool is disabled and `sage` is available instead. #[serde(default)] pub subagents: bool, /// Enables automatic VS Code extension installation when Forge runs inside /// VS Code and the extension is not already installed. #[serde(default)] pub auto_install_vscode_extension: bool, /// When `true`, all system messages in the conversation are merged into a /// single leading system message before the request is sent. Enable this /// for providers that reject requests containing system messages after /// user or assistant turns (e.g. vLLM, NVIDIA NIM). #[serde(default)] pub merge_system_messages: bool, } impl ForgeConfig { /// Reads and merges configuration from all sources, returning the resolved /// [`ForgeConfig`]. /// /// # Errors /// /// Returns an error if the config path cannot be resolved, the file cannot /// be read, or deserialization fails. pub fn read() -> crate::Result { ConfigReader::default() .read_legacy() .read_defaults() .read_global() .read_env() .build() } /// Writes the configuration to the user config file. /// /// # Errors /// /// Returns an error if the configuration cannot be serialized or written to /// disk. pub fn write(&self) -> crate::Result<()> { let path = ConfigReader::config_path(); ConfigWriter::new(self.clone()).write(&path) } } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use super::*; use crate::reader::ConfigReader; #[test] fn test_f32_temperature_round_trip() { let fixture = ForgeConfig { temperature: Some(Decimal(0.1)), ..Default::default() }; let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap(); assert!( toml.contains("temperature = 0.1\n"), "expected `temperature = 0.1` in TOML output, got:\n{toml}" ); } #[test] fn test_f32_top_p_round_trip() { let fixture = ForgeConfig { top_p: Some(Decimal(0.9)), ..Default::default() }; let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap(); assert!( toml.contains("top_p = 0.9\n"), "expected `top_p = 0.9` in TOML output, got:\n{toml}" ); } #[test] fn test_f32_temperature_deserialize_round_trip() { let fixture = ForgeConfig { temperature: Some(Decimal(0.1)), ..Default::default() }; let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap(); let actual = ConfigReader::default().read_toml(&toml).build().unwrap(); assert_eq!(actual.temperature, fixture.temperature); } #[test] fn test_provider_static_model_list_deserialization() { let fixture = r#" [[providers]] id = "ollama" url = "http://127.0.0.1:8000/v1/chat/completions" response_type = "OpenAI" auth_methods = ["api_key"] [[providers.models]] id = "Qwen3.6-35B-A3b-q3-mlx" name = "Qwen3.5-35B" description = "Qwen local reasoning model with advanced problem-solving capabilities" context_length = 262144 tools_supported = true supports_parallel_tool_calls = true supports_reasoning = true input_modalities = ["text"] [[providers.models]] id = "llama3.2-3b" name = "Llama 3.2 3B" description = "Meta Llama 3.2 3B lightweight local model" context_length = 131072 tools_supported = true supports_parallel_tool_calls = false supports_reasoning = false input_modalities = ["text"] "#; let actual = ConfigReader::default().read_toml(fixture).build().unwrap(); let expected = vec![ProviderEntry { id: "ollama".to_string(), url: "http://127.0.0.1:8000/v1/chat/completions".to_string(), response_type: Some(ProviderResponseType::OpenAI), auth_methods: vec![ProviderAuthMethod::ApiKey], models: Some(ModelListConfig::Hardcoded(vec![ forge_domain::Model::new("Qwen3.6-35B-A3b-q3-mlx") .name("Qwen3.5-35B".to_string()) .description( "Qwen local reasoning model with advanced problem-solving capabilities" .to_string(), ) .context_length(262144) .tools_supported(true) .supports_parallel_tool_calls(true) .supports_reasoning(true) .input_modalities(vec![forge_domain::InputModality::Text]), forge_domain::Model::new("llama3.2-3b") .name("Llama 3.2 3B".to_string()) .description("Meta Llama 3.2 3B lightweight local model".to_string()) .context_length(131072) .tools_supported(true) .supports_parallel_tool_calls(false) .supports_reasoning(false) .input_modalities(vec![forge_domain::InputModality::Text]), ])), ..Default::default() }]; assert_eq!(actual.providers, expected); } #[test] fn test_provider_url_model_list_deserialization() { let fixture = r#" [[providers]] id = "my_provider" url = "http://example.com/v1/chat/completions" models = "http://example.com/v1/models" "#; let actual = ConfigReader::default().read_toml(fixture).build().unwrap(); let expected = vec![ProviderEntry { id: "my_provider".to_string(), url: "http://example.com/v1/chat/completions".to_string(), models: Some(ModelListConfig::Url( "http://example.com/v1/models".to_string(), )), ..Default::default() }]; assert_eq!(actual.providers, expected); } #[test] fn test_auto_install_vscode_extension_defaults_to_true() { let actual = ConfigReader::default().read_defaults().build().unwrap(); assert_eq!(actual.auto_install_vscode_extension, true); } #[test] fn test_auto_install_vscode_extension_can_be_disabled() { let toml = "auto_install_vscode_extension = false\n"; let actual = ConfigReader::default() .read_defaults() .read_toml(toml) .build() .unwrap(); assert_eq!(actual.auto_install_vscode_extension, false); } }