diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 207b3fe5..e294a783 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 153d19da..e38f62da 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 fe12f2de..7d505beb 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
```
@@ -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);
```
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 8913ff73..636dcaaa 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 d5fa77c1..47c37da3 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 c21c8b1f..51ae2a09 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/core/ObjectMappers.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/core/ObjectMappers.kt
index 1fd764a9..b9f14020 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())
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 6988fd7b..cd6855ac 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 2aea4f17..f066025d 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 848b413e..a47e15ef 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 9efb7faf..384aed00 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 e88a1803..67d3ed02 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 ec717d24..bcf00759 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
diff --git a/build.gradle.kts b/build.gradle.kts
index 9e7e1001..bcda09b6 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 {
@@ -21,7 +21,6 @@ subprojects {
group = "Verification"
description = "Verifies all source files are formatted."
}
- apply(plugin = "org.jetbrains.dokka")
}
subprojects {