diff --git a/bedrock.md b/bedrock.md index db928aa82..a9537f64e 100644 --- a/bedrock.md +++ b/bedrock.md @@ -1,8 +1,9 @@ # 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. +OpenAI-compatible Amazon Bedrock Mantle and Runtime endpoints. It uses the AWS SDK for Java 2.x +credential chain and signs the final HTTP request with SigV4 on every attempt. Existing clients +continue to use Mantle by default. ## Installation @@ -24,6 +25,66 @@ implementation("com.openai:openai-java-bedrock:4.51.0") +## Bedrock Runtime + +Select `BedrockEndpoint.RUNTIME` to use the Bedrock Runtime OpenAI-compatible endpoint. This +selects the regional `bedrock-runtime` hostname and the `bedrock` SigV4 signing service while +retaining the standard OpenAI Chat Completions and Responses APIs: + +```java +import com.openai.bedrock.BedrockEndpoint; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.BedrockOpenAIOkHttpClient; +import com.openai.models.chat.completions.ChatCompletionCreateParams; + +OpenAIClient client = BedrockOpenAIOkHttpClient.builder() + .endpoint(BedrockEndpoint.RUNTIME) + .awsRegion("us-east-1") + .build(); + +ChatCompletionCreateParams params = ChatCompletionCreateParams.builder() + .model("us.openai.gpt-5.6-sol") + .addUserMessage("Say hello from Amazon Bedrock Runtime") + .build(); + +client.chat().completions().create(params).choices().stream() + .flatMap(choice -> choice.message().content().stream()) + .forEach(System.out::println); +``` + +Use the cross-region inference-profile identifier configured for your AWS account and region. +Examples include `us.openai.gpt-5.6-sol`, `us.openai.gpt-5.6-terra`, and +`us.openai.gpt-5.6-luna`; AWS rejects bare model identifiers for these deployments. Availability, +global inference-profile access, supported API routes, authentication methods, streaming behavior, +and model permissions depend on AWS account configuration and the selected model. + +The default Runtime URL is `https://bedrock-runtime.{region}.amazonaws.com/openai/v1` in standard +AWS regions. The SDK selects the appropriate DNS suffix automatically for China, European Sovereign +Cloud, and ISO partitions. Canonical Runtime URLs, including FIPS and dual-stack URLs, also infer +Runtime mode when passed to `baseUrl(...)` or `AWS_BEDROCK_BASE_URL`. + +Run the included example with your existing AWS credentials: + +```shell +AWS_REGION=us-east-1 BEDROCK_MODEL=us.openai.gpt-5.6-sol \ + ./gradlew :openai-java-example:run -Pexample=BedrockRuntimeChat +``` + +Set `BEDROCK_STREAM=true` to stream Chat Completions. Set `BEDROCK_AUTH=bearer` and +`AWS_BEARER_TOKEN_BEDROCK` to use a Bedrock bearer token instead of SigV4. Set `AWS_PROFILE` to +select an explicit AWS profile and ensure a stale environment bearer token does not take precedence. + +An opt-in live test exercises Sol, Terra, and Luna inference profiles using real AWS credentials: + +```shell +BEDROCK_LIVE_TEST=1 AWS_REGION=us-east-1 \ + ./gradlew :openai-java-bedrock:test --tests '*BedrockRuntimeLiveTest' +``` + +Set `BEDROCK_LIVE_AUTH=bearer`, `BEDROCK_LIVE_API=responses`, or +`BEDROCK_LIVE_STREAM=true` to choose the authentication mode, API, and streaming behavior. Use +`BEDROCK_LIVE_MODELS` to provide a comma-separated set of account-enabled inference profiles. + ## Standard AWS credentials Configure AWS credentials as you normally would, then provide the region: @@ -54,12 +115,14 @@ Base URL resolution follows this order: 1. `baseUrl(...)` 2. `AWS_BEDROCK_BASE_URL` -3. `https://bedrock-mantle.{region}.api.aws/openai/v1` +3. the regional endpoint selected by `endpoint(...)`: + - Mantle: `https://bedrock-mantle.{region}.api.aws/openai/v1` + - Runtime: `https://bedrock-runtime.{region}.amazonaws.com/openai/v1` -The `bedrock-mantle` SigV4 service name is intentional. Bedrock's OpenAI-compatible route is -model-dependent: models such as `openai.gpt-5.5` use `/openai/v1`, while -`openai.gpt-oss-120b` uses `/v1`. The builder defaults to `/openai/v1`; configure the -model's documented route explicitly when it differs: +Mantle requests use the `bedrock-mantle` SigV4 service name. Runtime requests use the `bedrock` +SigV4 service name. Bedrock's OpenAI-compatible route is model-dependent: models such as +`openai.gpt-5.5` use `/openai/v1`, while `openai.gpt-oss-120b` uses `/v1`. Both endpoint families +default to `/openai/v1`; configure the model's documented route explicitly when it differs: ```java OpenAIClient client = BedrockOpenAIOkHttpClient.builder() @@ -68,6 +131,16 @@ OpenAIClient client = BedrockOpenAIOkHttpClient.builder() .build(); ``` +Runtime deployments that require the `/v1` route can be configured similarly: + +```java +OpenAIClient client = BedrockOpenAIOkHttpClient.builder() + .endpoint(BedrockEndpoint.RUNTIME) + .awsRegion("us-east-1") + .baseUrl("https://bedrock-runtime.us-east-1.amazonaws.com/v1") + .build(); +``` + ## Named profile ```java @@ -115,6 +188,10 @@ OpenAIClient client = BedrockOpenAIOkHttpClient.builder() Explicit bearer and AWS credential modes are mutually exclusive. +If a shell contains a stale `AWS_BEARER_TOKEN_BEDROCK`, default-chain authentication will use that +token instead of signing with SigV4. Unset the variable, or select an explicit AWS profile, +credentials provider, or static credentials to force SigV4. + ## Async and streaming responses The same client configuration supports asynchronous and streaming calls: @@ -135,6 +212,10 @@ pass `authenticationExecutor(...)` to use a caller-owned executor instead. - Do not ship AWS credentials in browser or untrusted client applications. - Prefer temporary credentials, roles, profiles, and workload identities over long-lived keys. +- Canonical AWS Bedrock URLs require HTTPS. Their endpoint family and region must match the + selected endpoint and configured signing region. +- Custom proxy or test-server endpoints default to Mantle signing. Explicitly select Runtime when + a custom hostname should use the Bedrock Runtime signing service. - 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/src/main/kotlin/com/openai/bedrock/BedrockAuth.kt b/openai-java-bedrock/src/main/kotlin/com/openai/bedrock/BedrockAuth.kt index f6a0e65dc..48267498c 100644 --- a/openai-java-bedrock/src/main/kotlin/com/openai/bedrock/BedrockAuth.kt +++ b/openai-java-bedrock/src/main/kotlin/com/openai/bedrock/BedrockAuth.kt @@ -29,19 +29,24 @@ 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.EndpointTag +import software.amazon.awssdk.regions.PartitionEndpointKey +import software.amazon.awssdk.regions.PartitionMetadata 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" +private val AWS_REGION_PATTERN = Regex("^[a-z]{2,8}(?:-[a-z0-9]+)+-\\d+$") internal data class BedrockConfiguration( + val endpoint: BedrockEndpoint, val baseUrl: String, val authenticator: HttpRequestAuthenticator, ) internal data class BedrockAuthOptions( + val endpoint: BedrockEndpoint?, val awsRegion: String?, val baseUrl: String?, val apiKey: String?, @@ -106,21 +111,38 @@ internal fun BedrockAuthOptions.resolve( ) } - val environmentBaseUrl = normalizeEnvironment(getenv(ENV_BASE_URL)) - val resolvedRegion by lazy { + val configuredRegion by lazy { 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) + val resolvedRegion by lazy { + (configuredRegion ?: regionProvider()?.id()).also(::validateRegion) + } + val configuredBaseUrl = normalizedBaseUrl ?: normalizeEnvironment(getenv(ENV_BASE_URL)) + val normalizedResolvedBaseUrl = + if (configuredBaseUrl != null) { + normalizeBaseUrl(configuredBaseUrl) + } else { + val region = + resolvedRegion + ?: throw OpenAIException( + "Bedrock requires an AWS region. Pass `awsRegion` to the builder, or set `AWS_REGION` or `AWS_DEFAULT_REGION`." + ) + val selectedEndpoint = endpoint ?: BedrockEndpoint.MANTLE + val hostname = + when (selectedEndpoint) { + BedrockEndpoint.MANTLE -> "bedrock-mantle.$region.api.aws" + BedrockEndpoint.RUNTIME -> + "bedrock-runtime.$region.${runtimeDnsSuffixes(region).first}" + } + "https://$hostname/openai/v1" + } + val parsedEndpoint = parseBedrockEndpointHostname(normalizedResolvedBaseUrl.toHttpUrl().host) + val resolvedEndpoint = endpoint ?: parsedEndpoint?.endpoint ?: BedrockEndpoint.MANTLE + val canonicalRegion = + if (parsedEndpoint != null) normalizedRegion?.also(::validateRegion) else null + validateCanonicalEndpoint(normalizedResolvedBaseUrl, resolvedEndpoint, canonicalRegion) val resolvedAuthenticationExecutor by lazy { authenticationExecutor ?: newAuthenticationExecutor() } @@ -128,6 +150,7 @@ internal fun BedrockAuthOptions.resolve( if (skipAuth) { return BedrockConfiguration( + resolvedEndpoint, normalizedResolvedBaseUrl, NoAuthAuthenticator(normalizedResolvedBaseUrl), ) @@ -149,6 +172,7 @@ internal fun BedrockAuthOptions.resolve( if (bearerSupplier != null) { return BedrockConfiguration( + resolvedEndpoint, normalizedResolvedBaseUrl, BearerAuthenticator( normalizedResolvedBaseUrl, @@ -164,6 +188,7 @@ internal fun BedrockAuthOptions.resolve( ?: throw OpenAIException( "Bedrock requires an AWS region. Pass `awsRegion` to the builder, or set `AWS_REGION` or `AWS_DEFAULT_REGION`." ) + validateCanonicalEndpoint(normalizedResolvedBaseUrl, resolvedEndpoint, region) val (credentialsProvider, ownsCredentialsProvider) = when { staticCredentials != null -> AwsCredentialsProvider { staticCredentials } to false @@ -174,9 +199,11 @@ internal fun BedrockAuthOptions.resolve( } return BedrockConfiguration( + resolvedEndpoint, normalizedResolvedBaseUrl, SigV4Authenticator( baseUrl = normalizedResolvedBaseUrl, + endpoint = resolvedEndpoint, region = Region.of(region), credentialsProvider = credentialsProvider, ownsCredentialsProvider = ownsCredentialsProvider, @@ -238,6 +265,59 @@ private fun normalize(name: String, value: String?): String? { private fun normalizeEnvironment(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() } +private fun validateRegion(region: String?) { + if (region != null && !AWS_REGION_PATTERN.matches(region)) { + throw OpenAIException( + "The Bedrock AWS region is invalid. Use a standard AWS region such as `us-east-1`." + ) + } +} + +private fun runtimeDnsSuffixes(region: String): Pair = + PartitionMetadata.of(Region.of(region)).let { partition -> + val dualStackEndpoint = PartitionEndpointKey.builder().tags(EndpointTag.DUALSTACK).build() + partition.dnsSuffix() to partition.dnsSuffix(dualStackEndpoint) + } + +private data class CanonicalBedrockEndpoint(val endpoint: BedrockEndpoint, val region: String) + +private fun parseBedrockEndpointHostname(hostname: String): CanonicalBedrockEndpoint? { + val parts = hostname.removeSuffix(".").lowercase().split('.') + if (parts.size < 3) return null + + val service = parts[0] + val region = parts[1] + val suffix = parts.drop(2).joinToString(".") + if (service == "bedrock-mantle" && suffix == "api.aws") { + return CanonicalBedrockEndpoint(BedrockEndpoint.MANTLE, region) + } + if (service == "bedrock-runtime" || service == "bedrock-runtime-fips") { + val (standardSuffix, dualStackSuffix) = runtimeDnsSuffixes(region) + if (suffix == standardSuffix || suffix == dualStackSuffix) { + return CanonicalBedrockEndpoint(BedrockEndpoint.RUNTIME, region) + } + } + return null +} + +private fun validateCanonicalEndpoint(baseUrl: String, endpoint: BedrockEndpoint, region: String?) { + val parsedUrl = baseUrl.toHttpUrl() + val canonical = parseBedrockEndpointHostname(parsedUrl.host) ?: return + if (parsedUrl.scheme != "https") { + throw OpenAIException("Canonical Amazon Bedrock endpoints require HTTPS.") + } + if (canonical.endpoint != endpoint) { + throw OpenAIException( + "The Bedrock ${canonical.endpoint.name.lowercase()} hostname does not match the selected `${endpoint.name.lowercase()}` endpoint." + ) + } + if (region != null && canonical.region != region) { + throw OpenAIException( + "The Bedrock endpoint region `${canonical.region}` does not match the configured AWS region `$region`." + ) + } +} + private fun normalizeBaseUrl(value: String): String = try { value.toHttpUrl().toString().removeSuffix("/") @@ -309,6 +389,7 @@ private class BearerAuthenticator( private class SigV4Authenticator( baseUrl: String, + private val endpoint: BedrockEndpoint, private val region: Region, private val credentialsProvider: AwsCredentialsProvider, private val ownsCredentialsProvider: Boolean, @@ -367,7 +448,7 @@ private class SigV4Authenticator( payload(ContentStreamProvider.fromByteArray(bodyBytes)) } } - .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, BEDROCK_SERVICE) + .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, endpoint.signingService) .putProperty(AwsV4HttpSigner.REGION_NAME, region.id()) .putProperty(HttpSigner.SIGNING_CLOCK, clock) } @@ -422,14 +503,15 @@ private class SigV4Authenticator( } 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()) { + val canonical = parseBedrockEndpointHostname(url.host) ?: return + if (canonical.endpoint != endpoint) { + throw OpenAIException( + "The Bedrock ${canonical.endpoint.name.lowercase()} hostname does not match the selected `${endpoint.name.lowercase()}` endpoint." + ) + } + if (canonical.region != region.id()) { throw OpenAIException( - "The Bedrock endpoint region `$canonicalRegion` does not match the SigV4 region `${region.id()}`." + "The Bedrock endpoint region `${canonical.region}` does not match the SigV4 region `${region.id()}`." ) } } diff --git a/openai-java-bedrock/src/main/kotlin/com/openai/bedrock/BedrockEndpoint.kt b/openai-java-bedrock/src/main/kotlin/com/openai/bedrock/BedrockEndpoint.kt new file mode 100644 index 000000000..9780cc606 --- /dev/null +++ b/openai-java-bedrock/src/main/kotlin/com/openai/bedrock/BedrockEndpoint.kt @@ -0,0 +1,13 @@ +package com.openai.bedrock + +/** Selects an Amazon Bedrock endpoint family and its matching AWS SigV4 signing service. */ +enum class BedrockEndpoint { + /** Uses the existing Bedrock Mantle endpoint and the `bedrock-mantle` signing service. */ + MANTLE, + + /** Uses the regional Bedrock Runtime endpoint and the `bedrock` signing service. */ + RUNTIME; + + internal val signingService: String + get() = if (this == RUNTIME) "bedrock" else "bedrock-mantle" +} 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 index eead9cc9b..ed33dd637 100644 --- 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 @@ -2,6 +2,7 @@ package com.openai.client.okhttp import com.fasterxml.jackson.databind.json.JsonMapper import com.openai.bedrock.BedrockAuthOptions +import com.openai.bedrock.BedrockEndpoint import com.openai.bedrock.resolve import com.openai.client.OpenAIClient import com.openai.core.LogLevel @@ -44,6 +45,7 @@ class BedrockOpenAIOkHttpClient private constructor() { class Builder internal constructor() { private val delegate = OpenAIOkHttpClient.builder().followRedirects(false) + private var endpoint: BedrockEndpoint? = null private var awsRegion: String? = null private var baseUrl: String? = null private var apiKey: String? = null @@ -57,6 +59,14 @@ class BedrockOpenAIOkHttpClient private constructor() { private var clock: Clock = Clock.systemUTC() private var authenticationExecutor: Executor? = null + /** + * Selects the Bedrock endpoint family and corresponding SigV4 signing service. + * + * Defaults to [BedrockEndpoint.MANTLE] unless a canonical Bedrock endpoint override + * identifies another family. Use [BedrockEndpoint.RUNTIME] for Runtime Chat Completions. + */ + fun endpoint(endpoint: BedrockEndpoint) = apply { this.endpoint = endpoint } + /** Sets the AWS region used for endpoint resolution and SigV4 signing. */ fun awsRegion(awsRegion: String?) = apply { this.awsRegion = awsRegion } @@ -68,7 +78,7 @@ class BedrockOpenAIOkHttpClient private constructor() { /** * Overrides the Bedrock API root. Defaults to `AWS_BEDROCK_BASE_URL`, then the regional - * `https://bedrock-mantle.{region}.api.aws/openai/v1` endpoint. + * Mantle or Runtime `/openai/v1` endpoint selected with [endpoint]. */ fun baseUrl(baseUrl: String?) = apply { this.baseUrl = baseUrl } @@ -221,6 +231,7 @@ class BedrockOpenAIOkHttpClient private constructor() { fun build(): OpenAIClient { val configuration = BedrockAuthOptions( + endpoint = endpoint, awsRegion = awsRegion, baseUrl = baseUrl, apiKey = apiKey, 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 index 3615cf5b5..2ccf8c2db 100644 --- a/openai-java-bedrock/src/test/kotlin/com/openai/bedrock/BedrockAuthTest.kt +++ b/openai-java-bedrock/src/test/kotlin/com/openai/bedrock/BedrockAuthTest.kt @@ -348,38 +348,284 @@ internal class BedrockAuthTest { .isEqualTo("https://bedrock-mantle.ap-southeast-2.api.aws/openai/v1") } + @Test + fun runtimeDerivesPartitionEndpointsAndSignsForTheBedrockService() { + val partitions = + listOf( + "us-east-1" to "amazonaws.com", + "cn-north-1" to "amazonaws.com.cn", + "eusc-de-east-1" to "amazonaws.eu", + "us-iso-east-1" to "c2s.ic.gov", + "us-isob-east-1" to "sc2s.sgov.gov", + "eu-isoe-west-1" to "cloud.adc-e.uk", + "us-isof-south-1" to "csp.hci.ic.gov", + ) + + partitions.forEach { (region, suffix) -> + val configuration = + options( + endpoint = BedrockEndpoint.RUNTIME, + awsRegion = region, + awsAccessKeyId = "ACCESSKEY", + awsSecretAccessKey = "secret", + ) + .resolve(getenv = { null }, regionProvider = { null }) + val baseUrl = "https://bedrock-runtime.$region.$suffix/openai/v1" + + assertThat(configuration.endpoint).isEqualTo(BedrockEndpoint.RUNTIME) + assertThat(configuration.baseUrl).isEqualTo(baseUrl) + assertThat( + configuration.authenticator + .authenticate(request(baseUrl)) + .headers + .values("Authorization") + .single() + ) + .contains("/$region/bedrock/aws4_request") + configuration.authenticator.close() + } + } + + @Test + fun canonicalRuntimeOverridesInferEndpointAndSigningService() { + val baseUrl = "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1" + val explicit = + options( + awsRegion = "us-east-1", + baseUrl = baseUrl, + awsAccessKeyId = "ACCESSKEY", + awsSecretAccessKey = "secret", + ) + .resolve(getenv = { null }, regionProvider = { null }) + val environment = + options(awsRegion = "us-east-1", apiKey = "token") + .resolve( + getenv = { name -> if (name == ENV_BASE_URL) baseUrl else null }, + regionProvider = { null }, + ) + + assertThat(explicit.endpoint).isEqualTo(BedrockEndpoint.RUNTIME) + assertThat( + explicit.authenticator + .authenticate(request(baseUrl)) + .headers + .values("Authorization") + .single() + ) + .contains("/us-east-1/bedrock/aws4_request") + assertThat(environment.endpoint).isEqualTo(BedrockEndpoint.RUNTIME) + assertThat( + environment.authenticator + .authenticate(request(baseUrl)) + .headers + .values("Authorization") + ) + .containsExactly("Bearer token") + explicit.authenticator.close() + environment.authenticator.close() + } + + @Test + fun canonicalRuntimeFipsAndDualStackHostsRetainEndpointSecurity() { + val hostnames = + listOf( + "bedrock-runtime.us-east-1.amazonaws.com", + "bedrock-runtime-fips.us-east-1.amazonaws.com", + "bedrock-runtime.us-east-1.api.aws", + "bedrock-runtime-fips.us-east-1.api.aws", + "bedrock-runtime.eusc-de-east-1.amazonaws.eu", + "bedrock-runtime-fips.eusc-de-east-1.api.amazonwebservices.eu", + "bedrock-runtime.cn-north-1.api.amazonwebservices.com.cn", + ) + + hostnames.forEach { hostname -> + val region = + hostname + .removePrefix("bedrock-runtime-fips.") + .removePrefix("bedrock-runtime.") + .substringBefore('.') + val baseUrl = "https://$hostname./openai/v1" + val configuration = + options( + endpoint = BedrockEndpoint.RUNTIME, + awsRegion = region, + baseUrl = baseUrl, + apiKey = "token", + ) + .resolve(getenv = { null }, regionProvider = { null }) + + assertThat(configuration.endpoint).isEqualTo(BedrockEndpoint.RUNTIME) + assertThat(configuration.baseUrl).isEqualTo(baseUrl) + configuration.authenticator.close() + + assertThatThrownBy { + options( + endpoint = BedrockEndpoint.RUNTIME, + awsRegion = region, + baseUrl = "http://$hostname./openai/v1", + apiKey = "token", + ) + .resolve(getenv = { null }, regionProvider = { null }) + } + .hasMessageContaining("require HTTPS") + } + } + + @Test + fun rejectsCanonicalEndpointFamilyAndRegionMismatches() { + val runtimeUrl = "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1" + + assertThatThrownBy { + options( + endpoint = BedrockEndpoint.MANTLE, + awsRegion = "us-east-1", + baseUrl = runtimeUrl, + apiKey = "token", + ) + .resolve(getenv = { null }, regionProvider = { null }) + } + .hasMessageContaining("does not match the selected `mantle` endpoint") + + assertThatThrownBy { + options( + endpoint = BedrockEndpoint.RUNTIME, + awsRegion = "us-west-2", + baseUrl = runtimeUrl, + apiKey = "token", + ) + .resolve(getenv = { null }, regionProvider = { null }) + } + .hasMessageContaining("does not match the configured AWS region") + } + + @Test + fun rejectsMalformedAwsRegionsBeforeEndpointConstruction() { + listOf( + "us-east-1.amazonaws.com@attacker.example#", + "us-east-1/../../attacker.example", + "us-east-1?target=attacker.example", + "not-a-region", + ) + .forEach { region -> + assertThatThrownBy { + options( + endpoint = BedrockEndpoint.RUNTIME, + awsRegion = region, + apiKey = "token", + ) + .resolve(getenv = { null }, regionProvider = { null }) + } + .hasMessageContaining("AWS region is invalid") + + assertThatThrownBy { + options(endpoint = BedrockEndpoint.RUNTIME, apiKey = "token") + .resolve( + getenv = { name -> if (name == "AWS_REGION") region else null }, + regionProvider = { null }, + ) + } + .hasMessageContaining("AWS region is invalid") + } + } + + @Test + fun customSignedEndpointsDefaultToMantleUnlessRuntimeIsSelected() { + val baseUrl = "https://bedrock.example.com/openai/v1" + + listOf( + Triple(null, baseUrl, "bedrock-mantle"), + Triple(null, null, "bedrock-mantle"), + Triple(BedrockEndpoint.MANTLE, baseUrl, "bedrock-mantle"), + Triple(BedrockEndpoint.RUNTIME, baseUrl, "bedrock"), + ) + .forEach { (endpoint, configuredBaseUrl, service) -> + val configuration = + options( + endpoint = endpoint, + awsRegion = "us-east-1", + baseUrl = configuredBaseUrl, + awsAccessKeyId = "ACCESSKEY", + awsSecretAccessKey = "secret", + ) + .resolve( + getenv = { name -> + if (configuredBaseUrl == null && name == ENV_BASE_URL) baseUrl + else null + }, + regionProvider = { null }, + ) + + assertThat(configuration.endpoint).isEqualTo(endpoint ?: BedrockEndpoint.MANTLE) + assertThat( + configuration.authenticator + .authenticate(request(baseUrl)) + .headers + .values("Authorization") + .single() + ) + .contains("/us-east-1/$service/aws4_request") + configuration.authenticator.close() + } + } + @Test fun explicitBaseUrlBearerModesDoNotResolveDefaultRegion() { + val customUrl = "https://bedrock.example.com/openai/v1" + val canonicalUrl = "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1" val configurations = listOf( - options(baseUrl = "https://bedrock.example.com/openai/v1", apiKey = "token"), - options( - baseUrl = "https://bedrock.example.com/openai/v1", - tokenProvider = Supplier { "token" }, - ), + options(baseUrl = customUrl, apiKey = "token"), + options(baseUrl = customUrl, tokenProvider = Supplier { "token" }), + options(baseUrl = canonicalUrl, apiKey = "token"), + options(baseUrl = canonicalUrl, tokenProvider = Supplier { "token" }), ) - configurations.forEach { options -> + configurations.forEach { configurationOptions -> + val environmentReads = mutableListOf() val configuration = - options.resolve( - getenv = { null }, + configurationOptions.resolve( + getenv = { name -> + environmentReads.add(name) + when (name) { + "AWS_REGION", + "AWS_DEFAULT_REGION" -> "auto" + else -> null + } + }, regionProvider = { error("default region provider must not be called") }, ) - assertThat(configuration.baseUrl).isEqualTo("https://bedrock.example.com/openai/v1") + assertThat(configuration.baseUrl).isEqualTo(configurationOptions.baseUrl) + assertThat(environmentReads).doesNotContain("AWS_REGION", "AWS_DEFAULT_REGION") + configuration.authenticator.close() } } @Test fun explicitBaseUrlSkipAuthDoesNotResolveDefaultRegion() { - val configuration = - options(baseUrl = "https://bedrock.example.com/openai/v1", skipAuth = true) - .resolve( - getenv = { null }, - regionProvider = { error("default region provider must not be called") }, - ) - - assertThat(configuration.baseUrl).isEqualTo("https://bedrock.example.com/openai/v1") + listOf( + "https://bedrock.example.com/openai/v1", + "https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1", + ) + .forEach { baseUrl -> + val environmentReads = mutableListOf() + val configuration = + options(baseUrl = baseUrl, skipAuth = true) + .resolve( + getenv = { name -> + environmentReads.add(name) + when (name) { + "AWS_REGION", + "AWS_DEFAULT_REGION" -> "auto" + else -> null + } + }, + regionProvider = { error("default region provider must not be called") }, + ) + + assertThat(configuration.baseUrl).isEqualTo(baseUrl) + assertThat(environmentReads).doesNotContain("AWS_REGION", "AWS_DEFAULT_REGION") + } } @Test @@ -450,18 +696,17 @@ internal class BedrockAuthTest { @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()) } + assertThatThrownBy { + 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 }) + } .isInstanceOf(OpenAIException::class.java) - .hasMessageContaining("does not match the SigV4 region") + .hasMessageContaining("does not match the configured AWS region") } @Test @@ -648,16 +893,19 @@ internal class BedrockAuthTest { .hasMessageContaining("replayable request body") } - private fun request(): HttpRequest = + private fun request( + baseUrl: String = "https://bedrock-mantle.us-east-1.api.aws/openai/v1" + ): HttpRequest = HttpRequest.builder() .method(HttpMethod.POST) - .baseUrl("https://bedrock-mantle.us-east-1.api.aws/openai/v1") + .baseUrl(baseUrl) .addPathSegment("responses") .putHeader("Content-Type", "application/json") .body(StringBody("{}")) .build() private fun options( + endpoint: BedrockEndpoint? = null, awsRegion: String? = null, baseUrl: String? = null, apiKey: String? = null, @@ -672,6 +920,7 @@ internal class BedrockAuthTest { authenticationExecutor: Executor? = null, ) = BedrockAuthOptions( + endpoint = endpoint, awsRegion = awsRegion, baseUrl = baseUrl, apiKey = apiKey, 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 index 145c24bd6..faf1e8bb9 100644 --- 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 @@ -1,9 +1,12 @@ package com.openai.client.okhttp +import com.github.tomakehurst.wiremock.client.WireMock.aResponse 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.post +import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor import com.github.tomakehurst.wiremock.client.WireMock.serviceUnavailable import com.github.tomakehurst.wiremock.client.WireMock.stubFor import com.github.tomakehurst.wiremock.client.WireMock.temporaryRedirect @@ -12,8 +15,11 @@ 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.bedrock.BedrockEndpoint import com.openai.core.LogLevel import com.openai.core.Sleeper +import com.openai.models.chat.completions.ChatCompletionCreateParams +import com.openai.models.responses.ResponseCreateParams import java.io.ByteArrayOutputStream import java.io.PrintStream import java.time.Clock @@ -33,6 +39,318 @@ import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider @ResourceLock("https://github.com/wiremock/wiremock/issues/169") internal class BedrockOpenAIOkHttpClientTest { + @Test + fun runtimeChatCompletionsUseBedrockSigV4Service(wmRuntimeInfo: WireMockRuntimeInfo) { + stubFor( + post(urlPathEqualTo("/openai/v1/chat/completions")) + .willReturn(okJson(runtimeChatCompletion())) + ) + val client = + BedrockOpenAIOkHttpClient.builder() + .endpoint(BedrockEndpoint.RUNTIME) + .baseUrl("${wmRuntimeInfo.httpBaseUrl}/openai/v1") + .awsRegion("us-east-1") + .awsAccessKeyId("ACCESSKEY") + .awsSecretAccessKey("fixture-secret-access-key") + .awsSessionToken("session-token") + .maxRetries(0) + .build() + + val completion = client.chat().completions().create(runtimeChatParams()) + + assertThat(completion.choices().single().message().content()).hasValue("Hello") + assertThat(completion.choices().single().finishReason().toString()).isEqualTo("stop") + assertThat(completion.usage().get().totalTokens()).isEqualTo(7) + val request = + findAll(postRequestedFor(urlPathEqualTo("/openai/v1/chat/completions"))).single() + assertThat(request.getHeader("Authorization")).contains("/us-east-1/bedrock/aws4_request") + assertThat(request.getHeader("X-Amz-Security-Token")).isEqualTo("session-token") + assertThat(request.bodyAsString).contains("us.openai.gpt-5.6-sol") + client.close() + } + + @Test + fun runtimeBearerAuthenticationSupportsChatAndResponses(wmRuntimeInfo: WireMockRuntimeInfo) { + stubFor( + post(urlPathEqualTo("/openai/v1/chat/completions")) + .willReturn(okJson(runtimeChatCompletion())) + ) + stubFor( + post(urlPathEqualTo("/openai/v1/responses")) + .willReturn( + okJson( + """{"id":"resp_runtime","object":"response","created_at":1,"model":"us.openai.gpt-5.6-sol","output":[]}""" + ) + ) + ) + val client = + BedrockOpenAIOkHttpClient.builder() + .endpoint(BedrockEndpoint.RUNTIME) + .baseUrl("${wmRuntimeInfo.httpBaseUrl}/openai/v1") + .apiKey("bedrock-token") + .maxRetries(0) + .build() + + val completion = client.chat().completions().create(runtimeChatParams()) + val response = + client + .responses() + .create( + ResponseCreateParams.builder() + .model("us.openai.gpt-5.6-sol") + .input("Say hello") + .build() + ) + + assertThat(completion.choices().single().message().content()).hasValue("Hello") + assertThat(response.id()).isEqualTo("resp_runtime") + assertThat( + findAll(postRequestedFor(urlPathEqualTo("/openai/v1/chat/completions"))) + .single() + .getHeader("Authorization") + ) + .isEqualTo("Bearer bedrock-token") + assertThat( + findAll(postRequestedFor(urlPathEqualTo("/openai/v1/responses"))) + .single() + .getHeader("Authorization") + ) + .isEqualTo("Bearer bedrock-token") + client.close() + } + + @Test + fun runtimeSigV4AuthenticationSupportsResponses(wmRuntimeInfo: WireMockRuntimeInfo) { + stubFor( + post(urlPathEqualTo("/openai/v1/responses")) + .willReturn( + okJson( + """{"id":"resp_runtime_sigv4","object":"response","created_at":1,"model":"us.openai.gpt-5.6-terra","output":[]}""" + ) + ) + ) + val client = + BedrockOpenAIOkHttpClient.builder() + .endpoint(BedrockEndpoint.RUNTIME) + .baseUrl("${wmRuntimeInfo.httpBaseUrl}/openai/v1") + .awsRegion("us-east-1") + .awsAccessKeyId("ACCESSKEY") + .awsSecretAccessKey("fixture-secret-access-key") + .maxRetries(0) + .build() + + val response = + client + .responses() + .create( + ResponseCreateParams.builder() + .model("us.openai.gpt-5.6-terra") + .input("Say hello") + .build() + ) + + assertThat(response.id()).isEqualTo("resp_runtime_sigv4") + val request = findAll(postRequestedFor(urlPathEqualTo("/openai/v1/responses"))).single() + assertThat(request.getHeader("Authorization")).contains("/us-east-1/bedrock/aws4_request") + assertThat(request.bodyAsString).contains("us.openai.gpt-5.6-terra") + client.close() + } + + @Test + fun runtimeStreamingPreservesChunkOrderAndTermination(wmRuntimeInfo: WireMockRuntimeInfo) { + stubFor( + post(urlPathEqualTo("/openai/v1/chat/completions")) + .willReturn( + aResponse() + .withHeader("Content-Type", "text/event-stream") + .withBody( + """ + data: {"id":"chatcmpl_runtime","object":"chat.completion.chunk","created":1,"model":"us.openai.gpt-5.6-sol","choices":[{"index":0,"delta":{"role":"assistant","content":"Hel"},"finish_reason":null}]} + + data: {"id":"chatcmpl_runtime","object":"chat.completion.chunk","created":1,"model":"us.openai.gpt-5.6-sol","choices":[{"index":0,"delta":{"content":"lo"},"finish_reason":"stop"}]} + + data: [DONE] + + """ + .trimIndent() + ) + ) + ) + val client = + BedrockOpenAIOkHttpClient.builder() + .endpoint(BedrockEndpoint.RUNTIME) + .baseUrl("${wmRuntimeInfo.httpBaseUrl}/openai/v1") + .awsRegion("us-east-1") + .awsAccessKeyId("ACCESSKEY") + .awsSecretAccessKey("fixture-secret-access-key") + .build() + val chunks = mutableListOf() + val finishReasons = mutableListOf() + + client.chat().completions().createStreaming(runtimeChatParams()).use { stream -> + stream.stream().forEach { chunk -> + val choice = chunk.choices().single() + choice.delta().content().ifPresent(chunks::add) + choice.finishReason().ifPresent { finishReasons.add(it.toString()) } + } + } + + assertThat(chunks).containsExactly("Hel", "lo") + assertThat(finishReasons).containsExactly("stop") + assertThat( + findAll(postRequestedFor(urlPathEqualTo("/openai/v1/chat/completions"))) + .single() + .getHeader("Authorization") + ) + .contains("/us-east-1/bedrock/aws4_request") + client.close() + } + + @Test + fun runtimeBearerStreamingPreservesChunkOrderAndTermination( + wmRuntimeInfo: WireMockRuntimeInfo + ) { + stubFor( + post(urlPathEqualTo("/openai/v1/chat/completions")) + .willReturn( + aResponse() + .withHeader("Content-Type", "text/event-stream") + .withBody( + """ + data: {"id":"chatcmpl_runtime","object":"chat.completion.chunk","created":1,"model":"us.openai.gpt-5.6-luna","choices":[{"index":0,"delta":{"content":"Bearer"},"finish_reason":"stop"}]} + + data: [DONE] + + """ + .trimIndent() + ) + ) + ) + val client = + BedrockOpenAIOkHttpClient.builder() + .endpoint(BedrockEndpoint.RUNTIME) + .baseUrl("${wmRuntimeInfo.httpBaseUrl}/openai/v1") + .apiKey("bedrock-streaming-token") + .build() + val params = + ChatCompletionCreateParams.builder() + .model("us.openai.gpt-5.6-luna") + .addUserMessage("Say hello") + .build() + val chunks = mutableListOf() + + client.chat().completions().createStreaming(params).use { stream -> + stream.stream().forEach { chunk -> + chunk.choices().single().delta().content().ifPresent(chunks::add) + } + } + + assertThat(chunks).containsExactly("Bearer") + assertThat( + findAll(postRequestedFor(urlPathEqualTo("/openai/v1/chat/completions"))) + .single() + .getHeader("Authorization") + ) + .isEqualTo("Bearer bedrock-streaming-token") + client.close() + } + + @Test + fun runtimeResponseStreamingSupportsBearerAuthentication(wmRuntimeInfo: WireMockRuntimeInfo) { + verifyRuntimeResponseStreaming(wmRuntimeInfo, bearer = true) + } + + @Test + fun runtimeResponseStreamingSupportsSigV4Authentication(wmRuntimeInfo: WireMockRuntimeInfo) { + verifyRuntimeResponseStreaming(wmRuntimeInfo, bearer = false) + } + + private fun verifyRuntimeResponseStreaming( + wmRuntimeInfo: WireMockRuntimeInfo, + bearer: Boolean, + ) { + stubFor( + post(urlPathEqualTo("/openai/v1/responses")) + .willReturn( + aResponse() + .withHeader("Content-Type", "text/event-stream") + .withBody( + """ + data: {"type":"response.output_text.delta","content_index":0,"delta":"Runtime","item_id":"item_1","logprobs":[],"output_index":0,"sequence_number":1} + + data: {"type":"response.output_text.delta","content_index":0,"delta":" stream","item_id":"item_1","logprobs":[],"output_index":0,"sequence_number":2} + + data: [DONE] + + """ + .trimIndent() + ) + ) + ) + val builder = + BedrockOpenAIOkHttpClient.builder() + .endpoint(BedrockEndpoint.RUNTIME) + .baseUrl("${wmRuntimeInfo.httpBaseUrl}/openai/v1") + if (bearer) { + builder.apiKey("bedrock-streaming-token") + } else { + builder + .awsRegion("us-east-1") + .awsAccessKeyId("ACCESSKEY") + .awsSecretAccessKey("fixture-secret-access-key") + } + val client = builder.build() + val params = + ResponseCreateParams.builder().model("us.openai.gpt-5.6-sol").input("Say hello").build() + val chunks = mutableListOf() + + client.responses().createStreaming(params).use { stream -> + stream.stream().forEach { event -> + event.outputTextDelta().ifPresent { chunks.add(it.delta()) } + } + } + + assertThat(chunks).containsExactly("Runtime", " stream") + val authorization = + findAll(postRequestedFor(urlPathEqualTo("/openai/v1/responses"))) + .single() + .getHeader("Authorization") + if (bearer) { + assertThat(authorization).isEqualTo("Bearer bedrock-streaming-token") + } else { + assertThat(authorization).contains("/us-east-1/bedrock/aws4_request") + } + client.close() + } + + @Test + fun runtimeAsyncChatCompletionsUseBedrockSigV4Service(wmRuntimeInfo: WireMockRuntimeInfo) { + stubFor( + post(urlPathEqualTo("/openai/v1/chat/completions")) + .willReturn(okJson(runtimeChatCompletion())) + ) + val client = + BedrockOpenAIOkHttpClient.builder() + .endpoint(BedrockEndpoint.RUNTIME) + .baseUrl("${wmRuntimeInfo.httpBaseUrl}/openai/v1") + .awsRegion("us-east-1") + .awsAccessKeyId("ACCESSKEY") + .awsSecretAccessKey("fixture-secret-access-key") + .build() + .async() + + val completion = client.chat().completions().create(runtimeChatParams()).join() + + assertThat(completion.choices().single().message().content()).hasValue("Hello") + assertThat( + findAll(postRequestedFor(urlPathEqualTo("/openai/v1/chat/completions"))) + .single() + .getHeader("Authorization") + ) + .contains("/us-east-1/bedrock/aws4_request") + client.close() + } + @Test fun retriesResolveFreshCredentialsAndSignAgain(wmRuntimeInfo: WireMockRuntimeInfo) { stubRetryingModelsResponse() @@ -65,6 +383,28 @@ internal class BedrockOpenAIOkHttpClientTest { client.close() } + @Test + fun runtimeRetriesResolveFreshCredentialsAndSignAgain(wmRuntimeInfo: WireMockRuntimeInfo) { + stubRetryingModelsResponse() + val providerCalls = AtomicInteger() + val client = + client( + wmRuntimeInfo.httpBaseUrl, + rotatingProvider(providerCalls), + BedrockEndpoint.RUNTIME, + ) + + 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") }).allMatch { authorization -> + authorization.contains("/us-east-1/bedrock/aws4_request") + } + client.close() + } + @Test fun bearerProviderResolvesFreshTokenOnEveryRetry(wmRuntimeInfo: WireMockRuntimeInfo) { stubRetryingModelsResponse() @@ -145,6 +485,7 @@ internal class BedrockOpenAIOkHttpClientTest { try { val client = BedrockOpenAIOkHttpClient.builder() + .endpoint(BedrockEndpoint.MANTLE) .baseUrl(wmRuntimeInfo.httpBaseUrl) .awsRegion("us-east-1") .awsAccessKeyId("LOGACCESSKEY") @@ -168,8 +509,13 @@ internal class BedrockOpenAIOkHttpClientTest { assertThat(logs).doesNotContain("log-session-token") } - private fun client(baseUrl: String, provider: AwsCredentialsProvider) = + private fun client( + baseUrl: String, + provider: AwsCredentialsProvider, + endpoint: BedrockEndpoint = BedrockEndpoint.MANTLE, + ) = BedrockOpenAIOkHttpClient.builder() + .endpoint(endpoint) .baseUrl(baseUrl) .awsRegion("us-east-1") .awsCredentialsProvider(provider) @@ -178,6 +524,15 @@ internal class BedrockOpenAIOkHttpClientTest { .maxRetries(1) .build() + private fun runtimeChatParams(): ChatCompletionCreateParams = + ChatCompletionCreateParams.builder() + .model("us.openai.gpt-5.6-sol") + .addUserMessage("Say hello") + .build() + + private fun runtimeChatCompletion(): String = + """{"id":"chatcmpl_runtime","object":"chat.completion","created":1,"model":"us.openai.gpt-5.6-sol","choices":[{"index":0,"message":{"role":"assistant","content":"Hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}""" + private fun rotatingProvider(providerCalls: AtomicInteger) = AwsCredentialsProvider { val accessKey = if (providerCalls.getAndIncrement() == 0) "FIRSTACCESSKEY" else "SECONDACCESSKEY" diff --git a/openai-java-bedrock/src/test/kotlin/com/openai/client/okhttp/BedrockRuntimeLiveTest.kt b/openai-java-bedrock/src/test/kotlin/com/openai/client/okhttp/BedrockRuntimeLiveTest.kt new file mode 100644 index 000000000..3e01859d7 --- /dev/null +++ b/openai-java-bedrock/src/test/kotlin/com/openai/client/okhttp/BedrockRuntimeLiveTest.kt @@ -0,0 +1,120 @@ +package com.openai.client.okhttp + +import com.openai.bedrock.BedrockEndpoint +import com.openai.client.OpenAIClient +import com.openai.models.chat.completions.ChatCompletionCreateParams +import com.openai.models.responses.ResponseCreateParams +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable + +@EnabledIfEnvironmentVariable(named = "BEDROCK_LIVE_TEST", matches = "1") +internal class BedrockRuntimeLiveTest { + + @Test + fun runtimeInferenceProfilesCompleteSelectedApiRequests() { + val client = createClient() + val api = environment("BEDROCK_LIVE_API") ?: "chat" + val streaming = environment("BEDROCK_LIVE_STREAM") == "true" + + try { + models().forEach { model -> + when (api) { + "chat" -> verifyChat(client, model, streaming) + "responses" -> verifyResponse(client, model, streaming) + else -> error("BEDROCK_LIVE_API must be chat or responses.") + } + } + } finally { + client.close() + } + } + + private fun createClient(): OpenAIClient { + val region = environment("AWS_REGION") ?: environment("AWS_DEFAULT_REGION") + val builder = BedrockOpenAIOkHttpClient.builder().endpoint(BedrockEndpoint.RUNTIME) + if (region != null) { + builder.awsRegion(region) + } + + when (environment("BEDROCK_LIVE_AUTH") ?: "sigv4") { + "bearer" -> + builder.apiKey( + environment("AWS_BEARER_TOKEN_BEDROCK") + ?: error("Bearer live tests require AWS_BEARER_TOKEN_BEDROCK.") + ) + "sigv4" -> { + val profile = environment("AWS_PROFILE") + if (profile != null) { + builder.awsProfile(profile) + } else { + check(environment("AWS_BEARER_TOKEN_BEDROCK") == null) { + "Unset AWS_BEARER_TOKEN_BEDROCK or set AWS_PROFILE to use SigV4." + } + } + } + else -> error("BEDROCK_LIVE_AUTH must be sigv4 or bearer.") + } + + return builder.build() + } + + private fun models(): List = + (environment("BEDROCK_LIVE_MODELS") + ?: "us.openai.gpt-5.6-sol,us.openai.gpt-5.6-terra,us.openai.gpt-5.6-luna") + .split(',') + .map(String::trim) + .filter(String::isNotEmpty) + .also { check(it.isNotEmpty()) { "BEDROCK_LIVE_MODELS must not be empty." } } + + private fun verifyChat(client: OpenAIClient, model: String, streaming: Boolean) { + val params = + ChatCompletionCreateParams.builder() + .model(model) + .addUserMessage("Reply with one short greeting.") + .build() + + if (streaming) { + val chunks = mutableListOf() + client.chat().completions().createStreaming(params).use { stream -> + stream.stream().forEach { chunk -> + chunk.choices().forEach { choice -> + choice.delta().content().ifPresent(chunks::add) + } + } + } + assertThat(chunks) + .describedAs("streaming Chat Completions output for %s", model) + .isNotEmpty() + } else { + val completion = client.chat().completions().create(params) + assertThat(completion.choices()) + .describedAs("Chat Completions output for %s", model) + .isNotEmpty() + } + } + + private fun verifyResponse(client: OpenAIClient, model: String, streaming: Boolean) { + val params = + ResponseCreateParams.builder() + .model(model) + .input("Reply with one short greeting.") + .build() + + if (streaming) { + val chunks = mutableListOf() + client.responses().createStreaming(params).use { stream -> + stream.stream().forEach { event -> + event.outputTextDelta().ifPresent { chunks.add(it.delta()) } + } + } + assertThat(chunks).describedAs("streaming Responses output for %s", model).isNotEmpty() + } else { + val response = client.responses().create(params) + assertThat(response.id()).describedAs("Responses identifier for %s", model).isNotBlank() + } + } + + private fun environment(name: String): String? = + System.getenv(name)?.trim()?.takeIf(String::isNotEmpty) +} diff --git a/openai-java-example/src/main/java/com/openai/example/BedrockRuntimeChatExample.java b/openai-java-example/src/main/java/com/openai/example/BedrockRuntimeChatExample.java new file mode 100644 index 000000000..ef3a50ebb --- /dev/null +++ b/openai-java-example/src/main/java/com/openai/example/BedrockRuntimeChatExample.java @@ -0,0 +1,68 @@ +package com.openai.example; + +import com.openai.bedrock.BedrockEndpoint; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.BedrockOpenAIOkHttpClient; +import com.openai.core.http.StreamResponse; +import com.openai.models.chat.completions.ChatCompletionChunk; +import com.openai.models.chat.completions.ChatCompletionCreateParams; + +public final class BedrockRuntimeChatExample { + private BedrockRuntimeChatExample() {} + + public static void main(String[] args) { + String model = environmentOrDefault("BEDROCK_MODEL", "us.openai.gpt-5.6-sol"); + String auth = environmentOrDefault("BEDROCK_AUTH", "sigv4"); + + BedrockOpenAIOkHttpClient.Builder builder = + BedrockOpenAIOkHttpClient.builder().endpoint(BedrockEndpoint.RUNTIME); + + if ("bearer".equals(auth)) { + String bearerToken = System.getenv("AWS_BEARER_TOKEN_BEDROCK"); + if (bearerToken == null || bearerToken.isBlank()) { + throw new IllegalArgumentException("BEDROCK_AUTH=bearer requires AWS_BEARER_TOKEN_BEDROCK."); + } + builder.apiKey(bearerToken); + } else if ("sigv4".equals(auth)) { + String profile = System.getenv("AWS_PROFILE"); + if (profile != null && !profile.isBlank()) { + // Explicit profiles take precedence over an environment bearer token. + builder.awsProfile(profile); + } else if (System.getenv("AWS_BEARER_TOKEN_BEDROCK") != null) { + throw new IllegalArgumentException("Unset AWS_BEARER_TOKEN_BEDROCK or set AWS_PROFILE to use SigV4."); + } + } else { + throw new IllegalArgumentException("BEDROCK_AUTH must be sigv4 or bearer."); + } + + OpenAIClient client = builder.build(); + ChatCompletionCreateParams params = ChatCompletionCreateParams.builder() + .model(model) + .addUserMessage("Say hello from Amazon Bedrock Runtime") + .build(); + + try { + if (Boolean.parseBoolean(System.getenv("BEDROCK_STREAM"))) { + try (StreamResponse stream = + client.chat().completions().createStreaming(params)) { + stream.stream() + .flatMap(chunk -> chunk.choices().stream()) + .flatMap(choice -> choice.delta().content().stream()) + .forEach(System.out::print); + } + System.out.println(); + } else { + client.chat().completions().create(params).choices().stream() + .flatMap(choice -> choice.message().content().stream()) + .forEach(System.out::println); + } + } finally { + client.close(); + } + } + + private static String environmentOrDefault(String name, String defaultValue) { + String value = System.getenv(name); + return value == null || value.isBlank() ? defaultValue : value; + } +}