diff --git a/README.md b/README.md index 518a2a582..4bd8aeff3 100644 --- a/README.md +++ b/README.md @@ -974,6 +974,113 @@ AnthropicClient client = AnthropicOkHttpClient.builder() .build(); ``` +### Interceptors + +To intercept all requests and responses _after_ [retries](#retries), configure the client using the `interceptor` method: + +```java +import com.anthropic.client.AnthropicClient; +import com.anthropic.client.okhttp.AnthropicOkHttpClient; +import com.anthropic.core.RequestOptions; +import com.anthropic.core.http.HttpClient; +import com.anthropic.core.http.HttpRequest; +import com.anthropic.core.http.HttpResponse; +import java.util.concurrent.CompletableFuture; + +class LoggingHttpClient implements HttpClient { + + private final HttpClient delegate; + + LoggingHttpClient(HttpClient delegate) { + this.delegate = delegate; + } + + @Override + public HttpResponse execute( + HttpRequest request, + RequestOptions requestOptions + ) { + System.out.println("Sending request..."); + HttpResponse response = delegate.execute( + // Optionally modify the request + request.toBuilder().putHeader("X-Request-ID", "42").build(), + requestOptions + ); + System.out.println("Received response!"); + // Optionally modify the response by implementing `HttpResponse` + return response; + } + + @Override + public CompletableFuture executeAsync( + HttpRequest request, + RequestOptions requestOptions + ) { + System.out.println("Sending request..."); + CompletableFuture responseFuture = delegate.executeAsync( + // Optionally modify the request + request.toBuilder().putHeader("X-Request-ID", "42").build(), + requestOptions + ); + return responseFuture.thenApply(response -> { + System.out.println("Received response!"); + // Optionally modify the response by implementing `HttpResponse` + return response; + }); + } + + @Override + public void close() { + delegate.close(); + } +} + +AnthropicClient client = AnthropicOkHttpClient.builder() + .fromEnv() + // Or pass a lambda + .interceptor(LoggingHttpClient::new) + .build(); +``` + +To intercept _before_ retries, which is useful for logging and tracing, configure the client using the `networkInterceptor` method instead: + +```java +import com.anthropic.client.AnthropicClient; +import com.anthropic.client.okhttp.AnthropicOkHttpClient; + +AnthropicClient client = AnthropicOkHttpClient.builder() + .fromEnv() + // Or pass a lambda + .networkInterceptor(LoggingHttpClient::new) + .build(); +``` + +Or configure the client to intercept synchronous calls only, if you don't use [asynchronous execution](#asynchronous-execution): + +```java +import com.anthropic.client.AnthropicClient; +import com.anthropic.client.okhttp.AnthropicOkHttpClient; +import com.anthropic.core.http.HttpResponse; +import com.anthropic.core.http.Interceptor; + +AnthropicClient client = AnthropicOkHttpClient.builder() + .fromEnv() + // Or `networkInterceptor` + .interceptor(Interceptor.syncOnly((httpClient, request, requestOptions) -> { + System.out.println("Sending request..."); + HttpResponse response = httpClient.execute(request, response); + System.out.println("Received response!"); + return response; + })) + .build(); +``` + +Or configure the client to intercept asynchronous calls only, if you _only_ use asynchronous execution, using `Interceptor.asyncOnly`. + +> [!NOTE] +> Only a single `interceptor` and a single `networkInterceptor` can be configured. To configure multiple +> layers of wrapping, perform all the wrapping in a single call. + ### Custom HTTP client The SDK consists of three artifacts: diff --git a/anthropic-java-client-okhttp/src/main/kotlin/com/anthropic/client/okhttp/AnthropicOkHttpClient.kt b/anthropic-java-client-okhttp/src/main/kotlin/com/anthropic/client/okhttp/AnthropicOkHttpClient.kt index a60b5cf56..772dc7d08 100644 --- a/anthropic-java-client-okhttp/src/main/kotlin/com/anthropic/client/okhttp/AnthropicOkHttpClient.kt +++ b/anthropic-java-client-okhttp/src/main/kotlin/com/anthropic/client/okhttp/AnthropicOkHttpClient.kt @@ -9,6 +9,7 @@ import com.anthropic.client.AnthropicClientImpl import com.anthropic.core.ClientOptions import com.anthropic.core.Timeout import com.anthropic.core.http.Headers +import com.anthropic.core.http.Interceptor import com.anthropic.core.http.QueryParams import com.anthropic.core.jsonMapper import com.fasterxml.jackson.databind.json.JsonMapper @@ -96,6 +97,41 @@ class AnthropicOkHttpClient private constructor() { fun hostnameVerifier(hostnameVerifier: Optional) = hostnameVerifier(hostnameVerifier.getOrNull()) + /** + * Wraps the HTTP client using the given [interceptor]. + * + * The HTTP client may perform retries. Use [networkInterceptor] to wrap the raw HTTP client + * before retry logic. + * + * Also note that calling [interceptor] multiple times overwrites the previous call. To + * apply multiple layers of wrapping, perform all the wrapping in a single call. + */ + fun interceptor(interceptor: Interceptor?) = apply { + clientOptions.interceptor(interceptor) + } + + /** Alias for calling [Builder.interceptor] with `interceptor.orElse(null)`. */ + fun interceptor(interceptor: Optional) = interceptor(interceptor.getOrNull()) + + /** + * Wraps the raw HTTP client using the given [interceptor]. + * + * The raw HTTP client does _not_ perform retries. Use [interceptor] to wrap the HTTP client + * after retry logic. + * + * Also note that calling [networkInterceptor] multiple times overwrites the previous call. + * To apply multiple layers of wrapping, perform all the wrapping in a single call. + */ + fun networkInterceptor(networkInterceptor: Interceptor?) = apply { + clientOptions.networkInterceptor(networkInterceptor) + } + + /** + * Alias for calling [Builder.networkInterceptor] with `networkInterceptor.orElse(null)`. + */ + fun networkInterceptor(networkInterceptor: Optional) = + networkInterceptor(networkInterceptor.getOrNull()) + /** * 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). diff --git a/anthropic-java-client-okhttp/src/main/kotlin/com/anthropic/client/okhttp/AnthropicOkHttpClientAsync.kt b/anthropic-java-client-okhttp/src/main/kotlin/com/anthropic/client/okhttp/AnthropicOkHttpClientAsync.kt index 2e4058187..3bdfe49aa 100644 --- a/anthropic-java-client-okhttp/src/main/kotlin/com/anthropic/client/okhttp/AnthropicOkHttpClientAsync.kt +++ b/anthropic-java-client-okhttp/src/main/kotlin/com/anthropic/client/okhttp/AnthropicOkHttpClientAsync.kt @@ -9,6 +9,7 @@ import com.anthropic.client.AnthropicClientAsyncImpl import com.anthropic.core.ClientOptions import com.anthropic.core.Timeout import com.anthropic.core.http.Headers +import com.anthropic.core.http.Interceptor import com.anthropic.core.http.QueryParams import com.anthropic.core.jsonMapper import com.fasterxml.jackson.databind.json.JsonMapper @@ -98,6 +99,41 @@ class AnthropicOkHttpClientAsync private constructor() { fun hostnameVerifier(hostnameVerifier: Optional) = hostnameVerifier(hostnameVerifier.getOrNull()) + /** + * Wraps the HTTP client using the given [interceptor]. + * + * The HTTP client may perform retries. Use [networkInterceptor] to wrap the raw HTTP client + * before retry logic. + * + * Also note that calling [interceptor] multiple times overwrites the previous call. To + * apply multiple layers of wrapping, perform all the wrapping in a single call. + */ + fun interceptor(interceptor: Interceptor?) = apply { + clientOptions.interceptor(interceptor) + } + + /** Alias for calling [Builder.interceptor] with `interceptor.orElse(null)`. */ + fun interceptor(interceptor: Optional) = interceptor(interceptor.getOrNull()) + + /** + * Wraps the raw HTTP client using the given [interceptor]. + * + * The raw HTTP client does _not_ perform retries. Use [interceptor] to wrap the HTTP client + * after retry logic. + * + * Also note that calling [networkInterceptor] multiple times overwrites the previous call. + * To apply multiple layers of wrapping, perform all the wrapping in a single call. + */ + fun networkInterceptor(networkInterceptor: Interceptor?) = apply { + clientOptions.networkInterceptor(networkInterceptor) + } + + /** + * Alias for calling [Builder.networkInterceptor] with `networkInterceptor.orElse(null)`. + */ + fun networkInterceptor(networkInterceptor: Optional) = + networkInterceptor(networkInterceptor.getOrNull()) + /** * 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). diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/core/ClientOptions.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/core/ClientOptions.kt index e2baefffc..9a2e3f447 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/core/ClientOptions.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/core/ClientOptions.kt @@ -4,9 +4,11 @@ package com.anthropic.core import com.anthropic.core.http.Headers import com.anthropic.core.http.HttpClient +import com.anthropic.core.http.Interceptor import com.anthropic.core.http.PhantomReachableClosingHttpClient import com.anthropic.core.http.QueryParams import com.anthropic.core.http.RetryingHttpClient +import com.anthropic.core.http.intercept import com.fasterxml.jackson.databind.json.JsonMapper import java.time.Clock import java.time.Duration @@ -21,6 +23,8 @@ class ClientOptions private constructor( private val originalHttpClient: HttpClient, @get:JvmName("httpClient") val httpClient: HttpClient, + private val interceptor: Interceptor?, + private val networkInterceptor: Interceptor?, /** * 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). @@ -46,6 +50,28 @@ private constructor( } } + /** + * Wraps the HTTP client using the given [interceptor]. + * + * The HTTP client may perform retries. Use [networkInterceptor] to wrap the raw HTTP client + * before retry logic. + * + * Also note that calling [interceptor] multiple times overwrites the previous call. To apply + * multiple layers of wrapping, perform all the wrapping in a single call. + */ + fun interceptor(): Optional = Optional.ofNullable(interceptor) + + /** + * Wraps the raw HTTP client using the given [interceptor]. + * + * The raw HTTP client does _not_ perform retries. Use [interceptor] to wrap the HTTP client + * after retry logic. + * + * Also note that calling [networkInterceptor] multiple times overwrites the previous call. To + * apply multiple layers of wrapping, perform all the wrapping in a single call. + */ + fun networkInterceptor(): Optional = Optional.ofNullable(networkInterceptor) + fun baseUrl(): String? = baseUrl fun toBuilder() = Builder().from(this) @@ -67,6 +93,8 @@ private constructor( class Builder internal constructor() { private var httpClient: HttpClient? = null + private var interceptor: Interceptor? = null + private var networkInterceptor: Interceptor? = null private var checkJacksonVersionCompatibility: Boolean = true private var jsonMapper: JsonMapper = jsonMapper() private var streamHandlerExecutor: Executor? = null @@ -81,6 +109,8 @@ private constructor( @JvmSynthetic internal fun from(clientOptions: ClientOptions) = apply { httpClient = clientOptions.originalHttpClient + interceptor = clientOptions.interceptor + networkInterceptor = clientOptions.networkInterceptor checkJacksonVersionCompatibility = clientOptions.checkJacksonVersionCompatibility jsonMapper = clientOptions.jsonMapper streamHandlerExecutor = clientOptions.streamHandlerExecutor @@ -97,6 +127,39 @@ private constructor( this.httpClient = PhantomReachableClosingHttpClient(httpClient) } + /** + * Wraps the HTTP client using the given [interceptor]. + * + * The HTTP client may perform retries. Use [networkInterceptor] to wrap the raw HTTP client + * before retry logic. + * + * Also note that calling [interceptor] multiple times overwrites the previous call. To + * apply multiple layers of wrapping, perform all the wrapping in a single call. + */ + fun interceptor(interceptor: Interceptor?) = apply { this.interceptor = interceptor } + + /** Alias for calling [Builder.interceptor] with `interceptor.orElse(null)`. */ + fun interceptor(interceptor: Optional) = interceptor(interceptor.getOrNull()) + + /** + * Wraps the raw HTTP client using the given [interceptor]. + * + * The raw HTTP client does _not_ perform retries. Use [interceptor] to wrap the HTTP client + * after retry logic. + * + * Also note that calling [networkInterceptor] multiple times overwrites the previous call. + * To apply multiple layers of wrapping, perform all the wrapping in a single call. + */ + fun networkInterceptor(networkInterceptor: Interceptor?) = apply { + this.networkInterceptor = networkInterceptor + } + + /** + * Alias for calling [Builder.networkInterceptor] with `networkInterceptor.orElse(null)`. + */ + fun networkInterceptor(networkInterceptor: Optional) = + networkInterceptor(networkInterceptor.getOrNull()) + /** * 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). @@ -249,11 +312,21 @@ private constructor( return ClientOptions( httpClient, - RetryingHttpClient.builder() - .httpClient(httpClient) - .clock(clock) - .maxRetries(maxRetries) - .build(), + interceptor.intercept( + // Add default post-retries interceptors around this client. + RetryingHttpClient.builder() + .httpClient( + networkInterceptor.intercept( + // Add default pre-retries interceptors around this client. + httpClient + ) + ) + .clock(clock) + .maxRetries(maxRetries) + .build() + ), + interceptor, + networkInterceptor, checkJacksonVersionCompatibility, jsonMapper, streamHandlerExecutor diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/core/http/HttpClientExecute.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/core/http/HttpClientExecute.kt new file mode 100644 index 000000000..812ad5894 --- /dev/null +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/core/http/HttpClientExecute.kt @@ -0,0 +1,14 @@ +// File generated from our OpenAPI spec by Stainless. + +package com.anthropic.core.http + +import com.anthropic.core.RequestOptions + +fun interface HttpClientExecute { + + fun execute( + httpClient: HttpClient, + request: HttpRequest, + requestOptions: RequestOptions, + ): HttpResponse +} diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/core/http/HttpClientExecuteAsync.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/core/http/HttpClientExecuteAsync.kt new file mode 100644 index 000000000..bd835fd85 --- /dev/null +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/core/http/HttpClientExecuteAsync.kt @@ -0,0 +1,15 @@ +// File generated from our OpenAPI spec by Stainless. + +package com.anthropic.core.http + +import com.anthropic.core.RequestOptions +import java.util.concurrent.CompletableFuture + +fun interface HttpClientExecuteAsync { + + fun executeAsync( + httpClient: HttpClient, + request: HttpRequest, + requestOptions: RequestOptions, + ): CompletableFuture +} diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/core/http/Interceptor.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/core/http/Interceptor.kt new file mode 100644 index 000000000..1cc825bde --- /dev/null +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/core/http/Interceptor.kt @@ -0,0 +1,55 @@ +// File generated from our OpenAPI spec by Stainless. + +package com.anthropic.core.http + +import com.anthropic.core.RequestOptions +import java.util.concurrent.CompletableFuture + +fun interface Interceptor { + + companion object { + + @JvmStatic + fun syncOnly(httpClientExecute: HttpClientExecute) = Interceptor { client -> + object : HttpClient { + override fun execute( + request: HttpRequest, + requestOptions: RequestOptions, + ): HttpResponse = httpClientExecute.execute(client, request, requestOptions) + + override fun executeAsync( + request: HttpRequest, + requestOptions: RequestOptions, + ): CompletableFuture = + throw UnsupportedOperationException("Sync only client does not support async") + + override fun close() = client.close() + } + } + + @JvmStatic + fun asyncOnly(httpClientExecuteAsync: HttpClientExecuteAsync) = Interceptor { client -> + object : HttpClient { + override fun execute( + request: HttpRequest, + requestOptions: RequestOptions, + ): HttpResponse = + throw UnsupportedOperationException("Async only client does not support sync") + + override fun executeAsync( + request: HttpRequest, + requestOptions: RequestOptions, + ): CompletableFuture = + httpClientExecuteAsync.executeAsync(client, request, requestOptions) + + override fun close() = client.close() + } + } + } + + fun intercept(httpClient: HttpClient): HttpClient +} + +@JvmSynthetic +internal fun Interceptor?.intercept(httpClient: HttpClient) = + this?.intercept(httpClient) ?: httpClient