diff --git a/README.md b/README.md
index 3fc418846..f9f978586 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,35 @@ ChatCompletionCreateParams params = ChatCompletionCreateParams.builder()
ChatCompletion chatCompletion = client.chat().completions().create(params);
```
+## Amazon Bedrock
+
+Use the optional `openai-java-bedrock` artifact to call OpenAI-compatible APIs on Amazon Bedrock
+with normal AWS credentials:
+
+
+
+```kotlin
+implementation("com.openai:openai-java-bedrock:4.41.0")
+```
+
+
+
+```java
+import com.openai.client.OpenAIClient;
+import com.openai.client.okhttp.BedrockOpenAIOkHttpClient;
+
+// Uses the standard AWS credential chain, including environment credentials,
+// ~/.aws/credentials, AWS_PROFILE, workload roles, and instance metadata.
+OpenAIClient client = BedrockOpenAIOkHttpClient.builder()
+ .awsRegion("us-east-1")
+ .build();
+```
+
+Requests are signed with fresh AWS credentials on every attempt. Existing
+`AWS_BEARER_TOKEN_BEDROCK` bearer credentials remain supported as a compatibility fallback. See
+the [Amazon Bedrock guide](bedrock.md) for named profiles, temporary credentials, custom credential
+providers, async streaming, precedence rules, and security guidance.
+
## Client configuration
Configure the client using system properties or environment variables:
diff --git a/bedrock.md b/bedrock.md
new file mode 100644
index 000000000..89464679a
--- /dev/null
+++ b/bedrock.md
@@ -0,0 +1,128 @@
+# OpenAI on Amazon Bedrock
+
+The optional `openai-java-bedrock` artifact configures the standard OpenAI Java client for the
+OpenAI-compatible Amazon Bedrock Mantle endpoint. It uses the AWS SDK for Java 2.x credential chain
+and signs the final HTTP request with SigV4 on every attempt.
+
+## Installation
+
+Use this artifact instead of adding AWS dependencies to the base OpenAI package yourself.
+
+
+
+```kotlin
+implementation("com.openai:openai-java-bedrock:4.41.0")
+```
+
+```xml
+
+ com.openai
+ openai-java-bedrock
+ 4.41.0
+
+```
+
+
+
+## Standard AWS credentials
+
+Configure AWS credentials as you normally would, then provide the region:
+
+```java
+import com.openai.client.OpenAIClient;
+import com.openai.client.okhttp.BedrockOpenAIOkHttpClient;
+
+OpenAIClient client = BedrockOpenAIOkHttpClient.builder()
+ .awsRegion("us-east-1")
+ .build();
+```
+
+The default AWS chain supports system properties, `AWS_ACCESS_KEY_ID`,
+`AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `~/.aws/credentials`, `~/.aws/config`,
+`AWS_PROFILE`, IAM Identity Center (SSO), assume-role and web-identity profiles, container
+credentials, and instance-profile credentials. The SDK resolves fresh credentials and signs again
+before every retry.
+
+Region resolution follows this order:
+
+1. `awsRegion(...)`
+2. `AWS_REGION`
+3. `AWS_DEFAULT_REGION`
+4. the AWS SDK's default region provider chain
+
+Base URL resolution follows this order:
+
+1. `baseUrl(...)`
+2. `AWS_BEDROCK_BASE_URL`
+3. `https://bedrock-mantle.{region}.api.aws/openai/v1`
+
+The `/openai/v1` route and the `bedrock-mantle` SigV4 service name are intentional.
+
+## Named profile
+
+```java
+OpenAIClient client = BedrockOpenAIOkHttpClient.builder()
+ .awsRegion("us-east-1")
+ .awsProfile("production")
+ .build();
+```
+
+## Explicit temporary credentials
+
+Prefer profiles and workload roles in production. Explicit credentials are useful for tests and
+short-lived credentials obtained elsewhere:
+
+```java
+OpenAIClient client = BedrockOpenAIOkHttpClient.builder()
+ .awsRegion("us-east-1")
+ .awsAccessKeyId(accessKeyId)
+ .awsSecretAccessKey(secretAccessKey)
+ .awsSessionToken(sessionToken)
+ .build();
+```
+
+For refreshable credentials, pass an AWS SDK `AwsCredentialsProvider`:
+
+```java
+OpenAIClient client = BedrockOpenAIOkHttpClient.builder()
+ .awsRegion("us-east-1")
+ .awsCredentialsProvider(credentialsProvider)
+ .build();
+```
+
+## Bearer-token compatibility
+
+`AWS_BEARER_TOKEN_BEDROCK` remains supported for compatibility and takes precedence over the
+default AWS chain. An explicit AWS credential mode takes precedence over that environment
+variable. You can also provide a bearer credential directly:
+
+```java
+OpenAIClient client = BedrockOpenAIOkHttpClient.builder()
+ .awsRegion("us-east-1")
+ .apiKey(bedrockBearerToken)
+ .build();
+```
+
+Explicit bearer and AWS credential modes are mutually exclusive.
+
+## Async and streaming responses
+
+The same client configuration supports asynchronous and streaming calls:
+
+```java
+OpenAIClientAsync client = BedrockOpenAIOkHttpClient.builder()
+ .awsRegion("us-east-1")
+ .build()
+ .async();
+```
+
+Response streaming is supported. SigV4 request bodies must be replayable so the SDK can hash them
+and safely retry; one-shot streaming request bodies are rejected before network I/O.
+
+## Security
+
+- Do not ship AWS credentials in browser or untrusted client applications.
+- Prefer temporary credentials, roles, profiles, and workload identities over long-lived keys.
+- Do not log access keys, secret keys, session tokens, bearer tokens, or signed authorization
+ headers. The SDK redacts `Authorization` and `X-Amz-Security-Token` from its HTTP logs.
+- OpenAI workload identity federation and AWS Bedrock SigV4 are separate authentication systems.
diff --git a/openai-java-bedrock/build.gradle.kts b/openai-java-bedrock/build.gradle.kts
new file mode 100644
index 000000000..00b8cbbc9
--- /dev/null
+++ b/openai-java-bedrock/build.gradle.kts
@@ -0,0 +1,40 @@
+plugins {
+ id("openai.kotlin")
+ id("openai.publish")
+}
+
+dependencies {
+ api(project(":openai-java-client-okhttp"))
+
+ // Keep AWS dependencies isolated to this optional Bedrock artifact.
+ api(platform("software.amazon.awssdk:bom:2.46.8"))
+ api("software.amazon.awssdk:auth")
+ api("software.amazon.awssdk:regions")
+
+ implementation("software.amazon.awssdk:http-client-spi")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+
+ // Optional provider-chain branches loaded by the AWS SDK at runtime for web identity,
+ // assume-role, and IAM Identity Center profiles.
+ runtimeOnly("software.amazon.awssdk:sts") {
+ exclude(group = "software.amazon.awssdk", module = "apache5-client")
+ exclude(group = "software.amazon.awssdk", module = "netty-nio-client")
+ }
+ runtimeOnly("software.amazon.awssdk:sso") {
+ exclude(group = "software.amazon.awssdk", module = "apache5-client")
+ exclude(group = "software.amazon.awssdk", module = "netty-nio-client")
+ }
+ runtimeOnly("software.amazon.awssdk:ssooidc") {
+ exclude(group = "software.amazon.awssdk", module = "apache5-client")
+ exclude(group = "software.amazon.awssdk", module = "netty-nio-client")
+ }
+ runtimeOnly("software.amazon.awssdk:url-connection-client")
+
+ testImplementation(kotlin("test"))
+ testImplementation("com.github.tomakehurst:wiremock-jre8:2.35.2")
+ testImplementation("org.assertj:assertj-core:3.27.7")
+ testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3")
+ testImplementation("org.junit-pioneer:junit-pioneer:1.9.1")
+ testImplementation("org.mockito:mockito-core:5.14.2")
+ testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0")
+}
diff --git a/openai-java-bedrock/src/main/kotlin/com/openai/bedrock/BedrockAuth.kt b/openai-java-bedrock/src/main/kotlin/com/openai/bedrock/BedrockAuth.kt
new file mode 100644
index 000000000..b015c130a
--- /dev/null
+++ b/openai-java-bedrock/src/main/kotlin/com/openai/bedrock/BedrockAuth.kt
@@ -0,0 +1,400 @@
+package com.openai.bedrock
+
+import com.openai.core.http.Headers
+import com.openai.core.http.HttpRequest
+import com.openai.core.http.HttpRequestAuthenticator
+import com.openai.errors.OpenAIException
+import java.io.ByteArrayOutputStream
+import java.net.URI
+import java.time.Clock
+import java.util.concurrent.CompletableFuture
+import java.util.function.Supplier
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
+import software.amazon.awssdk.auth.credentials.AwsCredentials
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider
+import software.amazon.awssdk.auth.credentials.AwsSessionCredentials
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider
+import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider
+import software.amazon.awssdk.http.ContentStreamProvider
+import software.amazon.awssdk.http.SdkHttpMethod
+import software.amazon.awssdk.http.SdkHttpRequest
+import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner
+import software.amazon.awssdk.http.auth.spi.signer.HttpSigner
+import software.amazon.awssdk.http.auth.spi.signer.SignRequest
+import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity
+import software.amazon.awssdk.regions.Region
+import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain
+
+internal const val BEDROCK_SERVICE = "bedrock-mantle"
+internal const val ENV_BEARER_TOKEN = "AWS_BEARER_TOKEN_BEDROCK"
+internal const val ENV_BASE_URL = "AWS_BEDROCK_BASE_URL"
+
+internal data class BedrockConfiguration(
+ val baseUrl: String,
+ val authenticator: HttpRequestAuthenticator,
+)
+
+internal data class BedrockAuthOptions(
+ val awsRegion: String?,
+ val baseUrl: String?,
+ val apiKey: String?,
+ val tokenProvider: Supplier?,
+ val awsAccessKeyId: String?,
+ val awsSecretAccessKey: String?,
+ val awsSessionToken: String?,
+ val awsProfile: String?,
+ val awsCredentialsProvider: AwsCredentialsProvider?,
+ val skipAuth: Boolean,
+ val clock: Clock,
+)
+
+internal fun BedrockAuthOptions.resolve(
+ getenv: (String) -> String? = System::getenv,
+ regionProvider: () -> Region? = {
+ try {
+ DefaultAwsRegionProviderChain.builder().build().region
+ } catch (_: RuntimeException) {
+ null
+ }
+ },
+): BedrockConfiguration {
+ val normalizedRegion = normalize("awsRegion", awsRegion)
+ val normalizedBaseUrl = normalize("baseUrl", baseUrl)
+ val normalizedApiKey = normalize("apiKey", apiKey)
+ val normalizedProfile = normalize("awsProfile", awsProfile)
+
+ if (apiKey != null && normalizedApiKey == null) {
+ throw OpenAIException("The Bedrock bearer credential must not be empty.")
+ }
+ if (tokenProvider != null && apiKey != null) {
+ throw OpenAIException(
+ "The `apiKey` and `bedrockTokenProvider` options are mutually exclusive. Configure only one."
+ )
+ }
+ if (awsProfile != null && normalizedProfile == null) {
+ throw OpenAIException("The Bedrock AWS `awsProfile` must not be empty.")
+ }
+
+ val staticCredentials = resolveStaticCredentials()
+ val awsModeCount =
+ listOf(staticCredentials != null, normalizedProfile != null, awsCredentialsProvider != null)
+ .count { it }
+ if (awsModeCount > 1) {
+ throw OpenAIException(
+ "Bedrock authentication is ambiguous. Configure exactly one explicit AWS mode: static credentials, profile, or credential provider."
+ )
+ }
+
+ val explicitBearer = normalizedApiKey != null || tokenProvider != null
+ val explicitAws = awsModeCount == 1
+ if (explicitBearer && explicitAws) {
+ throw OpenAIException(
+ "Bearer and AWS credential authentication are mutually exclusive. Configure exactly one explicit mode: bearer credential, static AWS credentials, profile, or credential provider."
+ )
+ }
+ if (skipAuth && (explicitBearer || explicitAws)) {
+ throw OpenAIException(
+ "Bedrock authentication is ambiguous. `skipAuth` cannot be combined with bearer or AWS credentials."
+ )
+ }
+
+ val environmentBaseUrl = normalizeEnvironment(getenv(ENV_BASE_URL))
+ val resolvedRegion =
+ normalizedRegion
+ ?: normalizeEnvironment(getenv("AWS_REGION"))
+ ?: normalizeEnvironment(getenv("AWS_DEFAULT_REGION"))
+ ?: regionProvider()?.id()
+ val resolvedBaseUrl =
+ normalizedBaseUrl
+ ?: environmentBaseUrl
+ ?: resolvedRegion?.let { "https://bedrock-mantle.$it.api.aws/openai/v1" }
+ ?: throw OpenAIException(
+ "Bedrock requires an AWS region. Pass `awsRegion` to the builder, or set `AWS_REGION` or `AWS_DEFAULT_REGION`."
+ )
+ val normalizedResolvedBaseUrl = normalizeBaseUrl(resolvedBaseUrl)
+
+ if (skipAuth) {
+ return BedrockConfiguration(
+ normalizedResolvedBaseUrl,
+ NoAuthAuthenticator(normalizedResolvedBaseUrl),
+ )
+ }
+
+ val bearerSupplier =
+ when {
+ tokenProvider != null -> tokenProvider
+ normalizedApiKey != null -> Supplier { normalizedApiKey }
+ !explicitAws && normalizeEnvironment(getenv(ENV_BEARER_TOKEN)) != null ->
+ Supplier {
+ normalizeEnvironment(getenv(ENV_BEARER_TOKEN))
+ ?: throw OpenAIException(
+ "Could not find credentials for Bedrock. Set `AWS_BEARER_TOKEN_BEDROCK` or configure AWS credential authentication."
+ )
+ }
+ else -> null
+ }
+
+ if (bearerSupplier != null) {
+ return BedrockConfiguration(
+ normalizedResolvedBaseUrl,
+ BearerAuthenticator(normalizedResolvedBaseUrl, bearerSupplier),
+ )
+ }
+
+ val region =
+ resolvedRegion
+ ?: throw OpenAIException(
+ "Bedrock requires an AWS region. Pass `awsRegion` to the builder, or set `AWS_REGION` or `AWS_DEFAULT_REGION`."
+ )
+ val credentialsProvider =
+ when {
+ staticCredentials != null -> AwsCredentialsProvider { staticCredentials }
+ normalizedProfile != null -> ProfileCredentialsProvider.create(normalizedProfile)
+ awsCredentialsProvider != null -> awsCredentialsProvider
+ else -> DefaultCredentialsProvider.builder().build()
+ }
+
+ return BedrockConfiguration(
+ normalizedResolvedBaseUrl,
+ SigV4Authenticator(
+ baseUrl = normalizedResolvedBaseUrl,
+ region = Region.of(region),
+ credentialsProvider = credentialsProvider,
+ defaultChain = !explicitAws,
+ clock = clock,
+ ),
+ )
+}
+
+private fun BedrockAuthOptions.resolveStaticCredentials(): AwsCredentials? {
+ val hasAccessKey = awsAccessKeyId != null
+ val hasSecretKey = awsSecretAccessKey != null
+ if (hasAccessKey != hasSecretKey || (awsSessionToken != null && !hasAccessKey)) {
+ throw OpenAIException(
+ "Static AWS credentials require both `awsAccessKeyId` and `awsSecretAccessKey`. An `awsSessionToken` may only be used with both."
+ )
+ }
+ if (!hasAccessKey) return null
+
+ val accessKey = awsAccessKeyId!!.trim()
+ val secretKey = awsSecretAccessKey!!.trim()
+ if (accessKey.isEmpty() || secretKey.isEmpty()) {
+ throw OpenAIException(
+ "Static AWS credentials require non-empty `awsAccessKeyId` and `awsSecretAccessKey` values."
+ )
+ }
+ val sessionToken = awsSessionToken?.trim()
+ if (awsSessionToken != null && sessionToken.isNullOrEmpty()) {
+ throw OpenAIException("A static AWS `awsSessionToken` must not be empty when provided.")
+ }
+
+ return if (sessionToken == null) AwsBasicCredentials.create(accessKey, secretKey)
+ else AwsSessionCredentials.create(accessKey, secretKey, sessionToken)
+}
+
+private fun normalize(name: String, value: String?): String? {
+ val normalized = value?.trim()?.takeIf { it.isNotEmpty() }
+ if (value != null && normalized == null && (name == "awsRegion" || name == "baseUrl")) {
+ throw OpenAIException("The Bedrock `$name` must not be empty.")
+ }
+ return normalized
+}
+
+private fun normalizeEnvironment(value: String?): String? =
+ value?.trim()?.takeIf { it.isNotEmpty() }
+
+private fun normalizeBaseUrl(value: String): String =
+ try {
+ value.toHttpUrl().toString().removeSuffix("/")
+ } catch (cause: IllegalArgumentException) {
+ throw OpenAIException("The Bedrock `baseUrl` must be a valid HTTP URL.", cause)
+ }
+
+private abstract class OriginBoundAuthenticator(baseUrl: String) : HttpRequestAuthenticator {
+ private val origin = baseUrl.toHttpUrl().origin()
+
+ protected fun resolvedUrl(request: HttpRequest): HttpUrl {
+ val url = request.resolveUrl()
+ if (url.origin() != origin) {
+ throw OpenAIException(
+ "Bedrock provider authentication cannot be used with a request to a different origin."
+ )
+ }
+ return url
+ }
+}
+
+private class NoAuthAuthenticator(baseUrl: String) : OriginBoundAuthenticator(baseUrl) {
+ override fun authenticate(request: HttpRequest): HttpRequest {
+ resolvedUrl(request)
+ return request
+ }
+}
+
+private class BearerAuthenticator(baseUrl: String, private val tokenProvider: Supplier) :
+ OriginBoundAuthenticator(baseUrl) {
+ override fun authenticate(request: HttpRequest): HttpRequest {
+ resolvedUrl(request)
+ assertNoAuthorization(request.headers)
+ val token =
+ try {
+ tokenProvider.get()
+ } catch (cause: Throwable) {
+ throw OpenAIException("Failed to resolve a bearer credential for Bedrock.", cause)
+ }
+ if (token.isNullOrBlank()) {
+ throw OpenAIException(
+ "The Bedrock bearer credential provider must return a non-empty string."
+ )
+ }
+ return request.toBuilder().replaceHeaders("Authorization", "Bearer $token").build()
+ }
+}
+
+private class SigV4Authenticator(
+ baseUrl: String,
+ private val region: Region,
+ private val credentialsProvider: AwsCredentialsProvider,
+ private val defaultChain: Boolean,
+ private val clock: Clock,
+) : OriginBoundAuthenticator(baseUrl) {
+ private val signer = AwsV4HttpSigner.create()
+
+ override fun authenticate(request: HttpRequest): HttpRequest {
+ val url = resolvedUrl(request)
+ validateCanonicalRegion(url)
+ assertNoAuthorization(request.headers)
+ if (request.body?.repeatable() == false) {
+ throw OpenAIException(
+ "Bedrock SigV4 authentication requires a replayable request body. Buffer the body before sending or use bearer authentication."
+ )
+ }
+
+ val preparedRequest =
+ request
+ .toBuilder()
+ .removeAllHeaders(
+ setOf(
+ "Authorization",
+ "X-Amz-Date",
+ "X-Amz-Security-Token",
+ "X-Amz-Content-Sha256",
+ )
+ )
+ .apply {
+ request.body?.contentType()?.let { contentType ->
+ if (request.headers.values("Content-Type").isEmpty()) {
+ putHeader("Content-Type", contentType)
+ }
+ }
+ }
+ .build()
+ val bodyBytes =
+ preparedRequest.body?.let { body ->
+ ByteArrayOutputStream().use { output ->
+ body.writeTo(output)
+ output.toByteArray()
+ }
+ }
+ val awsRequest = preparedRequest.toAwsRequest(url.toUri())
+ val credentials = resolveCredentials()
+ val signed =
+ signer.sign { signRequest: SignRequest.Builder ->
+ signRequest
+ .identity(credentials)
+ .request(awsRequest)
+ .apply {
+ if (bodyBytes != null) {
+ payload(ContentStreamProvider.fromByteArray(bodyBytes))
+ }
+ }
+ .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, BEDROCK_SERVICE)
+ .putProperty(AwsV4HttpSigner.REGION_NAME, region.id())
+ .putProperty(HttpSigner.SIGNING_CLOCK, clock)
+ }
+
+ return preparedRequest.toBuilder().headers(signed.request().headers()).build()
+ }
+
+ override fun authenticateAsync(request: HttpRequest): CompletableFuture =
+ CompletableFuture.supplyAsync { authenticate(request) }
+
+ override fun close() {
+ (credentialsProvider as? AutoCloseable)?.close()
+ }
+
+ private fun resolveCredentials(): AwsCredentials {
+ val credentials =
+ try {
+ credentialsProvider.resolveCredentials()
+ } catch (cause: Throwable) {
+ val message =
+ if (defaultChain)
+ "Could not find credentials for Bedrock. Pass AWS credentials to the builder or configure the default AWS credential chain."
+ else
+ "Failed to resolve AWS credentials for Bedrock. Verify your AWS profile, environment variables, or runtime identity configuration and try again."
+ throw OpenAIException(message, cause)
+ }
+ if (
+ credentials.accessKeyId().isNullOrBlank() ||
+ credentials.secretAccessKey().isNullOrBlank() ||
+ (credentials is AwsSessionCredentials && credentials.sessionToken().isBlank())
+ ) {
+ throw OpenAIException(
+ "Failed to resolve AWS credentials for Bedrock. Verify your AWS profile, environment variables, or runtime identity configuration and try again."
+ )
+ }
+ return credentials
+ }
+
+ private fun validateCanonicalRegion(url: HttpUrl) {
+ val canonicalRegion =
+ Regex("^bedrock-mantle\\.([a-z0-9-]+)\\.api\\.aws$", RegexOption.IGNORE_CASE)
+ .matchEntire(url.host)
+ ?.groupValues
+ ?.get(1)
+ if (canonicalRegion != null && canonicalRegion != region.id()) {
+ throw OpenAIException(
+ "The Bedrock endpoint region `$canonicalRegion` does not match the SigV4 region `${region.id()}`."
+ )
+ }
+ }
+}
+
+private fun HttpRequest.toAwsRequest(uri: URI): SdkHttpRequest {
+ val builder =
+ SdkHttpRequest.builder().uri(uri).method(SdkHttpMethod.fromValue(method.name)).apply {
+ this@toAwsRequest.headers.names().forEach { name ->
+ if (!name.equals("connection", ignoreCase = true)) {
+ this@toAwsRequest.headers.values(name).forEach { value ->
+ appendHeader(name, value)
+ }
+ }
+ }
+ }
+ return builder.build()
+}
+
+private fun assertNoAuthorization(headers: Headers) {
+ if (headers.values("Authorization").isNotEmpty()) {
+ throw OpenAIException(
+ "Bedrock provider authentication cannot be combined with a custom `Authorization` header."
+ )
+ }
+}
+
+private fun HttpRequest.resolveUrl(): HttpUrl =
+ baseUrl
+ .toHttpUrl()
+ .newBuilder()
+ .apply {
+ pathSegments.forEach(::addPathSegment)
+ queryParams.keys().forEach { key ->
+ queryParams.values(key).forEach { value -> addQueryParameter(key, value) }
+ }
+ }
+ .build()
+
+private fun HttpUrl.origin(): String = "$scheme://$host:$port"
diff --git a/openai-java-bedrock/src/main/kotlin/com/openai/client/okhttp/BedrockOpenAIOkHttpClient.kt b/openai-java-bedrock/src/main/kotlin/com/openai/client/okhttp/BedrockOpenAIOkHttpClient.kt
new file mode 100644
index 000000000..3c1e9a33e
--- /dev/null
+++ b/openai-java-bedrock/src/main/kotlin/com/openai/client/okhttp/BedrockOpenAIOkHttpClient.kt
@@ -0,0 +1,232 @@
+package com.openai.client.okhttp
+
+import com.fasterxml.jackson.databind.json.JsonMapper
+import com.openai.bedrock.BedrockAuthOptions
+import com.openai.bedrock.resolve
+import com.openai.client.OpenAIClient
+import com.openai.core.LogLevel
+import com.openai.core.Sleeper
+import com.openai.core.Timeout
+import com.openai.core.http.Headers
+import com.openai.core.http.ProxyAuthenticator
+import com.openai.core.http.QueryParams
+import java.net.Proxy
+import java.time.Clock
+import java.time.Duration
+import java.util.Optional
+import java.util.concurrent.Executor
+import java.util.concurrent.ExecutorService
+import java.util.function.Supplier
+import javax.net.ssl.HostnameVerifier
+import javax.net.ssl.SSLSocketFactory
+import javax.net.ssl.X509TrustManager
+import kotlin.jvm.optionals.getOrNull
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider
+import software.amazon.awssdk.regions.Region
+
+/**
+ * Builds an [OpenAIClient] configured for OpenAI-compatible APIs on Amazon Bedrock.
+ *
+ * By default, authentication follows the normal AWS credential provider chain, including
+ * environment variables, shared credentials and config files, named profiles, workload credentials,
+ * and instance metadata. Requests are signed with AWS SigV4 on every attempt.
+ */
+class BedrockOpenAIOkHttpClient private constructor() {
+
+ companion object {
+ /** Returns a mutable builder for constructing a Bedrock client. */
+ @JvmStatic fun builder() = Builder()
+
+ /** Builds a client using the standard AWS credential and region provider chains. */
+ @JvmStatic fun fromEnv(): OpenAIClient = builder().build()
+ }
+
+ class Builder internal constructor() {
+ private val delegate = OpenAIOkHttpClient.builder().followRedirects(false)
+
+ private var awsRegion: String? = null
+ private var baseUrl: String? = null
+ private var apiKey: String? = null
+ private var tokenProvider: Supplier? = null
+ private var awsAccessKeyId: String? = null
+ private var awsSecretAccessKey: String? = null
+ private var awsSessionToken: String? = null
+ private var awsProfile: String? = null
+ private var awsCredentialsProvider: AwsCredentialsProvider? = null
+ private var skipAuth: Boolean = false
+ private var clock: Clock = Clock.systemUTC()
+
+ /** Sets the AWS region used for endpoint resolution and SigV4 signing. */
+ fun awsRegion(awsRegion: String?) = apply { this.awsRegion = awsRegion }
+
+ /** Sets the AWS region used for endpoint resolution and SigV4 signing. */
+ fun awsRegion(awsRegion: Region) = awsRegion(awsRegion.id())
+
+ /** Alias for calling [awsRegion] with `awsRegion.orElse(null)`. */
+ fun awsRegion(awsRegion: Optional) = awsRegion(awsRegion.getOrNull())
+
+ /**
+ * Overrides the Bedrock API root. Defaults to `AWS_BEDROCK_BASE_URL`, then the regional
+ * `https://bedrock-mantle.{region}.api.aws/openai/v1` endpoint.
+ */
+ fun baseUrl(baseUrl: String?) = apply { this.baseUrl = baseUrl }
+
+ /** Alias for calling [baseUrl] with `baseUrl.orElse(null)`. */
+ fun baseUrl(baseUrl: Optional) = baseUrl(baseUrl.getOrNull())
+
+ /** Uses an explicit Bedrock bearer credential instead of AWS SigV4. */
+ fun apiKey(apiKey: String?) = apply { this.apiKey = apiKey }
+
+ /** Alias for calling [apiKey] with `apiKey.orElse(null)`. */
+ fun apiKey(apiKey: Optional) = apiKey(apiKey.getOrNull())
+
+ /** Resolves a fresh Bedrock bearer credential before every request attempt. */
+ fun bedrockTokenProvider(tokenProvider: Supplier) = apply {
+ this.tokenProvider = tokenProvider
+ }
+
+ /** Sets an explicit AWS access key ID. Must be paired with [awsSecretAccessKey]. */
+ fun awsAccessKeyId(awsAccessKeyId: String?) = apply { this.awsAccessKeyId = awsAccessKeyId }
+
+ /** Sets an explicit AWS secret access key. Must be paired with [awsAccessKeyId]. */
+ fun awsSecretAccessKey(awsSecretAccessKey: String?) = apply {
+ this.awsSecretAccessKey = awsSecretAccessKey
+ }
+
+ /** Sets an optional session token for explicit temporary AWS credentials. */
+ fun awsSessionToken(awsSessionToken: String?) = apply {
+ this.awsSessionToken = awsSessionToken
+ }
+
+ /** Resolves AWS credentials from a named shared-config profile. */
+ fun awsProfile(awsProfile: String?) = apply { this.awsProfile = awsProfile }
+
+ /** Uses a custom, refreshable AWS credentials provider. */
+ fun awsCredentialsProvider(awsCredentialsProvider: AwsCredentialsProvider) = apply {
+ this.awsCredentialsProvider = awsCredentialsProvider
+ }
+
+ /**
+ * Skips Bedrock authentication. This is intended for proxies or tests that authenticate
+ * requests outside this SDK.
+ */
+ fun skipAuth(skipAuth: Boolean) = apply { this.skipAuth = skipAuth }
+
+ fun dispatcherExecutorService(dispatcherExecutorService: ExecutorService?) = apply {
+ delegate.dispatcherExecutorService(dispatcherExecutorService)
+ }
+
+ fun proxy(proxy: Proxy?) = apply { delegate.proxy(proxy) }
+
+ fun proxyAuthenticator(proxyAuthenticator: ProxyAuthenticator?) = apply {
+ delegate.proxyAuthenticator(proxyAuthenticator)
+ }
+
+ fun maxIdleConnections(maxIdleConnections: Int?) = apply {
+ delegate.maxIdleConnections(maxIdleConnections)
+ }
+
+ fun keepAliveDuration(keepAliveDuration: Duration?) = apply {
+ delegate.keepAliveDuration(keepAliveDuration)
+ }
+
+ fun sslSocketFactory(sslSocketFactory: SSLSocketFactory?) = apply {
+ delegate.sslSocketFactory(sslSocketFactory)
+ }
+
+ fun trustManager(trustManager: X509TrustManager?) = apply {
+ delegate.trustManager(trustManager)
+ }
+
+ fun hostnameVerifier(hostnameVerifier: HostnameVerifier?) = apply {
+ delegate.hostnameVerifier(hostnameVerifier)
+ }
+
+ fun checkJacksonVersionCompatibility(check: Boolean) = apply {
+ delegate.checkJacksonVersionCompatibility(check)
+ }
+
+ fun jsonMapper(jsonMapper: JsonMapper) = apply { delegate.jsonMapper(jsonMapper) }
+
+ fun streamHandlerExecutor(streamHandlerExecutor: Executor) = apply {
+ delegate.streamHandlerExecutor(streamHandlerExecutor)
+ }
+
+ fun sleeper(sleeper: Sleeper) = apply { delegate.sleeper(sleeper) }
+
+ fun clock(clock: Clock) = apply {
+ this.clock = clock
+ delegate.clock(clock)
+ }
+
+ fun responseValidation(responseValidation: Boolean) = apply {
+ delegate.responseValidation(responseValidation)
+ }
+
+ fun timeout(timeout: Timeout) = apply { delegate.timeout(timeout) }
+
+ fun timeout(timeout: Duration) = apply { delegate.timeout(timeout) }
+
+ fun maxRetries(maxRetries: Int) = apply { delegate.maxRetries(maxRetries) }
+
+ fun logLevel(logLevel: LogLevel) = apply { delegate.logLevel(logLevel) }
+
+ fun headers(headers: Headers) = apply { delegate.headers(headers) }
+
+ fun headers(headers: Map>) = apply { delegate.headers(headers) }
+
+ fun putHeader(name: String, value: String) = apply { delegate.putHeader(name, value) }
+
+ fun putHeaders(name: String, values: Iterable) = apply {
+ delegate.putHeaders(name, values)
+ }
+
+ fun putAllHeaders(headers: Headers) = apply { delegate.putAllHeaders(headers) }
+
+ fun replaceHeaders(name: String, value: String) = apply {
+ delegate.replaceHeaders(name, value)
+ }
+
+ fun removeHeaders(name: String) = apply { delegate.removeHeaders(name) }
+
+ fun queryParams(queryParams: QueryParams) = apply { delegate.queryParams(queryParams) }
+
+ fun queryParams(queryParams: Map>) = apply {
+ delegate.queryParams(queryParams)
+ }
+
+ fun putQueryParam(key: String, value: String) = apply { delegate.putQueryParam(key, value) }
+
+ fun putQueryParams(key: String, values: Iterable) = apply {
+ delegate.putQueryParams(key, values)
+ }
+
+ fun removeQueryParams(key: String) = apply { delegate.removeQueryParams(key) }
+
+ /** Returns a standard OpenAI client configured for Amazon Bedrock. */
+ fun build(): OpenAIClient {
+ val configuration =
+ BedrockAuthOptions(
+ awsRegion = awsRegion,
+ baseUrl = baseUrl,
+ apiKey = apiKey,
+ tokenProvider = tokenProvider,
+ awsAccessKeyId = awsAccessKeyId,
+ awsSecretAccessKey = awsSecretAccessKey,
+ awsSessionToken = awsSessionToken,
+ awsProfile = awsProfile,
+ awsCredentialsProvider = awsCredentialsProvider,
+ skipAuth = skipAuth,
+ clock = clock,
+ )
+ .resolve(::getEnv)
+
+ return delegate
+ .baseUrl(configuration.baseUrl)
+ .httpRequestAuthenticator(configuration.authenticator)
+ .build()
+ }
+
+ @JvmSynthetic internal fun getEnv(name: String): String? = System.getenv(name)
+ }
+}
diff --git a/openai-java-bedrock/src/test/kotlin/com/openai/bedrock/BedrockAuthTest.kt b/openai-java-bedrock/src/test/kotlin/com/openai/bedrock/BedrockAuthTest.kt
new file mode 100644
index 000000000..9a4d06ea2
--- /dev/null
+++ b/openai-java-bedrock/src/test/kotlin/com/openai/bedrock/BedrockAuthTest.kt
@@ -0,0 +1,558 @@
+package com.openai.bedrock
+
+import com.openai.core.http.HttpMethod
+import com.openai.core.http.HttpRequest
+import com.openai.core.http.HttpRequestBody
+import com.openai.core.jsonMapper
+import com.openai.errors.OpenAIException
+import java.io.OutputStream
+import java.nio.file.Files
+import java.nio.file.Path
+import java.time.Clock
+import java.time.Instant
+import java.time.ZoneOffset
+import java.util.concurrent.atomic.AtomicInteger
+import java.util.function.Supplier
+import org.assertj.core.api.Assertions.assertThat
+import org.assertj.core.api.Assertions.assertThatThrownBy
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+import org.junit.jupiter.api.parallel.ResourceLock
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
+import software.amazon.awssdk.auth.credentials.AwsCredentials
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider
+import software.amazon.awssdk.regions.Region
+
+internal class BedrockAuthTest {
+
+ @Test
+ fun sigV4MatchesSharedFixture() {
+ val fixture =
+ javaClass.getResourceAsStream("/fixtures/bedrock/v1/sigv4.json").use { input ->
+ jsonMapper().readTree(input)
+ }
+ val credentials = fixture["credentials"]
+ val expected = fixture["expected"]
+ val requestFixture = fixture["request"]
+ val configuration =
+ options(
+ awsRegion = fixture["region"].asText(),
+ awsAccessKeyId = credentials["accessKeyId"].asText(),
+ awsSecretAccessKey = credentials["secretAccessKey"].asText(),
+ awsSessionToken = credentials["sessionToken"].asText(),
+ clock =
+ Clock.fixed(Instant.parse(fixture["signingDate"].asText()), ZoneOffset.UTC),
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+
+ val signed =
+ configuration.authenticator.authenticate(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl("https://bedrock-mantle.us-east-1.api.aws/openai/v1")
+ .addPathSegment("responses")
+ .putHeader("Content-Type", requestFixture["contentType"].asText())
+ .body(StringBody(requestFixture["body"].asText()))
+ .build()
+ )
+
+ assertThat(signed.headers.values("X-Amz-Date")).containsExactly(expected["date"].asText())
+ assertThat(signed.headers.values("X-Amz-Content-Sha256"))
+ .containsExactly(expected["payloadHash"].asText())
+ assertThat(signed.headers.values("X-Amz-Security-Token"))
+ .containsExactly(credentials["sessionToken"].asText())
+ assertThat(signed.headers.values("Authorization"))
+ .containsExactly(expected["authorization"].asText())
+ }
+
+ @Test
+ @ResourceLock("aws-system-properties")
+ fun namedProfileReadsSharedCredentialsFile(@TempDir directory: Path) {
+ val credentialsFile = directory.resolve("credentials")
+ Files.write(
+ credentialsFile,
+ """
+ [fixture]
+ aws_access_key_id = FILEACCESSKEY
+ aws_secret_access_key = filesecret
+ aws_session_token = file-session-token
+ """
+ .trimIndent()
+ .toByteArray(),
+ )
+ val previous = System.getProperty("aws.sharedCredentialsFile")
+ System.setProperty("aws.sharedCredentialsFile", credentialsFile.toString())
+
+ try {
+ val configuration =
+ options(awsRegion = "us-east-1", awsProfile = "fixture")
+ .resolve(
+ getenv = { name ->
+ if (name == ENV_BEARER_TOKEN) "ignored-environment-bearer" else null
+ },
+ regionProvider = { null },
+ )
+ val signed = configuration.authenticator.authenticate(request())
+
+ assertThat(signed.headers.values("Authorization").single())
+ .contains("Credential=FILEACCESSKEY/")
+ assertThat(signed.headers.values("X-Amz-Security-Token"))
+ .containsExactly("file-session-token")
+ } finally {
+ if (previous == null) System.clearProperty("aws.sharedCredentialsFile")
+ else System.setProperty("aws.sharedCredentialsFile", previous)
+ }
+ }
+
+ @Test
+ fun explicitAwsCredentialsTakePrecedenceOverEnvironmentBearer() {
+ val configuration =
+ options(
+ awsRegion = "us-east-1",
+ awsAccessKeyId = "AKIDEXAMPLE",
+ awsSecretAccessKey = "secret",
+ )
+ .resolve(
+ getenv = { name ->
+ if (name == ENV_BEARER_TOKEN) "environment-token" else null
+ },
+ regionProvider = { null },
+ )
+
+ val signed = configuration.authenticator.authenticate(request())
+
+ assertThat(signed.headers.values("Authorization").single()).startsWith("AWS4-HMAC-SHA256")
+ }
+
+ @Test
+ fun environmentBearerTakesPrecedenceOverDefaultChain() {
+ val configuration =
+ options(awsRegion = "us-east-1")
+ .resolve(
+ getenv = { name ->
+ if (name == ENV_BEARER_TOKEN) "environment-token" else null
+ },
+ regionProvider = { null },
+ )
+
+ val authenticated = configuration.authenticator.authenticate(request())
+
+ assertThat(authenticated.headers.values("Authorization"))
+ .containsExactly("Bearer environment-token")
+ }
+
+ @Test
+ fun tokenProviderResolvesFreshCredentialsAndReportsInvalidResults() {
+ val calls = AtomicInteger()
+ val configuration =
+ options(
+ awsRegion = "us-east-1",
+ tokenProvider = Supplier { "token-${calls.incrementAndGet()}" },
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+
+ assertThat(
+ configuration.authenticator.authenticate(request()).headers.values("Authorization")
+ )
+ .containsExactly("Bearer token-1")
+ assertThat(
+ configuration.authenticator.authenticate(request()).headers.values("Authorization")
+ )
+ .containsExactly("Bearer token-2")
+
+ val blankProvider =
+ options(awsRegion = "us-east-1", tokenProvider = Supplier { " " })
+ .resolve(getenv = { null }, regionProvider = { null })
+ assertThatThrownBy { blankProvider.authenticator.authenticate(request()) }
+ .isInstanceOf(OpenAIException::class.java)
+ .hasMessageContaining("non-empty string")
+
+ val failingProvider =
+ options(
+ awsRegion = "us-east-1",
+ tokenProvider = Supplier { throw IllegalStateException("token failure") },
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+ assertThatThrownBy { failingProvider.authenticator.authenticate(request()) }
+ .isInstanceOf(OpenAIException::class.java)
+ .hasMessageContaining("Failed to resolve a bearer credential")
+ .hasCauseInstanceOf(IllegalStateException::class.java)
+ }
+
+ @Test
+ fun environmentBearerReportsWhenCredentialDisappears() {
+ val reads = AtomicInteger()
+ val configuration =
+ options(awsRegion = "us-east-1")
+ .resolve(
+ getenv = { name ->
+ if (name == ENV_BEARER_TOKEN && reads.getAndIncrement() == 0) {
+ "short-lived-token"
+ } else {
+ null
+ }
+ },
+ regionProvider = { null },
+ )
+
+ assertThatThrownBy { configuration.authenticator.authenticate(request()) }
+ .isInstanceOf(OpenAIException::class.java)
+ .hasMessageContaining("Failed to resolve a bearer credential")
+ .hasRootCauseMessage(
+ "Could not find credentials for Bedrock. Set `AWS_BEARER_TOKEN_BEDROCK` or configure AWS credential authentication."
+ )
+ }
+
+ @Test
+ fun endpointResolutionUsesExplicitThenEnvironmentThenRegionalDefault() {
+ val explicit =
+ options(
+ awsRegion = "us-east-1",
+ baseUrl = "https://explicit.example.com/openai/v1/",
+ apiKey = "token",
+ )
+ .resolve(
+ getenv = { name ->
+ if (name == ENV_BASE_URL) "https://environment.example.com" else null
+ },
+ regionProvider = { null },
+ )
+ val environment =
+ options(awsRegion = "us-east-1", apiKey = "token")
+ .resolve(
+ getenv = { name ->
+ if (name == ENV_BASE_URL) "https://environment.example.com/" else null
+ },
+ regionProvider = { null },
+ )
+ val regional =
+ options(awsRegion = "us-east-1", apiKey = "token")
+ .resolve(getenv = { null }, regionProvider = { null })
+ val awsRegionEnvironment =
+ options(apiKey = "token")
+ .resolve(
+ getenv = { name -> if (name == "AWS_REGION") "us-west-2" else null },
+ regionProvider = { null },
+ )
+ val defaultRegionEnvironment =
+ options(apiKey = "token")
+ .resolve(
+ getenv = { name -> if (name == "AWS_DEFAULT_REGION") "eu-central-1" else null },
+ regionProvider = { null },
+ )
+ val providerRegion =
+ options(apiKey = "token")
+ .resolve(getenv = { null }, regionProvider = { Region.of("ap-southeast-2") })
+
+ assertThat(explicit.baseUrl).isEqualTo("https://explicit.example.com/openai/v1")
+ assertThat(environment.baseUrl).isEqualTo("https://environment.example.com")
+ assertThat(regional.baseUrl).isEqualTo("https://bedrock-mantle.us-east-1.api.aws/openai/v1")
+ assertThat(awsRegionEnvironment.baseUrl)
+ .isEqualTo("https://bedrock-mantle.us-west-2.api.aws/openai/v1")
+ assertThat(defaultRegionEnvironment.baseUrl)
+ .isEqualTo("https://bedrock-mantle.eu-central-1.api.aws/openai/v1")
+ assertThat(providerRegion.baseUrl)
+ .isEqualTo("https://bedrock-mantle.ap-southeast-2.api.aws/openai/v1")
+ }
+
+ @Test
+ fun skipAuthPassesThroughSameOriginAndRejectsDifferentOrigin() {
+ val configuration =
+ options(baseUrl = "https://bedrock.example.com/openai/v1", skipAuth = true)
+ .resolve(getenv = { null }, regionProvider = { null })
+ val sameOrigin =
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl("https://bedrock.example.com/openai/v1")
+ .addPathSegment("responses")
+ .build()
+ val differentOrigin =
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl("https://other.example.com/openai/v1")
+ .addPathSegment("responses")
+ .build()
+
+ assertThat(configuration.authenticator.authenticate(sameOrigin)).isSameAs(sameOrigin)
+ assertThatThrownBy { configuration.authenticator.authenticate(differentOrigin) }
+ .isInstanceOf(OpenAIException::class.java)
+ .hasMessageContaining("different origin")
+ }
+
+ @Test
+ fun signingIncludesBodyContentTypeAndQueryParameters() {
+ val configuration =
+ options(
+ awsRegion = "us-east-1",
+ awsAccessKeyId = "AKIDEXAMPLE",
+ awsSecretAccessKey = "secret",
+ clock = Clock.fixed(Instant.parse("2026-06-01T12:34:56Z"), ZoneOffset.UTC),
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+
+ fun signed(queryValue: String) =
+ configuration.authenticator.authenticate(
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl("https://bedrock-mantle.us-east-1.api.aws/openai/v1")
+ .addPathSegment("responses")
+ .putQueryParam("preview", queryValue)
+ .body(StringBody("{}"))
+ .build()
+ )
+
+ val first = signed("one")
+ val second = signed("two")
+
+ assertThat(first.headers.values("Content-Type")).containsExactly("application/json")
+ assertThat(first.headers.values("Authorization").single())
+ .isNotEqualTo(second.headers.values("Authorization").single())
+ }
+
+ @Test
+ fun rejectsCustomAuthorizationHeader() {
+ val configuration =
+ options(awsRegion = "us-east-1", apiKey = "token")
+ .resolve(getenv = { null }, regionProvider = { null })
+ val request = request().toBuilder().putHeader("Authorization", "Bearer custom").build()
+
+ assertThatThrownBy { configuration.authenticator.authenticate(request) }
+ .isInstanceOf(OpenAIException::class.java)
+ .hasMessageContaining("custom `Authorization` header")
+ }
+
+ @Test
+ fun rejectsCanonicalEndpointRegionMismatch() {
+ val configuration =
+ options(
+ awsRegion = "us-west-2",
+ baseUrl = "https://bedrock-mantle.us-east-1.api.aws/openai/v1",
+ awsAccessKeyId = "access",
+ awsSecretAccessKey = "secret",
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+
+ assertThatThrownBy { configuration.authenticator.authenticate(request()) }
+ .isInstanceOf(OpenAIException::class.java)
+ .hasMessageContaining("does not match the SigV4 region")
+ }
+
+ @Test
+ fun missingRegionIsActionable() {
+ assertThatThrownBy {
+ options(apiKey = "token").resolve(getenv = { null }, regionProvider = { null })
+ }
+ .isInstanceOf(OpenAIException::class.java)
+ .hasMessageContaining("AWS region")
+ }
+
+ @Test
+ fun rejectsPartialStaticCredentials() {
+ assertThatThrownBy {
+ options(awsRegion = "us-east-1", awsAccessKeyId = "access")
+ .resolve(getenv = { null }, regionProvider = { null })
+ }
+ .isInstanceOf(OpenAIException::class.java)
+ .hasMessageContaining("both `awsAccessKeyId` and `awsSecretAccessKey`")
+ }
+
+ @Test
+ fun rejectsConflictingExplicitAuthModes() {
+ assertThatThrownBy {
+ options(
+ awsRegion = "us-east-1",
+ apiKey = "bearer",
+ awsAccessKeyId = "access",
+ awsSecretAccessKey = "secret",
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+ }
+ .isInstanceOf(OpenAIException::class.java)
+ .hasMessageContaining("mutually exclusive")
+ }
+
+ @Test
+ fun rejectsEmptyAndAmbiguousConfiguration() {
+ assertThatThrownBy {
+ options(awsRegion = "us-east-1", apiKey = " ")
+ .resolve(getenv = { null }, regionProvider = { null })
+ }
+ .hasMessageContaining("bearer credential must not be empty")
+ assertThatThrownBy {
+ options(
+ awsRegion = "us-east-1",
+ apiKey = "token",
+ tokenProvider = Supplier { "token" },
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+ }
+ .hasMessageContaining("mutually exclusive")
+ assertThatThrownBy {
+ options(awsRegion = "us-east-1", awsProfile = " ")
+ .resolve(getenv = { null }, regionProvider = { null })
+ }
+ .hasMessageContaining("awsProfile")
+ assertThatThrownBy {
+ options(
+ awsRegion = "us-east-1",
+ awsAccessKeyId = "access",
+ awsSecretAccessKey = "secret",
+ awsProfile = "profile",
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+ }
+ .hasMessageContaining("exactly one explicit AWS mode")
+ assertThatThrownBy {
+ options(awsRegion = "us-east-1", apiKey = "token", skipAuth = true)
+ .resolve(getenv = { null }, regionProvider = { null })
+ }
+ .hasMessageContaining("skipAuth")
+ assertThatThrownBy {
+ options(
+ awsRegion = "us-east-1",
+ awsAccessKeyId = " ",
+ awsSecretAccessKey = "secret",
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+ }
+ .hasMessageContaining("non-empty")
+ assertThatThrownBy {
+ options(
+ awsRegion = "us-east-1",
+ awsAccessKeyId = "access",
+ awsSecretAccessKey = "secret",
+ awsSessionToken = " ",
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+ }
+ .hasMessageContaining("awsSessionToken")
+ assertThatThrownBy {
+ options(awsRegion = " ", apiKey = "token")
+ .resolve(getenv = { null }, regionProvider = { null })
+ }
+ .hasMessageContaining("awsRegion")
+ assertThatThrownBy {
+ options(baseUrl = "not a URL", apiKey = "token")
+ .resolve(getenv = { null }, regionProvider = { null })
+ }
+ .hasMessageContaining("valid HTTP URL")
+ }
+
+ @Test
+ fun credentialProviderFailuresAreActionableAndProviderIsClosed() {
+ val failingConfiguration =
+ options(
+ awsRegion = "us-east-1",
+ awsCredentialsProvider =
+ AwsCredentialsProvider { throw IllegalStateException("credential failure") },
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+ assertThatThrownBy { failingConfiguration.authenticator.authenticate(request()) }
+ .isInstanceOf(OpenAIException::class.java)
+ .hasMessageContaining("Failed to resolve AWS credentials")
+ .hasCauseInstanceOf(IllegalStateException::class.java)
+
+ val emptyCredentials =
+ object : AwsCredentials {
+ override fun accessKeyId(): String = ""
+
+ override fun secretAccessKey(): String = ""
+ }
+ val invalidConfiguration =
+ options(
+ awsRegion = "us-east-1",
+ awsCredentialsProvider = AwsCredentialsProvider { emptyCredentials },
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+ assertThatThrownBy { invalidConfiguration.authenticator.authenticate(request()) }
+ .isInstanceOf(OpenAIException::class.java)
+ .hasMessageContaining("Failed to resolve AWS credentials")
+
+ val closeableProvider = CloseableCredentialsProvider()
+ val closeableConfiguration =
+ options(awsRegion = "us-east-1", awsCredentialsProvider = closeableProvider)
+ .resolve(getenv = { null }, regionProvider = { null })
+ closeableConfiguration.authenticator.authenticate(request())
+ closeableConfiguration.authenticator.close()
+
+ assertThat(closeableProvider.closed).isTrue()
+ }
+
+ @Test
+ fun rejectsNonReplayableRequestBodyBeforeCredentialResolution() {
+ val configuration =
+ options(
+ awsRegion = "us-east-1",
+ awsAccessKeyId = "AKIDEXAMPLE",
+ awsSecretAccessKey = "secret",
+ )
+ .resolve(getenv = { null }, regionProvider = { null })
+ val request = request().toBuilder().body(StringBody("body", repeatable = false)).build()
+
+ assertThatThrownBy { configuration.authenticator.authenticate(request) }
+ .isInstanceOf(OpenAIException::class.java)
+ .hasMessageContaining("replayable request body")
+ }
+
+ private fun request(): HttpRequest =
+ HttpRequest.builder()
+ .method(HttpMethod.POST)
+ .baseUrl("https://bedrock-mantle.us-east-1.api.aws/openai/v1")
+ .addPathSegment("responses")
+ .putHeader("Content-Type", "application/json")
+ .body(StringBody("{}"))
+ .build()
+
+ private fun options(
+ awsRegion: String? = null,
+ baseUrl: String? = null,
+ apiKey: String? = null,
+ tokenProvider: Supplier? = null,
+ awsAccessKeyId: String? = null,
+ awsSecretAccessKey: String? = null,
+ awsSessionToken: String? = null,
+ awsProfile: String? = null,
+ awsCredentialsProvider: AwsCredentialsProvider? = null,
+ skipAuth: Boolean = false,
+ clock: Clock = Clock.systemUTC(),
+ ) =
+ BedrockAuthOptions(
+ awsRegion = awsRegion,
+ baseUrl = baseUrl,
+ apiKey = apiKey,
+ tokenProvider = tokenProvider,
+ awsAccessKeyId = awsAccessKeyId,
+ awsSecretAccessKey = awsSecretAccessKey,
+ awsSessionToken = awsSessionToken,
+ awsProfile = awsProfile,
+ awsCredentialsProvider = awsCredentialsProvider,
+ skipAuth = skipAuth,
+ clock = clock,
+ )
+
+ private class CloseableCredentialsProvider : AwsCredentialsProvider, AutoCloseable {
+ var closed = false
+
+ override fun resolveCredentials(): AwsCredentials =
+ AwsBasicCredentials.create("CLOSEABLEACCESSKEY", "closeable-secret-key")
+
+ override fun close() {
+ closed = true
+ }
+ }
+
+ private class StringBody(value: String, private val repeatable: Boolean = true) :
+ HttpRequestBody {
+ private val bytes = value.toByteArray()
+
+ override fun writeTo(outputStream: OutputStream) = outputStream.write(bytes)
+
+ override fun contentType(): String = "application/json"
+
+ override fun contentLength(): Long = bytes.size.toLong()
+
+ override fun repeatable(): Boolean = repeatable
+
+ override fun close() {}
+ }
+}
diff --git a/openai-java-bedrock/src/test/kotlin/com/openai/client/okhttp/BedrockOpenAIOkHttpClientTest.kt b/openai-java-bedrock/src/test/kotlin/com/openai/client/okhttp/BedrockOpenAIOkHttpClientTest.kt
new file mode 100644
index 000000000..145c24bd6
--- /dev/null
+++ b/openai-java-bedrock/src/test/kotlin/com/openai/client/okhttp/BedrockOpenAIOkHttpClientTest.kt
@@ -0,0 +1,211 @@
+package com.openai.client.okhttp
+
+import com.github.tomakehurst.wiremock.client.WireMock.findAll
+import com.github.tomakehurst.wiremock.client.WireMock.get
+import com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor
+import com.github.tomakehurst.wiremock.client.WireMock.okJson
+import com.github.tomakehurst.wiremock.client.WireMock.serviceUnavailable
+import com.github.tomakehurst.wiremock.client.WireMock.stubFor
+import com.github.tomakehurst.wiremock.client.WireMock.temporaryRedirect
+import com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo
+import com.github.tomakehurst.wiremock.client.WireMock.verify
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo
+import com.github.tomakehurst.wiremock.junit5.WireMockTest
+import com.github.tomakehurst.wiremock.stubbing.Scenario
+import com.openai.core.LogLevel
+import com.openai.core.Sleeper
+import java.io.ByteArrayOutputStream
+import java.io.PrintStream
+import java.time.Clock
+import java.time.Duration
+import java.time.Instant
+import java.time.ZoneOffset
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.atomic.AtomicInteger
+import org.assertj.core.api.Assertions.assertThat
+import org.assertj.core.api.Assertions.assertThatThrownBy
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.parallel.ResourceLock
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider
+
+@WireMockTest
+@ResourceLock("https://github.com/wiremock/wiremock/issues/169")
+internal class BedrockOpenAIOkHttpClientTest {
+
+ @Test
+ fun retriesResolveFreshCredentialsAndSignAgain(wmRuntimeInfo: WireMockRuntimeInfo) {
+ stubRetryingModelsResponse()
+ val providerCalls = AtomicInteger()
+ val provider = rotatingProvider(providerCalls)
+ val client = client(wmRuntimeInfo.httpBaseUrl, provider)
+
+ val models = client.models().list()
+
+ assertThat(models.data()).isEmpty()
+ assertThat(providerCalls).hasValue(2)
+ verify(2, getRequestedFor(urlPathEqualTo("/models")))
+ val requests = findAll(getRequestedFor(urlPathEqualTo("/models")))
+ assertThat(requests[0].getHeader("Authorization")).contains("Credential=FIRSTACCESSKEY/")
+ assertThat(requests[1].getHeader("Authorization")).contains("Credential=SECONDACCESSKEY/")
+ client.close()
+ }
+
+ @Test
+ fun asyncClientUsesTheSamePerAttemptSigningPath(wmRuntimeInfo: WireMockRuntimeInfo) {
+ stubRetryingModelsResponse()
+ val providerCalls = AtomicInteger()
+ val client = client(wmRuntimeInfo.httpBaseUrl, rotatingProvider(providerCalls)).async()
+
+ val models = client.models().list().join()
+
+ assertThat(models.data()).isEmpty()
+ assertThat(providerCalls).hasValue(2)
+ verify(2, getRequestedFor(urlPathEqualTo("/models")))
+ client.close()
+ }
+
+ @Test
+ fun bearerProviderResolvesFreshTokenOnEveryRetry(wmRuntimeInfo: WireMockRuntimeInfo) {
+ stubRetryingModelsResponse()
+ val providerCalls = AtomicInteger()
+ val client =
+ BedrockOpenAIOkHttpClient.builder()
+ .baseUrl(wmRuntimeInfo.httpBaseUrl)
+ .bedrockTokenProvider { "token-${providerCalls.incrementAndGet()}" }
+ .sleeper(NoopSleeper)
+ .maxRetries(1)
+ .build()
+
+ val models = client.models().list()
+
+ assertThat(models.data()).isEmpty()
+ assertThat(providerCalls).hasValue(2)
+ val requests = findAll(getRequestedFor(urlPathEqualTo("/models")))
+ assertThat(requests.map { it.getHeader("Authorization") })
+ .containsExactly("Bearer token-1", "Bearer token-2")
+ client.close()
+ }
+
+ @Test
+ fun skipAuthSendsUnsignedRequests(wmRuntimeInfo: WireMockRuntimeInfo) {
+ stubFor(
+ get(urlPathEqualTo("/models")).willReturn(okJson("{\"object\":\"list\",\"data\":[]}"))
+ )
+ val client =
+ BedrockOpenAIOkHttpClient.builder()
+ .baseUrl(wmRuntimeInfo.httpBaseUrl)
+ .skipAuth(true)
+ .maxRetries(0)
+ .build()
+
+ val models = client.models().list()
+
+ assertThat(models.data()).isEmpty()
+ assertThat(
+ findAll(getRequestedFor(urlPathEqualTo("/models")))
+ .single()
+ .getHeader("Authorization")
+ )
+ .isNull()
+ client.close()
+ }
+
+ @Test
+ fun signedRequestsDoNotFollowRedirects(wmRuntimeInfo: WireMockRuntimeInfo) {
+ stubFor(get(urlPathEqualTo("/models")).willReturn(temporaryRedirect("/redirected-models")))
+ stubFor(
+ get(urlPathEqualTo("/redirected-models"))
+ .willReturn(okJson("{\"object\":\"list\",\"data\":[]}"))
+ )
+ val client =
+ client(
+ wmRuntimeInfo.httpBaseUrl,
+ AwsCredentialsProvider {
+ AwsBasicCredentials.create("ACCESSKEY", "fixture-secret-access-key")
+ },
+ )
+
+ assertThatThrownBy { client.models().list() }.isInstanceOf(RuntimeException::class.java)
+ verify(1, getRequestedFor(urlPathEqualTo("/models")))
+ verify(0, getRequestedFor(urlPathEqualTo("/redirected-models")))
+ client.close()
+ }
+
+ @Test
+ @ResourceLock("System.err")
+ fun debugLogsRedactAwsAuthorizationAndSessionToken(wmRuntimeInfo: WireMockRuntimeInfo) {
+ stubFor(
+ get(urlPathEqualTo("/models")).willReturn(okJson("{\"object\":\"list\",\"data\":[]}"))
+ )
+ val originalError = System.err
+ val logOutput = ByteArrayOutputStream()
+ System.setErr(PrintStream(logOutput))
+
+ try {
+ val client =
+ BedrockOpenAIOkHttpClient.builder()
+ .baseUrl(wmRuntimeInfo.httpBaseUrl)
+ .awsRegion("us-east-1")
+ .awsAccessKeyId("LOGACCESSKEY")
+ .awsSecretAccessKey("log-secret-access-key")
+ .awsSessionToken("log-session-token")
+ .logLevel(LogLevel.DEBUG)
+ .maxRetries(0)
+ .build()
+
+ client.models().list()
+ client.close()
+ } finally {
+ System.setErr(originalError)
+ }
+
+ val logs = logOutput.toString("UTF-8")
+ assertThat(logs).contains("Authorization: ██")
+ assertThat(logs).contains("X-Amz-Security-Token: ██")
+ assertThat(logs).doesNotContain("LOGACCESSKEY")
+ assertThat(logs).doesNotContain("log-secret-access-key")
+ assertThat(logs).doesNotContain("log-session-token")
+ }
+
+ private fun client(baseUrl: String, provider: AwsCredentialsProvider) =
+ BedrockOpenAIOkHttpClient.builder()
+ .baseUrl(baseUrl)
+ .awsRegion("us-east-1")
+ .awsCredentialsProvider(provider)
+ .clock(Clock.fixed(Instant.parse("2026-06-01T12:34:56Z"), ZoneOffset.UTC))
+ .sleeper(NoopSleeper)
+ .maxRetries(1)
+ .build()
+
+ private fun rotatingProvider(providerCalls: AtomicInteger) = AwsCredentialsProvider {
+ val accessKey =
+ if (providerCalls.getAndIncrement() == 0) "FIRSTACCESSKEY" else "SECONDACCESSKEY"
+ AwsBasicCredentials.create(accessKey, "fixture-secret-access-key")
+ }
+
+ private fun stubRetryingModelsResponse() {
+ stubFor(
+ get(urlPathEqualTo("/models"))
+ .inScenario("retry")
+ .whenScenarioStateIs(Scenario.STARTED)
+ .willReturn(serviceUnavailable())
+ .willSetStateTo("success")
+ )
+ stubFor(
+ get(urlPathEqualTo("/models"))
+ .inScenario("retry")
+ .whenScenarioStateIs("success")
+ .willReturn(okJson("{\"object\":\"list\",\"data\":[]}"))
+ )
+ }
+
+ private object NoopSleeper : Sleeper {
+ override fun sleep(duration: Duration) {}
+
+ override fun sleepAsync(duration: Duration): CompletableFuture =
+ CompletableFuture.completedFuture(null)
+
+ override fun close() {}
+ }
+}
diff --git a/openai-java-bedrock/src/test/resources/fixtures/bedrock/v1/sigv4.json b/openai-java-bedrock/src/test/resources/fixtures/bedrock/v1/sigv4.json
new file mode 100644
index 000000000..441c63499
--- /dev/null
+++ b/openai-java-bedrock/src/test/resources/fixtures/bedrock/v1/sigv4.json
@@ -0,0 +1,23 @@
+{
+ "$comment": "Uses AWS SigV4 documentation test credentials. These are public examples, not real AWS credentials.",
+ "signingDate": "2025-01-02T03:04:05.000Z",
+ "region": "us-east-1",
+ "service": "bedrock-mantle",
+ "credentials": {
+ "accessKeyId": "AKIDEXAMPLE",
+ "secretAccessKey": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
+ "sessionToken": "fixture-session-token"
+ },
+ "request": {
+ "method": "POST",
+ "url": "https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses",
+ "body": "{\"model\":\"gpt-4o\",\"input\":\"hello\"}",
+ "contentType": "application/json"
+ },
+ "expected": {
+ "date": "20250102T030405Z",
+ "payloadHash": "50329e51ad520f21b77bad0b01999930ff556cd1bf18434701251ba6c9f877bc",
+ "canonicalRequestHash": "1b69b17ef7548a7bf16a6ee749acfd44b7793e04216345dddd6cfaf4c01bfde5",
+ "authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20250102/us-east-1/bedrock-mantle/aws4_request, SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=dc20c8fbe516daf0ccae7e5b7a78fc2936870413a9f1af6e1b2d44b970ce411f"
+ }
+}
diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OkHttpClient.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OkHttpClient.kt
index b4aa82d2f..5a034f7bd 100644
--- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OkHttpClient.kt
+++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OkHttpClient.kt
@@ -111,6 +111,7 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie
class Builder internal constructor() {
private var timeout: Timeout = Timeout.default()
+ private var followRedirects: Boolean = true
private var proxy: Proxy? = null
private var proxyAuthenticator: ProxyAuthenticator? = null
private var maxIdleConnections: Int? = null
@@ -124,6 +125,11 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie
fun timeout(timeout: Duration) = timeout(Timeout.builder().request(timeout).build())
+ @JvmSynthetic
+ internal fun followRedirects(followRedirects: Boolean) = apply {
+ this.followRedirects = followRedirects
+ }
+
fun proxy(proxy: Proxy?) = apply { this.proxy = proxy }
fun proxyAuthenticator(proxyAuthenticator: ProxyAuthenticator?) = apply {
@@ -173,6 +179,8 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie
okhttp3.OkHttpClient.Builder()
// `RetryingHttpClient` handles retries if the user enabled them.
.retryOnConnectionFailure(false)
+ .followRedirects(followRedirects)
+ .followSslRedirects(followRedirects)
.connectTimeout(timeout.connect())
.readTimeout(timeout.read())
.writeTimeout(timeout.write())
diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt
index 59ae7e571..31fd61632 100644
--- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt
+++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClient.kt
@@ -15,6 +15,7 @@ import com.openai.core.Timeout
import com.openai.core.http.AsyncStreamResponse
import com.openai.core.http.Headers
import com.openai.core.http.HttpClient
+import com.openai.core.http.HttpRequestAuthenticator
import com.openai.core.http.ProxyAuthenticator
import com.openai.core.http.QueryParams
import com.openai.credential.Credential
@@ -53,6 +54,7 @@ class OpenAIOkHttpClient private constructor() {
private var clientOptions: ClientOptions.Builder = ClientOptions.builder()
private var dispatcherExecutorService: ExecutorService? = null
+ private var followRedirects: Boolean = true
private var proxy: Proxy? = null
private var proxyAuthenticator: ProxyAuthenticator? = null
private var maxIdleConnections: Int? = null
@@ -73,6 +75,12 @@ class OpenAIOkHttpClient private constructor() {
this.dispatcherExecutorService = dispatcherExecutorService
}
+ /** Configures whether the underlying transport follows redirects automatically. */
+ @JvmSynthetic
+ fun followRedirects(followRedirects: Boolean) = apply {
+ this.followRedirects = followRedirects
+ }
+
/**
* Alias for calling [Builder.dispatcherExecutorService] with
* `dispatcherExecutorService.orElse(null)`.
@@ -315,6 +323,12 @@ class OpenAIOkHttpClient private constructor() {
fun credential(credential: Credential) = apply { clientOptions.credential(credential) }
+ /** Configures full-request authentication for an OpenAI-owned provider integration. */
+ @JvmSynthetic
+ fun httpRequestAuthenticator(httpRequestAuthenticator: HttpRequestAuthenticator) = apply {
+ clientOptions.httpRequestAuthenticator(httpRequestAuthenticator)
+ }
+
fun workloadIdentity(workloadIdentity: WorkloadIdentity?) = apply {
clientOptions.workloadIdentity(workloadIdentity)
}
@@ -447,6 +461,7 @@ class OpenAIOkHttpClient private constructor() {
.httpClient(
OkHttpClient.builder()
.timeout(clientOptions.timeout())
+ .followRedirects(followRedirects)
.proxy(proxy)
.proxyAuthenticator(proxyAuthenticator)
.maxIdleConnections(maxIdleConnections)
diff --git a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt
index a2756a623..65ff1c65c 100644
--- a/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt
+++ b/openai-java-client-okhttp/src/main/kotlin/com/openai/client/okhttp/OpenAIOkHttpClientAsync.kt
@@ -15,6 +15,7 @@ import com.openai.core.Timeout
import com.openai.core.http.AsyncStreamResponse
import com.openai.core.http.Headers
import com.openai.core.http.HttpClient
+import com.openai.core.http.HttpRequestAuthenticator
import com.openai.core.http.ProxyAuthenticator
import com.openai.core.http.QueryParams
import com.openai.credential.Credential
@@ -53,6 +54,7 @@ class OpenAIOkHttpClientAsync private constructor() {
private var clientOptions: ClientOptions.Builder = ClientOptions.builder()
private var dispatcherExecutorService: ExecutorService? = null
+ private var followRedirects: Boolean = true
private var proxy: Proxy? = null
private var proxyAuthenticator: ProxyAuthenticator? = null
private var maxIdleConnections: Int? = null
@@ -73,6 +75,12 @@ class OpenAIOkHttpClientAsync private constructor() {
this.dispatcherExecutorService = dispatcherExecutorService
}
+ /** Configures whether the underlying transport follows redirects automatically. */
+ @JvmSynthetic
+ fun followRedirects(followRedirects: Boolean) = apply {
+ this.followRedirects = followRedirects
+ }
+
/**
* Alias for calling [Builder.dispatcherExecutorService] with
* `dispatcherExecutorService.orElse(null)`.
@@ -315,6 +323,12 @@ class OpenAIOkHttpClientAsync private constructor() {
fun credential(credential: Credential) = apply { clientOptions.credential(credential) }
+ /** Configures full-request authentication for an OpenAI-owned provider integration. */
+ @JvmSynthetic
+ fun httpRequestAuthenticator(httpRequestAuthenticator: HttpRequestAuthenticator) = apply {
+ clientOptions.httpRequestAuthenticator(httpRequestAuthenticator)
+ }
+
fun workloadIdentity(workloadIdentity: WorkloadIdentity?) = apply {
clientOptions.workloadIdentity(workloadIdentity)
}
@@ -447,6 +461,7 @@ class OpenAIOkHttpClientAsync private constructor() {
.httpClient(
OkHttpClient.builder()
.timeout(clientOptions.timeout())
+ .followRedirects(followRedirects)
.proxy(proxy)
.proxyAuthenticator(proxyAuthenticator)
.maxIdleConnections(maxIdleConnections)
diff --git a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt
index e1c430085..f4f16bf85 100644
--- a/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt
+++ b/openai-java-core/src/main/kotlin/com/openai/core/ClientOptions.kt
@@ -10,8 +10,10 @@ import com.openai.azure.AzureUrlCategory
import com.openai.azure.AzureUrlPathMode
import com.openai.azure.credential.AzureApiKeyCredential
import com.openai.core.http.AsyncStreamResponse
+import com.openai.core.http.AuthenticatingHttpClient
import com.openai.core.http.Headers
import com.openai.core.http.HttpClient
+import com.openai.core.http.HttpRequestAuthenticator
import com.openai.core.http.LoggingHttpClient
import com.openai.core.http.PhantomReachableClosingHttpClient
import com.openai.core.http.QueryParams
@@ -42,6 +44,7 @@ private constructor(
* This class takes ownership of the client and closes it when closed.
*/
@get:JvmName("httpClient") val httpClient: HttpClient,
+ private val httpRequestAuthenticator: HttpRequestAuthenticator?,
/**
* Whether to throw an exception if any of the Jackson versions detected at runtime are
* incompatible with the SDK's minimum supported Jackson version (2.13.4).
@@ -191,6 +194,7 @@ private constructor(
class Builder internal constructor() {
private var httpClient: HttpClient? = null
+ private var httpRequestAuthenticator: HttpRequestAuthenticator? = null
private var checkJacksonVersionCompatibility: Boolean = true
private var jsonMapper: JsonMapper = jsonMapper()
private var streamHandlerExecutor: Executor? = null
@@ -216,6 +220,7 @@ private constructor(
@JvmSynthetic
internal fun from(clientOptions: ClientOptions) = apply {
httpClient = clientOptions.originalHttpClient
+ httpRequestAuthenticator = clientOptions.httpRequestAuthenticator
checkJacksonVersionCompatibility = clientOptions.checkJacksonVersionCompatibility
jsonMapper = clientOptions.jsonMapper
streamHandlerExecutor = clientOptions.streamHandlerExecutor
@@ -230,7 +235,10 @@ private constructor(
logLevel = clientOptions.logLevel
apiKey = clientOptions.apiKey
adminApiKey = clientOptions.adminApiKey
- credential = clientOptions.credential.takeUnless { it === AdminApiKeyOnlyCredential }
+ credential =
+ clientOptions.credential.takeUnless {
+ it === AdminApiKeyOnlyCredential || it === HttpRequestAuthenticatorCredential
+ }
azureServiceVersion = clientOptions.azureServiceVersion
azureUrlPathMode = clientOptions.azureUrlPathMode
organization = clientOptions.organization
@@ -249,6 +257,17 @@ private constructor(
this.httpClient = PhantomReachableClosingHttpClient(httpClient)
}
+ /**
+ * Configures authentication that must inspect the complete HTTP request.
+ *
+ * This is reserved for OpenAI-owned provider integrations. The authenticator runs once per
+ * request attempt after request construction and immediately before transport logging.
+ */
+ @JvmSynthetic
+ fun httpRequestAuthenticator(httpRequestAuthenticator: HttpRequestAuthenticator?) = apply {
+ this.httpRequestAuthenticator = httpRequestAuthenticator
+ }
+
/**
* Whether to throw an exception if any of the Jackson versions detected at runtime are
* incompatible with the SDK's minimum supported Jackson version (2.13.4).
@@ -504,6 +523,16 @@ private constructor(
httpClient: HttpClient,
jsonMapper: JsonMapper,
): Credential {
+ if (httpRequestAuthenticator != null) {
+ if (
+ credential != null || workloadIdentity != null || !adminApiKey.isNullOrEmpty()
+ ) {
+ throw IllegalStateException(
+ "Provider authentication cannot be combined with credential (apiKey), workloadIdentity, or adminApiKey"
+ )
+ }
+ return HttpRequestAuthenticatorCredential
+ }
if (
credential != null &&
credential !== AdminApiKeyOnlyCredential &&
@@ -669,21 +698,32 @@ private constructor(
val effectiveWorkloadIdentityAuth =
(credential as? WorkloadIdentityCredential)?.getAuth()
- val workloadIdentityHttpClient =
- WorkloadIdentityHttpClient(
- delegate = httpClient,
- workloadIdentityAuth = effectiveWorkloadIdentityAuth,
- )
+ val loggingDelegate =
+ if (httpRequestAuthenticator != null) httpClient
+ else
+ WorkloadIdentityHttpClient(
+ delegate = httpClient,
+ workloadIdentityAuth = effectiveWorkloadIdentityAuth,
+ )
+
+ val loggingHttpClient =
+ LoggingHttpClient.builder()
+ .httpClient(loggingDelegate)
+ .clock(clock)
+ .level(logLevel)
+ .build()
+
+ val perAttemptHttpClient =
+ httpRequestAuthenticator?.let { authenticator ->
+ AuthenticatingHttpClient(
+ delegate = loggingHttpClient,
+ authenticator = authenticator,
+ )
+ } ?: loggingHttpClient
val wrappedHttpClient =
RetryingHttpClient.builder()
- .httpClient(
- LoggingHttpClient.builder()
- .httpClient(workloadIdentityHttpClient)
- .clock(clock)
- .level(logLevel)
- .build()
- )
+ .httpClient(perAttemptHttpClient)
.sleeper(sleeper)
.clock(clock)
.maxRetries(maxRetries)
@@ -692,6 +732,7 @@ private constructor(
return ClientOptions(
httpClient,
wrappedHttpClient,
+ httpRequestAuthenticator,
checkJacksonVersionCompatibility,
jsonMapper,
streamHandlerExecutor,
@@ -735,7 +776,9 @@ private constructor(
@JvmSynthetic
internal fun securityHeaders(security: SecurityOptions): Headers {
val headers = Headers.builder()
- var isSatisfied = false
+ var isSatisfied =
+ credential === HttpRequestAuthenticatorCredential &&
+ (security.bearerAuth || security.adminApiKeyAuth)
if (security.bearerAuth) {
when {
@@ -753,6 +796,9 @@ private constructor(
credential is WorkloadIdentityCredential -> {
isSatisfied = true
}
+ credential === HttpRequestAuthenticatorCredential -> {
+ isSatisfied = true
+ }
}
}
if (security.adminApiKeyAuth) {
@@ -780,3 +826,5 @@ private constructor(
}
private object AdminApiKeyOnlyCredential : Credential
+
+private object HttpRequestAuthenticatorCredential : Credential
diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/AuthenticatingHttpClient.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/AuthenticatingHttpClient.kt
new file mode 100644
index 000000000..0358eb63b
--- /dev/null
+++ b/openai-java-core/src/main/kotlin/com/openai/core/http/AuthenticatingHttpClient.kt
@@ -0,0 +1,26 @@
+package com.openai.core.http
+
+import com.openai.core.RequestOptions
+import java.util.concurrent.CompletableFuture
+
+internal class AuthenticatingHttpClient(
+ private val delegate: HttpClient,
+ private val authenticator: HttpRequestAuthenticator,
+) : HttpClient {
+
+ override fun execute(request: HttpRequest, requestOptions: RequestOptions): HttpResponse =
+ delegate.execute(authenticator.authenticate(request), requestOptions)
+
+ override fun executeAsync(
+ request: HttpRequest,
+ requestOptions: RequestOptions,
+ ): CompletableFuture =
+ authenticator.authenticateAsync(request).thenCompose { authenticated ->
+ delegate.executeAsync(authenticated, requestOptions)
+ }
+
+ override fun close() {
+ authenticator.close()
+ delegate.close()
+ }
+}
diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/HttpRequestAuthenticator.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/HttpRequestAuthenticator.kt
new file mode 100644
index 000000000..8fbf9ec3a
--- /dev/null
+++ b/openai-java-core/src/main/kotlin/com/openai/core/http/HttpRequestAuthenticator.kt
@@ -0,0 +1,26 @@
+package com.openai.core.http
+
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Authenticates a fully constructed HTTP request immediately before it is sent.
+ *
+ * This hook is intended for OpenAI-owned provider integrations whose authentication depends on the
+ * complete request, such as AWS SigV4. It runs once per request attempt, including retries, before
+ * transport logging so signed headers can be redacted consistently.
+ */
+interface HttpRequestAuthenticator : AutoCloseable {
+
+ fun authenticate(request: HttpRequest): HttpRequest
+
+ fun authenticateAsync(request: HttpRequest): CompletableFuture =
+ try {
+ CompletableFuture.completedFuture(authenticate(request))
+ } catch (throwable: Throwable) {
+ val future = CompletableFuture()
+ future.completeExceptionally(throwable)
+ future
+ }
+
+ override fun close() {}
+}
diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/LoggingHttpClient.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/LoggingHttpClient.kt
index 464a6f690..8085c82c7 100644
--- a/openai-java-core/src/main/kotlin/com/openai/core/http/LoggingHttpClient.kt
+++ b/openai-java-core/src/main/kotlin/com/openai/core/http/LoggingHttpClient.kt
@@ -193,7 +193,14 @@ private constructor(
private var httpClient: HttpClient? = null
private var redactedHeaders: Set =
- setOf("authorization", "api-key", "x-api-key", "cookie", "set-cookie")
+ setOf(
+ "authorization",
+ "api-key",
+ "x-api-key",
+ "x-amz-security-token",
+ "cookie",
+ "set-cookie",
+ )
private var clock: Clock = Clock.systemUTC()
private var level: LogLevel? = null
@@ -211,7 +218,8 @@ private constructor(
/**
* Sensitive headers to redact from logs.
*
- * Defaults to `Set.of("authorization", "api-key", "x-api-key", "cookie", "set-cookie")`.
+ * Defaults to `Set.of("authorization", "api-key", "x-api-key", "x-amz-security-token",
+ * "cookie", "set-cookie")`.
*/
fun redactedHeaders(redactedHeaders: Set) = apply {
this.redactedHeaders = redactedHeaders
diff --git a/openai-java-core/src/test/kotlin/com/openai/core/ClientOptionsTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/ClientOptionsTest.kt
index 9677f51b4..46a2857a3 100644
--- a/openai-java-core/src/test/kotlin/com/openai/core/ClientOptionsTest.kt
+++ b/openai-java-core/src/test/kotlin/com/openai/core/ClientOptionsTest.kt
@@ -8,6 +8,8 @@ import com.openai.auth.SubjectTokenType
import com.openai.auth.WorkloadIdentity
import com.openai.azure.credential.AzureApiKeyCredential
import com.openai.core.http.HttpClient
+import com.openai.core.http.HttpRequest
+import com.openai.core.http.HttpRequestAuthenticator
import com.openai.credential.BearerTokenCredential
import com.openai.credential.WorkloadIdentityCredential
import java.util.concurrent.CompletableFuture
@@ -133,6 +135,52 @@ internal class ClientOptionsTest {
assertThat(thrown.message).contains("At least one credential source")
}
+ @Test
+ fun build_withHttpRequestAuthenticator_satisfiesAuthenticationAndSurvivesCloning() {
+ val authenticator =
+ object : HttpRequestAuthenticator {
+ override fun authenticate(request: HttpRequest): HttpRequest = request
+ }
+
+ val clientOptions =
+ ClientOptions.builder()
+ .httpClient(httpClient)
+ .httpRequestAuthenticator(authenticator)
+ .build()
+ .toBuilder()
+ .build()
+
+ assertThat(
+ clientOptions.securityHeaders(SecurityOptions.builder().bearerAuth(true).build())
+ )
+ .isEqualTo(com.openai.core.http.Headers.builder().build())
+ assertThat(
+ clientOptions.securityHeaders(
+ SecurityOptions.builder().adminApiKeyAuth(true).build()
+ )
+ )
+ .isEqualTo(com.openai.core.http.Headers.builder().build())
+ }
+
+ @Test
+ fun build_withHttpRequestAuthenticatorAndApiKey_throws() {
+ val authenticator =
+ object : HttpRequestAuthenticator {
+ override fun authenticate(request: HttpRequest): HttpRequest = request
+ }
+
+ val thrown =
+ assertThrows {
+ ClientOptions.builder()
+ .httpClient(httpClient)
+ .httpRequestAuthenticator(authenticator)
+ .apiKey("test-api-key")
+ .build()
+ }
+
+ assertThat(thrown.message).contains("Provider authentication cannot be combined")
+ }
+
@Test
fun putHeader_canOverwriteDefaultHeader() {
val clientOptions =
diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/LoggingHttpClientTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/LoggingHttpClientTest.kt
index 222abedc6..8488c1be7 100644
--- a/openai-java-core/src/test/kotlin/com/openai/core/http/LoggingHttpClientTest.kt
+++ b/openai-java-core/src/test/kotlin/com/openai/core/http/LoggingHttpClientTest.kt
@@ -246,7 +246,7 @@ internal class LoggingHttpClientTest {
loggingClient(
fakeHttpClient(),
LogLevel.DEBUG,
- redactedHeaders = setOf("Authorization", "X-Secret"),
+ redactedHeaders = setOf("Authorization", "X-Amz-Security-Token", "X-Secret"),
)
client
@@ -256,6 +256,7 @@ internal class LoggingHttpClientTest {
.baseUrl("https://api.example.com")
.addPathSegment("test")
.putHeader("Authorization", "Bearer token-123")
+ .putHeader("X-Amz-Security-Token", "aws-session-token")
.putHeader("X-Secret", "secret-value")
.putHeader("X-Public", "public-value")
.build(),
@@ -269,6 +270,7 @@ internal class LoggingHttpClientTest {
"""
|--> GET https://api.example.com/test
|Authorization: ██
+ |X-Amz-Security-Token: ██
|X-Public: public-value
|X-Secret: ██
|--> END GET
@@ -871,7 +873,14 @@ internal class LoggingHttpClientTest {
level: LogLevel,
clock: Clock = clockFrom(Instant.parse("1998-04-21T00:00:00Z")),
redactedHeaders: Set =
- setOf("authorization", "api-key", "x-api-key", "cookie", "set-cookie"),
+ setOf(
+ "authorization",
+ "api-key",
+ "x-api-key",
+ "x-amz-security-token",
+ "cookie",
+ "set-cookie",
+ ),
): LoggingHttpClient =
LoggingHttpClient.builder()
.httpClient(httpClient)
diff --git a/openai-java-example/build.gradle.kts b/openai-java-example/build.gradle.kts
index 5f729ccd6..287258fe8 100644
--- a/openai-java-example/build.gradle.kts
+++ b/openai-java-example/build.gradle.kts
@@ -9,6 +9,7 @@ repositories {
dependencies {
implementation(project(":openai-java"))
+ implementation(project(":openai-java-bedrock"))
implementation("com.azure:azure-identity:1.15.0")
}
diff --git a/openai-java-example/src/main/java/com/openai/example/BedrockResponsesExample.java b/openai-java-example/src/main/java/com/openai/example/BedrockResponsesExample.java
new file mode 100644
index 000000000..8fb9ce6fa
--- /dev/null
+++ b/openai-java-example/src/main/java/com/openai/example/BedrockResponsesExample.java
@@ -0,0 +1,26 @@
+package com.openai.example;
+
+import com.openai.client.OpenAIClient;
+import com.openai.client.okhttp.BedrockOpenAIOkHttpClient;
+import com.openai.models.responses.ResponseCreateParams;
+
+public final class BedrockResponsesExample {
+ private BedrockResponsesExample() {}
+
+ public static void main(String[] args) {
+ // Uses the standard AWS credential chain, including ~/.aws/credentials and AWS_PROFILE.
+ OpenAIClient client =
+ BedrockOpenAIOkHttpClient.builder().awsRegion("us-east-1").build();
+
+ ResponseCreateParams params = ResponseCreateParams.builder()
+ .input("Say hello from OpenAI on Bedrock")
+ .model("openai.gpt-oss-120b")
+ .build();
+
+ client.responses().create(params).output().stream()
+ .flatMap(item -> item.message().stream())
+ .flatMap(message -> message.content().stream())
+ .flatMap(content -> content.outputText().stream())
+ .forEach(outputText -> System.out.println(outputText.text()));
+ }
+}
diff --git a/openai-java-example/src/main/java/com/openai/example/BedrockResponsesStreamingAsyncExample.java b/openai-java-example/src/main/java/com/openai/example/BedrockResponsesStreamingAsyncExample.java
new file mode 100644
index 000000000..d65baead2
--- /dev/null
+++ b/openai-java-example/src/main/java/com/openai/example/BedrockResponsesStreamingAsyncExample.java
@@ -0,0 +1,27 @@
+package com.openai.example;
+
+import com.openai.client.OpenAIClientAsync;
+import com.openai.client.okhttp.BedrockOpenAIOkHttpClient;
+import com.openai.models.responses.ResponseCreateParams;
+
+public final class BedrockResponsesStreamingAsyncExample {
+ private BedrockResponsesStreamingAsyncExample() {}
+
+ public static void main(String[] args) {
+ OpenAIClientAsync client = BedrockOpenAIOkHttpClient.builder()
+ .awsRegion("us-east-1")
+ .build()
+ .async();
+
+ ResponseCreateParams params = ResponseCreateParams.builder()
+ .input("Count to five")
+ .model("openai.gpt-oss-120b")
+ .build();
+
+ client.responses()
+ .createStreaming(params)
+ .subscribe(event -> event.outputTextDelta().ifPresent(delta -> System.out.print(delta.delta())))
+ .onCompleteFuture()
+ .join();
+ }
+}
diff --git a/release-please-config.json b/release-please-config.json
index 8f987198a..49224ae00 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -62,6 +62,7 @@
"release-type": "simple",
"extra-files": [
"README.md",
+ "bedrock.md",
"build.gradle.kts"
]
-}
\ No newline at end of file
+}