From de9e5fbf65cbb305258a1d6c75a3a835969eb5be Mon Sep 17 00:00:00 2001 From: resrever <29237040+resrever@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:59:49 -0400 Subject: [PATCH 1/5] feat(openai_responses): support aws_profile SigV4 auth for Bedrock OpenAI-wire The OpenAI Responses provider path previously stubbed out AuthDetails::AwsProfile (no auth header emitted), so AWS Bedrock's OpenAI-compatible endpoint (bedrock-mantle..api.aws/openai/v1/responses, which serves models like gpt-5.5 / gpt-5.4 over the OpenAI Responses wire but requires SigV4) returned 401 "Missing 'authorization' or 'x-api-key' header". This mirrors what PR #2831 did for BedrockProvider, but for the OpenAIResponses path: - Preserve the configured URL path when it ends in /responses (so /openai/v1/responses is not rewritten to /v1/responses). - Add sigv4_headers(): when the credential is AwsProfile, load profile creds via aws_config, SigV4-sign the request body (service "bedrock", region from the AWS_REGION url param), and inject Authorization / x-amz-date / x-amz-security-token. - Integrate into chat(); api_key / bearer / oauth behavior is unchanged. Adds aws-sigv4 1.4.4 (already in the lockfile via the AWS SDK). All existing tests pass. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/forge_repo/Cargo.toml | 1 + .../provider/openai_responses/repository.rs | 139 +++++++++++++++++- 4 files changed, 141 insertions(+), 1 deletion(-) 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..71a18e4236 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,7 @@ 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 +152,113 @@ 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 +268,7 @@ 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 +300,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()) From d087334cbb07a5481830c45988e60f825071e9f6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:26:30 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- .../provider/openai_responses/repository.rs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/forge_repo/src/provider/openai_responses/repository.rs b/crates/forge_repo/src/provider/openai_responses/repository.rs index 71a18e4236..b050e60a93 100644 --- a/crates/forge_repo/src/provider/openai_responses/repository.rs +++ b/crates/forge_repo/src/provider/openai_responses/repository.rs @@ -48,7 +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") + || 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. @@ -170,12 +174,7 @@ impl OpenAIResponsesProvider { body: &[u8], content_type: &str, ) -> anyhow::Result>> { - let profile = match self - .provider - .credential - .as_ref() - .map(|c| &c.auth_details) - { + 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), }; @@ -235,10 +234,9 @@ impl OpenAIResponsesProvider { ) .context("Failed to build signable request")?; - let (signing_instructions, _signature) = - sign(signable_request, &signing_params) - .context("SigV4 signing failed")? - .into_parts(); + 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 @@ -268,7 +266,8 @@ impl OpenAIResponsesProvider { context: ChatContext, ) -> ResultStream { let conversation_id = context.conversation_id.as_ref().map(ToString::to_string); - let mut 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()); From 2d8df763d99e9f75a2106a6a0d2b5af799e4be31 Mon Sep 17 00:00:00 2001 From: Scott Young Date: Sat, 6 Jun 2026 15:30:57 -0400 Subject: [PATCH 3/5] feat(provider): add bedrock_openai_responses entry and path-preservation test Makes the new OpenAI-Responses SigV4 path usable end-to-end: registers a built-in provider (response_type OpenAIResponses, URL templated on AWS_REGION and ending in /openai/v1/responses so the path-preservation branch triggers, auth_methods [aws_profile]). api_key is intentionally omitted because the OpenAI-Responses api_key path sends Authorization: Bearer, which is not valid SigV4 for this endpoint. Model context_length values are placeholders pending confirmation against the live Mantle endpoint. Adds a hermetic regression test asserting the Bedrock /openai/v1/responses path is preserved (not rewritten to /v1/responses). SigV4 header generation needs real AWS credentials, so it is not unit-tested here. Co-authored-by: Claude Opus 4.8 --- .../provider/openai_responses/repository.rs | 36 +++++++++++++++++++ crates/forge_repo/src/provider/provider.json | 29 +++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/crates/forge_repo/src/provider/openai_responses/repository.rs b/crates/forge_repo/src/provider/openai_responses/repository.rs index b050e60a93..aecce21638 100644 --- a/crates/forge_repo/src/provider/openai_responses/repository.rs +++ b/crates/forge_repo/src/provider/openai_responses/repository.rs @@ -922,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..ee3b2fcb36 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": "gpt-5.5", + "name": "GPT-5.5", + "description": "OpenAI GPT-5.5 served via AWS Bedrock (OpenAI Responses wire, SigV4 auth)", + "context_length": 400000, + "tools_supported": true, + "supports_parallel_tool_calls": true, + "supports_reasoning": true, + "input_modalities": ["text", "image"] + }, + { + "id": "gpt-5.4", + "name": "GPT-5.4", + "description": "OpenAI GPT-5.4 served via AWS Bedrock (OpenAI Responses wire, SigV4 auth)", + "context_length": 400000, + "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", From 0681726c1c9804577b7bfdfecedcbdfc9fb95302 Mon Sep 17 00:00:00 2001 From: Scott Young <29237040+resrever@users.noreply.github.com> Date: Sat, 6 Jun 2026 17:45:54 -0400 Subject: [PATCH 4/5] fix(provider): use openai.gpt-5.x model ids for bedrock_openai_responses Live testing against the Bedrock OpenAI-wire endpoint shows the model ids must be prefixed with openai. (openai.gpt-5.5 / openai.gpt-5.4); the bare gpt-5.x ids were not accepted. Co-authored-by: Claude Opus 4.8 --- crates/forge_repo/src/provider/provider.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge_repo/src/provider/provider.json b/crates/forge_repo/src/provider/provider.json index ee3b2fcb36..39f19bd603 100644 --- a/crates/forge_repo/src/provider/provider.json +++ b/crates/forge_repo/src/provider/provider.json @@ -625,7 +625,7 @@ "url": "https://bedrock-mantle.{{AWS_REGION}}.api.aws/openai/v1/responses", "models": [ { - "id": "gpt-5.5", + "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": 400000, @@ -635,7 +635,7 @@ "input_modalities": ["text", "image"] }, { - "id": "gpt-5.4", + "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": 400000, From 903f48ac5627c8785567affe70d8bb477e889a39 Mon Sep 17 00:00:00 2001 From: Scott Young <29237040+resrever@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:01:42 -0400 Subject: [PATCH 5/5] fix(provider): set bedrock_openai_responses context_length to 1M GPT-5.5 / GPT-5.4 on the Bedrock OpenAI Responses (bedrock-mantle) endpoint expose a 1M-token context window per AWS and OpenAI docs; the 400k placeholder was the Codex-surface figure. Matches the repo other GPT-5.4 entries. Co-authored-by: Claude Opus 4.8 --- crates/forge_repo/src/provider/provider.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/forge_repo/src/provider/provider.json b/crates/forge_repo/src/provider/provider.json index 39f19bd603..84a0b92527 100644 --- a/crates/forge_repo/src/provider/provider.json +++ b/crates/forge_repo/src/provider/provider.json @@ -628,7 +628,7 @@ "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": 400000, + "context_length": 1000000, "tools_supported": true, "supports_parallel_tool_calls": true, "supports_reasoning": true, @@ -638,7 +638,7 @@ "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": 400000, + "context_length": 1000000, "tools_supported": true, "supports_parallel_tool_calls": true, "supports_reasoning": true,