diff --git a/crates/forge_app/src/dto/anthropic/response.rs b/crates/forge_app/src/dto/anthropic/response.rs index ba32540fe7..08fe6db801 100644 --- a/crates/forge_app/src/dto/anthropic/response.rs +++ b/crates/forge_app/src/dto/anthropic/response.rs @@ -24,11 +24,16 @@ pub struct ListModelResponse { pub struct Model { pub id: String, pub display_name: Option, + /// Context window size reported by the API. When present, takes precedence + /// over the hardcoded fallback in `get_context_length`. + pub context_length: Option, } impl From for forge_domain::Model { fn from(value: Model) -> Self { - let context_length = get_context_length(&value.id); + let context_length = value + .context_length + .or_else(|| get_context_length(&value.id)); let input_modalities = if value.id.contains("claude-3") || value.id.contains("claude-4") || value.id.contains("claude-sonnet") @@ -768,6 +773,7 @@ mod tests { let fixture = Model { id: "claude-sonnet-4-5-20250929".to_string(), display_name: Some("Claude 3.5 Sonnet (New)".to_string()), + context_length: None, }; let actual: forge_domain::Model = fixture.into(); @@ -782,6 +788,7 @@ mod tests { let fixture = Model { id: "unknown-claude-model".to_string(), display_name: Some("Unknown Model".to_string()), + context_length: None, }; let actual: forge_domain::Model = fixture.into(); @@ -790,6 +797,35 @@ mod tests { assert_eq!(actual.id.as_str(), "unknown-claude-model"); } + #[test] + fn test_model_conversion_api_context_length_takes_precedence() { + // When the API reports context_length (e.g., 1M with beta header), + // it should take precedence over the hardcoded fallback (200K). + let fixture = Model { + id: "claude-sonnet-4-5-20250929".to_string(), + display_name: Some("Claude Sonnet 4.5".to_string()), + context_length: Some(1_048_576), + }; + + let actual: forge_domain::Model = fixture.into(); + + assert_eq!(actual.context_length, Some(1_048_576)); + } + + #[test] + fn test_model_conversion_unknown_model_with_api_context_length() { + // Even for unknown models, API-provided context_length should be used. + let fixture = Model { + id: "claude-future-6-0".to_string(), + display_name: Some("Claude Future".to_string()), + context_length: Some(500_000), + }; + + let actual: forge_domain::Model = fixture.into(); + + assert_eq!(actual.context_length, Some(500_000)); + } + #[test] fn test_ping_event_with_string_cost() { // Fixture: OpenCode Zen sends cost as a string in a ping event diff --git a/crates/forge_repo/src/provider/anthropic.rs b/crates/forge_repo/src/provider/anthropic.rs index 3292f5ab9f..e19ea99a51 100644 --- a/crates/forge_repo/src/provider/anthropic.rs +++ b/crates/forge_repo/src/provider/anthropic.rs @@ -81,6 +81,14 @@ impl Anthropic { } } + // Append provider-level custom headers (e.g., for proxies that require + // different header names like `api-key` instead of `x-api-key`) + if let Some(custom_headers) = &self.provider.custom_headers { + for (k, v) in custom_headers { + headers.push((k.clone(), v.clone())); + } + } + headers } } @@ -855,4 +863,63 @@ mod tests { "Vertex AI requests should include anthropic_version" ); } + + #[test] + fn test_get_headers_includes_custom_headers() { + let chat_url = Url::parse("https://proxy.example.com/v1/messages").unwrap(); + let model_url = Url::parse("https://proxy.example.com/v1/models").unwrap(); + + let mut custom = std::collections::HashMap::new(); + custom.insert("api-key".to_string(), "my-proxy-key".to_string()); + custom.insert("x-custom-tag".to_string(), "forge".to_string()); + + let provider = Provider { + id: forge_app::domain::ProviderId::ANTHROPIC, + provider_type: forge_domain::ProviderType::Llm, + response: Some(forge_app::domain::ProviderResponse::Anthropic), + url: chat_url, + credential: Some(forge_domain::AuthCredential { + id: forge_app::domain::ProviderId::ANTHROPIC, + auth_details: forge_domain::AuthDetails::ApiKey(forge_domain::ApiKey::from( + "sk-test-key".to_string(), + )), + url_params: std::collections::HashMap::new(), + }), + auth_methods: vec![forge_domain::AuthMethod::ApiKey], + url_params: vec![], + models: Some(forge_domain::ModelSource::Url(model_url)), + custom_headers: Some(custom), + }; + + let fixture = Anthropic::new( + Arc::new(MockHttpClient::new()), + provider, + "2023-06-01".to_string(), + false, + ); + + let actual = fixture.get_headers(); + + // Custom headers should be present + assert!( + actual + .iter() + .any(|(k, v)| k == "api-key" && v == "my-proxy-key"), + "custom_headers should be appended to request headers" + ); + assert!( + actual + .iter() + .any(|(k, v)| k == "x-custom-tag" && v == "forge"), + "all custom_headers entries should be included" + ); + + // Standard headers should still be present + assert!( + actual + .iter() + .any(|(k, v)| k == "x-api-key" && v == "sk-test-key"), + "standard x-api-key should still be present" + ); + } }