diff --git a/anthropic-client/anthropic-client-core/build.gradle.kts b/anthropic-client/anthropic-client-core/build.gradle.kts index bbb2fd4..0a6a1a1 100644 --- a/anthropic-client/anthropic-client-core/build.gradle.kts +++ b/anthropic-client/anthropic-client-core/build.gradle.kts @@ -38,6 +38,7 @@ kotlin { implementation(libs.app.cash.turbine) implementation("com.tngtech.archunit:archunit-junit5:1.1.0") implementation("org.reflections:reflections:0.10.2") + implementation(libs.org.skyscreamer.jsonassert) } } } diff --git a/anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/ContentSerializer.kt b/anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/ContentSerializer.kt new file mode 100644 index 0000000..2d5d243 --- /dev/null +++ b/anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/ContentSerializer.kt @@ -0,0 +1,59 @@ +package com.tddworks.anthropic.api.messages.api + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.* + +object ContentSerializer : KSerializer { + override val descriptor: SerialDescriptor + get() = buildClassSerialDescriptor("Content") + + override fun deserialize(decoder: Decoder): Content { + val jsonElement = decoder.decodeSerializableValue(JsonElement.serializer()) + return when (jsonElement) { + is JsonPrimitive -> Content.TextContent(jsonElement.content) + is JsonArray -> { + val items = jsonElement.map { element -> + val jsonObj = element.jsonObject + + when (jsonObj["type"]?.jsonPrimitive?.content) { + "text" -> BlockMessageContent.TextContent( + text = jsonObj["text"]?.jsonPrimitive?.content + ?: throw IllegalArgumentException("Missing text") + ) + + "image" -> BlockMessageContent.ImageContent( + source = BlockMessageContent.ImageContent.Source( + mediaType = jsonObj["source"]?.jsonObject?.get("media_type")?.jsonPrimitive?.content + ?: throw IllegalArgumentException("Missing media_type"), + data = jsonObj["source"]?.jsonObject?.get("data")?.jsonPrimitive?.content + ?: throw IllegalArgumentException("Missing data"), + type = jsonObj["source"]?.jsonObject?.get("type")?.jsonPrimitive?.content + ?: throw IllegalArgumentException("Missing type") + ) + ) + + else -> throw IllegalArgumentException("Unsupported content block type") + } + + } + Content.BlockContent(blocks = items) + } + + else -> throw IllegalArgumentException("Unsupported content format") + } + } + + override fun serialize(encoder: Encoder, value: Content) { + when (value) { + is Content.TextContent -> encoder.encodeString(value.text) + is Content.BlockContent -> encoder.encodeSerializableValue( + ListSerializer(BlockMessageContent.serializer()), value.blocks + ) + } + } +} \ No newline at end of file diff --git a/anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/Message.kt b/anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/Message.kt index 8fd857c..0a8af4a 100644 --- a/anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/Message.kt +++ b/anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/Message.kt @@ -1,5 +1,6 @@ package com.tddworks.anthropic.api.messages.api +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -13,13 +14,73 @@ import kotlinx.serialization.Serializable * Example with a single user message: * * [{"role": "user", "content": "Hello, Claude"}] + * + * Each input message content may be either a single string or an array of content blocks, where each block has a specific type. Using a string for content is shorthand for an array of one content block of type "text". The following input messages are equivalent: + * {"role": "user", "content": "Hello, Claude"} + * {"role": "user", "content": [{"type": "text", "text": "Hello, Claude"}]} */ @Serializable data class Message( val role: Role, - val content: String, + val content: Content, ) { companion object { - fun user(content: String) = Message(Role.User, content) + fun user(content: String) = Message(Role.User, Content.TextContent(content)) + } +} + +@Serializable(with = ContentSerializer::class) +sealed interface Content { + data class TextContent( + val text: String, + ) : Content + + data class BlockContent( + val blocks: List, + ) : Content +} + + +/** + * https://docs.anthropic.com/en/docs/build-with-claude/vision#prompt-examples + * { + * "role": "user", + * "content": [ + * { + * "type": "image", + * "source": { + * "type": "base64", + * "media_type": image1_media_type, + * "data": image1_data, + * }, + * }, + * { + * "type": "text", + * "text": "Describe this image." + * } + * ], + * } + */ +@Serializable +sealed interface BlockMessageContent { + + @Serializable + @SerialName("image") + data class ImageContent( + val source: Source + ) : BlockMessageContent { + @Serializable + data class Source( + @SerialName("media_type") val mediaType: String, + val data: String, + val type: String + ) } -} \ No newline at end of file + + @Serializable + @SerialName("text") + data class TextContent( + val text: String, + ) : BlockMessageContent + +} diff --git a/anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/MessageContentSerializer.kt b/anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/MessageContentSerializer.kt new file mode 100644 index 0000000..5689cc0 --- /dev/null +++ b/anthropic-client/anthropic-client-core/src/commonMain/kotlin/com/tddworks/anthropic/api/messages/api/MessageContentSerializer.kt @@ -0,0 +1,39 @@ +package com.tddworks.anthropic.api.messages.api + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject + +/** + * { + * "role": "user", + * "content": [ + * { + * "type": "image", + * "source": { + * "type": "base64", + * "media_type": image1_media_type, + * "data": image1_data, + * }, + * }, + * { + * "type": "text", + * "text": "Describe this image." + * } + * ], + * } + */ +//internal object MessageContentSerializer : +// JsonContentPolymorphicSerializer(Content::class) { +// override fun selectDeserializer(element: JsonElement): KSerializer { +// val jsonObject = element.jsonObject +// return when { +// "source" in jsonObject -> BlockMessageContent.ImageContent.serializer() +// "text" in jsonObject -> BlockMessageContent.TextContent.serializer() +// else -> throw SerializationException("Unknown Content type") +// } +// +// } +//} \ No newline at end of file diff --git a/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/BlockMessageContentTest.kt b/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/BlockMessageContentTest.kt new file mode 100644 index 0000000..d74dc7d --- /dev/null +++ b/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/BlockMessageContentTest.kt @@ -0,0 +1,68 @@ +package com.tddworks.anthropic.api.messages.api + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import org.skyscreamer.jsonassert.JSONAssert + +class BlockMessageContentTest { + + @Test + fun `should serialize image message content`() { + // Given + val messageContent = BlockMessageContent.ImageContent( + source = BlockMessageContent.ImageContent.Source( + mediaType = "image1_media_type", + data = "image1_data", + type = "base64", + ), + ) + + // When + val result = Json.encodeToString( + BlockMessageContent.serializer(), + messageContent + ) + + // Then + JSONAssert.assertEquals( + """ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image1_media_type", + "data": "image1_data" + } + } + """.trimIndent(), + result, + false + ) + } + + @Test + fun `should serialize message content`() { + // Given + val messageContent = BlockMessageContent.TextContent( + text = "some-text", + ) + + // When + val result = Json.encodeToString( + BlockMessageContent.serializer(), + messageContent + ) + + // Then + JSONAssert.assertEquals( + """ + { + "text": "some-text", + "type": "text" + } + """.trimIndent(), + result, + false + ) + } +} \ No newline at end of file diff --git a/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/ContentTest.kt b/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/ContentTest.kt new file mode 100644 index 0000000..fe67442 --- /dev/null +++ b/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/ContentTest.kt @@ -0,0 +1,82 @@ +package com.tddworks.anthropic.api.messages.api + +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.skyscreamer.jsonassert.JSONAssert + + +/** + * Each input message content may be either a single string or an array of content blocks, where each block has a specific type. Using a string for content is shorthand for an array of one content block of type "text". The following input messages are equivalent: + * {"role": "user", "content": "Hello, Claude"} + * {"role": "user", "content": [{"type": "text", "text": "Hello, Claude"}]} + */ +class ContentTest { + + @Test + fun `should serialize multiple content`() { + // Given + val content = Content.BlockContent( + listOf( + BlockMessageContent.ImageContent( + source = BlockMessageContent.ImageContent.Source( + mediaType = "image1_media_type", + data = "image1_data", + type = "base64", + ), + ), + BlockMessageContent.TextContent( + text = "some-text", + ), + ) + ) + + // When + val result = Json.encodeToString( + Content.serializer(), + content + ) + + // Then + JSONAssert.assertEquals( + """ + [ + { + "source": { + "data": "image1_data", + "media_type": "image1_media_type", + "type": "base64" + }, + "type": "image" + }, + { + "text": "some-text", + "type": "text" + } + ] + """.trimIndent(), + result, + false + ) + } + + @Test + fun `should serialize single string content`() { + // Given + val content = Content.TextContent("Hello, Claude") + + // When + val result = Json.encodeToString( + Content.serializer(), + content + ) + + // Then + assertEquals( + """ + "Hello, Claude" + """.trimIndent(), result + ) + } + +} \ No newline at end of file diff --git a/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/CreateMessageRequestTest.kt b/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/CreateMessageRequestTest.kt index 622ed46..6b65ea4 100644 --- a/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/CreateMessageRequestTest.kt +++ b/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/CreateMessageRequestTest.kt @@ -4,8 +4,74 @@ import com.tddworks.anthropic.api.AnthropicModel import com.tddworks.anthropic.api.prettyJson import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test +import org.skyscreamer.jsonassert.JSONAssert +/** + * https://docs.anthropic.com/en/api/messages + * Each input message content may be either a single string or an array of content blocks, where each block has a specific type. Using a string for content is shorthand for an array of one content block of type "text". The following input messages are equivalent: + * {"role": "user", "content": "Hello, Claude"} + * {"role": "user", "content": [{"type": "text", "text": "Hello, Claude"}]} + */ class CreateMessageRequestTest { + + @Test + fun `should convert request to correct json with multiple messages`() { + val json = """ + { + "messages": [{ + "role": "user", + "content": [{ + "type": "image", + "source": { + "type": "base64", + "media_type": "123", + "data": "23" + } + }, + { + "type": "text", + "text": "Describe this image." + } + ] + }], + "system": null, + "max_tokens": 1024, + "model": "claude-3-haiku-20240307", + "stream": null + } + """.trimIndent() + + val request = CreateMessageRequest( + messages = listOf( + Message( + role = Role.User, + content = Content.BlockContent( + listOf( + BlockMessageContent.ImageContent( + source = BlockMessageContent.ImageContent.Source( + mediaType = "123", + data = "23", + type = "base64" + ) + ), + BlockMessageContent.TextContent( + text = "Describe this image." + ) + ) + ) + ) + ), + maxTokens = 1024, + model = AnthropicModel.CLAUDE_3_HAIKU + ) + + JSONAssert.assertEquals( + json, + prettyJson.encodeToString(CreateMessageRequest.serializer(), request), + false + ) + } + @Test fun `should convert request to correct json`() { val json = """ @@ -29,6 +95,9 @@ class CreateMessageRequestTest { model = AnthropicModel.CLAUDE_3_HAIKU ) - assertEquals(json, prettyJson.encodeToString(CreateMessageRequest.serializer(), request)) + assertEquals( + json, + prettyJson.encodeToString(CreateMessageRequest.serializer(), request) + ) } } \ No newline at end of file diff --git a/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/MessageTest.kt b/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/MessageTest.kt index 2d50b83..0330b5a 100644 --- a/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/MessageTest.kt +++ b/anthropic-client/anthropic-client-core/src/jvmTest/kotlin/com/tddworks/anthropic/api/messages/api/MessageTest.kt @@ -1,22 +1,123 @@ package com.tddworks.anthropic.api.messages.api +import kotlinx.serialization.json.Json import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test +import org.skyscreamer.jsonassert.JSONAssert +/** + * Each input message content may be either a single string or an array of content blocks, where each block has a specific type. Using a string for content is shorthand for an array of one content block of type "text". The following input messages are equivalent: + * + * {"role": "user", "content": "Hello, Claude"} + * {"role": "user", "content": [{"type": "text", "text": "Hello, Claude"}]} + */ class MessageTest { + @Test + fun `should serialize an array of content blocks`() { + // Given + val userMessage = Message( + role = Role.User, + content = Content.BlockContent( + listOf( + BlockMessageContent.ImageContent( + source = BlockMessageContent.ImageContent.Source( + mediaType = "image1_media_type", + data = "image1_data", + type = "base64", + ), + ), + BlockMessageContent.TextContent( + text = "some-text", + ) + ) + ) + ) + + // When + val result = Json.encodeToString( + Message.serializer(), + userMessage + ) + + // Then + JSONAssert.assertEquals( + """ + { + "role": "user", + "content": [ + { + "source": { + "data": "image1_data", + "media_type": "image1_media_type", + "type": "base64" + }, + "type": "image" + }, + { + "text": "some-text", + "type": "text" + } + ] + } + """.trimIndent(), + result, + false + ) + } + + @Test + fun `should serialize single line message content`() { + // Given + val assistantMessage = Message( + role = Role.Assistant, + content = Content.TextContent( + text = "message" + ) + ) + + // When + val result = Json.encodeToString( + Message.serializer(), + assistantMessage + ) + + // Then + JSONAssert.assertEquals( + """ + { + "role": "assistant", + "content": "message" + } + """.trimIndent(), + result, + false + ) + } + @Test fun `should return assistant message name`() { - val assistantMessage = Message(role = Role.Assistant, content = "message") + val assistantMessage = Message( + role = Role.Assistant, + content = Content.TextContent( + text = "message" + ) + ) assertEquals(Role.Assistant, assistantMessage.role) - assertEquals("message", assistantMessage.content) + assertEquals( + "message", + (assistantMessage.content as Content.TextContent).text + ) } @Test fun `should return user message name`() { val userMessage = Message.user("message") assertEquals(Role.User, userMessage.role) - assertEquals("message", userMessage.content) + assertEquals( + "message", + (userMessage.content as Content.TextContent).text + ) } } diff --git a/anthropic-client/anthropic-client-darwin/build.gradle.kts b/anthropic-client/anthropic-client-darwin/build.gradle.kts index 0d9f8bf..97eac35 100644 --- a/anthropic-client/anthropic-client-darwin/build.gradle.kts +++ b/anthropic-client/anthropic-client-darwin/build.gradle.kts @@ -12,7 +12,7 @@ kotlin { iosSimulatorArm64() ).forEach { macosTarget -> macosTarget.binaries.framework { - baseName = "anthropic-client-darwin" + baseName = "AnthropicClient" export(projects.anthropicClient.anthropicClientCore) isStatic = true }