From ffb078b8285c3a52c45502dabac5ed1890460db8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 21:58:46 +0000 Subject: [PATCH 1/5] docs: remove bad semicolon --- README.md | 93 ------------------------------------------------------- 1 file changed, 93 deletions(-) diff --git a/README.md b/README.md index fe12f2deb..182296a90 100644 --- a/README.md +++ b/README.md @@ -46,99 +46,6 @@ MessageCreateParams params = MessageCreateParams.builder() .maxTokens(1024L) .addUserMessage("Hello, Claude") .model(Model.CLAUDE_OPUS_4_6) - .maxTokens(JsonMissing.of()) - .build(); -``` - -### Response properties - -To access undocumented response properties, call the `_additionalProperties()` method: - -```java -import com.anthropic.core.JsonValue; -import java.util.Map; - -Map additionalProperties = client.messages().create(params)._additionalProperties(); -JsonValue secretPropertyValue = additionalProperties.get("secretProperty"); - -String result = secretPropertyValue.accept(new JsonValue.Visitor<>() { - @Override - public String visitNull() { - return "It's null!"; - } - - @Override - public String visitBoolean(boolean value) { - return "It's a boolean!"; - } - - @Override - public String visitNumber(Number value) { - return "It's a number!"; - } - - // Other methods include `visitMissing`, `visitString`, `visitArray`, and `visitObject` - // The default implementation of each unimplemented method delegates to `visitDefault`, which throws by default, but can also be overridden -}); -``` - -To access a property's raw JSON value, which may be undocumented, call its `_` prefixed method: - -```java -import com.anthropic.core.JsonField; -import java.util.Optional; - -JsonField maxTokens = client.messages().create(params)._maxTokens(); - -if (maxTokens.isMissing()) { - // The property is absent from the JSON response -} else if (maxTokens.isNull()) { - // The property was set to literal null -} else { - // Check if value was provided as a string - // Other methods include `asNumber()`, `asBoolean()`, etc. - Optional jsonString = maxTokens.asString(); - - // Try to deserialize into a custom type - MyClass myObject = maxTokens.asUnknown().orElseThrow().convert(MyClass.class); -} -``` - -### Response validation - -In rare cases, the API may return a response that doesn't match the expected type. For example, the SDK may expect a property to contain a `String`, but the API could return something else. - -By default, the SDK will not throw an exception in this case. It will throw [`AnthropicInvalidDataException`](anthropic-java-core/src/main/kotlin/com/anthropic/errors/AnthropicInvalidDataException.kt) only if you directly access the property. - -Validating the response is _not_ forwards compatible with new types from the API for existing fields. - -If you would still prefer to check that the response is completely well-typed upfront, then either call `validate()`: - -```java -import com.anthropic.models.messages.Message; - -Message message = client.messages().create(params).validate(); -``` - -Or configure the method call to validate the response using the `responseValidation` method: - -```java -import com.anthropic.models.messages.Message; - -Message message = client.messages().create( - params, RequestOptions.builder().responseValidation(true).build() -); -``` - -Or configure the default for all method calls at the client level: - -```java -import com.anthropic.client.AnthropicClient; -import com.anthropic.client.okhttp.AnthropicOkHttpClient; - -AnthropicClient client = AnthropicOkHttpClient.builder() - .fromEnv() - .responseValidation(true) .build(); Message message = client.messages().create(params); ``` From d6b94f49fd06f0ceaf6293880e03e7ba95d95d41 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 20:10:56 +0000 Subject: [PATCH 2/5] chore: remove duplicated dokka setup --- build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 9e7e1001b..e1a56290c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,7 +21,6 @@ subprojects { group = "Verification" description = "Verifies all source files are formatted." } - apply(plugin = "org.jetbrains.dokka") } subprojects { From 556ef492e5a668f1964147b41808ca47a26bb906 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 23:27:04 +0000 Subject: [PATCH 3/5] perf(client): create one json mapper --- .../src/main/kotlin/com/anthropic/core/ObjectMappers.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/core/ObjectMappers.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/core/ObjectMappers.kt index 1fd764a9b..b9f140204 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/core/ObjectMappers.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/core/ObjectMappers.kt @@ -29,7 +29,9 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.temporal.ChronoField -fun jsonMapper(): JsonMapper = +fun jsonMapper(): JsonMapper = JSON_MAPPER + +private val JSON_MAPPER: JsonMapper = JsonMapper.builder() .addModule(kotlinModule()) .addModule(Jdk8Module()) From 578003afed53205ed23789845fc7ed0153ed1100 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 15:53:44 +0000 Subject: [PATCH 4/5] feat(client): allow targeting a workspace for OIDC federation token exchange --- .../anthropic/backends/AnthropicBackend.kt | 19 ++ .../config/ConfigurationFileProvider.kt | 7 + .../com/anthropic/config/ProfileConfig.kt | 2 + .../credentials/CredentialResolver.kt | 24 +- .../WorkloadIdentityCredentials.kt | 47 ++- .../backends/AnthropicBackendTest.kt | 59 ++++ .../com/anthropic/config/ProfileConfigTest.kt | 5 + .../credentials/CredentialResolverTest.kt | 280 +++++++++++++++++- .../WorkloadIdentityCredentialsTest.kt | 199 ++++++++++++- 9 files changed, 634 insertions(+), 8 deletions(-) diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/backends/AnthropicBackend.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/backends/AnthropicBackend.kt index 8913ff73b..636dcaaa7 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/backends/AnthropicBackend.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/backends/AnthropicBackend.kt @@ -52,6 +52,7 @@ private constructor( val federationRuleId: String, val organizationId: String, val serviceAccountId: String?, + val workspaceId: String?, ) fun applyCredentials(httpClient: HttpClient, clientOptionsBuilder: ClientOptions.Builder) { @@ -75,6 +76,7 @@ private constructor( config.federationRuleId, config.organizationId, config.serviceAccountId, + config.workspaceId, httpClient, jsonMapper, ) @@ -203,6 +205,22 @@ private constructor( federationRuleId: String, organizationId: String, serviceAccountId: String?, + ) = + federationTokenProvider( + identityTokenProvider, + federationRuleId, + organizationId, + serviceAccountId, + null, + ) + + @JvmSynthetic + internal fun federationTokenProvider( + identityTokenProvider: IdentityTokenProvider, + federationRuleId: String, + organizationId: String, + serviceAccountId: String?, + workspaceId: String?, ) = apply { clearCredentials() federationConfig = @@ -211,6 +229,7 @@ private constructor( federationRuleId, organizationId, serviceAccountId, + workspaceId, ) } diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/config/ConfigurationFileProvider.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/config/ConfigurationFileProvider.kt index d5fa77c1c..47c37da31 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/config/ConfigurationFileProvider.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/config/ConfigurationFileProvider.kt @@ -15,6 +15,7 @@ private constructor( private val envIdentityTokenFile: String? = null, private val envIdentityToken: String? = null, private val envServiceAccountId: String? = null, + private val envWorkspaceId: String? = null, ) : ProfileConfigProvider { init { @@ -53,6 +54,7 @@ private constructor( envIdentityTokenFile, envIdentityToken, envServiceAccountId, + envWorkspaceId, ) cachedConfig = config @@ -77,6 +79,7 @@ private constructor( private var envIdentityTokenFile: String? = null private var envIdentityToken: String? = null private var envServiceAccountId: String? = null + private var envWorkspaceId: String? = null fun profile(profile: String) = apply { this.profile = profile } @@ -94,12 +97,15 @@ private constructor( fun envServiceAccountId(value: String?) = apply { this.envServiceAccountId = value } + fun envWorkspaceId(value: String?) = apply { this.envWorkspaceId = value } + fun fromEnv() = apply { envFederationRuleId(System.getenv("ANTHROPIC_FEDERATION_RULE_ID")) envOrganizationId(System.getenv("ANTHROPIC_ORGANIZATION_ID")) envIdentityTokenFile(System.getenv("ANTHROPIC_IDENTITY_TOKEN_FILE")) envIdentityToken(System.getenv("ANTHROPIC_IDENTITY_TOKEN")) envServiceAccountId(System.getenv("ANTHROPIC_SERVICE_ACCOUNT_ID")) + envWorkspaceId(System.getenv("ANTHROPIC_WORKSPACE_ID")) } fun build(): ConfigurationFileProvider { @@ -112,6 +118,7 @@ private constructor( envIdentityTokenFile, envIdentityToken, envServiceAccountId, + envWorkspaceId, ) } } diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/config/ProfileConfig.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/config/ProfileConfig.kt index c21c8b1f9..51ae2a094 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/config/ProfileConfig.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/config/ProfileConfig.kt @@ -194,6 +194,7 @@ private constructor( envIdentityTokenFile: String?, envIdentityToken: String?, envServiceAccountId: String?, + envWorkspaceId: String?, ): ProfileConfig { val auth = authentication ?: return this val authIdentityToken = auth.identityToken().orElse(null) @@ -222,6 +223,7 @@ private constructor( return toBuilder() .authentication(filledAuth) .organizationId(organizationId ?: envOrganizationId) + .workspaceId(workspaceId ?: envWorkspaceId) .build() } diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/credentials/CredentialResolver.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/credentials/CredentialResolver.kt index 6988fd7b1..cd6855acc 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/credentials/CredentialResolver.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/credentials/CredentialResolver.kt @@ -62,6 +62,7 @@ private constructor( private val envIdentityTokenFile: String?, private val envIdentityToken: String?, private val envServiceAccountId: String?, + private val envWorkspaceId: String?, private val configDir: Path?, private val httpClient: HttpClient?, private val jsonMapper: JsonMapper, @@ -220,6 +221,7 @@ private constructor( federationRuleId, organizationId, auth.serviceAccountId().orElse(null), + config.workspaceId().orElse(null), client, jsonMapper, ) @@ -240,7 +242,10 @@ private constructor( CredentialResult( cachingProvider, config.baseUrl().orElse(null), - config.workspaceId().orElse(null), + // For federation profiles workspace_id is sent in the jwt-bearer exchange body, + // not as a request header (the minted token is already workspace-scoped, so the + // header would be ignored). + null, ) } AuthenticationType.USER_OAUTH -> { @@ -361,6 +366,7 @@ private constructor( .envIdentityTokenFile(envIdentityTokenFile) .envIdentityToken(envIdentityToken) .envServiceAccountId(envServiceAccountId) + .envWorkspaceId(envWorkspaceId) .build() val stepSources = mutableListOf() @@ -437,6 +443,7 @@ private constructor( federationRuleId, organizationId, envServiceAccountId, + envWorkspaceId, client, jsonMapper, ) @@ -482,6 +489,7 @@ private constructor( .envIdentityTokenFile(envIdentityTokenFile) .envIdentityToken(envIdentityToken) .envServiceAccountId(envServiceAccountId) + .envWorkspaceId(envWorkspaceId) .build() return resolveFromConfigurationProvider( @@ -539,6 +547,12 @@ private constructor( .envIdentityTokenFile(System.getenv("ANTHROPIC_IDENTITY_TOKEN_FILE")) .envIdentityToken(System.getenv("ANTHROPIC_IDENTITY_TOKEN")) .envServiceAccountId(System.getenv("ANTHROPIC_SERVICE_ACCOUNT_ID")) + // Coerce empty string to null so a defaulted-but-empty CI variable doesn't put + // "workspace_id": "" on the wire. The builder setter applies the same coercion so + // resolvers built directly (e.g. in tests) behave identically. + .envWorkspaceId( + System.getenv("ANTHROPIC_WORKSPACE_ID")?.takeUnless { it.isEmpty() } + ) .configDir(ConfigDir.resolve()?.let { Paths.get(it) }) .httpClient(httpClient) .build() @@ -554,6 +568,7 @@ private constructor( private var envIdentityTokenFile: String? = null private var envIdentityToken: String? = null private var envServiceAccountId: String? = null + private var envWorkspaceId: String? = null private var configDir: Path? = null private var httpClient: HttpClient? = null private var jsonMapper: JsonMapper = jsonMapper() @@ -589,6 +604,12 @@ private constructor( this.envServiceAccountId = envServiceAccountId } + fun envWorkspaceId(envWorkspaceId: String?) = apply { + // Coerce empty string to null so a defaulted-but-empty CI variable doesn't put + // "workspace_id": "" on the wire. + this.envWorkspaceId = envWorkspaceId?.takeUnless { it.isEmpty() } + } + fun configDir(configDir: Path?) = apply { this.configDir = configDir } fun httpClient(httpClient: HttpClient?) = apply { this.httpClient = httpClient } @@ -605,6 +626,7 @@ private constructor( envIdentityTokenFile, envIdentityToken, envServiceAccountId, + envWorkspaceId, configDir, httpClient, jsonMapper, diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/internal/credentials/WorkloadIdentityCredentials.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/internal/credentials/WorkloadIdentityCredentials.kt index 2aea4f173..f066025dd 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/internal/credentials/WorkloadIdentityCredentials.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/internal/credentials/WorkloadIdentityCredentials.kt @@ -30,13 +30,22 @@ internal data class TokenResponse( * * It obtains an identity token from the configured [IdentityTokenProvider] and exchanges it at the * token endpoint for an Anthropic access token, scoped by [federationRuleId], [organizationId], and - * optionally [serviceAccountId]. + * optionally [serviceAccountId] and [workspaceId]. + * + * @param workspaceId Optional `wrkspc_*` tagged ID, or the literal `"default"` to scope the token + * to the organization's default workspace. When omitted the server picks the rule's sole enabled + * workspace, else the org default if the rule covers it. Required when the rule enables more than + * one non-default workspace, or to target a specific workspace other than the one the server + * would pick. The minted token is workspace-scoped: per-request workspace selection (the + * `anthropic-workspace-id` header) is not supported for federation tokens — switching workspaces + * requires a new token exchange with a different `workspaceId`. */ internal class WorkloadIdentityCredentials( private val identityTokenProvider: IdentityTokenProvider, private val federationRuleId: String, private val organizationId: String, private val serviceAccountId: String?, + private val workspaceId: String?, private val httpClient: HttpClient, private val jsonMapper: JsonMapper, ) : AccessTokenProvider { @@ -85,6 +94,7 @@ internal class WorkloadIdentityCredentials( "organization_id" to organizationId, ) serviceAccountId?.let { params["service_account_id"] = it } + workspaceId?.let { params["workspace_id"] = it } return params } @@ -93,6 +103,7 @@ internal class WorkloadIdentityCredentials( val statusCode = res.statusCode() if (statusCode !in 200..299) { val bodyText = res.body().bufferedReader().readText() + warnFederationDiagnostics(statusCode) throw UnexpectedStatusCodeException.builder() .statusCode(statusCode) .headers(res.headers()) @@ -106,6 +117,40 @@ internal class WorkloadIdentityCredentials( } } + /** + * Logs a diagnostic hint to stderr for a 401 token-exchange response. Other statuses get no + * hint: a 5xx or 400 is unlikely to be a federation-configuration problem. + * + * The hint always reminds the caller to check that the federation rule matches the identity + * token and points at the Workload identity page of Claude Console for the authentication event + * log. When no `workspaceId` is configured it additionally suggests setting one, since a rule + * scoped to multiple workspaces is a common cause the server cannot resolve on its own. + * + * The hint is delivered via stderr (matching the SDK's other diagnostic warnings) rather than + * the exception message: the [UnexpectedStatusCodeException] thrown for token-exchange failures + * is Stainless-generated, `final`, and has a private constructor, so it can't carry a custom + * message without changing the catchable type. Both the exception type and `body()` stay + * byte-identical to what the server returned. + */ + private fun warnFederationDiagnostics(statusCode: Int) { + if (statusCode != 401) { + return + } + val parts = mutableListOf("Ensure your federation rule matches your identity token") + if (workspaceId == null) { + parts.add( + "If your federation rule is scoped to multiple workspaces, set the " + + "ANTHROPIC_WORKSPACE_ID environment variable, the 'workspace_id' config " + + "key, or ProfileConfig.Builder.workspaceId(...)" + ) + } + parts.add( + "View your authentication events in the Workload identity page of Claude Console " + + "for more details" + ) + System.err.println("WARNING: " + parts.joinToString(". ") + ".") + } + private fun tryParseJson(text: String): JsonValue = try { jsonMapper.readValue(text, JsonValue::class.java) diff --git a/anthropic-java-core/src/test/kotlin/com/anthropic/backends/AnthropicBackendTest.kt b/anthropic-java-core/src/test/kotlin/com/anthropic/backends/AnthropicBackendTest.kt index 848b413ee..a47e15efa 100644 --- a/anthropic-java-core/src/test/kotlin/com/anthropic/backends/AnthropicBackendTest.kt +++ b/anthropic-java-core/src/test/kotlin/com/anthropic/backends/AnthropicBackendTest.kt @@ -4,10 +4,15 @@ import com.anthropic.config.ProfileConfig import com.anthropic.config.ProfileConfigProvider import com.anthropic.core.RequestOptions import com.anthropic.core.auth.InMemoryIdentityTokenProvider +import com.anthropic.core.http.Headers import com.anthropic.core.http.HttpClient import com.anthropic.core.http.HttpMethod import com.anthropic.core.http.HttpRequest import com.anthropic.core.http.HttpResponse +import com.anthropic.core.jsonMapper +import com.fasterxml.jackson.module.kotlin.readValue +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.util.Optional import java.util.concurrent.CompletableFuture import org.assertj.core.api.Assertions.assertThat @@ -211,6 +216,60 @@ internal class AnthropicBackendTest { assertThat(credentials!!.provider).isNotNull() } + @Test + fun federationTokenProviderPassesWorkspaceIdInExchangeBody() { + val identityProvider = InMemoryIdentityTokenProvider("test-identity-token") + var exchangeBody: Map? = null + val mockHttpClient = + object : HttpClient { + override fun execute( + request: HttpRequest, + requestOptions: RequestOptions, + ): HttpResponse { + val out = ByteArrayOutputStream() + request.body?.writeTo(out) + exchangeBody = jsonMapper().readValue(out.toString("UTF-8")) + return object : HttpResponse { + override fun statusCode() = 200 + + override fun headers() = Headers.builder().build() + + override fun body() = + ByteArrayInputStream( + """{"access_token": "tok", "expires_in": 3600}""".toByteArray() + ) + + override fun close() {} + } + } + + override fun executeAsync( + request: HttpRequest, + requestOptions: RequestOptions, + ): CompletableFuture = + CompletableFuture.completedFuture(execute(request, requestOptions)) + + override fun close() {} + } + + val backend = + AnthropicBackend.builder() + .federationTokenProvider( + identityTokenProvider = identityProvider, + federationRuleId = "fdrl_test", + organizationId = "org_test", + serviceAccountId = null, + workspaceId = "wrkspc_x", + ) + .build() + + val credentials = backend.resolveCredentials(mockHttpClient) + assertThat(credentials).isNotNull() + credentials!!.provider.get("https://api.anthropic.com", false) + assertThat(exchangeBody).isNotNull + assertThat(exchangeBody!!["workspace_id"]).isEqualTo("wrkspc_x") + } + @Test fun resolveCredentialsReturnsNullWithoutFederationConfig() { val backend = AnthropicBackend.builder().apiKey("test-key").build() diff --git a/anthropic-java-core/src/test/kotlin/com/anthropic/config/ProfileConfigTest.kt b/anthropic-java-core/src/test/kotlin/com/anthropic/config/ProfileConfigTest.kt index 9efb7fafb..384aed002 100644 --- a/anthropic-java-core/src/test/kotlin/com/anthropic/config/ProfileConfigTest.kt +++ b/anthropic-java-core/src/test/kotlin/com/anthropic/config/ProfileConfigTest.kt @@ -99,11 +99,13 @@ internal class ProfileConfigTest { envIdentityTokenFile = "/env/token", envIdentityToken = null, envServiceAccountId = "svac_env", + envWorkspaceId = "wrkspc_env", ) val auth = filled.authentication().get() assertThat(auth.federationRuleId()).contains("fdrl_env") assertThat(filled.organizationId()).contains("org_env") + assertThat(filled.workspaceId()).contains("wrkspc_env") assertThat(auth.identityToken().get().path()).contains("/env/token") } @@ -124,6 +126,7 @@ internal class ProfileConfigTest { .build() ) .organizationId("org_config") + .workspaceId("wrkspc_config") .build() val filled = @@ -133,11 +136,13 @@ internal class ProfileConfigTest { envIdentityTokenFile = "/env/token", envIdentityToken = null, envServiceAccountId = null, + envWorkspaceId = "wrkspc_env", ) val auth = filled.authentication().get() assertThat(auth.federationRuleId()).contains("fdrl_config") assertThat(filled.organizationId()).contains("org_config") + assertThat(filled.workspaceId()).contains("wrkspc_config") assertThat(auth.identityToken().get().path()).contains("/config/token") } } diff --git a/anthropic-java-core/src/test/kotlin/com/anthropic/credentials/CredentialResolverTest.kt b/anthropic-java-core/src/test/kotlin/com/anthropic/credentials/CredentialResolverTest.kt index e88a18037..67d3ed029 100644 --- a/anthropic-java-core/src/test/kotlin/com/anthropic/credentials/CredentialResolverTest.kt +++ b/anthropic-java-core/src/test/kotlin/com/anthropic/credentials/CredentialResolverTest.kt @@ -14,7 +14,9 @@ import com.anthropic.core.http.HttpRequest import com.anthropic.core.http.HttpResponse import com.anthropic.core.jsonMapper import com.anthropic.errors.NoCredentialsException +import com.fasterxml.jackson.module.kotlin.readValue import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.nio.file.Files import java.nio.file.Path import java.time.Instant @@ -79,7 +81,9 @@ internal class CredentialResolverTest { override fun get(): ProfileConfig = config } - val mockClient = MockHttpClient { _ -> + var exchangeBody: Map? = null + val mockClient = MockHttpClient { request -> + exchangeBody = parseJsonParams(extractBody(request)) createResponse(200, """{"access_token": "config-provider-token", "expires_in": 3600}""") } @@ -94,9 +98,13 @@ internal class CredentialResolverTest { assertThat(result).isNotNull assertThat(result.baseUrl).isEqualTo("https://custom.config.provider") - assertThat(result.workspaceId).isEqualTo("wrk_config_provider") + // For oidc_federation, workspace_id goes in the jwt-bearer exchange body, not the + // anthropic-workspace-id header, so CredentialResult.workspaceId must be null. + assertThat(result.workspaceId).isNull() val token = result.provider.get("https://api.anthropic.com", false) assertThat(token.token).isEqualTo("config-provider-token") + assertThat(exchangeBody).isNotNull + assertThat(exchangeBody!!["workspace_id"]).isEqualTo("wrk_config_provider") } @Test @@ -170,13 +178,16 @@ internal class CredentialResolverTest { "path": "${tokenFile.toAbsolutePath()}" } }, - "organization_id": "org_explicit" + "organization_id": "org_explicit", + "workspace_id": "wrkspc_explicit" } """ .trimIndent() ) - val mockClient = MockHttpClient { _ -> + var exchangeBody: Map? = null + val mockClient = MockHttpClient { request -> + exchangeBody = parseJsonParams(extractBody(request)) createResponse( 200, """{"access_token": "explicit-profile-token", "expires_in": 3600}""", @@ -196,6 +207,8 @@ internal class CredentialResolverTest { assertThat(result).isNotNull val token = result.provider.get("https://api.anthropic.com", false) assertThat(token.token).isEqualTo("explicit-profile-token") + assertThat(exchangeBody).isNotNull + assertThat(exchangeBody!!["workspace_id"]).isEqualTo("wrkspc_explicit") } @Test @@ -250,6 +263,69 @@ internal class CredentialResolverTest { assertThat(token.token).isEqualTo("env-federation-value-token") } + @Test + fun step3EnvFederationPassesWorkspaceId(@TempDir tempDir: Path) { + var exchangeBody: Map? = null + val mockClient = MockHttpClient { request -> + exchangeBody = parseJsonParams(extractBody(request)) + createResponse( + 200, + """{"access_token": "env-federation-workspace-token", "expires_in": 3600}""", + ) + } + + val resolver = + CredentialResolver.builder() + .envFederationRuleId("fdrl_env") + .envOrganizationId("org_env") + .envIdentityToken("inline-identity-token") + .envWorkspaceId("wrkspc_01abc") + .configDir(tempDir) + .httpClient(mockClient) + .jsonMapper(jsonMapper()) + .build() + + val result = resolver.resolve() + + assertThat(result).isNotNull + result.provider.get("https://api.anthropic.com", false) + assertThat(exchangeBody).isNotNull + assertThat(exchangeBody!!["workspace_id"]).isEqualTo("wrkspc_01abc") + } + + @Test + fun step3EnvFederationCoercesEmptyWorkspaceIdToUnset(@TempDir tempDir: Path) { + // ANTHROPIC_WORKSPACE_ID="" (a defaulted-but-empty CI variable) is treated as unset — + // never put "workspace_id": "" on the wire. + var exchangeBody: Map? = null + val mockClient = MockHttpClient { request -> + exchangeBody = parseJsonParams(extractBody(request)) + createResponse( + 200, + """{"access_token": "env-federation-empty-workspace", "expires_in": 3600}""", + ) + } + + val resolver = + CredentialResolver.builder() + .envFederationRuleId("fdrl_env") + .envOrganizationId("org_env") + .envIdentityToken("inline-identity-token") + .envWorkspaceId("") + .configDir(tempDir) + .httpClient(mockClient) + .jsonMapper(jsonMapper()) + .build() + + val result = resolver.resolve() + + assertThat(result).isNotNull + assertThat(result.workspaceId).isNull() + result.provider.get("https://api.anthropic.com", false) + assertThat(exchangeBody).isNotNull + assertThat(exchangeBody!!).doesNotContainKey("workspace_id") + } + @Test fun step4FallbackProfileWins(@TempDir tempDir: Path) { val configDir = tempDir @@ -401,6 +477,119 @@ internal class CredentialResolverTest { assertThat(token.token).isEqualTo("filled-profile-token") } + @Test + fun envWorkspaceIdFillsMissingInProfile(@TempDir tempDir: Path) { + val configDir = tempDir + val configsDir = configDir.resolve("configs") + Files.createDirectories(configsDir) + + val tokenFile = tempDir.resolve("identity-token") + tokenFile.toFile().writeText("test-identity-token") + + val profileConfig = configsDir.resolve("no-workspace-profile.json") + profileConfig + .toFile() + .writeText( + """ + { + "authentication": { + "type": "oidc_federation", + "federation_rule_id": "fdrl_file", + "identity_token": { + "source": "file", + "path": "${tokenFile.toAbsolutePath()}" + } + }, + "organization_id": "org_file" + } + """ + .trimIndent() + ) + + var exchangeBody: Map? = null + val mockClient = MockHttpClient { request -> + exchangeBody = parseJsonParams(extractBody(request)) + createResponse( + 200, + """{"access_token": "filled-workspace-token", "expires_in": 3600}""", + ) + } + + val resolver = + CredentialResolver.builder() + .envProfile("no-workspace-profile") + .envWorkspaceId("wrkspc_from_env") + .configDir(configDir) + .httpClient(mockClient) + .jsonMapper(jsonMapper()) + .build() + + val result = resolver.resolve() + + assertThat(result).isNotNull + // For oidc_federation, workspace_id goes in the jwt-bearer exchange body, not the + // anthropic-workspace-id header, so CredentialResult.workspaceId must be null. + assertThat(result.workspaceId).isNull() + result.provider.get("https://api.anthropic.com", false) + assertThat(exchangeBody).isNotNull + assertThat(exchangeBody!!["workspace_id"]).isEqualTo("wrkspc_from_env") + } + + @Test + fun configFileWorkspaceIdBeatsEnvVar(@TempDir tempDir: Path) { + // Profile config wins over ANTHROPIC_WORKSPACE_ID — same precedence model as + // organization_id and the rest of the env-fillable fields. + val configDir = tempDir + val configsDir = configDir.resolve("configs") + Files.createDirectories(configsDir) + + val tokenFile = tempDir.resolve("identity-token") + tokenFile.toFile().writeText("test-identity-token") + + val profileConfig = configsDir.resolve("with-workspace-profile.json") + profileConfig + .toFile() + .writeText( + """ + { + "authentication": { + "type": "oidc_federation", + "federation_rule_id": "fdrl_file", + "identity_token": { + "source": "file", + "path": "${tokenFile.toAbsolutePath()}" + } + }, + "organization_id": "org_file", + "workspace_id": "wrkspc_file" + } + """ + .trimIndent() + ) + + var exchangeBody: Map? = null + val mockClient = MockHttpClient { request -> + exchangeBody = parseJsonParams(extractBody(request)) + createResponse(200, """{"access_token": "tok", "expires_in": 3600}""") + } + + val resolver = + CredentialResolver.builder() + .envProfile("with-workspace-profile") + .envWorkspaceId("wrkspc_env") + .configDir(configDir) + .httpClient(mockClient) + .jsonMapper(jsonMapper()) + .build() + + val result = resolver.resolve() + + assertThat(result).isNotNull + result.provider.get("https://api.anthropic.com", false) + assertThat(exchangeBody).isNotNull + assertThat(exchangeBody!!["workspace_id"]).isEqualTo("wrkspc_file") + } + @Test fun step2ExplicitProfileUsesConfigurationFileProvider(@TempDir tempDir: Path) { val configsDir = tempDir.resolve("configs") @@ -606,6 +795,80 @@ internal class CredentialResolverTest { assertThat(httpCalled).isFalse() } + @Test + fun userOAuth_workspaceIdPropagatedToCredentialResult(@TempDir tempDir: Path) { + // Regression: workspace_id suppression on CredentialResult is scoped to oidc_federation + // only. Non-federation profiles (e.g. user_oauth) must still emit the + // anthropic-workspace-id header, so the resolved CredentialResult must carry workspaceId. + val configDir = tempDir.resolve("configs") + java.nio.file.Files.createDirectories(configDir) + configDir + .resolve("myprofile.json") + .toFile() + .writeText( + """{"authentication": {"type": "user_oauth"}, "workspace_id": "wrkspc_oauth"}""" + ) + val credsDir = tempDir.resolve("credentials") + java.nio.file.Files.createDirectories(credsDir) + credsDir + .resolve("myprofile.json") + .toFile() + .writeText( + """{"type": "access_token", "access_token": "sidecar-at", "expires_at": ${Instant.now().plusSeconds(60).epochSecond}}""" + ) + val client = MockHttpClient { _ -> error("should not be called") } + + val resolver = + CredentialResolver.builder() + .envProfile("myprofile") + .configDir(tempDir) + .httpClient(client) + .jsonMapper(jsonMapper()) + .build() + + val result = resolver.resolve() + + assertThat(result.workspaceId).isEqualTo("wrkspc_oauth") + } + + @Test + fun userOAuth_envWorkspaceIdFillsMissingAndPropagatesToCredentialResult( + @TempDir tempDir: Path + ) { + // ANTHROPIC_WORKSPACE_ID fills workspace_id uniformly across profile types — not just + // federation. For user_oauth the filled value surfaces as the anthropic-workspace-id + // request header via CredentialResult.workspaceId (federation routes it into the exchange + // body instead and suppresses the header). + val configDir = tempDir.resolve("configs") + java.nio.file.Files.createDirectories(configDir) + configDir + .resolve("myprofile.json") + .toFile() + .writeText("""{"authentication": {"type": "user_oauth"}}""") + val credsDir = tempDir.resolve("credentials") + java.nio.file.Files.createDirectories(credsDir) + credsDir + .resolve("myprofile.json") + .toFile() + .writeText( + """{"type": "access_token", "access_token": "sidecar-at", "expires_at": ${Instant.now().plusSeconds(60).epochSecond}}""" + ) + val client = MockHttpClient { _ -> error("should not be called") } + + val resolver = + CredentialResolver.builder() + .envProfile("myprofile") + .envWorkspaceId("wrkspc_env") + .configDir(tempDir) + .httpClient(client) + .jsonMapper(jsonMapper()) + .build() + + val result = resolver.resolve() + + assertThat(result.workspaceId).isEqualTo("wrkspc_env") + } + @Test fun userOAuth_expiredTokenStillResolvesWhenRefreshAvailable(@TempDir tempDir: Path) { val configDir = tempDir.resolve("configs") @@ -681,6 +944,15 @@ internal class CredentialResolverTest { .hasMessageContaining("HTTP client required for user_oauth refresh") } + private fun extractBody(request: HttpRequest): String { + val body = request.body ?: return "" + val outputStream = ByteArrayOutputStream() + body.writeTo(outputStream) + return outputStream.toString("UTF-8") + } + + private fun parseJsonParams(body: String): Map = jsonMapper().readValue(body) + private fun createResponse(statusCode: Int, body: String): HttpResponse { return object : HttpResponse { override fun statusCode() = statusCode diff --git a/anthropic-java-core/src/test/kotlin/com/anthropic/credentials/WorkloadIdentityCredentialsTest.kt b/anthropic-java-core/src/test/kotlin/com/anthropic/credentials/WorkloadIdentityCredentialsTest.kt index ec717d24b..bcf007597 100644 --- a/anthropic-java-core/src/test/kotlin/com/anthropic/credentials/WorkloadIdentityCredentialsTest.kt +++ b/anthropic-java-core/src/test/kotlin/com/anthropic/credentials/WorkloadIdentityCredentialsTest.kt @@ -1,5 +1,6 @@ package com.anthropic.credentials +import com.anthropic.core.JsonValue import com.anthropic.core.RequestOptions import com.anthropic.core.auth.InMemoryIdentityTokenProvider import com.anthropic.core.http.HttpClient @@ -7,16 +8,20 @@ import com.anthropic.core.http.HttpMethod import com.anthropic.core.http.HttpRequest import com.anthropic.core.http.HttpResponse import com.anthropic.core.jsonMapper +import com.anthropic.errors.AnthropicServiceException import com.anthropic.errors.UnexpectedStatusCodeException import com.anthropic.internal.credentials.WorkloadIdentityCredentials import com.fasterxml.jackson.module.kotlin.readValue import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.io.PrintStream import java.time.Instant import java.util.concurrent.CompletableFuture import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatThrownBy import org.junit.jupiter.api.Test +import org.junit.jupiter.api.parallel.ResourceLock +import org.junit.jupiter.api.parallel.Resources internal class WorkloadIdentityCredentialsTest { @@ -34,6 +39,8 @@ internal class WorkloadIdentityCredentialsTest { assertThat(params["assertion"]).isEqualTo("identity-jwt") assertThat(params["federation_rule_id"]).isEqualTo("fdrl_123") assertThat(params["organization_id"]).isEqualTo("org_123") + assertThat(params).doesNotContainKey("service_account_id") + assertThat(params).doesNotContainKey("workspace_id") createResponse(200, """{"access_token": "exchanged-token", "expires_in": 3600}""") } @@ -44,6 +51,7 @@ internal class WorkloadIdentityCredentialsTest { federationRuleId = "fdrl_123", organizationId = "org_123", serviceAccountId = null, + workspaceId = null, httpClient = mockClient, jsonMapper = jsonMapper(), ) @@ -70,6 +78,7 @@ internal class WorkloadIdentityCredentialsTest { federationRuleId = "fdrl_123", organizationId = "org_123", serviceAccountId = "svac_456", + workspaceId = null, httpClient = mockClient, jsonMapper = jsonMapper(), ) @@ -78,12 +87,69 @@ internal class WorkloadIdentityCredentialsTest { assertThat(capturedParams).isNotNull assertThat(capturedParams!!["service_account_id"]).isEqualTo("svac_456") + assertThat(capturedParams!!).doesNotContainKey("workspace_id") } @Test + fun includesWorkspaceIdWhenProvided() { + val identityProvider = InMemoryIdentityTokenProvider("identity-jwt") + var capturedParams: Map? = null + val mockClient = MockHttpClient { request -> + capturedParams = parseJsonParams(extractBody(request)) + createResponse(200, """{"access_token": "token", "expires_in": 3600}""") + } + + val credentials = + WorkloadIdentityCredentials( + identityTokenProvider = identityProvider, + federationRuleId = "fdrl_123", + organizationId = "org_123", + serviceAccountId = null, + workspaceId = "wrkspc_01abc", + httpClient = mockClient, + jsonMapper = jsonMapper(), + ) + + credentials.get("https://api.anthropic.com", false) + + assertThat(capturedParams).isNotNull + assertThat(capturedParams!!["workspace_id"]).isEqualTo("wrkspc_01abc") + } + + @Test + fun includesWorkspaceIdDefaultSentinel() { + val identityProvider = InMemoryIdentityTokenProvider("identity-jwt") + var capturedParams: Map? = null + val mockClient = MockHttpClient { request -> + capturedParams = parseJsonParams(extractBody(request)) + createResponse(200, """{"access_token": "token", "expires_in": 3600}""") + } + + val credentials = + WorkloadIdentityCredentials( + identityTokenProvider = identityProvider, + federationRuleId = "fdrl_123", + organizationId = "org_123", + serviceAccountId = null, + workspaceId = "default", + httpClient = mockClient, + jsonMapper = jsonMapper(), + ) + + credentials.get("https://api.anthropic.com", false) + + assertThat(capturedParams).isNotNull + assertThat(capturedParams!!["workspace_id"]).isEqualTo("default") + } + + @Test + // A 401 logs a stderr hint as a side effect; lock so the write doesn't land in another test's + // captureStderr buffer when running in parallel. + @ResourceLock(Resources.SYSTEM_ERR) fun throwsOnNon200Response() { val identityProvider = InMemoryIdentityTokenProvider("identity-jwt") - val mockClient = MockHttpClient { createResponse(401, """{"error": "invalid_grant"}""") } + val serverBody = """{"error": "invalid_grant"}""" + val mockClient = MockHttpClient { createResponse(401, serverBody) } val credentials = WorkloadIdentityCredentials( @@ -91,12 +157,119 @@ internal class WorkloadIdentityCredentialsTest { federationRuleId = "fdrl_123", organizationId = "org_123", serviceAccountId = null, + workspaceId = null, httpClient = mockClient, jsonMapper = jsonMapper(), ) assertThatThrownBy { credentials.get("https://api.anthropic.com", false) } - .isInstanceOf(UnexpectedStatusCodeException::class.java) + .isInstanceOfSatisfying(AnthropicServiceException::class.java) { e -> + assertThat(e.statusCode()).isEqualTo(401) + assertThat(e.body()).isEqualTo(parsedJson(serverBody)) + } + } + + @Test + @ResourceLock(Resources.SYSTEM_ERR) + fun logsHintOn401WithoutWorkspaceId() { + // A 401 token exchange logs a federation-diagnostics hint. With no workspace_id configured + // the hint additionally suggests setting one. The hint goes to stderr (the SDK's + // diagnostic-warning channel); the thrown exception type and body() must stay + // byte-identical to what the server sent so callers that catch + // UnexpectedStatusCodeException or introspect the body see no deviation. + val identityProvider = InMemoryIdentityTokenProvider("identity-jwt") + val serverBody = """{"error": "unauthorized"}""" + val mockClient = MockHttpClient { createResponse(401, serverBody) } + + val credentials = + WorkloadIdentityCredentials( + identityTokenProvider = identityProvider, + federationRuleId = "fdrl_123", + organizationId = "org_123", + serviceAccountId = null, + workspaceId = null, + httpClient = mockClient, + jsonMapper = jsonMapper(), + ) + + val stderr = captureStderr { + assertThatThrownBy { credentials.get("https://api.anthropic.com", false) } + .isInstanceOfSatisfying(UnexpectedStatusCodeException::class.java) { e -> + assertThat(e.statusCode()).isEqualTo(401) + // The hint must not leak into the error body or message. + assertThat(e.body()).isEqualTo(parsedJson(serverBody)) + assertThat(e.message).doesNotContain("ANTHROPIC_WORKSPACE_ID") + } + } + + assertThat(stderr).contains("Ensure your federation rule matches your identity token") + assertThat(stderr).contains("ANTHROPIC_WORKSPACE_ID") + assertThat(stderr).contains("View your authentication events") + } + + @Test + @ResourceLock(Resources.SYSTEM_ERR) + fun logsHintWithoutWorkspaceGuidanceWhenWorkspaceIdSet() { + // When workspaceId is already set the workspace-scoping suggestion is dropped, but the + // rest of the hint (check the rule, check the auth events) is still emitted. + val identityProvider = InMemoryIdentityTokenProvider("identity-jwt") + val serverBody = """{"error": "unauthorized"}""" + val mockClient = MockHttpClient { createResponse(401, serverBody) } + + val credentials = + WorkloadIdentityCredentials( + identityTokenProvider = identityProvider, + federationRuleId = "fdrl_123", + organizationId = "org_123", + serviceAccountId = null, + workspaceId = "wrkspc_x", + httpClient = mockClient, + jsonMapper = jsonMapper(), + ) + + val stderr = captureStderr { + assertThatThrownBy { credentials.get("https://api.anthropic.com", false) } + .isInstanceOfSatisfying(UnexpectedStatusCodeException::class.java) { e -> + assertThat(e.statusCode()).isEqualTo(401) + assertThat(e.body()).isEqualTo(parsedJson(serverBody)) + } + } + + assertThat(stderr).contains("Ensure your federation rule") + assertThat(stderr).contains("View your authentication events") + assertThat(stderr).doesNotContain("ANTHROPIC_WORKSPACE_ID") + } + + @Test + @ResourceLock(Resources.SYSTEM_ERR) + fun doesNotLogHintOnNon401WithoutWorkspaceId() { + // The hint is 401-specific; a 5xx or 400 shouldn't suggest a federation-config change. + val identityProvider = InMemoryIdentityTokenProvider("identity-jwt") + val serverBody = """{"error": "server_error"}""" + val mockClient = MockHttpClient { createResponse(500, serverBody) } + + val credentials = + WorkloadIdentityCredentials( + identityTokenProvider = identityProvider, + federationRuleId = "fdrl_123", + organizationId = "org_123", + serviceAccountId = null, + workspaceId = null, + httpClient = mockClient, + jsonMapper = jsonMapper(), + ) + + val stderr = captureStderr { + assertThatThrownBy { credentials.get("https://api.anthropic.com", false) } + .isInstanceOfSatisfying(UnexpectedStatusCodeException::class.java) { e -> + assertThat(e.statusCode()).isEqualTo(500) + assertThat(e.body()).isEqualTo(parsedJson(serverBody)) + } + } + + assertThat(stderr).doesNotContain("Ensure your federation rule") + assertThat(stderr).doesNotContain("View your authentication events") + assertThat(stderr).doesNotContain("ANTHROPIC_WORKSPACE_ID") } @Test @@ -114,6 +287,7 @@ internal class WorkloadIdentityCredentialsTest { federationRuleId = "fdrl_123", organizationId = "org_123", serviceAccountId = null, + workspaceId = null, httpClient = mockClient, jsonMapper = jsonMapper(), ) @@ -126,6 +300,23 @@ internal class WorkloadIdentityCredentialsTest { assertThat(betaHeader).contains("oidc-federation-2026-04-01") } + /** + * Runs [block] with `System.err` redirected to a buffer and returns whatever was written. The + * SDK's diagnostic warnings (including the federation-diagnostics hint) go to stderr, so this + * is the channel to inspect. + */ + private fun captureStderr(block: () -> Unit): String { + val original = System.err + val buffer = ByteArrayOutputStream() + System.setErr(PrintStream(buffer, true, "UTF-8")) + try { + block() + } finally { + System.setErr(original) + } + return buffer.toString("UTF-8") + } + private fun extractBody(request: HttpRequest): String { val body = request.body ?: return "" val outputStream = ByteArrayOutputStream() @@ -135,6 +326,10 @@ internal class WorkloadIdentityCredentialsTest { private fun parseJsonParams(body: String): Map = jsonMapper().readValue(body) + /** Parses a JSON string into the same [JsonValue] form the SDK exposes via `body()`. */ + private fun parsedJson(json: String): JsonValue = + jsonMapper().readValue(json, JsonValue::class.java) + private fun createResponse(statusCode: Int, body: String): HttpResponse { return object : HttpResponse { override fun statusCode() = statusCode From c4ff268646fd14f9175fe3ac47129e530bde1a21 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 15:54:16 +0000 Subject: [PATCH 5/5] chore: release main --- .release-please-manifest.json | 2 +- CHANGELOG.md | 23 +++++++++++++++++++++++ README.md | 4 ++-- build.gradle.kts | 2 +- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 207b3fe5e..e294a783a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "2.28.0", + ".": "2.29.0", "anthropic-java-aws": "0.2.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 153d19da7..e38f62da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 2.29.0 (2026-05-05) + +Full Changelog: [v2.28.0...v2.29.0](https://github.com/anthropics/anthropic-sdk-java/compare/v2.28.0...v2.29.0) + +### Features + +* **client:** allow targeting a workspace for OIDC federation token exchange ([578003a](https://github.com/anthropics/anthropic-sdk-java/commit/578003afed53205ed23789845fc7ed0153ed1100)) + + +### Performance Improvements + +* **client:** create one json mapper ([556ef49](https://github.com/anthropics/anthropic-sdk-java/commit/556ef492e5a668f1964147b41808ca47a26bb906)) + + +### Chores + +* remove duplicated dokka setup ([d6b94f4](https://github.com/anthropics/anthropic-sdk-java/commit/d6b94f49fd06f0ceaf6293880e03e7ba95d95d41)) + + +### Documentation + +* remove bad semicolon ([ffb078b](https://github.com/anthropics/anthropic-sdk-java/commit/ffb078b8285c3a52c45502dabac5ed1890460db8)) + ## 2.28.0 (2026-05-04) Full Changelog: [v2.27.0...v2.28.0](https://github.com/anthropics/anthropic-sdk-java/compare/v2.27.0...v2.28.0) diff --git a/README.md b/README.md index 182296a90..7d505beb5 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.28.0") +implementation("com.anthropic:anthropic-java:2.29.0") ``` ### Maven @@ -24,7 +24,7 @@ implementation("com.anthropic:anthropic-java:2.28.0") com.anthropic anthropic-java - 2.28.0 + 2.29.0 ``` diff --git a/build.gradle.kts b/build.gradle.kts index e1a56290c..bcda09b69 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ repositories { allprojects { group = "com.anthropic" - version = "2.28.0" // x-release-please-version + version = "2.29.0" // x-release-please-version } subprojects {