Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions crates/forge_config/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<forge_domain::Model>,
},
/// A static list of models defined directly in the configuration.
Hardcoded(Vec<forge_domain::Model>),
}
Expand Down Expand Up @@ -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();
Expand Down
99 changes: 99 additions & 0 deletions crates/forge_domain/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, curated: Vec<Model>) -> Vec<Self> {
let mut merged: Vec<Self> = 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<String> for ModelId {
Expand Down Expand Up @@ -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");
}
}
21 changes: 21 additions & 0 deletions crates/forge_domain/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,9 +264,30 @@ pub enum ProviderResponse {
pub enum ModelSource<T> {
/// 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<Model>,
},
Hardcoded(Vec<Model>),
}

impl<T: AsRef<str>> ModelSource<T> {
/// 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<T> {
pub id: ProviderId,
Expand Down
49 changes: 49 additions & 0 deletions crates/forge_repo/src/provider/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,55 @@ impl<T: HttpInfra> Anthropic<T> {
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())
}
}
}
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions crates/forge_repo/src/provider/bedrock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![]),
}
}
Expand Down
62 changes: 60 additions & 2 deletions crates/forge_repo/src/provider/google.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,14 @@ impl<T: HttpInfra> Google<T> {
struct ModelsResponse {
models: Vec<forge_app::dto::google::Model>,
}

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))
Expand All @@ -132,6 +135,61 @@ impl<T: HttpInfra> Google<T> {
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<forge_app::dto::google::Model>,
}

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())
}
}
}
}
}
}
Expand Down
Loading
Loading