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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions crates/forge_repo/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
174 changes: 173 additions & 1 deletion crates/forge_repo/src/provider/openai_responses/repository.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down Expand Up @@ -44,6 +48,11 @@ impl<H: HttpInfra> OpenAIResponsesProvider<H> {
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.
Expand Down Expand Up @@ -147,6 +156,107 @@ impl<H: HttpInfra> OpenAIResponsesProvider<H> {

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<Option<Vec<(String, String)>>> {
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<T: HttpInfra> OpenAIResponsesProvider<T> {
Expand All @@ -156,7 +266,8 @@ impl<T: HttpInfra> OpenAIResponsesProvider<T> {
context: ChatContext,
) -> ResultStream<ChatCompletionMessage, anyhow::Error> {
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());

Expand Down Expand Up @@ -188,6 +299,31 @@ impl<T: HttpInfra> OpenAIResponsesProvider<T> {
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())
Expand Down Expand Up @@ -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::<MockHttpClient>::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 {
Expand Down
29 changes: 29 additions & 0 deletions crates/forge_repo/src/provider/provider.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading