Skip to content

Commit 4e2d666

Browse files
authored
feat(config): add [[providers]] in .forge.toml (#2821)
1 parent 105f2b4 commit 4e2d666

3 files changed

Lines changed: 308 additions & 1 deletion

File tree

crates/forge_config/src/config.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::HashMap;
12
use std::path::PathBuf;
23

34
use derive_setters::Setters;
@@ -11,6 +12,92 @@ use crate::{
1112
AutoDumpFormat, Compact, Decimal, HttpConfig, ModelConfig, ReasoningConfig, RetryConfig, Update,
1213
};
1314

15+
/// Wire protocol a provider uses for chat completions.
16+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)]
17+
pub enum ProviderResponseType {
18+
OpenAI,
19+
OpenAIResponses,
20+
Anthropic,
21+
Bedrock,
22+
Google,
23+
OpenCode,
24+
}
25+
26+
/// Category of a provider.
27+
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema, Dummy)]
28+
#[serde(rename_all = "snake_case")]
29+
pub enum ProviderTypeEntry {
30+
/// LLM provider for chat completions.
31+
#[default]
32+
Llm,
33+
/// Context engine provider for code indexing and search.
34+
ContextEngine,
35+
}
36+
37+
/// Authentication method supported by a provider.
38+
///
39+
/// Only the simple (non-OAuth) methods are available here; providers that
40+
/// require OAuth device or authorization-code flows must be configured via the
41+
/// file-based `provider.json` override instead.
42+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)]
43+
#[serde(rename_all = "snake_case")]
44+
pub enum ProviderAuthMethod {
45+
ApiKey,
46+
GoogleAdc,
47+
}
48+
49+
/// A URL parameter variable for a provider, used to substitute template
50+
/// variables in URL strings.
51+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)]
52+
#[serde(rename_all = "snake_case")]
53+
pub struct ProviderUrlParam {
54+
/// The environment variable name used as the template variable key.
55+
pub name: String,
56+
/// Optional preset values for this parameter shown as suggestions in the
57+
/// UI.
58+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
59+
pub options: Vec<String>,
60+
}
61+
62+
/// A single provider entry defined inline in `forge.toml`.
63+
///
64+
/// Inline providers are merged with the built-in provider list; entries with
65+
/// the same `id` override the corresponding built-in entry field-by-field,
66+
/// while entries with a new `id` are appended to the list.
67+
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)]
68+
#[serde(rename_all = "snake_case")]
69+
pub struct ProviderEntry {
70+
/// Unique provider identifier used in model paths (e.g. `"my_provider"`).
71+
pub id: String,
72+
/// Environment variable holding the API key for this provider.
73+
#[serde(default, skip_serializing_if = "Option::is_none")]
74+
pub api_key_var: Option<String>,
75+
/// URL template for chat completions; may contain `{{VAR}}` placeholders
76+
/// that are substituted from the credential's url params.
77+
pub url: String,
78+
/// URL template for fetching the model list; may contain `{{VAR}}`
79+
/// placeholders.
80+
#[serde(default, skip_serializing_if = "Option::is_none")]
81+
pub models: Option<String>,
82+
/// Wire protocol used by this provider.
83+
#[serde(default, skip_serializing_if = "Option::is_none")]
84+
pub response_type: Option<ProviderResponseType>,
85+
/// Environment variables whose values are substituted into `{{VAR}}`
86+
/// placeholders in the `url` and `models` templates.
87+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
88+
pub url_param_vars: Vec<ProviderUrlParam>,
89+
/// Additional HTTP headers sent with every request to this provider.
90+
#[serde(default, skip_serializing_if = "Option::is_none")]
91+
pub custom_headers: Option<HashMap<String, String>>,
92+
/// Provider category; defaults to `llm` when omitted.
93+
#[serde(default, skip_serializing_if = "Option::is_none")]
94+
pub provider_type: Option<ProviderTypeEntry>,
95+
/// Authentication methods supported by this provider; defaults to
96+
/// `["api_key"]` when omitted.
97+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
98+
pub auth_methods: Vec<ProviderAuthMethod>,
99+
}
100+
14101
/// Top-level Forge configuration merged from all sources (defaults, file,
15102
/// environment).
16103
#[derive(Default, Debug, Setters, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Dummy)]
@@ -170,6 +257,14 @@ pub struct ForgeConfig {
170257
/// token budget, and visibility of the model's thinking process.
171258
#[serde(default, skip_serializing_if = "Option::is_none")]
172259
pub reasoning: Option<ReasoningConfig>,
260+
261+
/// Additional provider definitions merged with the built-in provider list.
262+
///
263+
/// Entries with an `id` matching a built-in provider override its fields;
264+
/// entries with a new `id` are appended and become available for model
265+
/// selection.
266+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
267+
pub providers: Vec<ProviderEntry>,
173268
}
174269

175270
impl ForgeConfig {

crates/forge_repo/src/provider/provider_repo.rs

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,65 @@ fn merge_configs(base: &mut Vec<ProviderConfig>, other: Vec<ProviderConfig>) {
103103
base.extend(map.into_values());
104104
}
105105

106+
impl From<forge_config::ProviderUrlParam> for UrlParamVarConfig {
107+
fn from(param: forge_config::ProviderUrlParam) -> Self {
108+
if param.options.is_empty() {
109+
UrlParamVarConfig::Plain(param.name)
110+
} else {
111+
UrlParamVarConfig::WithOptions { name: param.name, options: param.options }
112+
}
113+
}
114+
}
115+
116+
impl From<forge_config::ProviderEntry> for ProviderConfig {
117+
fn from(entry: forge_config::ProviderEntry) -> Self {
118+
let provider_type = match entry.provider_type {
119+
Some(forge_config::ProviderTypeEntry::ContextEngine) => {
120+
forge_domain::ProviderType::ContextEngine
121+
}
122+
Some(forge_config::ProviderTypeEntry::Llm) | None => forge_domain::ProviderType::Llm,
123+
};
124+
125+
let auth_methods = if entry.auth_methods.is_empty() {
126+
vec![forge_domain::AuthMethod::ApiKey]
127+
} else {
128+
entry
129+
.auth_methods
130+
.into_iter()
131+
.map(|m| match m {
132+
forge_config::ProviderAuthMethod::ApiKey => forge_domain::AuthMethod::ApiKey,
133+
forge_config::ProviderAuthMethod::GoogleAdc => {
134+
forge_domain::AuthMethod::GoogleAdc
135+
}
136+
})
137+
.collect()
138+
};
139+
140+
let response_type = entry.response_type.map(|r| match r {
141+
forge_config::ProviderResponseType::OpenAI => ProviderResponse::OpenAI,
142+
forge_config::ProviderResponseType::OpenAIResponses => {
143+
ProviderResponse::OpenAIResponses
144+
}
145+
forge_config::ProviderResponseType::Anthropic => ProviderResponse::Anthropic,
146+
forge_config::ProviderResponseType::Bedrock => ProviderResponse::Bedrock,
147+
forge_config::ProviderResponseType::Google => ProviderResponse::Google,
148+
forge_config::ProviderResponseType::OpenCode => ProviderResponse::OpenCode,
149+
});
150+
151+
ProviderConfig {
152+
id: ProviderId::from(entry.id),
153+
provider_type,
154+
api_key_vars: entry.api_key_var,
155+
url_param_vars: entry.url_param_vars.into_iter().map(Into::into).collect(),
156+
response_type,
157+
url: entry.url,
158+
models: entry.models.map(Models::Url),
159+
auth_methods,
160+
custom_headers: entry.custom_headers,
161+
}
162+
}
163+
}
164+
106165
impl From<&ProviderConfig> for forge_domain::ProviderTemplate {
107166
fn from(config: &ProviderConfig) -> Self {
108167
let models = config.models.as_ref().map(|m| match m {
@@ -165,6 +224,17 @@ impl<F: EnvironmentInfra + FileReaderInfra + FileWriterInfra + HttpInfra>
165224
Ok(configs)
166225
}
167226

227+
/// Converts provider entries from `ForgeConfig` into `ProviderConfig`
228+
/// instances that can be merged into the provider list.
229+
fn get_config_provider_configs(&self) -> Vec<ProviderConfig> {
230+
self.infra
231+
.get_config()
232+
.providers
233+
.into_iter()
234+
.map(Into::into)
235+
.collect()
236+
}
237+
168238
async fn get_providers(&self) -> Vec<AnyProvider> {
169239
let configs = self.get_merged_configs().await;
170240

@@ -420,10 +490,12 @@ impl<F: EnvironmentInfra + FileReaderInfra + FileWriterInfra + HttpInfra>
420490
/// Returns merged provider configs (embedded + custom)
421491
async fn get_merged_configs(&self) -> Vec<ProviderConfig> {
422492
let mut configs = ProviderConfigs(get_provider_configs().clone());
423-
// Merge custom configs into embedded configs
493+
// Merge custom file configs into embedded configs
424494
configs.merge(ProviderConfigs(
425495
self.get_custom_provider_configs().await.unwrap_or_default(),
426496
));
497+
// Merge inline configs from ForgeConfig (forge.toml `providers` field)
498+
configs.merge(ProviderConfigs(self.get_config_provider_configs()));
427499

428500
configs.0
429501
}

forge.schema.json

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,13 @@
213213
"default": 0,
214214
"minimum": 0
215215
},
216+
"providers": {
217+
"description": "Additional provider definitions merged with the built-in provider list.\n\nEntries with an `id` matching a built-in provider override its fields;\nentries with a new `id` are appended and become available for model\nselection.",
218+
"type": "array",
219+
"items": {
220+
"$ref": "#/$defs/ProviderEntry"
221+
}
222+
},
216223
"reasoning": {
217224
"description": "Reasoning configuration applied to all agents; controls effort level,\ntoken budget, and visibility of the model's thinking process.",
218225
"anyOf": [
@@ -582,6 +589,139 @@
582589
}
583590
}
584591
},
592+
"ProviderAuthMethod": {
593+
"description": "Authentication method supported by a provider.\n\nOnly the simple (non-OAuth) methods are available here; providers that\nrequire OAuth device or authorization-code flows must be configured via the\nfile-based `provider.json` override instead.",
594+
"type": "string",
595+
"enum": [
596+
"api_key",
597+
"google_adc"
598+
]
599+
},
600+
"ProviderEntry": {
601+
"description": "A single provider entry defined inline in `forge.toml`.\n\nInline providers are merged with the built-in provider list; entries with\nthe same `id` override the corresponding built-in entry field-by-field,\nwhile entries with a new `id` are appended to the list.",
602+
"type": "object",
603+
"properties": {
604+
"api_key_var": {
605+
"description": "Environment variable holding the API key for this provider.",
606+
"type": [
607+
"string",
608+
"null"
609+
]
610+
},
611+
"auth_methods": {
612+
"description": "Authentication methods supported by this provider; defaults to\n`[\"api_key\"]` when omitted.",
613+
"type": "array",
614+
"items": {
615+
"$ref": "#/$defs/ProviderAuthMethod"
616+
}
617+
},
618+
"custom_headers": {
619+
"description": "Additional HTTP headers sent with every request to this provider.",
620+
"type": [
621+
"object",
622+
"null"
623+
],
624+
"additionalProperties": {
625+
"type": "string"
626+
}
627+
},
628+
"id": {
629+
"description": "Unique provider identifier used in model paths (e.g. `\"my_provider\"`).",
630+
"type": "string"
631+
},
632+
"models": {
633+
"description": "URL template for fetching the model list; may contain `{{VAR}}`\nplaceholders.",
634+
"type": [
635+
"string",
636+
"null"
637+
]
638+
},
639+
"provider_type": {
640+
"description": "Provider category; defaults to `llm` when omitted.",
641+
"anyOf": [
642+
{
643+
"$ref": "#/$defs/ProviderTypeEntry"
644+
},
645+
{
646+
"type": "null"
647+
}
648+
]
649+
},
650+
"response_type": {
651+
"description": "Wire protocol used by this provider.",
652+
"anyOf": [
653+
{
654+
"$ref": "#/$defs/ProviderResponseType"
655+
},
656+
{
657+
"type": "null"
658+
}
659+
]
660+
},
661+
"url": {
662+
"description": "URL template for chat completions; may contain `{{VAR}}` placeholders\nthat are substituted from the credential's url params.",
663+
"type": "string"
664+
},
665+
"url_param_vars": {
666+
"description": "Environment variables whose values are substituted into `{{VAR}}`\nplaceholders in the `url` and `models` templates.",
667+
"type": "array",
668+
"items": {
669+
"$ref": "#/$defs/ProviderUrlParam"
670+
}
671+
}
672+
},
673+
"required": [
674+
"id",
675+
"url"
676+
]
677+
},
678+
"ProviderResponseType": {
679+
"description": "Wire protocol a provider uses for chat completions.",
680+
"type": "string",
681+
"enum": [
682+
"OpenAI",
683+
"OpenAIResponses",
684+
"Anthropic",
685+
"Bedrock",
686+
"Google",
687+
"OpenCode"
688+
]
689+
},
690+
"ProviderTypeEntry": {
691+
"description": "Category of a provider.",
692+
"oneOf": [
693+
{
694+
"description": "LLM provider for chat completions.",
695+
"type": "string",
696+
"const": "llm"
697+
},
698+
{
699+
"description": "Context engine provider for code indexing and search.",
700+
"type": "string",
701+
"const": "context_engine"
702+
}
703+
]
704+
},
705+
"ProviderUrlParam": {
706+
"description": "A URL parameter variable for a provider, used to substitute template\nvariables in URL strings.",
707+
"type": "object",
708+
"properties": {
709+
"name": {
710+
"description": "The environment variable name used as the template variable key.",
711+
"type": "string"
712+
},
713+
"options": {
714+
"description": "Optional preset values for this parameter shown as suggestions in the\nUI.",
715+
"type": "array",
716+
"items": {
717+
"type": "string"
718+
}
719+
}
720+
},
721+
"required": [
722+
"name"
723+
]
724+
},
585725
"ReasoningConfig": {
586726
"description": "Controls the reasoning behaviour of a model, including effort level, token\nbudget, and visibility of the thinking process.",
587727
"type": "object",

0 commit comments

Comments
 (0)