diff --git a/Cargo.lock b/Cargo.lock index bbd5b0f37d..fe399def3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2549,6 +2549,7 @@ dependencies = [ "aws-config", "aws-credential-types", "aws-sdk-bedrockruntime", + "aws-sigv4", "aws-smithy-async", "aws-smithy-runtime", "aws-smithy-runtime-api", diff --git a/Cargo.toml b/Cargo.toml index 092735ff4f..e69a36b965 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ aws-smithy-types = "1.4.3" aws-smithy-runtime-api = "1.11.3" aws-smithy-async = { version = "1.2.11", features = ["rt-tokio"] } aws-smithy-runtime = { version = "1.10", features = ["connector-hyper-0-14-x", "tls-rustls"] } +aws-sigv4 = "1.4.4" base64 = "0.22.1" bstr = "1.12.1" bytes = "1.11.1" diff --git a/crates/forge_repo/Cargo.toml b/crates/forge_repo/Cargo.toml index 788afb768f..8a15d2b701 100644 --- a/crates/forge_repo/Cargo.toml +++ b/crates/forge_repo/Cargo.toml @@ -37,6 +37,7 @@ aws-smithy-types.workspace = true aws-smithy-runtime-api.workspace = true aws-smithy-async.workspace = true aws-smithy-runtime.workspace = true +aws-sigv4.workspace = true lazy_static.workspace = true derive_setters.workspace = true base64.workspace = true diff --git a/crates/forge_repo/src/provider/openai_responses/repository.rs b/crates/forge_repo/src/provider/openai_responses/repository.rs index 99fd725dc0..aecce21638 100644 --- a/crates/forge_repo/src/provider/openai_responses/repository.rs +++ b/crates/forge_repo/src/provider/openai_responses/repository.rs @@ -1,7 +1,11 @@ use std::sync::Arc; +use std::time::SystemTime; use anyhow::Context as _; use async_openai::types::responses as oai; +use aws_credential_types::provider::ProvideCredentials as _; +use aws_sigv4::http_request::{SignableBody, SignableRequest, SigningSettings, sign}; +use aws_sigv4::sign::v4; use forge_app::domain::{ ChatCompletionMessage, Context as ChatContext, Model, ModelId, ResultStream, }; @@ -44,6 +48,11 @@ impl OpenAIResponsesProvider { if provider.id == ProviderId::CODEX || provider.id == ProviderId::OPENCODE_ZEN || provider.id == ProviderId::OPENAI_RESPONSES_COMPATIBLE + || provider + .url + .path() + .trim_end_matches('/') + .ends_with("/responses") { // These providers already configure a complete Responses endpoint, // so preserve the configured path exactly as-is. @@ -147,6 +156,107 @@ impl OpenAIResponsesProvider { headers } + + /// Computes SigV4 signing headers for an AWS-authenticated request. + /// + /// When the provider credential is `AuthDetails::AwsProfile`, this loads + /// credentials via the named AWS profile and returns the Authorization, + /// x-amz-date, and (when present) x-amz-security-token headers that must + /// be added to the outgoing request. The exact same `content-type` and + /// `host` values that are passed to this function must be sent with the + /// request, since SigV4 includes them in the signature. + /// + /// Returns `None` when the provider is not configured with an AWS profile. + async fn sigv4_headers( + &self, + method: &str, + url: &Url, + body: &[u8], + content_type: &str, + ) -> anyhow::Result>> { + let profile = match self.provider.credential.as_ref().map(|c| &c.auth_details) { + Some(forge_domain::AuthDetails::AwsProfile(p)) => p.as_ref().to_string(), + _ => return Ok(None), + }; + + // Read region from url_params, defaulting to us-east-1. + let region: String = self + .provider + .credential + .as_ref() + .and_then(|c| { + let key: forge_domain::URLParam = "AWS_REGION".to_string().into(); + c.url_params.get(&key).map(|v| v.to_string()) + }) + .unwrap_or_else(|| "us-east-1".to_string()); + + // Load credentials via the named profile. + let sdk_config = aws_config::from_env() + .profile_name(&profile) + .region(aws_sdk_bedrockruntime::config::Region::new(region.clone())) + .load() + .await; + + let creds_provider = sdk_config + .credentials_provider() + .context("No credentials provider found for AWS profile")?; + + let creds = creds_provider + .provide_credentials() + .await + .context("Failed to load AWS credentials from profile")?; + + // Build an Identity from the Credentials. + let identity: aws_smithy_runtime_api::client::identity::Identity = creds.clone().into(); + + // Build signing params. + let signing_settings = SigningSettings::default(); + let signing_params = v4::SigningParams::builder() + .identity(&identity) + .region(region.as_str()) + .name("bedrock") + .time(SystemTime::now()) + .settings(signing_settings) + .build() + .context("Failed to build SigV4 signing params")? + .into(); + + let host = url.host_str().context("URL has no host")?; + + // The headers we sign must be exactly what we send. + let headers_to_sign = [("host", host), ("content-type", content_type)]; + + let signable_request = SignableRequest::new( + method, + url.as_str(), + headers_to_sign.iter().map(|(k, v)| (*k, *v)), + SignableBody::Bytes(body), + ) + .context("Failed to build signable request")?; + + let (signing_instructions, _signature) = sign(signable_request, &signing_params) + .context("SigV4 signing failed")? + .into_parts(); + + // Collect headers from signing instructions (Authorization, x-amz-date, etc.). + let mut result: Vec<(String, String)> = signing_instructions + .headers() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + // Manually add x-amz-security-token if the credentials carry a session + // token and it wasn't already included by the signing instructions. + if let Some(token) = creds.session_token() { + let already_present = result + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("x-amz-security-token")); + if !already_present { + result.push(("x-amz-security-token".to_string(), token.to_string())); + } + } + + Ok(Some(result)) + } } impl OpenAIResponsesProvider { @@ -156,7 +266,8 @@ impl OpenAIResponsesProvider { context: ChatContext, ) -> ResultStream { let conversation_id = context.conversation_id.as_ref().map(ToString::to_string); - let headers = create_headers(self.get_headers_for_conversation(conversation_id.as_deref())); + let mut headers = + create_headers(self.get_headers_for_conversation(conversation_id.as_deref())); let mut request = oai::CreateResponse::from_domain(context)?; request.model = Some(model.as_str().to_string()); @@ -188,6 +299,31 @@ impl OpenAIResponsesProvider { return self.chat_codex_stream(headers, json_bytes).await; } + // For AWS profile auth, sign the request with SigV4 before dispatch. + if let Some(sig_headers) = self + .sigv4_headers("POST", &self.responses_url, &json_bytes, "application/json") + .await? + { + // Ensure content-type is present (SigV4 signed it; it must be sent). + headers.insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_static("application/json"), + ); + for (k, v) in sig_headers { + if let (Ok(name), Ok(value)) = ( + reqwest::header::HeaderName::from_bytes(k.as_bytes()), + reqwest::header::HeaderValue::from_str(&v), + ) { + headers.insert(name, value); + } + } + // Identify the client to the Mantle endpoint. + headers.insert( + reqwest::header::HeaderName::from_static("x-amzn-mantle-client-agent"), + reqwest::header::HeaderValue::from_static("forge"), + ); + } + let source = self .http .http_eventsource(&self.responses_url, Some(headers), json_bytes.into()) @@ -786,6 +922,42 @@ mod tests { ); } + #[test] + fn test_openai_responses_provider_new_preserves_bedrock_mantle_responses_path() { + // Bedrock's OpenAI-compatible endpoint serves /openai/v1/responses and must + // NOT be rewritten to /v1/responses (that rewrite hit the wrong path). The + // path-preservation branch keys off the URL ending in `/responses`, + // independent of provider id, which is what lets a Bedrock provider use it. + let provider = Provider { + id: ProviderId::from("bedrock_openai_responses".to_string()), + provider_type: forge_domain::ProviderType::Llm, + response: Some(ProviderResponse::OpenAIResponses), + url: Url::parse("https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses") + .unwrap(), + credential: make_credential( + ProviderId::from("bedrock_openai_responses".to_string()), + "test-key", + ), + custom_headers: None, + auth_methods: vec![forge_domain::AuthMethod::ApiKey], + url_params: vec![], + models: None, + }; + let infra = Arc::new(MockHttpClient { client: reqwest::Client::new() }); + let provider_impl = OpenAIResponsesProvider::::new(provider, infra); + + // Full path preserved (not collapsed to /v1/responses). + assert_eq!( + provider_impl.responses_url.as_str(), + "https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses" + ); + // api_base is the same URL with the trailing /responses trimmed. + assert_eq!( + provider_impl.api_base.as_str(), + "https://bedrock-mantle.us-east-1.api.aws/openai/v1" + ); + } + #[test] fn test_openai_responses_provider_new_with_codex_url() { let provider = Provider { diff --git a/crates/forge_repo/src/provider/provider.json b/crates/forge_repo/src/provider/provider.json index 04c42ecc0a..84a0b92527 100644 --- a/crates/forge_repo/src/provider/provider.json +++ b/crates/forge_repo/src/provider/provider.json @@ -618,6 +618,35 @@ "models": "{{OPENAI_URL}}/models", "auth_methods": ["api_key"] }, + { + "id": "bedrock_openai_responses", + "url_param_vars": ["AWS_REGION"], + "response_type": "OpenAIResponses", + "url": "https://bedrock-mantle.{{AWS_REGION}}.api.aws/openai/v1/responses", + "models": [ + { + "id": "openai.gpt-5.5", + "name": "GPT-5.5", + "description": "OpenAI GPT-5.5 served via AWS Bedrock (OpenAI Responses wire, SigV4 auth)", + "context_length": 1000000, + "tools_supported": true, + "supports_parallel_tool_calls": true, + "supports_reasoning": true, + "input_modalities": ["text", "image"] + }, + { + "id": "openai.gpt-5.4", + "name": "GPT-5.4", + "description": "OpenAI GPT-5.4 served via AWS Bedrock (OpenAI Responses wire, SigV4 auth)", + "context_length": 1000000, + "tools_supported": true, + "supports_parallel_tool_calls": true, + "supports_reasoning": true, + "input_modalities": ["text", "image"] + } + ], + "auth_methods": ["aws_profile"] + }, { "id": "anthropic", "api_key_vars": "ANTHROPIC_API_KEY",