diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c33eb8cb..bee3a52f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.42.0" + ".": "2.43.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d7509b..3cc51978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index b2531e66..ded12e0b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -24,7 +24,7 @@ implementation("com.anthropic:anthropic-java:2.42.0") com.anthropic anthropic-java - 2.42.0 + 2.43.0 ``` diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/helpers/BetaRefusalFallbackInterceptor.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/helpers/BetaRefusalFallbackInterceptor.kt index 60f765d5..c79d6769 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/helpers/BetaRefusalFallbackInterceptor.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/helpers/BetaRefusalFallbackInterceptor.kt @@ -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, @@ -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() } /** diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/helpers/StainlessHelperHeader.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/helpers/StainlessHelperHeader.kt new file mode 100644 index 00000000..08e25a2c --- /dev/null +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/helpers/StainlessHelperHeader.kt @@ -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() + 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(", ") +} diff --git a/anthropic-java-core/src/test/kotlin/com/anthropic/helpers/BetaRefusalFallbackInterceptorTest.kt b/anthropic-java-core/src/test/kotlin/com/anthropic/helpers/BetaRefusalFallbackInterceptorTest.kt index 76c6645a..8d0c2bcd 100644 --- a/anthropic-java-core/src/test/kotlin/com/anthropic/helpers/BetaRefusalFallbackInterceptorTest.kt +++ b/anthropic-java-core/src/test/kotlin/com/anthropic/helpers/BetaRefusalFallbackInterceptorTest.kt @@ -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) { diff --git a/anthropic-java-core/src/test/kotlin/com/anthropic/helpers/StainlessHelperHeaderTest.kt b/anthropic-java-core/src/test/kotlin/com/anthropic/helpers/StainlessHelperHeaderTest.kt new file mode 100644 index 00000000..929d428e --- /dev/null +++ b/anthropic-java-core/src/test/kotlin/com/anthropic/helpers/StainlessHelperHeaderTest.kt @@ -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") + } +} diff --git a/build.gradle.kts b/build.gradle.kts index d8b18b4f..135a99ad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,4 @@ allprojects { group = "com.anthropic" - version = "2.42.0" // x-release-please-version + version = "2.43.0" // x-release-please-version }