Skip to content
Open
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
38 changes: 37 additions & 1 deletion crates/forge_app/src/dto/anthropic/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,16 @@ pub struct ListModelResponse {
pub struct Model {
pub id: String,
pub display_name: Option<String>,
/// Context window size reported by the API. When present, takes precedence
/// over the hardcoded fallback in `get_context_length`.
pub context_length: Option<u64>,
}

impl From<Model> 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")
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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
Expand Down
67 changes: 67 additions & 0 deletions crates/forge_repo/src/provider/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ impl<H: HttpInfra> Anthropic<H> {
}
}

// 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
}
}
Expand Down Expand Up @@ -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"
);
}
}
Loading