Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "2.42.0"
".": "2.43.0"
}
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 2.43.0 (2026-06-18)

Full Changelog: [v2.42.0...v2.43.0](https://github.com/anthropics/anthropic-sdk-java/compare/v2.42.0...v2.43.0)

### Features

* **helpers:** introduce x-stainless-helper telemetry + tag the refusal-fallback interceptor ([#91](https://github.com/anthropics/anthropic-sdk-java/issues/91)) ([a14925b](https://github.com/anthropics/anthropic-sdk-java/commit/a14925bb5b3f1495a553e310e57705a597581b62))

## 2.42.0 (2026-06-18)

Full Changelog: [v2.41.1...v2.42.0](https://github.com/anthropics/anthropic-sdk-java/compare/v2.41.1...v2.42.0)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Full documentation is available at **[platform.claude.com/docs/en/api/sdks/java]
### Gradle

```kotlin
implementation("com.anthropic:anthropic-java:2.42.0")
implementation("com.anthropic:anthropic-java:2.43.0")
```

### Maven
Expand All @@ -24,7 +24,7 @@ implementation("com.anthropic:anthropic-java:2.42.0")
<dependency>
<groupId>com.anthropic</groupId>
<artifactId>anthropic-java</artifactId>
<version>2.42.0</version>
<version>2.43.0</version>
</dependency>
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -463,9 +463,10 @@ private constructor(
)
}

// Send the configured betas on this and every hop request derived from it.
// Send the configured betas on this and every hop request derived from it,
// and tag this and every hop with the interceptor's helper telemetry.
return PreparedRequest(
appendBetas(trimmedRequest),
withInterceptorHeaders(trimmedRequest),
body,
state,
index,
Expand Down Expand Up @@ -655,22 +656,29 @@ private constructor(
}

/**
* Returns a copy of the request with [betas] appended to its `anthropic-beta` header, skipping
* values already present (set by the caller or another interceptor).
* Returns a copy of the request with [betas] appended to its `anthropic-beta` header (skipping
* values already present) and the interceptor's helper-telemetry tag appended to
* `x-stainless-helper`. Single rebuild for both.
*/
private fun appendBetas(request: HttpRequest): HttpRequest {
if (betas.isEmpty()) {
return request
}
private fun withInterceptorHeaders(request: HttpRequest): HttpRequest {
val builder = request.toBuilder()
val existing =
request.headers.values("anthropic-beta").flatMapTo(mutableSetOf()) { value ->
value.split(",").map { it.trim() }
}
val missing = betas.map { it.asString() }.filter { existing.add(it) }
if (missing.isEmpty()) {
return request
if (missing.isNotEmpty()) {
builder.putHeaders("anthropic-beta", missing)
}
return request.toBuilder().putHeaders("anthropic-beta", missing).build()
return builder
.replaceHeaders(
STAINLESS_HELPER_HEADER,
mergedStainlessHelperValue(
request.headers,
StainlessHelperHeaderValue.FALLBACK_REFUSAL_MIDDLEWARE,
),
)
.build()
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.anthropic.helpers

import com.anthropic.core.http.Headers

/**
* Telemetry header naming the SDK helper(s) a request came from. Always this lowercase form;
* [Headers] is case-insensitive but a single canonical casing keeps every call site greppable.
*/
@JvmSynthetic internal const val STAINLESS_HELPER_HEADER = "x-stainless-helper"

/**
* The closed set of helper telemetry tags, shared verbatim across SDKs. A typo at any call site is
* a compile error rather than silently mistagged telemetry. Existing values keep their original
* spellings — telemetry consumers match on them, so renames lose history. New tags are hyphenated
* lowercase.
*/
internal enum class StainlessHelperHeaderValue(@JvmSynthetic internal val value: String) {
FALLBACK_REFUSAL_MIDDLEWARE("fallback-refusal-middleware");

override fun toString(): String = value
}

/**
* Returns the [STAINLESS_HELPER_HEADER] value to set after appending [value] to whatever is already
* present in [headers] — existing tags keep their position, the new tag goes at the end, duplicates
* are dropped, joined as one comma-separated string. The backend logs the header as one opaque
* string, so a second header line or a clobbered value loses data; callers set the result via
* `replaceHeaders(STAINLESS_HELPER_HEADER, …)`.
*/
@JvmSynthetic
internal fun mergedStainlessHelperValue(
headers: Headers,
value: StainlessHelperHeaderValue,
): String {
val tokens = LinkedHashSet<String>()
headers.values(STAINLESS_HELPER_HEADER).forEach { existing ->
existing.split(",").map(String::trim).filter(String::isNotEmpty).forEach(tokens::add)
}
tokens.add(value.value)
return tokens.joinToString(", ")
}
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,73 @@ internal class BetaRefusalFallbackInterceptorTest {
assertThat(fallbackState.index()).isEqualTo(-1)
}

@ParameterizedTest
@ValueSource(booleans = [false, true])
fun tagsTheOriginalAndFallbackRequests(async: Boolean) {
val httpClient =
FakeHttpClient(refusal("primary-model", "credit-token"), message("fallback-model"))
val interceptedClient =
BetaRefusalFallbackInterceptor.builder()
.addFallback(fallback("fallback-model"))
.build()
.intercept(httpClient)

interceptedClient.execute(
messagesRequest(),
RequestOptions.builder().fallbackState(BetaFallbackState.create()).build(),
async,
)

assertThat(httpClient.requests.map { it.headers.values(STAINLESS_HELPER_HEADER) })
.containsExactly(
listOf("fallback-refusal-middleware"),
listOf("fallback-refusal-middleware"),
)
}

@ParameterizedTest
@ValueSource(booleans = [false, true])
fun appendsToAHelperTagAlreadyOnTheRequest(async: Boolean) {
val httpClient = FakeHttpClient(message("primary-model"))
val interceptedClient =
BetaRefusalFallbackInterceptor.builder()
.addFallback(fallback("fallback-model"))
.build()
.intercept(httpClient)

interceptedClient.execute(
messagesRequest().toBuilder().putHeader("X-Stainless-Helper", "BetaToolRunner").build(),
RequestOptions.builder().fallbackState(BetaFallbackState.create()).build(),
async,
)

assertThat(httpClient.requests.single().headers.values(STAINLESS_HELPER_HEADER))
.containsExactly("BetaToolRunner, fallback-refusal-middleware")
}

@ParameterizedTest
@ValueSource(booleans = [false, true])
fun doesNotTagRequestsItPassesThrough(async: Boolean) {
val httpClient = FakeHttpClient(message("primary-model"))
val interceptedClient =
BetaRefusalFallbackInterceptor.builder()
.addFallback(fallback("fallback-model"))
.build()
.intercept(httpClient)

// the GA surface (no ?beta=true) is not applicable to this interceptor
val gaRequest =
HttpRequest.builder()
.method(HttpMethod.POST)
.baseUrl("https://api.example.com")
.addPathSegments("v1", "messages")
.body(HttpRequestBody.ofJson(messagesBody()))
.build()
interceptedClient.execute(gaRequest, RequestOptions.none(), async)

assertThat(httpClient.requests.single().headers.values(STAINLESS_HELPER_HEADER)).isEmpty()
}

@ParameterizedTest
@ValueSource(booleans = [false, true])
fun walksEachHopThroughTheChainUntilAModelAccepts(async: Boolean) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.anthropic.helpers

import com.anthropic.core.http.Headers
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

internal class StainlessHelperHeaderTest {

@Test
fun `appends to an empty headers`() {
val merged =
mergedStainlessHelperValue(
Headers.builder().build(),
StainlessHelperHeaderValue.FALLBACK_REFUSAL_MIDDLEWARE,
)
assertThat(merged).isEqualTo("fallback-refusal-middleware")
}

@Test
fun `appends to an existing tag`() {
val headers = Headers.builder().put(STAINLESS_HELPER_HEADER, "BetaToolRunner").build()
val merged =
mergedStainlessHelperValue(
headers,
StainlessHelperHeaderValue.FALLBACK_REFUSAL_MIDDLEWARE,
)
assertThat(merged).isEqualTo("BetaToolRunner, fallback-refusal-middleware")
}

@Test
fun `dedups`() {
val headers =
Headers.builder().put(STAINLESS_HELPER_HEADER, "fallback-refusal-middleware").build()
val merged =
mergedStainlessHelperValue(
headers,
StainlessHelperHeaderValue.FALLBACK_REFUSAL_MIDDLEWARE,
)
assertThat(merged).isEqualTo("fallback-refusal-middleware")
}

@Test
fun `collapses multiple lines and casings`() {
val headers =
Headers.builder()
.put("X-Stainless-Helper", "a")
.put(STAINLESS_HELPER_HEADER, "b, c")
.build()
val merged =
mergedStainlessHelperValue(
headers,
StainlessHelperHeaderValue.FALLBACK_REFUSAL_MIDDLEWARE,
)
assertThat(merged).isEqualTo("a, b, c, fallback-refusal-middleware")
}
}
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
allprojects {
group = "com.anthropic"
version = "2.42.0" // x-release-please-version
version = "2.43.0" // x-release-please-version
}
Loading