From 8ce1b5318001e079880bdbd3d9c6f592f34f2223 Mon Sep 17 00:00:00 2001 From: Rom Iluz Date: Sun, 5 Apr 2026 10:41:28 +0300 Subject: [PATCH] fix: apply custom_headers to Anthropic requests and honor API context_length Two bugs fixed: 1. Provider-level `custom_headers` from config are parsed but never injected into Anthropic HTTP requests. This breaks proxies that require alternative header names (e.g. Azure APIM's `api-key` instead of `x-api-key`). Fix: append custom_headers in `get_headers()` after standard headers. 2. The `Model` struct ignores the `context_length` field returned by the API's `/v1/models` endpoint. Instead, `get_context_length()` hardcodes all Claude models to 200K. This prevents operators from advertising larger context windows (e.g. 1M with the `context-1m-2025-08-07` beta). Fix: deserialize `context_length` from the API response and prefer it over the hardcoded fallback. --- .../forge_app/src/dto/anthropic/response.rs | 38 ++++++++++- crates/forge_repo/src/provider/anthropic.rs | 67 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/crates/forge_app/src/dto/anthropic/response.rs b/crates/forge_app/src/dto/anthropic/response.rs index da5b4aa9f1..160c075570 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") @@ -766,6 +771,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(); @@ -780,6 +786,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(); @@ -788,6 +795,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 c4df9cfc26..4637aa25fd 100644 --- a/crates/forge_repo/src/provider/anthropic.rs +++ b/crates/forge_repo/src/provider/anthropic.rs @@ -83,6 +83,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 } } @@ -779,6 +787,65 @@ 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" + ); + } } /// Repository for Anthropic provider responses