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 +}