File size: 20,107 Bytes
1851bae | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 | 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<String>,
/// 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<forge_domain::Model>),
}
/// 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<String>,
/// 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<ModelListConfig>,
/// Wire protocol used by this provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_type: Option<ProviderResponseType>,
/// 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<ProviderUrlParam>,
/// Additional HTTP headers sent with every request to this provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_headers: Option<HashMap<String, String>>,
/// Provider category; defaults to `llm` when omitted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_type: Option<ProviderTypeEntry>,
/// Authentication methods supported by this provider; defaults to
/// `["api_key"]` when omitted.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub auth_methods: Vec<ProviderAuthMethod>,
}
/// 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<RetryConfig>,
/// 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<HttpConfig>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// 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<AutoDumpFormat>,
/// 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<ModelConfig>,
/// Model and provider configuration used for commit message generation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<ModelConfig>,
/// 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<ModelConfig>,
// --- Workflow fields ---
/// Configuration for automatic Forge updates.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updates: Option<Update>,
/// 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<Decimal>,
/// 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<Decimal>,
/// 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<u32>,
/// 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<u32>,
/// 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<usize>,
/// 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<usize>,
/// 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<Compact>,
/// 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<ReasoningConfig>,
/// 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<ProviderEntry>,
/// 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<ForgeConfig> {
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);
}
}
|