diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/BetaMessageParam.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/BetaMessageParam.kt index 06cfe2950..d7d0eaf2c 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/BetaMessageParam.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/BetaMessageParam.kt @@ -80,6 +80,18 @@ private constructor( fun _additionalProperties(): Map = Collections.unmodifiableMap(additionalProperties) + @JvmSynthetic + internal fun isEmptyAssistant(): Boolean = + _role().asString().orElse(null) == "assistant" && + _content() + .asKnown() + .map { + it.string().map(String::isEmpty).orElseGet { + it.betaContentBlockParams().map(List<*>::isEmpty).orElse(false) + } + } + .orElse(false) + fun toBuilder() = Builder().from(this) companion object { diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/MessageCountTokensParams.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/MessageCountTokensParams.kt index d46ba1b25..a119d38ea 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/MessageCountTokensParams.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/MessageCountTokensParams.kt @@ -1847,7 +1847,13 @@ private constructor( fun addMessage(message: BetaMessageParam) = apply { messages = (messages ?: JsonField.of(mutableListOf())).also { - checkKnown("messages", it).add(message) + checkKnown("messages", it).apply { + // Empty assistant content is only valid as the final message. + if (lastOrNull()?.isEmptyAssistant() == true) { + removeLast() + } + add(message) + } } } diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/MessageCreateParams.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/MessageCreateParams.kt index a0b29866c..6ff5ecccd 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/MessageCreateParams.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/MessageCreateParams.kt @@ -2809,7 +2809,13 @@ private constructor( fun addMessage(message: BetaMessageParam) = apply { messages = (messages ?: JsonField.of(mutableListOf())).also { - checkKnown("messages", it).add(message) + checkKnown("messages", it).apply { + // Empty assistant content is only valid as the final message. + if (lastOrNull()?.isEmptyAssistant() == true) { + removeLast() + } + add(message) + } } } diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/batches/BatchCreateParams.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/batches/BatchCreateParams.kt index 48dfb43be..10298efdb 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/batches/BatchCreateParams.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/models/beta/messages/batches/BatchCreateParams.kt @@ -1720,7 +1720,13 @@ private constructor( fun addMessage(message: BetaMessageParam) = apply { messages = (messages ?: JsonField.of(mutableListOf())).also { - checkKnown("messages", it).add(message) + checkKnown("messages", it).apply { + // Empty assistant content is only valid as the final message. + if (lastOrNull()?.isEmptyAssistant() == true) { + removeLast() + } + add(message) + } } } diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/MessageCountTokensParams.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/MessageCountTokensParams.kt index 819a07d79..ab89f239f 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/MessageCountTokensParams.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/MessageCountTokensParams.kt @@ -1478,7 +1478,13 @@ private constructor( fun addMessage(message: MessageParam) = apply { messages = (messages ?: JsonField.of(mutableListOf())).also { - checkKnown("messages", it).add(message) + checkKnown("messages", it).apply { + // Empty assistant content is only valid as the final message. + if (lastOrNull()?.isEmptyAssistant() == true) { + removeLast() + } + add(message) + } } } diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/MessageCreateParams.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/MessageCreateParams.kt index 059142222..cade594fe 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/MessageCreateParams.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/MessageCreateParams.kt @@ -2173,7 +2173,13 @@ private constructor( fun addMessage(message: MessageParam) = apply { messages = (messages ?: JsonField.of(mutableListOf())).also { - checkKnown("messages", it).add(message) + checkKnown("messages", it).apply { + // Empty assistant content is only valid as the final message. + if (lastOrNull()?.isEmptyAssistant() == true) { + removeLast() + } + add(message) + } } } diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/MessageParam.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/MessageParam.kt index b75d5ad9f..3237d220c 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/MessageParam.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/MessageParam.kt @@ -80,6 +80,18 @@ private constructor( fun _additionalProperties(): Map = Collections.unmodifiableMap(additionalProperties) + @JvmSynthetic + internal fun isEmptyAssistant(): Boolean = + _role().asString().orElse(null) == "assistant" && + _content() + .asKnown() + .map { + it.string().map(String::isEmpty).orElseGet { + it.blockParams().map(List<*>::isEmpty).orElse(false) + } + } + .orElse(false) + fun toBuilder() = Builder().from(this) companion object { diff --git a/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/batches/BatchCreateParams.kt b/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/batches/BatchCreateParams.kt index 3c268455c..17a302825 100644 --- a/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/batches/BatchCreateParams.kt +++ b/anthropic-java-core/src/main/kotlin/com/anthropic/models/messages/batches/BatchCreateParams.kt @@ -1504,7 +1504,13 @@ private constructor( fun addMessage(message: MessageParam) = apply { messages = (messages ?: JsonField.of(mutableListOf())).also { - checkKnown("messages", it).add(message) + checkKnown("messages", it).apply { + // Empty assistant content is only valid as the final message. + if (lastOrNull()?.isEmptyAssistant() == true) { + removeLast() + } + add(message) + } } } diff --git a/anthropic-java-core/src/test/kotlin/com/anthropic/models/beta/messages/Issue260RoundTripTest.kt b/anthropic-java-core/src/test/kotlin/com/anthropic/models/beta/messages/Issue260RoundTripTest.kt new file mode 100644 index 000000000..53e003c9f --- /dev/null +++ b/anthropic-java-core/src/test/kotlin/com/anthropic/models/beta/messages/Issue260RoundTripTest.kt @@ -0,0 +1,46 @@ +package com.anthropic.models.beta.messages + +import com.anthropic.core.jsonMapper +import com.anthropic.models.messages.Model +import com.fasterxml.jackson.module.kotlin.jacksonTypeRef +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class Issue260RoundTripTest { + + private val jsonMapper = jsonMapper() + + @Test + fun `empty assistant response is omitted before a subsequent request message`() { + val message = + jsonMapper.readValue( + """{"role":"assistant","content":[]}""", + jacksonTypeRef(), + ) + + val paramsBuilder = + MessageCreateParams.builder() + .model(Model.CLAUDE_SONNET_4_20250514) + .maxTokens(64) + .addUserMessage("first question") + .addMessage(message) + + val finalAssistantJson = + jsonMapper.valueToTree( + paramsBuilder.build()._body() + ) + + assertThat(finalAssistantJson.at("/messages/1/role").textValue()).isEqualTo("assistant") + assertThat(finalAssistantJson.at("/messages/1/content")).isEmpty() + + val followUpJson = + jsonMapper.valueToTree( + paramsBuilder.addUserMessage("follow-up question").build()._body() + ) + + assertThat(followUpJson.at("/messages").size()).isEqualTo(2) + assertThat(followUpJson.at("/messages/0/content").textValue()).isEqualTo("first question") + assertThat(followUpJson.at("/messages/1/content").textValue()) + .isEqualTo("follow-up question") + } +} diff --git a/anthropic-java-core/src/test/kotlin/com/anthropic/models/messages/Issue260RoundTripTest.kt b/anthropic-java-core/src/test/kotlin/com/anthropic/models/messages/Issue260RoundTripTest.kt new file mode 100644 index 000000000..0e48a5f09 --- /dev/null +++ b/anthropic-java-core/src/test/kotlin/com/anthropic/models/messages/Issue260RoundTripTest.kt @@ -0,0 +1,95 @@ +package com.anthropic.models.messages + +import com.anthropic.core.jsonMapper +import com.fasterxml.jackson.module.kotlin.jacksonTypeRef +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class Issue260RoundTripTest { + + private val jsonMapper = jsonMapper() + + @Test + fun `web search response can be converted back into request params`() { + val message = + readMessage( + """ + { + "role": "assistant", + "content": [{ + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_123", + "caller": {"type": "direct"}, + "content": [{ + "type": "web_search_result", + "url": "https://example.com", + "title": "Example", + "encrypted_content": "encrypted" + }] + }] + } + """ + ) + + val json = + jsonMapper.valueToTree(message.toParam()) + + assertThat(json.at("/content/0/content/0/encrypted_content").textValue()) + .isEqualTo("encrypted") + } + + @Test + fun `missing web search content no longer throws while converting to params`() { + val message = + readMessage( + """ + { + "role": "assistant", + "content": [{ + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_123", + "caller": {"type": "direct"} + }] + } + """ + ) + + val json = + jsonMapper.valueToTree(message.toParam()) + + assertThat(json.at("/content/0").has("content")).isFalse() + } + + @Test + fun `empty assistant response is omitted before a subsequent request message`() { + val message = readMessage("""{"role":"assistant","content":[]}""") + + val paramsBuilder = + MessageCreateParams.builder() + .model(Model.CLAUDE_SONNET_4_20250514) + .maxTokens(64) + .addUserMessage("first question") + .addMessage(message) + + val finalAssistantJson = + jsonMapper.valueToTree( + paramsBuilder.build()._body() + ) + + assertThat(finalAssistantJson.at("/messages/1/role").textValue()).isEqualTo("assistant") + assertThat(finalAssistantJson.at("/messages/1/content")).isEmpty() + + val followUpJson = + jsonMapper.valueToTree( + paramsBuilder.addUserMessage("follow-up question").build()._body() + ) + + assertThat(followUpJson.at("/messages").size()).isEqualTo(2) + assertThat(followUpJson.at("/messages/0/content").textValue()).isEqualTo("first question") + assertThat(followUpJson.at("/messages/1/content").textValue()) + .isEqualTo("follow-up question") + } + + private fun readMessage(json: String): Message = + jsonMapper.readValue(json.trimIndent(), jacksonTypeRef()) +}