diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 2e2af49569..0f189de0e3 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::PathBuf; use derive_setters::Setters; @@ -11,6 +12,92 @@ 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, +} + +/// 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, + /// URL template for fetching the model list; may contain `{{VAR}}` + /// placeholders. + #[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)] @@ -170,6 +257,14 @@ pub struct ForgeConfig { /// 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, } impl ForgeConfig { diff --git a/crates/forge_repo/src/provider/provider_repo.rs b/crates/forge_repo/src/provider/provider_repo.rs index 8af74b5b07..5ff2a23982 100644 --- a/crates/forge_repo/src/provider/provider_repo.rs +++ b/crates/forge_repo/src/provider/provider_repo.rs @@ -103,6 +103,65 @@ fn merge_configs(base: &mut Vec, other: Vec) { base.extend(map.into_values()); } +impl From for UrlParamVarConfig { + fn from(param: forge_config::ProviderUrlParam) -> Self { + if param.options.is_empty() { + UrlParamVarConfig::Plain(param.name) + } else { + UrlParamVarConfig::WithOptions { name: param.name, options: param.options } + } + } +} + +impl From for ProviderConfig { + fn from(entry: forge_config::ProviderEntry) -> Self { + let provider_type = match entry.provider_type { + Some(forge_config::ProviderTypeEntry::ContextEngine) => { + forge_domain::ProviderType::ContextEngine + } + Some(forge_config::ProviderTypeEntry::Llm) | None => forge_domain::ProviderType::Llm, + }; + + let auth_methods = if entry.auth_methods.is_empty() { + vec![forge_domain::AuthMethod::ApiKey] + } else { + entry + .auth_methods + .into_iter() + .map(|m| match m { + forge_config::ProviderAuthMethod::ApiKey => forge_domain::AuthMethod::ApiKey, + forge_config::ProviderAuthMethod::GoogleAdc => { + forge_domain::AuthMethod::GoogleAdc + } + }) + .collect() + }; + + let response_type = entry.response_type.map(|r| match r { + forge_config::ProviderResponseType::OpenAI => ProviderResponse::OpenAI, + forge_config::ProviderResponseType::OpenAIResponses => { + ProviderResponse::OpenAIResponses + } + forge_config::ProviderResponseType::Anthropic => ProviderResponse::Anthropic, + forge_config::ProviderResponseType::Bedrock => ProviderResponse::Bedrock, + forge_config::ProviderResponseType::Google => ProviderResponse::Google, + forge_config::ProviderResponseType::OpenCode => ProviderResponse::OpenCode, + }); + + ProviderConfig { + id: ProviderId::from(entry.id), + provider_type, + api_key_vars: entry.api_key_var, + url_param_vars: entry.url_param_vars.into_iter().map(Into::into).collect(), + response_type, + url: entry.url, + models: entry.models.map(Models::Url), + auth_methods, + custom_headers: entry.custom_headers, + } + } +} + impl From<&ProviderConfig> for forge_domain::ProviderTemplate { fn from(config: &ProviderConfig) -> Self { let models = config.models.as_ref().map(|m| match m { @@ -165,6 +224,17 @@ impl Ok(configs) } + /// Converts provider entries from `ForgeConfig` into `ProviderConfig` + /// instances that can be merged into the provider list. + fn get_config_provider_configs(&self) -> Vec { + self.infra + .get_config() + .providers + .into_iter() + .map(Into::into) + .collect() + } + async fn get_providers(&self) -> Vec { let configs = self.get_merged_configs().await; @@ -420,10 +490,12 @@ impl /// Returns merged provider configs (embedded + custom) async fn get_merged_configs(&self) -> Vec { let mut configs = ProviderConfigs(get_provider_configs().clone()); - // Merge custom configs into embedded configs + // Merge custom file configs into embedded configs configs.merge(ProviderConfigs( self.get_custom_provider_configs().await.unwrap_or_default(), )); + // Merge inline configs from ForgeConfig (forge.toml `providers` field) + configs.merge(ProviderConfigs(self.get_config_provider_configs())); configs.0 } diff --git a/forge.schema.json b/forge.schema.json index b5851006b1..f925ff6a5f 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -213,6 +213,13 @@ "default": 0, "minimum": 0 }, + "providers": { + "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.", + "type": "array", + "items": { + "$ref": "#/$defs/ProviderEntry" + } + }, "reasoning": { "description": "Reasoning configuration applied to all agents; controls effort level,\ntoken budget, and visibility of the model's thinking process.", "anyOf": [ @@ -582,6 +589,139 @@ } } }, + "ProviderAuthMethod": { + "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.", + "type": "string", + "enum": [ + "api_key", + "google_adc" + ] + }, + "ProviderEntry": { + "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.", + "type": "object", + "properties": { + "api_key_var": { + "description": "Environment variable holding the API key for this provider.", + "type": [ + "string", + "null" + ] + }, + "auth_methods": { + "description": "Authentication methods supported by this provider; defaults to\n`[\"api_key\"]` when omitted.", + "type": "array", + "items": { + "$ref": "#/$defs/ProviderAuthMethod" + } + }, + "custom_headers": { + "description": "Additional HTTP headers sent with every request to this provider.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "id": { + "description": "Unique provider identifier used in model paths (e.g. `\"my_provider\"`).", + "type": "string" + }, + "models": { + "description": "URL template for fetching the model list; may contain `{{VAR}}`\nplaceholders.", + "type": [ + "string", + "null" + ] + }, + "provider_type": { + "description": "Provider category; defaults to `llm` when omitted.", + "anyOf": [ + { + "$ref": "#/$defs/ProviderTypeEntry" + }, + { + "type": "null" + } + ] + }, + "response_type": { + "description": "Wire protocol used by this provider.", + "anyOf": [ + { + "$ref": "#/$defs/ProviderResponseType" + }, + { + "type": "null" + } + ] + }, + "url": { + "description": "URL template for chat completions; may contain `{{VAR}}` placeholders\nthat are substituted from the credential's url params.", + "type": "string" + }, + "url_param_vars": { + "description": "Environment variables whose values are substituted into `{{VAR}}`\nplaceholders in the `url` and `models` templates.", + "type": "array", + "items": { + "$ref": "#/$defs/ProviderUrlParam" + } + } + }, + "required": [ + "id", + "url" + ] + }, + "ProviderResponseType": { + "description": "Wire protocol a provider uses for chat completions.", + "type": "string", + "enum": [ + "OpenAI", + "OpenAIResponses", + "Anthropic", + "Bedrock", + "Google", + "OpenCode" + ] + }, + "ProviderTypeEntry": { + "description": "Category of a provider.", + "oneOf": [ + { + "description": "LLM provider for chat completions.", + "type": "string", + "const": "llm" + }, + { + "description": "Context engine provider for code indexing and search.", + "type": "string", + "const": "context_engine" + } + ] + }, + "ProviderUrlParam": { + "description": "A URL parameter variable for a provider, used to substitute template\nvariables in URL strings.", + "type": "object", + "properties": { + "name": { + "description": "The environment variable name used as the template variable key.", + "type": "string" + }, + "options": { + "description": "Optional preset values for this parameter shown as suggestions in the\nUI.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name" + ] + }, "ReasoningConfig": { "description": "Controls the reasoning behaviour of a model, including effort level, token\nbudget, and visibility of the thinking process.", "type": "object",