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);
    }
}