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 13ee78016..ef2c0805c 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 @@ -40,7 +40,10 @@ import okio.buffer import okio.sink class OkHttpClient -internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClient) : HttpClient { +internal constructor( + @JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClient, + private val sendStainlessHeaders: Boolean = true, +) : HttpClient { override fun execute(request: HttpRequest, requestOptions: RequestOptions): HttpResponse { val call = newCall(request, requestOptions) @@ -104,7 +107,7 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie } val client = clientBuilder.build() - return client.newCall(request.toRequest(client)) + return client.newCall(request.toRequest(client, sendStainlessHeaders)) } companion object { @@ -123,6 +126,7 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie private var sslSocketFactory: SSLSocketFactory? = null private var trustManager: X509TrustManager? = null private var hostnameVerifier: HostnameVerifier? = null + private var sendStainlessHeaders: Boolean = true fun timeout(timeout: Timeout) = apply { this.timeout = timeout } @@ -177,6 +181,11 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie this.hostnameVerifier = hostnameVerifier } + @JvmSynthetic + internal fun sendStainlessHeaders(sendStainlessHeaders: Boolean) = apply { + this.sendStainlessHeaders = sendStainlessHeaders + } + fun build(): OkHttpClient = OkHttpClient( okhttp3.OkHttpClient.Builder() @@ -238,12 +247,16 @@ internal constructor(@JvmSynthetic internal val okHttpClient: okhttp3.OkHttpClie // We usually make all our requests to the same host so it makes sense to // raise the per-host limit to the overall limit. dispatcher.maxRequestsPerHost = dispatcher.maxRequests - } + }, + sendStainlessHeaders, ) } } -private fun HttpRequest.toRequest(client: okhttp3.OkHttpClient?): Request { +private fun HttpRequest.toRequest( + client: okhttp3.OkHttpClient?, + sendStainlessHeaders: Boolean = true, +): Request { var body: RequestBody? = body?.toRequestBody() if (body == null && requiresBody(method)) { body = "".toRequestBody() @@ -252,7 +265,7 @@ private fun HttpRequest.toRequest(client: okhttp3.OkHttpClient?): Request { val builder = Request.Builder().url(toUrl()).method(method.name, body) headers.names().forEach { name -> headers.values(name).forEach { builder.addHeader(name, it) } } - if (client != null) { + if (client != null && sendStainlessHeaders) { if ( !headers.names().contains("X-Stainless-Read-Timeout") && client.readTimeoutMillis != 0 ) { 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 10eb8e2a4..e38f6076e 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 @@ -52,6 +52,7 @@ class OpenAIOkHttpClient private constructor() { class Builder internal constructor() { private var clientOptions: ClientOptions.Builder = ClientOptions.builder() + private var sendStainlessHeaders: Boolean = true private var dispatcherExecutorService: ExecutorService? = null private var followRedirects: Boolean = true private var proxy: Proxy? = null @@ -370,6 +371,16 @@ class OpenAIOkHttpClient private constructor() { clientOptions.azureUrlPathMode(azureUrlPathMode) } + /** + * Whether to send the SDK's default `X-Stainless-*` telemetry headers. + * + * Defaults to `true`. + */ + fun sendStainlessHeaders(sendStainlessHeaders: Boolean) = apply { + this.sendStainlessHeaders = sendStainlessHeaders + clientOptions.sendStainlessHeaders(sendStainlessHeaders) + } + fun organization(organization: String?) = apply { clientOptions.organization(organization) } /** Alias for calling [Builder.organization] with `organization.orElse(null)`. */ @@ -512,6 +523,7 @@ class OpenAIOkHttpClient private constructor() { .maxIdleConnections(maxIdleConnections) .keepAliveDuration(keepAliveDuration) .dispatcherExecutorService(dispatcherExecutorService) + .sendStainlessHeaders(sendStainlessHeaders) .sslSocketFactory(sslSocketFactory) .trustManager(trustManager) .hostnameVerifier(hostnameVerifier) 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 26049634e..f11fc6f35 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 @@ -52,6 +52,7 @@ class OpenAIOkHttpClientAsync private constructor() { class Builder internal constructor() { private var clientOptions: ClientOptions.Builder = ClientOptions.builder() + private var sendStainlessHeaders: Boolean = true private var dispatcherExecutorService: ExecutorService? = null private var followRedirects: Boolean = true private var proxy: Proxy? = null @@ -370,6 +371,16 @@ class OpenAIOkHttpClientAsync private constructor() { clientOptions.azureUrlPathMode(azureUrlPathMode) } + /** + * Whether to send the SDK's default `X-Stainless-*` telemetry headers. + * + * Defaults to `true`. + */ + fun sendStainlessHeaders(sendStainlessHeaders: Boolean) = apply { + this.sendStainlessHeaders = sendStainlessHeaders + clientOptions.sendStainlessHeaders(sendStainlessHeaders) + } + fun organization(organization: String?) = apply { clientOptions.organization(organization) } /** Alias for calling [Builder.organization] with `organization.orElse(null)`. */ @@ -512,6 +523,7 @@ class OpenAIOkHttpClientAsync private constructor() { .maxIdleConnections(maxIdleConnections) .keepAliveDuration(keepAliveDuration) .dispatcherExecutorService(dispatcherExecutorService) + .sendStainlessHeaders(sendStainlessHeaders) .sslSocketFactory(sslSocketFactory) .trustManager(trustManager) .hostnameVerifier(hostnameVerifier) diff --git a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OkHttpClientTest.kt b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OkHttpClientTest.kt index 7f87a5137..65a3fbc7d 100644 --- a/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OkHttpClientTest.kt +++ b/openai-java-client-okhttp/src/test/kotlin/com/openai/client/okhttp/OkHttpClientTest.kt @@ -99,6 +99,30 @@ internal class OkHttpClientTest { assertThat(responseFuture.isCancelled).isTrue() } } + + @Test + fun execute_stainlessHeadersCanBeDisabled() { + stubFor(post(urlPathEqualTo("/something")).willReturn(ok())) + val client = OkHttpClient.builder().sendStainlessHeaders(false).build() + + client.use { + val response = + client.execute( + HttpRequest.builder() + .method(HttpMethod.POST) + .baseUrl(baseUrl) + .addPathSegment("something") + .build() + ) + response.close() + } + + verify( + postRequestedFor(urlPathEqualTo("/something")) + .withoutHeader("X-Stainless-Read-Timeout") + .withoutHeader("X-Stainless-Timeout") + ) + } } private class TrackingResponseBody : ResponseBody() { 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 277954de3..e7437fca6 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 @@ -137,6 +137,8 @@ private constructor( @get:JvmName("credential") val credential: Credential, @get:JvmName("azureServiceVersion") val azureServiceVersion: AzureOpenAIServiceVersion?, @get:JvmName("azureUrlPathMode") val azureUrlPathMode: AzureUrlPathMode, + /** Whether to send the SDK's default `X-Stainless-*` telemetry headers. */ + @get:JvmName("sendStainlessHeaders") val sendStainlessHeaders: Boolean, private val organization: String?, private val project: String?, private val webhookSecret: String?, @@ -216,6 +218,7 @@ private constructor( private var credential: Credential? = null private var azureServiceVersion: AzureOpenAIServiceVersion? = null private var azureUrlPathMode: AzureUrlPathMode = AzureUrlPathMode.AUTO + private var sendStainlessHeaders: Boolean = true private var adminApiKey: String? = null private var organization: String? = null private var project: String? = null @@ -251,6 +254,10 @@ private constructor( } azureServiceVersion = clientOptions.azureServiceVersion azureUrlPathMode = clientOptions.azureUrlPathMode + sendStainlessHeaders = clientOptions.sendStainlessHeaders + if (!sendStainlessHeaders) { + headers.removeAll(STAINLESS_HEADER_NAMES) + } organization = clientOptions.organization project = clientOptions.project webhookSecret = clientOptions.webhookSecret @@ -450,6 +457,19 @@ private constructor( this.azureUrlPathMode = azureUrlPathMode } + /** + * Whether to send the SDK's default `X-Stainless-*` telemetry headers. + * + * Defaults to `true`. Set to `false` when a provider or gateway imposes a strict request + * header limit, such as Azure OpenAI returning HTTP 431 for the default telemetry headers. + */ + fun sendStainlessHeaders(sendStainlessHeaders: Boolean) = apply { + this.sendStainlessHeaders = sendStainlessHeaders + if (!sendStainlessHeaders) { + headers.removeAll(STAINLESS_HEADER_NAMES) + } + } + fun organization(organization: String?) = apply { this.organization = organization } /** Alias for calling [Builder.organization] with `organization.orElse(null)`. */ @@ -711,14 +731,16 @@ private constructor( val headers = Headers.builder() val queryParams = QueryParams.builder() - headers.put("X-Stainless-Lang", "java") - headers.put("X-Stainless-Arch", getOsArch()) - headers.put("X-Stainless-OS", getOsName()) - headers.put("X-Stainless-OS-Version", getOsVersion()) - headers.put("X-Stainless-Package-Version", getPackageVersion()) - headers.put("X-Stainless-Runtime", "JRE") - headers.put("X-Stainless-Runtime-Version", getJavaVersion()) - headers.put("X-Stainless-Kotlin-Version", KotlinVersion.CURRENT.toString()) + if (sendStainlessHeaders) { + headers.put("X-Stainless-Lang", "java") + headers.put("X-Stainless-Arch", getOsArch()) + headers.put("X-Stainless-OS", getOsName()) + headers.put("X-Stainless-OS-Version", getOsVersion()) + headers.put("X-Stainless-Package-Version", getPackageVersion()) + headers.put("X-Stainless-Runtime", "JRE") + headers.put("X-Stainless-Runtime-Version", getJavaVersion()) + headers.put("X-Stainless-Kotlin-Version", KotlinVersion.CURRENT.toString()) + } // We replace after all the default headers to allow end-users to overwrite them. headers.replaceAll(this.headers.build()) @@ -778,6 +800,7 @@ private constructor( .sleeper(sleeper) .clock(clock) .maxRetries(maxRetries) + .sendStainlessHeaders(sendStainlessHeaders) .build() return ClientOptions( @@ -802,6 +825,7 @@ private constructor( credential, azureServiceVersion, azureUrlPathMode, + sendStainlessHeaders, organization, project, webhookSecret, @@ -880,3 +904,18 @@ private constructor( private object AdminApiKeyOnlyCredential : Credential private object HttpRequestAuthenticatorCredential : Credential + +private val STAINLESS_HEADER_NAMES = + setOf( + "X-Stainless-Lang", + "X-Stainless-Arch", + "X-Stainless-OS", + "X-Stainless-OS-Version", + "X-Stainless-Package-Version", + "X-Stainless-Runtime", + "X-Stainless-Runtime-Version", + "X-Stainless-Kotlin-Version", + "X-Stainless-Retry-Count", + "X-Stainless-Read-Timeout", + "X-Stainless-Timeout", + ) diff --git a/openai-java-core/src/main/kotlin/com/openai/core/http/RetryingHttpClient.kt b/openai-java-core/src/main/kotlin/com/openai/core/http/RetryingHttpClient.kt index 317205674..55dc2a7e6 100644 --- a/openai-java-core/src/main/kotlin/com/openai/core/http/RetryingHttpClient.kt +++ b/openai-java-core/src/main/kotlin/com/openai/core/http/RetryingHttpClient.kt @@ -28,6 +28,7 @@ private constructor( private val clock: Clock, private val maxRetries: Int, private val idempotencyHeader: String?, + private val sendStainlessHeaders: Boolean, ) : HttpClient { override fun execute(request: HttpRequest, requestOptions: RequestOptions): HttpResponse { @@ -35,7 +36,8 @@ private constructor( // Don't send the current retry count in the headers if the caller set their own value. val shouldSendRetryCount = - !modifiedRequest.headers.names().contains("X-Stainless-Retry-Count") + sendStainlessHeaders && + !modifiedRequest.headers.names().contains("X-Stainless-Retry-Count") var retries = 0 @@ -79,7 +81,8 @@ private constructor( // Don't send the current retry count in the headers if the caller set their own value. val shouldSendRetryCount = - !modifiedRequest.headers.names().contains("X-Stainless-Retry-Count") + sendStainlessHeaders && + !modifiedRequest.headers.names().contains("X-Stainless-Retry-Count") var retries = 0 @@ -237,6 +240,7 @@ private constructor( private var clock: Clock = Clock.systemUTC() private var maxRetries: Int = 2 private var idempotencyHeader: String? = null + private var sendStainlessHeaders: Boolean = true fun httpClient(httpClient: HttpClient) = apply { this.httpClient = httpClient } @@ -248,6 +252,10 @@ private constructor( fun idempotencyHeader(header: String) = apply { this.idempotencyHeader = header } + fun sendStainlessHeaders(sendStainlessHeaders: Boolean) = apply { + this.sendStainlessHeaders = sendStainlessHeaders + } + fun build(): HttpClient = RetryingHttpClient( checkRequired("httpClient", httpClient), @@ -255,6 +263,7 @@ private constructor( clock, maxRetries, idempotencyHeader, + sendStainlessHeaders, ) } } 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 2817c98ef..0e0f91f1d 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 @@ -192,6 +192,48 @@ internal class ClientOptionsTest { assertThat(clientOptions.headers.values("User-Agent")).containsExactly("My User Agent") } + @Test + fun build_withStainlessHeadersDisabled_doesNotIncludeStainlessHeaders() { + val clientOptions = + ClientOptions.builder() + .httpClient(httpClient) + .apiKey("My API Key") + .sendStainlessHeaders(false) + .build() + + assertThat(clientOptions.sendStainlessHeaders).isFalse() + assertThat(clientOptions.headers.names()).noneMatch { it.startsWith("X-Stainless-") } + } + + @Test + fun toBuilder_preservesStainlessHeadersSetting() { + val clientOptions = + ClientOptions.builder() + .httpClient(httpClient) + .apiKey("My API Key") + .sendStainlessHeaders(false) + .build() + .toBuilder() + .build() + + assertThat(clientOptions.sendStainlessHeaders).isFalse() + assertThat(clientOptions.headers.names()).noneMatch { it.startsWith("X-Stainless-") } + } + + @Test + fun toBuilder_canDisableStainlessHeadersAfterTheyWereMaterialized() { + val clientOptions = + ClientOptions.builder() + .httpClient(httpClient) + .apiKey("My API Key") + .build() + .toBuilder() + .sendStainlessHeaders(false) + .build() + + assertThat(clientOptions.headers.names()).noneMatch { it.startsWith("X-Stainless-") } + } + @Test fun toBuilder_organizationCanBeUpdated() { var clientOptions = diff --git a/openai-java-core/src/test/kotlin/com/openai/core/http/RetryingHttpClientTest.kt b/openai-java-core/src/test/kotlin/com/openai/core/http/RetryingHttpClientTest.kt index c33a6d8db..50c86c5c8 100644 --- a/openai-java-core/src/test/kotlin/com/openai/core/http/RetryingHttpClientTest.kt +++ b/openai-java-core/src/test/kotlin/com/openai/core/http/RetryingHttpClientTest.kt @@ -122,6 +122,34 @@ internal class RetryingHttpClientTest { assertNoResponseLeaks() } + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun execute_stainlessHeadersCanBeDisabled(async: Boolean) { + stubFor(post(urlPathEqualTo("/something")).willReturn(ok())) + val sleeper = RecordingSleeper() + val retryingClient = + retryingHttpClientBuilder(sleeper) + .sendStainlessHeaders(false) + .build() + + val response = + retryingClient.execute( + HttpRequest.builder() + .method(HttpMethod.POST) + .baseUrl(baseUrl) + .addPathSegment("something") + .build(), + async, + ) + + response.close() + verify( + postRequestedFor(urlPathEqualTo("/something")) + .withoutHeader("X-Stainless-Retry-Count") + ) + assertNoResponseLeaks() + } + @ParameterizedTest @ValueSource(booleans = [false, true]) fun execute_withIdempotencyHeader(async: Boolean) {