Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate opt-out through withOptions transport clones

When a client initially uses the default and is later cloned with client.withOptions(b -> b.sendStainlessHeaders(false)), ClientOptions.Builder.from() reuses the original HttpClient, whose new transport-level flag was fixed to true at construction. The clone omits static and retry headers, but this call still causes the reused OkHttp transport to add X-Stainless-Read-Timeout and X-Stainless-Timeout; with the nonzero default timeouts, the Azure workaround remains incomplete. Fresh evidence in this revision is that the transport now captures the flag immutably while withOptions continues reusing originalHttpClient; propagate the updated policy to cloned transports or make request-time injection consult the current options, and cover the public withOptions flow on the wire.

AGENTS.md reference: AGENTS.md:L41-L45

Useful? React with 👍 / 👎.

}

companion object {
Expand All @@ -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 }

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)`. */
Expand Down Expand Up @@ -512,6 +523,7 @@ class OpenAIOkHttpClient private constructor() {
.maxIdleConnections(maxIdleConnections)
.keepAliveDuration(keepAliveDuration)
.dispatcherExecutorService(dispatcherExecutorService)
.sendStainlessHeaders(sendStainlessHeaders)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the opt-out to X.509 transports

When sendStainlessHeaders(false) is combined with x509WorkloadIdentity(...), build() selects the x509ClientOptions branch and never reaches this only propagation into the OkHttp builder. X509Transport.bind() constructs both underlying OkHttp clients with the default true, so their requests still receive the timeout telemetry headers. Fresh evidence beyond the earlier general transport concern is this alternate X.509 construction path; pass the setting into the bound transport and add a wire-level X.509 regression test.

AGENTS.md reference: AGENTS.md:L41-L45

Useful? React with 👍 / 👎.

.sslSocketFactory(sslSocketFactory)
.trustManager(trustManager)
.hostnameVerifier(hostnameVerifier)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)`. */
Expand Down Expand Up @@ -512,6 +523,7 @@ class OpenAIOkHttpClientAsync private constructor() {
.maxIdleConnections(maxIdleConnections)
.keepAliveDuration(keepAliveDuration)
.dispatcherExecutorService(dispatcherExecutorService)
.sendStainlessHeaders(sendStainlessHeaders)
.sslSocketFactory(sslSocketFactory)
.trustManager(trustManager)
.hostnameVerifier(hostnameVerifier)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -251,6 +254,10 @@ private constructor(
}
azureServiceVersion = clientOptions.azureServiceVersion
azureUrlPathMode = clientOptions.azureUrlPathMode
sendStainlessHeaders = clientOptions.sendStainlessHeaders

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove materialized headers when disabling after cloning

When sendStainlessHeaders(false) is applied to a builder obtained from toBuilder() whose source used the default true, from() has already copied the source's materialized X-Stainless-* headers into this.headers at line 243. The subsequent build skips generating new defaults but restores those copied headers at line 739, leaving the option false while all eight static telemetry headers are still sent. This also makes the post-build withOptions workaround ineffective; remove the SDK-generated headers when disabling or keep user-supplied and generated headers separate.

Useful? React with 👍 / 👎.

if (!sendStainlessHeaders) {
headers.removeAll(STAINLESS_HEADER_NAMES)
}
Comment on lines +258 to +260

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve explicit Stainless headers across no-op clones

If options are built with .sendStainlessHeaders(false).putHeader("X-Stainless-Lang", "custom"), the later explicit header is retained by build(), but a no-op toBuilder().build() copies it and then removes it here solely because the saved flag is false. Consequently, withOptions for an unrelated setting can silently discard caller-provided headers. Fresh evidence in this revision is the new blanket cleanup added to address materialized defaults; distinguish generated defaults from user headers rather than deleting every matching name during cloning, and cover cloning after an explicit post-opt-out header.

AGENTS.md reference: AGENTS.md:L41-L45

Useful? React with 👍 / 👎.

organization = clientOptions.organization
project = clientOptions.project
webhookSecret = clientOptions.webhookSecret
Expand Down Expand Up @@ -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)
}
}
Comment on lines +466 to +471

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expose the option through the public OkHttp builders

Azure users normally construct clients with OpenAIOkHttpClient.builder() or OpenAIOkHttpClientAsync.builder(), but neither builder delegates this new setting to its internal ClientOptions.Builder, unlike the other client options. Therefore .sendStainlessHeaders(false) is unavailable in the standard construction flow targeted by this workaround; users would have to discover the indirect post-build withOptions path instead.

Useful? React with 👍 / 👎.


fun organization(organization: String?) = apply { this.organization = organization }

/** Alias for calling [Builder.organization] with `organization.orElse(null)`. */
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the switch for headers added during request execution

When this option is false, only the static headers stored in ClientOptions.headers are omitted. Every request still receives X-Stainless-Retry-Count in RetryingHttpClient.kt lines 36-45/80-92, and the default OkHttp transport adds X-Stainless-Read-Timeout and X-Stainless-Timeout in OkHttpClient.kt lines 255-269. Consequently, users near Azure's custom-header limit still send three SDK telemetry headers and can continue receiving HTTP 431; propagate the setting into these request-time injection paths and verify the headers captured on the wire.

Useful? React with 👍 / 👎.

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())

Expand Down Expand Up @@ -778,6 +800,7 @@ private constructor(
.sleeper(sleeper)
.clock(clock)
.maxRetries(maxRetries)
.sendStainlessHeaders(sendStainlessHeaders)
.build()

return ClientOptions(
Expand All @@ -802,6 +825,7 @@ private constructor(
credential,
azureServiceVersion,
azureUrlPathMode,
sendStainlessHeaders,
organization,
project,
webhookSecret,
Expand Down Expand Up @@ -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",
)
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,16 @@ 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 {
var modifiedRequest = maybeAddIdempotencyHeader(request)

// 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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 }

Expand All @@ -248,13 +252,18 @@ 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),
sleeper ?: DefaultSleeper(),
clock,
maxRetries,
idempotencyHeader,
sendStainlessHeaders,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading