diff --git a/crates/forge_config/src/config.rs b/crates/forge_config/src/config.rs index 5c7ed51f90..e65def8b76 100644 --- a/crates/forge_config/src/config.rs +++ b/crates/forge_config/src/config.rs @@ -63,13 +63,23 @@ pub struct ProviderUrlParam { pub optional: bool, } -/// Source of models for a provider: either a URL to fetch them from or a -/// static list defined inline. +/// Source of models for a provider: a URL to fetch them from, a live fetch +/// with a curated fallback, 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), + /// Fetch the live model list from `url` (e.g. `/v1/models`), enrich it + /// with curated metadata from `fallback` (matched by model id), and fall + /// back to `fallback` as-is when the fetch fails. + Dynamic { + /// Endpoint to fetch the live model list from (e.g. `/v1/models`). + url: String, + /// Curated metadata overlaid on the live list; the sole source when + /// the live fetch fails. + fallback: Vec, + }, /// A static list of models defined directly in the configuration. Hardcoded(Vec), } @@ -494,6 +504,43 @@ models = "http://example.com/v1/models" assert_eq!(actual.providers, expected); } + #[test] + fn test_provider_dynamic_model_list_with_inline_fallback_deserialization() { + let fixture = r#" +[[providers]] +id = "kimi_coding" +url = "https://api.kimi.com/coding/v1/chat/completions" + +[providers.models] +url = "https://api.kimi.com/coding/v1/models" + +[[providers.models.fallback]] +id = "k3" +name = "Kimi k3" +context_length = 262144 +tools_supported = true +"#; + + let actual = ConfigReader::default().read_toml(fixture).build().unwrap(); + + let expected = vec![ProviderEntry { + id: "kimi_coding".to_string(), + url: "https://api.kimi.com/coding/v1/chat/completions".to_string(), + models: Some(ModelListConfig::Dynamic { + url: "https://api.kimi.com/coding/v1/models".to_string(), + fallback: vec![ + forge_domain::Model::new("k3") + .name("Kimi k3".to_string()) + .context_length(262144) + .tools_supported(true), + ], + }), + ..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(); diff --git a/crates/forge_domain/src/model.rs b/crates/forge_domain/src/model.rs index d4b3bda2dd..307f2b8ff5 100644 --- a/crates/forge_domain/src/model.rs +++ b/crates/forge_domain/src/model.rs @@ -77,6 +77,40 @@ impl Model { input_modalities: default_input_modalities(), } } + + /// Merges live model ids with curated metadata. + /// + /// Every live model id produces an entry; curated entries with a matching + /// id overlay their metadata (name, context length, tool/reasoning + /// support, modalities). Curated entries not present in the live list are + /// appended so metadata-only models (e.g. behind beta flags) remain + /// selectable. + pub fn merge_live(live_ids: Vec, curated: Vec) -> Vec { + let mut merged: Vec = live_ids + .into_iter() + .map(|id| match curated.iter().find(|m| m.id.as_str() == id) { + Some(curated_model) => { + let mut model = Self::new(id); + model.name = curated_model.name.clone(); + model.description = curated_model.description.clone(); + model.context_length = curated_model.context_length; + model.tools_supported = curated_model.tools_supported; + model.supports_parallel_tool_calls = curated_model.supports_parallel_tool_calls; + model.supports_reasoning = curated_model.supports_reasoning; + model.input_modalities = curated_model.input_modalities.clone(); + model + } + None => Self::new(id), + }) + .collect(); + + for curated_model in curated { + if !merged.iter().any(|m| m.id == curated_model.id) { + merged.push(curated_model); + } + } + merged + } } impl From for ModelId { @@ -104,3 +138,68 @@ impl std::str::FromStr for ModelId { Ok(ModelId(s.to_string())) } } + +#[cfg(test)] +mod merge_live_tests { + use super::*; + + #[test] + fn merge_live_emits_one_entry_per_live_id_with_default_metadata() { + let merged = Model::merge_live(vec!["a".to_string(), "b".to_string()], vec![]); + + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].id.as_str(), "a"); + assert_eq!(merged[1].id.as_str(), "b"); + assert_eq!(merged[0].context_length, None); + assert_eq!(merged[0].tools_supported, None); + assert_eq!(merged[0].input_modalities, vec![InputModality::Text]); + } + + #[test] + fn merge_live_overlays_curated_metadata_onto_matching_live_id() { + let curated = Model::new("a") + .name("Alpha".to_string()) + .context_length(131072) + .tools_supported(true) + .supports_reasoning(true) + .input_modalities(vec![InputModality::Text, InputModality::Image]); + + let merged = Model::merge_live(vec!["a".to_string()], vec![curated]); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].id.as_str(), "a"); + assert_eq!(merged[0].name.as_deref(), Some("Alpha")); + assert_eq!(merged[0].context_length, Some(131072)); + assert_eq!(merged[0].tools_supported, Some(true)); + assert_eq!(merged[0].supports_reasoning, Some(true)); + assert_eq!( + merged[0].input_modalities, + vec![InputModality::Text, InputModality::Image] + ); + } + + #[test] + fn merge_live_appends_curated_entries_missing_from_live_list() { + let curated_beta = Model::new("beta-only") + .name("Beta".to_string()) + .context_length(8192); + + let merged = Model::merge_live(vec!["a".to_string()], vec![curated_beta]); + + assert_eq!(merged.len(), 2); + assert_eq!(merged[0].id.as_str(), "a"); + assert_eq!(merged[1].id.as_str(), "beta-only"); + assert_eq!(merged[1].context_length, Some(8192)); + } + + #[test] + fn merge_live_deduplicates_curated_entries_that_already_match_live_ids() { + let curated = Model::new("a").name("Alpha".to_string()); + + let merged = Model::merge_live(vec!["a".to_string()], vec![curated]); + + // "a" appears once in the merged result, not twice. + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].id.as_str(), "a"); + } +} diff --git a/crates/forge_domain/src/provider.rs b/crates/forge_domain/src/provider.rs index 72c914589f..4d1f2501e1 100644 --- a/crates/forge_domain/src/provider.rs +++ b/crates/forge_domain/src/provider.rs @@ -264,9 +264,30 @@ pub enum ProviderResponse { pub enum ModelSource { /// Can be a `Url` or a `Template` Url(T), + /// Models are fetched live from `url` (typically the provider's + /// `/v1/models` endpoint) and enriched with curated metadata from + /// `fallback` (matched by model id). If the fetch fails for any reason + /// (network, auth, schema), the curated `fallback` list is used as-is. + Dynamic { + /// Endpoint to fetch the live model list from (e.g. `/v1/models`). + url: T, + /// Curated metadata overlaid on top of the live list, and the sole + /// source when the live fetch fails. + fallback: Vec, + }, Hardcoded(Vec), } +impl> ModelSource { + /// Returns the fetch URL if this source is dynamic. + pub fn dynamic_url(&self) -> Option<&T> { + match self { + ModelSource::Dynamic { url, .. } => Some(url), + _ => None, + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Provider { pub id: ProviderId, diff --git a/crates/forge_repo/src/provider/anthropic.rs b/crates/forge_repo/src/provider/anthropic.rs index 9bcb906cb9..6b6c67b6f8 100644 --- a/crates/forge_repo/src/provider/anthropic.rs +++ b/crates/forge_repo/src/provider/anthropic.rs @@ -289,6 +289,55 @@ impl Anthropic { debug!("Using hardcoded models"); Ok(models.clone()) } + forge_domain::ModelSource::Dynamic { url, fallback } => { + debug!(url = %url, "Fetching dynamic models"); + + let fetch_result = async { + let response = self + .http + .http_get(url, Some(create_headers(self.get_headers(None)))) + .await + .with_context(|| format_http_context(None, "GET", url)) + .with_context(|| "Failed to fetch models")?; + + let status = response.status(); + let ctx_msg = format_http_context(Some(status), "GET", url); + let text = response + .text() + .await + .with_context(|| ctx_msg.clone()) + .with_context(|| "Failed to decode response into text")?; + + if !status.is_success() { + anyhow::bail!("{}: {}", ctx_msg, text); + } + + let response: ListModelResponse = serde_json::from_str(&text) + .with_context(|| ctx_msg) + .with_context(|| "Failed to deserialize models response")?; + Ok(response + .data + .into_iter() + .map(|m| m.id.to_string()) + .collect()) + } + .await; + + match fetch_result { + Ok(live_ids) => Ok(forge_app::domain::Model::merge_live( + live_ids, + fallback.clone(), + )), + Err(error) => { + tracing::warn!( + error = ?error, + provider = %self.provider.id, + "Dynamic model fetch failed; falling back to curated list" + ); + Ok(fallback.clone()) + } + } + } } } } diff --git a/crates/forge_repo/src/provider/bedrock.rs b/crates/forge_repo/src/provider/bedrock.rs index 7b3689c8e0..05784043f9 100644 --- a/crates/forge_repo/src/provider/bedrock.rs +++ b/crates/forge_repo/src/provider/bedrock.rs @@ -296,6 +296,10 @@ impl BedrockProvider { // Return hardcoded models from configuration match &self.provider.models { Some(forge_domain::ModelSource::Hardcoded(models)) => Ok(models.clone()), + Some(forge_domain::ModelSource::Dynamic { fallback, .. }) => { + // No list API to query; curated fallback is the authoritative source + Ok(fallback.clone()) + } _ => Ok(vec![]), } } diff --git a/crates/forge_repo/src/provider/google.rs b/crates/forge_repo/src/provider/google.rs index c70b2b28c8..381874e061 100644 --- a/crates/forge_repo/src/provider/google.rs +++ b/crates/forge_repo/src/provider/google.rs @@ -116,11 +116,14 @@ impl Google { struct ModelsResponse { models: Vec, } - let response: ModelsResponse = serde_json::from_str(&text) .with_context(|| ctx_msg) .with_context(|| "Failed to deserialize models response")?; - Ok(response.models.into_iter().map(Into::into).collect()) + Ok(response + .models + .into_iter() + .map(forge_domain::Model::from) + .collect()) } else { // treat non 200 response as error. Err(anyhow::anyhow!(text)) @@ -132,6 +135,61 @@ impl Google { debug!("Using hardcoded models"); Ok(models.clone()) } + forge_domain::ModelSource::Dynamic { url, fallback } => { + debug!(url = %url, "Fetching dynamic models"); + + let fetch_result = async { + let response = self + .http + .http_get(url, Some(create_headers(self.get_headers()))) + .await + .with_context(|| format_http_context(None, "GET", url)) + .with_context(|| "Failed to fetch models")?; + + let status = response.status(); + let ctx_msg = format_http_context(Some(status), "GET", url); + let text = response + .text() + .await + .with_context(|| ctx_msg.clone()) + .with_context(|| "Failed to decode response into text")?; + + if !status.is_success() { + anyhow::bail!("{}: {}", ctx_msg, text); + } + + // Google's models endpoint returns { "models": [...] } + #[derive(serde::Deserialize)] + struct ModelsResponse { + models: Vec, + } + + let response: ModelsResponse = serde_json::from_str(&text) + .with_context(|| ctx_msg) + .with_context(|| "Failed to deserialize models response")?; + Ok(response + .models + .into_iter() + .map(forge_domain::Model::from) + .map(|m| m.id.to_string()) + .collect()) + } + .await; + + match fetch_result { + Ok(live_ids) => Ok(forge_app::domain::Model::merge_live( + live_ids, + fallback.clone(), + )), + Err(error) => { + tracing::warn!( + error = ?error, + "Dynamic model fetch failed; falling back to curated list" + ); + Ok(fallback.clone()) + } + } + } } } } diff --git a/crates/forge_repo/src/provider/openai.rs b/crates/forge_repo/src/provider/openai.rs index 9f262e98ed..b18c94a821 100644 --- a/crates/forge_repo/src/provider/openai.rs +++ b/crates/forge_repo/src/provider/openai.rs @@ -306,6 +306,30 @@ impl OpenAIProvider { debug!("Using hardcoded models"); Ok(models.clone()) } + forge_domain::ModelSource::Dynamic { url, fallback } => { + debug!(url = %url, "Fetching dynamic models"); + match self.fetch_models(url.as_str()).await { + Ok(response) => { + let data: ListModelResponse = serde_json::from_str(&response) + .with_context(|| format_http_context(None, "GET", url)) + .with_context(|| "Failed to deserialize models response")?; + let live_ids = + data.data.into_iter().map(|m| m.id.to_string()).collect(); + Ok(forge_app::domain::Model::merge_live( + live_ids, + fallback.clone(), + )) + } + Err(error) => { + tracing::warn!( + error = ?error, + provider = %self.provider.id, + "Dynamic model fetch failed; falling back to curated list" + ); + Ok(fallback.clone()) + } + } + } } } } @@ -583,6 +607,29 @@ mod tests { )) } + fn create_provider_with_dynamic( + base_url: &str, + dynamic_url: reqwest::Url, + fallback: Vec, + ) -> anyhow::Result> { + let provider = Provider { + id: ProviderId::OPENAI, + provider_type: forge_domain::ProviderType::Llm, + response: Some(ProviderResponse::OpenAI), + url: reqwest::Url::parse(base_url)?, + credential: make_credential(ProviderId::OPENAI, "test-api-key"), + custom_headers: None, + auth_methods: vec![forge_domain::AuthMethod::ApiKey], + url_params: vec![], + models: Some(forge_domain::ModelSource::Dynamic { url: dynamic_url, fallback }), + }; + + Ok(OpenAIProvider::new( + provider, + Arc::new(MockHttpClient::new()), + )) + } + fn create_mock_models_response() -> serde_json::Value { serde_json::json!({ "data": [ @@ -680,6 +727,182 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_dynamic_models_success_overlays_curated_metadata() -> anyhow::Result<()> { + let mut fixture = MockServer::new().await; + let mock = fixture + .mock_models(create_mock_models_response(), 200) + .await; + + let curated = vec![forge_app::domain::Model { + id: forge_app::domain::ModelId::new("model-1"), + name: Some("Model One Curated".to_string()), + description: Some("Curated override".to_string()), + context_length: Some(16384), + tools_supported: Some(true), + supports_parallel_tool_calls: Some(false), + supports_reasoning: Some(true), + input_modalities: vec![forge_app::domain::InputModality::Text], + }]; + + let dynamic_url = reqwest::Url::parse(&fixture.url())?.join("/models")?; + let provider = create_provider_with_dynamic(&fixture.url(), dynamic_url, curated)?; + let actual = provider.models().await?; + + mock.assert_async().await; + + // Live fetch returns 2 ids; merge should produce 2 entries. + assert_eq!( + actual.len(), + 2, + "expected 2 merged models, got {}", + actual.len() + ); + let model_1 = actual + .iter() + .find(|m| m.id.as_str() == "model-1") + .expect("model-1 should be present"); + assert_eq!(model_1.name.as_deref(), Some("Model One Curated")); + assert_eq!(model_1.context_length, Some(16384)); + Ok(()) + } + + #[tokio::test] + async fn test_dynamic_models_failure_falls_back_to_curated() -> anyhow::Result<()> { + let mut fixture = MockServer::new().await; + // Mock a 401 — fetch_models errors, Dynamic arm must catch and return + // the curated fallback list instead of bubbling up. + let mock = fixture + .mock_models(create_error_response("Invalid API key", 401), 401) + .await; + + let curated = vec![ + forge_app::domain::Model { + id: forge_app::domain::ModelId::new("curated-a"), + name: Some("Curated A".to_string()), + ..forge_app::domain::Model::new("curated-a") + }, + forge_app::domain::Model { + id: forge_app::domain::ModelId::new("curated-b"), + name: Some("Curated B".to_string()), + ..forge_app::domain::Model::new("curated-b") + }, + ]; + + let dynamic_url = reqwest::Url::parse(&fixture.url())?.join("/models")?; + let provider = create_provider_with_dynamic(&fixture.url(), dynamic_url, curated)?; + let actual = provider.models().await?; + + mock.assert_async().await; + assert_eq!(actual.len(), 2); + let ids: Vec<&str> = actual.iter().map(|m| m.id.as_str()).collect(); + assert!(ids.contains(&"curated-a")); + assert!(ids.contains(&"curated-b")); + Ok(()) + } + + #[tokio::test] + async fn test_dynamic_models_appends_curated_extras_missing_from_live() -> anyhow::Result<()> { + let mut fixture = MockServer::new().await; + let mock = fixture + .mock_models(create_mock_models_response(), 200) + .await; + + let curated = vec![forge_app::domain::Model { + id: forge_app::domain::ModelId::new("beta-only-model"), + name: Some("Beta only".to_string()), + ..forge_app::domain::Model::new("beta-only-model") + }]; + + let dynamic_url = reqwest::Url::parse(&fixture.url())?.join("/models")?; + let provider = create_provider_with_dynamic(&fixture.url(), dynamic_url, curated)?; + let actual = provider.models().await?; + + mock.assert_async().await; + // Live fetch returns 2 ids; merge appends the curated extras that are + // not in the live list, so total should be 3. + assert_eq!(actual.len(), 3); + assert!(actual.iter().any(|m| m.id.as_str() == "beta-only-model")); + Ok(()) + } + + // The `test_dynamic_models_*` tests above use the project's `MockServer` + // helper, which internally wraps `mockito::Server` with a real TCP socket — + // so they already exercise the wire path end-to-end. The next test uses + // `mockito::Server` directly (no helper wrapper) to make the + // wire-level integration scope explicit. + + #[tokio::test] + async fn test_dynamic_models_mockito_direct_wire_integration() -> anyhow::Result<()> { + use mockito::Server; + + let mut server = Server::new_async().await; + let _mock = server + .mock("GET", "/v1/models") + .match_header("authorization", "Bearer test-api-key") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + serde_json::json!({ + "data": [ + { "id": "live-only-model", "name": "Live Only", "context_length": 4096 }, + { "id": "merged-model", "name": "Live Curated", "context_length": 8192 } + ] + }) + .to_string(), + ) + .create_async() + .await; + + // Direct re-build of Provider with ModelSource::Dynamic, mirroring the + // crate-wide dispatch path but bypassing OpenAIProvider::new so we can + // assert the wire request shape independently. + let base_url = format!("{}/v1/chat/completions", server.url()); + let dynamic_url = reqwest::Url::parse(&format!("{}/v1/models", server.url()))?; + let fallback = vec![forge_app::domain::Model { + id: forge_app::domain::ModelId::new("merged-model"), + name: Some("Curated Override".to_string()), + description: Some("Server metadata should win where present.".to_string()), + context_length: Some(16384), + tools_supported: Some(true), + supports_parallel_tool_calls: Some(false), + supports_reasoning: Some(true), + input_modalities: vec![forge_app::domain::InputModality::Text], + }]; + + let provider = Provider { + id: ProviderId::OPENAI, + provider_type: forge_domain::ProviderType::Llm, + response: Some(ProviderResponse::OpenAI), + url: reqwest::Url::parse(&base_url)?, + credential: make_credential(ProviderId::OPENAI, "test-api-key"), + custom_headers: None, + auth_methods: vec![forge_domain::AuthMethod::ApiKey], + url_params: vec![], + models: Some(forge_domain::ModelSource::Dynamic { url: dynamic_url, fallback }), + }; + let openai_provider = OpenAIProvider::new(provider, Arc::new(MockHttpClient::new())); + + let actual = openai_provider.models().await?; + + _mock.assert_async().await; + + // 2 live + 0 fallback extras (both curated ids are covered by live) = 2. + assert_eq!( + actual.len(), + 2, + "live ids (live-only-model, merged-model) should produce 2 entries" + ); + let merged = actual + .iter() + .find(|m| m.id.as_str() == "merged-model") + .expect("merged-model should be present"); + // merge_live overlays: curated `name` wins when curated entry has a name. + assert_eq!(merged.name.as_deref(), Some("Curated Override")); + assert_eq!(merged.context_length, Some(16384)); + Ok(()) + } + #[test] fn test_error_deserialization() -> Result<()> { let content = serde_json::to_string(&serde_json::json!({ diff --git a/crates/forge_repo/src/provider/openai_responses/repository.rs b/crates/forge_repo/src/provider/openai_responses/repository.rs index 847e570c73..3f09570edc 100644 --- a/crates/forge_repo/src/provider/openai_responses/repository.rs +++ b/crates/forge_repo/src/provider/openai_responses/repository.rs @@ -700,6 +700,50 @@ impl + 'stat async fn models(&self, provider: Provider) -> anyhow::Result> { match provider.models().cloned() { Some(forge_domain::ModelSource::Hardcoded(models)) => Ok(models), + Some(forge_domain::ModelSource::Dynamic { url, fallback }) => { + let fetch_result = async { + let provider_client = + OpenAIResponsesProvider::new(provider.clone(), self.infra.clone()); + let headers = create_headers(provider_client.get_headers()); + let response = self + .infra + .http_get(&url, Some(headers)) + .await + .with_context(|| format_http_context(None, "GET", &url)) + .with_context(|| "Failed to fetch models")?; + + let status = response.status(); + let ctx_message = format_http_context(Some(status), "GET", &url); + let response_text = response + .text() + .await + .with_context(|| ctx_message.clone()) + .with_context(|| "Failed to decode response into text")?; + + if !status.is_success() { + anyhow::bail!("{}: {}", ctx_message, response_text); + } + + let data: forge_app::dto::openai::ListModelResponse = + serde_json::from_str(&response_text) + .with_context(|| format_http_context(None, "GET", &url)) + .with_context(|| "Failed to deserialize models response")?; + Ok(data.data.into_iter().map(|m| m.id.to_string()).collect()) + } + .await; + + match fetch_result { + Ok(live_ids) => Ok(Model::merge_live(live_ids, fallback)), + Err(error) => { + tracing::warn!( + error = ?error, + provider = %provider.id, + "Dynamic model fetch failed; falling back to curated list" + ); + Ok(fallback) + } + } + } Some(forge_domain::ModelSource::Url(url)) => { let provider_client = OpenAIResponsesProvider::new(provider, self.infra.clone()); let headers = create_headers(provider_client.get_headers()); diff --git a/crates/forge_repo/src/provider/opencode.rs b/crates/forge_repo/src/provider/opencode.rs index dc534f4062..deb2ce6d27 100644 --- a/crates/forge_repo/src/provider/opencode.rs +++ b/crates/forge_repo/src/provider/opencode.rs @@ -131,6 +131,7 @@ impl + Sync> if let Some(models) = provider.models() { match models { forge_domain::ModelSource::Hardcoded(models) => Ok(models.clone()), + forge_domain::ModelSource::Dynamic { fallback, .. } => Ok(fallback.clone()), forge_domain::ModelSource::Url(_) => { // Should not happen for OpenCode Zen as we hardcode models Ok(vec![]) diff --git a/crates/forge_repo/src/provider/provider.json b/crates/forge_repo/src/provider/provider.json index 27da81ff9a..129fd486c6 100644 --- a/crates/forge_repo/src/provider/provider.json +++ b/crates/forge_repo/src/provider/provider.json @@ -116,7 +116,9 @@ "url_param_vars": [], "response_type": "OpenAI", "url": "https://api.openai.com/v1/chat/completions", - "models": [ + "models": { + "url": "https://api.openai.com/v1/models", + "fallback": [ { "id": "o1", "name": "O1", @@ -615,8 +617,11 @@ "supports_reasoning": true, "input_modalities": ["text", "image"] } - ], - "auth_methods": ["api_key"] + ] + }, + "auth_methods": [ + "api_key" + ] }, { "id": "openai_compatible", @@ -633,7 +638,9 @@ "url_param_vars": [], "response_type": "OpenAI", "url": "https://api.kimi.com/coding/v1/chat/completions", - "models": [ + "models": { + "url": "https://api.kimi.com/coding/v1/models", + "fallback": [ { "id": "k3", "name": "Kimi K3", @@ -664,8 +671,11 @@ "supports_reasoning": true, "input_modalities": ["text", "image"] } + ] + }, + "auth_methods": [ + "api_key" ], - "auth_methods": ["api_key"], "custom_headers": { "User-Agent": "KimiCLI/1.0.0" } @@ -677,58 +687,13 @@ "response_type": "OpenAI", "url": "https://api.moonshot.ai/v1/chat/completions", "models": [ - { - "id": "kimi-k3", - "name": "Kimi K3", - "description": "Moonshot AI Kimi K3 flagship model with 2.8T parameters, 1M-token context, native vision, always-on reasoning, and tool calling capabilities", - "context_length": 1048576, - "tools_supported": true, - "supports_parallel_tool_calls": true, - "supports_reasoning": true, - "input_modalities": ["text", "image"] - }, - { - "id": "kimi-k2.7-code", - "name": "Kimi K2.7 Code", - "description": "Moonshot AI Kimi K2.7 Code model with 256K context, reasoning, and tool calling capabilities", - "context_length": 262144, - "tools_supported": true, - "supports_parallel_tool_calls": true, - "supports_reasoning": true, - "input_modalities": ["text", "image"] - }, - { - "id": "kimi-k2.6", - "name": "Kimi K2.6", - "description": "Moonshot AI Kimi K2.6 model with multimodal input, reasoning, and tool calling capabilities", - "context_length": 262144, - "tools_supported": true, - "supports_parallel_tool_calls": true, - "supports_reasoning": true, - "input_modalities": ["text", "image"] - }, - { - "id": "kimi-k2.5", - "name": "Kimi K2.5", - "description": "Moonshot AI Kimi K2.5 model with multimodal input, reasoning, and tool calling capabilities", - "context_length": 262144, - "tools_supported": true, - "supports_parallel_tool_calls": true, - "supports_reasoning": true, - "input_modalities": ["text", "image"] - }, - { - "id": "kimi-k2-thinking", - "name": "Kimi K2 Thinking", - "description": "Moonshot AI Kimi K2 Thinking model with 256K context, reasoning, and tool calling capabilities", - "context_length": 262144, - "tools_supported": true, - "supports_parallel_tool_calls": true, - "supports_reasoning": true, - "input_modalities": ["text"] - } + {"id": "kimi-k3", "name": "Kimi K3", "description": "Moonshot AI's Kimi K3 reasoning model with extended context and tool use", "context_length": 262144, "tools_supported": true, "supports_parallel_tool_calls": true, "supports_reasoning": true, "input_modalities": ["text"]}, + {"id": "kimi-k2-0905-preview", "name": "Kimi K2 0905 Preview", "description": "Moonshot AI's Kimi K2 model snapshot from September 2025", "context_length": 262144, "tools_supported": true, "supports_parallel_tool_calls": true, "supports_reasoning": true, "input_modalities": ["text"]}, + {"id": "kimi-k2-turbo-preview", "name": "Kimi K2 Turbo Preview", "description": "Faster Kimi K2 variant optimized for latency-sensitive workloads", "context_length": 262144, "tools_supported": true, "supports_parallel_tool_calls": true, "supports_reasoning": false, "input_modalities": ["text"]} ], - "auth_methods": ["api_key"] + "auth_methods": [ + "api_key" + ] }, { "id": "openai_responses_compatible", @@ -754,7 +719,9 @@ "url_param_vars": [], "response_type": "Anthropic", "url": "https://api.anthropic.com/v1/messages", - "models": [ + "models": { + "url": "https://api.anthropic.com/v1/models", + "fallback": [ { "id": "claude-fable-5", "name": "Claude Fable 5", @@ -911,7 +878,8 @@ "supports_parallel_tool_calls": false, "input_modalities": ["text", "image"] } - ], + ] + }, "auth_methods": [ { "oauth_code": { @@ -957,7 +925,9 @@ "url_param_vars": [], "response_type": "OpenAI", "url": "https://api.neuralwatt.com/v1/chat/completions", - "models": [ + "models": { + "url": "https://api.neuralwatt.com/v1/models", + "fallback": [ { "id": "qwen3.5-397b", "name": "Qwen3.5 397B", @@ -1138,8 +1108,11 @@ "supports_reasoning": false, "input_modalities": ["text", "image"] } - ], - "auth_methods": ["api_key"] + ] + }, + "auth_methods": [ + "api_key" + ] }, { "id": "zai", @@ -1147,7 +1120,9 @@ "url_param_vars": [], "response_type": "OpenAI", "url": "https://api.z.ai/api/paas/v4/chat/completions", - "models": [ + "models": { + "url": "https://api.z.ai/api/paas/v4/models", + "fallback": [ { "id": "glm-5.2", "name": "GLM-5.2", @@ -1288,8 +1263,11 @@ "supports_reasoning": false, "input_modalities": ["text"] } - ], - "auth_methods": ["api_key"] + ] + }, + "auth_methods": [ + "api_key" + ] }, { "id": "zai_coding", @@ -1297,7 +1275,9 @@ "url_param_vars": [], "response_type": "OpenAI", "url": "https://api.z.ai/api/coding/paas/v4/chat/completions", - "models": [ + "models": { + "url": "https://api.z.ai/api/coding/paas/v4/models", + "fallback": [ { "id": "glm-5.2", "name": "GLM-5.2", @@ -1448,8 +1428,11 @@ "supports_reasoning": true, "input_modalities": ["text", "image"] } - ], - "auth_methods": ["api_key"] + ] + }, + "auth_methods": [ + "api_key" + ] }, { "id": "big_model", @@ -3939,7 +3922,9 @@ "url_param_vars": [], "response_type": "OpenAI", "url": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions", - "models": [ + "models": { + "url": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/models", + "fallback": [ { "id": "qwen3.8-max-preview", "name": "Qwen3.8 Max Preview", @@ -4090,8 +4075,11 @@ "supports_reasoning": true, "input_modalities": ["text"] } - ], - "auth_methods": ["api_key"] + ] + }, + "auth_methods": [ + "api_key" + ] }, { "id": "novita", @@ -4456,17 +4444,12 @@ "response_type": "OpenAIResponses", "url": "https://api.meta.ai/v1/responses", "models": [ - { - "id": "muse-spark-1.1", - "name": "Muse Spark 1.1", - "description": "Meta's multimodal model for agentic tool calling, coding, structured output, image and video understanding, and long-context reasoning", - "context_length": 1048576, - "tools_supported": true, - "supports_parallel_tool_calls": true, - "supports_reasoning": true, - "input_modalities": ["text", "image"] - } + {"id": "muse-spark-1.1", "name": "Muse Spark 1.1", "description": "Meta's Muse Spark multimodal reasoning model with native tool use", "context_length": 1048576, "tools_supported": true, "supports_parallel_tool_calls": true, "supports_reasoning": true, "input_modalities": ["text", "image"]}, + {"id": "muse-spark-1.0", "name": "Muse Spark 1.0", "description": "First-generation Muse Spark model with broad multimodal capability", "context_length": 524288, "tools_supported": true, "supports_parallel_tool_calls": true, "supports_reasoning": true, "input_modalities": ["text", "image"]}, + {"id": "llama-4-maverick", "name": "Llama 4 Maverick", "description": "Open-weight Llama 4 Maverick model served by Meta's Muse API", "context_length": 1048576, "tools_supported": true, "supports_parallel_tool_calls": true, "supports_reasoning": false, "input_modalities": ["text", "image"]} ], - "auth_methods": ["api_key"] + "auth_methods": [ + "api_key" + ] } ] diff --git a/crates/forge_repo/src/provider/provider_repo.rs b/crates/forge_repo/src/provider/provider_repo.rs index c8bb120b7d..4ca699c37e 100644 --- a/crates/forge_repo/src/provider/provider_repo.rs +++ b/crates/forge_repo/src/provider/provider_repo.rs @@ -16,6 +16,15 @@ use serde::Deserialize; enum Models { /// Models are fetched from a URL Url(String), + /// Models are fetched live from `url`, enriched with curated metadata + /// from `fallback`, falling back to `fallback` when the fetch fails + Dynamic { + /// Endpoint to fetch the live model list from (e.g. `/v1/models`). + url: String, + /// Curated metadata overlaid on the live list; sole source on fetch + /// failure. + fallback: Vec, + }, /// Models are hardcoded in the configuration Hardcoded(Vec), } @@ -202,6 +211,9 @@ impl From for ProviderConfig { let models = entry.models.map(|m| match m { forge_config::ModelListConfig::Url(url) => Models::Url(url), + forge_config::ModelListConfig::Dynamic { url, fallback } => { + Models::Dynamic { url, fallback } + } forge_config::ModelListConfig::Hardcoded(model_list) => Models::Hardcoded(model_list), }); @@ -225,6 +237,10 @@ impl From<&ProviderConfig> for forge_domain::ProviderTemplate { Models::Url(model_url_template) => forge_domain::ModelSource::Url( forge_domain::Template::::new(model_url_template), ), + Models::Dynamic { url, fallback } => forge_domain::ModelSource::Dynamic { + url: forge_domain::Template::::new(url), + fallback: fallback.clone(), + }, Models::Hardcoded(model_list) => { forge_domain::ModelSource::Hardcoded(model_list.clone()) } @@ -477,6 +493,10 @@ impl< Models::Url(model_url_template) => forge_domain::ModelSource::Url( forge_domain::Template::::new(model_url_template), ), + Models::Dynamic { url, fallback } => forge_domain::ModelSource::Dynamic { + url: forge_domain::Template::::new(url), + fallback: fallback.clone(), + }, Models::Hardcoded(model_list) => { forge_domain::ModelSource::Hardcoded(model_list.clone()) } @@ -777,7 +797,7 @@ mod tests { assert!(model_url.contains("api-version")); assert!(model_url.contains("/models")); } - Models::Hardcoded(_) => panic!("Expected Models::Url variant"), + _ => panic!("Expected Models::Url variant"), } } @@ -826,7 +846,7 @@ mod tests { assert_eq!(config.url, "{{OPENAI_URL}}/responses"); match config.models.as_ref().unwrap() { Models::Url(model_url) => assert_eq!(model_url, "{{OPENAI_URL}}/models"), - Models::Hardcoded(_) => panic!("Expected Models::Url variant"), + _ => panic!("Expected Models::Url variant"), } } @@ -917,30 +937,34 @@ mod tests { config.url.as_str(), "https://api.neuralwatt.com/v1/chat/completions" ); - // Neuralwatt exposes a non-standard /models schema, so models are - // hardcoded in provider.json instead of fetched from the URL. + // Neuralwatt exposes a non-standard /models schema, so the curated + // fallback in provider.json is overlaid onto the live fetch. match config.models.as_ref().expect("models should be present") { - Models::Hardcoded(models) => { + Models::Dynamic { url, fallback } => { + assert_eq!( + url, "https://api.neuralwatt.com/v1/models", + "neuralwatt should fetch from /v1/models" + ); assert!( - models.iter().any(|m| m.id.as_str() == "glm-5.2"), - "expected glm-5.2 to be present in hardcoded models" + fallback.iter().any(|m| m.id.as_str() == "glm-5.2"), + "expected glm-5.2 to be present in fallback" ); assert!( - models.iter().any(|m| m.id.as_str() == "qwen3.5-397b"), - "expected qwen3.5-397b to be present in hardcoded models" + fallback.iter().any(|m| m.id.as_str() == "qwen3.5-397b"), + "expected qwen3.5-397b to be present in fallback" ); assert!( - models.iter().any(|m| m.id.as_str() == "glm-5.2-flex"), - "expected glm-5.2-flex to be present in hardcoded models" + fallback.iter().any(|m| m.id.as_str() == "glm-5.2-flex"), + "expected glm-5.2-flex to be present in fallback" ); assert!( - models + fallback .iter() .any(|m| m.id.as_str() == "kimi-k2.7-code-flex"), - "expected kimi-k2.7-code-flex to be present in hardcoded models" + "expected kimi-k2.7-code-flex to be present in fallback" ); } - other => panic!("expected hardcoded models, got {other:?}"), + other => panic!("expected dynamic models, got {other:?}"), } } @@ -1036,16 +1060,21 @@ mod tests { "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions" ); // Alibaba Token Plan exposes an OpenAI-compatible endpoint but no - // capability metadata via /models, so models are hardcoded in - // provider.json. + // capability metadata via /models, so the curated fallback in + // provider.json is overlaid onto the live fetch. match config.models.as_ref().expect("models should be present") { - Models::Hardcoded(models) => { + Models::Dynamic { url, fallback } => { + assert_eq!( + url, + "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/models", + "alibaba_token_plan should fetch from compatible-mode /v1/models" + ); assert!( - models.iter().any(|m| m.id.as_str() == "qwen3.7-max"), - "expected qwen3.7-max to be present in hardcoded models" + fallback.iter().any(|m| m.id.as_str() == "qwen3.7-max"), + "expected qwen3.7-max to be present in fallback" ); } - other => panic!("expected hardcoded models, got {other:?}"), + other => panic!("expected dynamic models, got {other:?}"), } } @@ -1091,16 +1120,21 @@ mod tests { config.url.as_str(), "https://api.kimi.com/coding/v1/chat/completions" ); - // Kimi Code's /models endpoint omits capability metadata, so models are - // hardcoded using the platform's canonical model IDs (k3, etc.). + // Kimi Code's /models endpoint omits capability metadata, so the + // curated fallback in provider.json (with platform-canonical ids like + // k3) is overlaid onto the live fetch. match config.models.as_ref().expect("models should be present") { - Models::Hardcoded(models) => { + Models::Dynamic { url, fallback } => { + assert_eq!( + url, "https://api.kimi.com/coding/v1/models", + "kimi_coding should fetch from /coding/v1/models" + ); assert!( - models.iter().any(|m| m.id.as_str() == "k3"), - "expected k3 to be present in hardcoded models" + fallback.iter().any(|m| m.id.as_str() == "k3"), + "expected k3 to be present in fallback" ); } - other => panic!("expected hardcoded models, got {other:?}"), + other => panic!("expected dynamic models, got {other:?}"), } } @@ -1172,6 +1206,45 @@ mod tests { assert_eq!(actual, expected); } + + #[test] + fn test_provider_entry_with_dynamic_models_converts_to_dynamic() { + let fallback_model = forge_app::domain::Model::new("k3") + .name("Kimi k3".to_string()) + .context_length(262144) + .tools_supported(true); + + let entry = forge_config::ProviderEntry { + id: "kimi_coding".to_string(), + url: "https://api.kimi.com/coding/v1/chat/completions".to_string(), + response_type: Some(forge_config::ProviderResponseType::OpenAI), + auth_methods: vec![forge_config::ProviderAuthMethod::ApiKey], + models: Some(forge_config::ModelListConfig::Dynamic { + url: "https://api.kimi.com/coding/v1/models".to_string(), + fallback: vec![fallback_model.clone()], + }), + ..Default::default() + }; + + let actual = ProviderConfig::from(entry); + + let expected = ProviderConfig { + id: ProviderId::from("kimi_coding".to_string()), + provider_type: forge_domain::ProviderType::Llm, + api_key_vars: None, + url_param_vars: vec![], + response_type: Some(forge_app::domain::ProviderResponse::OpenAI), + url: "https://api.kimi.com/coding/v1/chat/completions".to_string(), + models: Some(Models::Dynamic { + url: "https://api.kimi.com/coding/v1/models".to_string(), + fallback: vec![fallback_model], + }), + auth_methods: vec![forge_domain::AuthMethod::ApiKey], + custom_headers: None, + }; + + assert_eq!(actual, expected); + } } #[cfg(test)] @@ -1714,7 +1787,7 @@ mod env_tests { "https://{{AZURE_RESOURCE_NAME}}.openai.azure.com/openai/models?api-version={{AZURE_API_VERSION}}" ); } - forge_domain::ModelSource::Hardcoded(_) => panic!("Expected ModelSource::Url variant"), + _ => panic!("Expected ModelSource::Url variant"), } } diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index 3e279aae9b..b108d3d038 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -266,6 +266,12 @@ mod tests { >::new( url.as_str() )), + ModelSource::Dynamic { url, fallback } => ModelSource::Dynamic { + url: forge_domain::Template::::new( + url.as_str(), + ), + fallback: fallback.clone(), + }, ModelSource::Hardcoded(list) => ModelSource::Hardcoded(list.clone()), }), auth_methods: p.auth_methods.clone(), diff --git a/crates/forge_services/src/provider_service.rs b/crates/forge_services/src/provider_service.rs index 5baac55ebb..ced6fba819 100644 --- a/crates/forge_services/src/provider_service.rs +++ b/crates/forge_services/src/provider_service.rs @@ -86,6 +86,19 @@ impl ForgeProviderService { .ok(); model_url.map(ModelSource::Url) } + ModelSource::Dynamic { url, fallback } => { + let model_url = self + .render_url_template( + &url.template, + &credential.url_params, + &template_provider.url_params, + ) + .ok(); + model_url.map(|rendered| ModelSource::Dynamic { + url: rendered, + fallback: fallback.clone(), + }) + } ModelSource::Hardcoded(list) => Some(ModelSource::Hardcoded(list.clone())), }); diff --git a/docs/contracts/provider-models/provider-model.schema.json b/docs/contracts/provider-models/provider-model.schema.json new file mode 100644 index 0000000000..7f9b2358a8 --- /dev/null +++ b/docs/contracts/provider-models/provider-model.schema.json @@ -0,0 +1,200 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/KooshaPari/forgecode/docs/contracts/provider-models/provider-model.schema.json", + "title": "ProviderModelContract", + "description": "Language-agnostic contract for the provider/model registry shared across forgecode (Rust), OmniRoute (TypeScript), and cliproxy (Go). Version: 1.0.0. Each repo implements this contract in its native language; forgecode's forge_domain crate is the reference implementation.", + "version": "1.0.0", + "$defs": { + "ModelId": { + "type": "string", + "description": "Unique identifier for a model within a provider, e.g. 'claude-3-5-sonnet-20241022' or 'gpt-4o'.", + "minLength": 1 + }, + "ProviderId": { + "type": "string", + "description": "Unique identifier for a provider, e.g. 'anthropic', 'openai', 'open_router'. Built-in providers use snake_case.", + "minLength": 1 + }, + "InputModality": { + "type": "string", + "enum": ["text", "image"], + "description": "Input modality supported by a model. 'text' is universal; 'image' indicates vision capability." + }, + "Model": { + "type": "object", + "description": "A single model offered by a provider.", + "required": ["id"], + "properties": { + "id": { + "$ref": "#/$defs/ModelId" + }, + "name": { + "type": ["string", "null"], + "description": "Human-readable display name, e.g. 'Claude 3.5 Sonnet'." + }, + "description": { + "type": ["string", "null"], + "description": "Short description of the model's characteristics." + }, + "context_length": { + "type": ["integer", "null"], + "minimum": 0, + "description": "Maximum context window in tokens. Null when unknown." + }, + "tools_supported": { + "type": ["boolean", "null"], + "description": "Whether the model supports tool/function calling." + }, + "supports_parallel_tool_calls": { + "type": ["boolean", "null"], + "description": "Whether the model can invoke multiple tools in a single response turn." + }, + "supports_reasoning": { + "type": ["boolean", "null"], + "description": "Whether the model exposes a reasoning/thinking trace (e.g. Claude extended thinking, o1-series)." + }, + "input_modalities": { + "type": "array", + "items": { "$ref": "#/$defs/InputModality" }, + "default": ["text"], + "description": "Input modalities accepted by the model. Defaults to ['text'] when omitted." + } + }, + "additionalProperties": false + }, + "ProviderType": { + "type": "string", + "enum": ["llm", "context_engine"], + "description": "Category of the provider. 'llm' for chat completion providers (default); 'context_engine' for code indexing / search providers." + }, + "AuthKind": { + "type": "string", + "enum": ["api_key", "oauth2", "aws_bedrock", "none"], + "description": "Authentication mechanism required by the provider. 'api_key': static bearer token. 'oauth2': device-flow or token-refresh required. 'aws_bedrock': AWS SigV4 signing. 'none': no auth (local/open providers)." + }, + "ProviderConfig": { + "type": "object", + "description": "Configuration for a single provider, merging built-in defaults with user overrides.", + "required": ["id", "base_url"], + "properties": { + "id": { + "$ref": "#/$defs/ProviderId" + }, + "base_url": { + "type": "string", + "format": "uri", + "description": "Base URL for the provider's API endpoint." + }, + "provider_type": { + "$ref": "#/$defs/ProviderType", + "default": "llm" + }, + "auth_kind": { + "$ref": "#/$defs/AuthKind", + "default": "api_key" + }, + "models": { + "description": "Model list source. Either an explicit static array, or an object with `url` (live models endpoint, e.g. `/v1/models`) and `fallback` (curated metadata overlaid on the live list by model id; sole source when the live fetch fails). If absent, the registry fetches the live model list from the provider's models endpoint.", + "oneOf": [ + { + "type": "array", + "items": { "$ref": "#/$defs/Model" } + }, + { + "type": "object", + "required": ["url", "fallback"], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Live models endpoint, e.g. 'https://api.openai.com/v1/models'." + }, + "fallback": { + "type": "array", + "items": { "$ref": "#/$defs/Model" }, + "description": "Curated metadata overlaid on the live list, matched by model id." + } + }, + "additionalProperties": false + } + ] + }, + "env_key": { + "type": ["string", "null"], + "description": "Name of the environment variable that holds the API key for this provider, e.g. 'OPENAI_API_KEY'." + } + }, + "additionalProperties": false + }, + "SseStopRule": { + "type": "object", + "description": "Defines the terminal-marker rule set for SSE streams. Reference implementation: forge_eventsource::is_sse_terminal.", + "properties": { + "terminal_data_values": { + "type": "array", + "items": { "type": "string" }, + "default": ["[DONE]", ""], + "description": "SSE event data field values that signal end-of-stream. '[DONE]' is the canonical OpenAI/Anthropic sentinel; '' (empty) is a keepalive/implicit-end marker." + }, + "openai_finish_reasons": { + "type": "array", + "items": { "type": "string" }, + "default": ["stop", "length", "content_filter", "tool_calls", "function_call"], + "description": "Values of choices[0].finish_reason that indicate the model has finished generating." + }, + "anthropic_stop_fields": { + "type": "array", + "items": { "type": "string" }, + "default": ["stop_reason", "message_delta.stop_reason"], + "description": "Anthropic SSE event fields that carry the stop reason." + }, + "synthetic_done_on_silent_close": { + "type": "boolean", + "default": true, + "description": "When true, implementations must emit a synthetic [DONE] marker when the upstream connection closes without an explicit terminal event." + } + }, + "additionalProperties": false + }, + "OAuthRefreshPolicy": { + "type": "object", + "description": "Parameterized OAuth token refresh policy. The refresh_lead_seconds field is per-provider-overridable; the default 300 s matches all three repos (forgecode, OmniRoute, cliproxy for most providers). Providers with non-standard leads (e.g. codebuddy 86400 s) set their own override.", + "properties": { + "default_refresh_lead_seconds": { + "type": "integer", + "minimum": 0, + "default": 300, + "description": "Default number of seconds before token expiry at which a refresh should be triggered. Equivalent to forgecode `chrono::Duration::minutes(5)`, OmniRoute `TOKEN_EXPIRY_BUFFER = 5*60*1000`, cliproxy `5 * time.Minute`." + }, + "needs_refresh_semantics": { + "type": "string", + "const": "now + lead >= expires_at", + "description": "Boolean predicate: a token needs refresh when the current time plus the lead window meets or exceeds the expiry timestamp." + } + }, + "additionalProperties": false + } + }, + "type": "object", + "description": "Top-level contract document. A registry implementation MUST satisfy the constraints for Model and ProviderConfig; SSE and OAuth rules are normative for stream and auth layers respectively.", + "properties": { + "version": { + "type": "string", + "description": "Contract version following semver.", + "default": "1.0.0" + }, + "providers": { + "type": "array", + "items": { "$ref": "#/$defs/ProviderConfig" }, + "description": "Array of provider configurations." + }, + "sse_stop_rule": { + "$ref": "#/$defs/SseStopRule", + "description": "Normative SSE terminal-marker rules for all stream implementations." + }, + "oauth_refresh_policy": { + "$ref": "#/$defs/OAuthRefreshPolicy", + "description": "Normative OAuth token refresh policy." + } + } +} diff --git a/forge.schema.json b/forge.schema.json index 3701953b01..b725eddbee 100644 --- a/forge.schema.json +++ b/forge.schema.json @@ -726,12 +726,55 @@ "description": "URL template used to fetch the model list dynamically.", "type": "string" }, + { + "description": "Fetch the live model list from `url` (e.g. `/v1/models`), enrich it\nwith curated metadata from `fallback` (matched by model id), and fall\nback to `fallback` as-is when the fetch fails.", + "properties": { + "fallback": { + "description": "Curated metadata overlaid on the live list; the sole source when\nthe live fetch fails.", + "items": { + "$ref": "#/$defs/Model" + }, + "type": "array" + }, + "url": { + "description": "Endpoint to fetch the live model list from (e.g. `/v1/models`).", + "type": "string" + } + }, + "required": [ + "url", + "fallback" + ], + "type": "object" + }, { "description": "A static list of models defined directly in the configuration.", "type": "array", "items": { "$ref": "#/$defs/Model" - } + }, + "type": "array" + } + ], + "description": "Source of models for a provider: a URL to fetch them from, a live fetch\nwith a curated fallback, or a static list defined inline." + }, + "OutputMode": { + "description": "Controls the verbosity of forge's tool output formatting.\n\nThe output mode affects how tool results are rendered in the chat UI:\n- `Concise`: Minimal output, just the essential information (default for\n most users).\n- `Compact`: Same as concise but with extra whitespace trimming and\n aggressive line folding for terminal-friendly display.\n- `Verbose`: Full output including all metadata, reasoning traces, and\n intermediate computation steps. Useful for debugging.", + "oneOf": [ + { + "const": "concise", + "description": "Minimal output (default).", + "type": "string" + }, + { + "const": "compact", + "description": "Extra whitespace-trimmed variant of concise for terminal display.", + "type": "string" + }, + { + "const": "verbose", + "description": "Full output with all metadata and intermediate steps.", + "type": "string" } ] },