diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index b247d35..70db9aa 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -27,6 +27,6 @@ jobs: run: sudo apt-get install -y libcurl4-gnutls-dev - name: Build - run: ./gradlew build + run: ./gradlew -PjvmOnlyBuild=false build env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/build-main.yml b/.github/workflows/build-main.yml index 2887fb9..d5692bc 100644 --- a/.github/workflows/build-main.yml +++ b/.github/workflows/build-main.yml @@ -23,7 +23,7 @@ jobs: run: sudo apt-get install -y libcurl4-gnutls-dev - name: Build - run: ./gradlew build sourcesJar dokkaHtml publish + run: ./gradlew -PjvmOnlyBuild=false build sourcesJar dokkaHtml publish env: ORG_GRADLE_PROJECT_githubActor: ${{ secrets.GITHUBACTOR }} ORG_GRADLE_PROJECT_githubToken: ${{ secrets.GITHUBTOKEN }} diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 8168b26..87735c1 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -42,7 +42,7 @@ jobs: ORG_GRADLE_PROJECT_sonatypeUser: ${{ secrets.SONATYPE_USER }} ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.SONATYPE_PASSWORD }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - run: ./gradlew -Pversion=$VERSION build sourcesJar dokkaHtml publishToSonatype closeAndReleaseSonatypeStagingRepository + run: ./gradlew -Pversion=$VERSION -PjvmOnlyBuild=false build sourcesJar dokkaHtml publishToSonatype closeAndReleaseSonatypeStagingRepository - name: Find branch from tag id: find-branch diff --git a/.gitignore b/.gitignore index c52b98f..8b0a0ba 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ bin/ .DS_Store /*.hprof + +/kotlin-js-store diff --git a/README.md b/README.md index b2852d8..5ac27cb 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ and many other environments. ## Usage -[!CAUTION] +> [!CAUTION] > This SDK is in the early stage of development, so still a subject to API changes, > however at the same time it is completely functional and passing all the > [test cases](src/commonTest/kotlin). @@ -78,7 +78,7 @@ dependencies { } ``` -, ff you are planning to use tools, you will also need: +, if you are planning to use tools, you will also need: ```kotlin plugins { @@ -132,7 +132,7 @@ If you want to write AI agents, you need tools, and this is where this library s ```kotlin @AnthropicTool("get_weather") @Description("Get the weather for a specific location") -data class WeatherTool(val location: String): UsableTool { +data class WeatherTool(val location: String): ToolInput { override fun use( toolUseId: String ) = ToolResult( @@ -152,7 +152,7 @@ fun main() = runBlocking { val initialResponse = client.messages.create { messages = conversation - useTools() + allTools() } println("Initial response:") println(initialResponse) @@ -192,7 +192,7 @@ internet or DB connection pool to access the database. ```kotlin @AnthropicTool("query_database") @Description("Executes SQL on the database") -data class DatabaseQueryTool(val sql: String): UsableTool { +data class QueryDatabase(val sql: String): ToolInput { @Transient internal lateinit var connection: Connection @@ -213,14 +213,14 @@ data class DatabaseQueryTool(val sql: String): UsableTool { fun main() = runBlocking { val client = Anthropic { - tool { + tool { connection = DriverManager.getConnection("jdbc:...") } } val response = client.messages.create { +Message { +"Select all the users who never logged in to the the system" } - useTools() + singleTool() } val tool = response.content.filterIsInstance().first() diff --git a/build.gradle.kts b/build.gradle.kts index 274995f..b8a8cba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeTest plugins { @@ -25,6 +26,8 @@ val javaTarget = libs.versions.javaTarget.get() val kotlinTarget = KotlinVersion.fromVersion(libs.versions.kotlinTarget.get()) val isReleaseBuild = !project.version.toString().endsWith("-SNAPSHOT") +val jvmOnlyBuild: String? by project +val isJvmOnlyBuild: Boolean = (jvmOnlyBuild == null) || (jvmOnlyBuild!!.uppercase() == "true") val githubActor: String? by project val githubToken: String? by project val signingKey: String? by project @@ -64,6 +67,14 @@ kotlin { } } + if (!isJvmOnlyBuild) { + + js { + browser() + nodejs() + binaries.library() + } + // linuxX64() // // mingwX64() @@ -78,6 +89,8 @@ kotlin { // else -> throw GradleException("Host OS is not supported in Kotlin/Native.") // } + } + sourceSets { commonMain { @@ -109,21 +122,23 @@ kotlin { } } - linuxTest { - dependencies { - implementation(libs.ktor.client.curl) + if (!isJvmOnlyBuild) { + linuxTest { + dependencies { + implementation(libs.ktor.client.curl) + } } - } - mingwTest { - dependencies { - implementation(libs.ktor.client.curl) + mingwTest { + dependencies { + implementation(libs.ktor.client.curl) + } } - } - macosTest { - dependencies { - implementation(libs.ktor.client.darwin) + macosTest { + dependencies { + implementation(libs.ktor.client.darwin) + } } } @@ -157,8 +172,19 @@ tasks.withType { enabled = !skipTests } -tasks.withType { - enabled = !skipTests + + +if (!isJvmOnlyBuild) { + + tasks.withType { + enabled = !skipTests + } + + tasks.withType { + // for now always skip JS tests, until we will find how to safely pass apiKey to them + enabled = false + } + } powerAssert { diff --git a/gradle.properties b/gradle.properties index 83dc384..b7bbf97 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,4 +2,4 @@ kotlin.code.style=official kotlin.js.generate.executable.default=false kotlin.native.ignoreDisabledTargets=true group=com.xemantic.anthropic -version=0.5-SNAPSHOT +version=0.7-SNAPSHOT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 06ad04e..196dc0e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,13 +5,12 @@ javaTarget = "17" kotlin = "2.0.21" kotlinxCoroutines = "1.9.0" kotlinxDatetime = "0.6.1" -ktor = "3.0.0" +ktor = "3.0.1" kotest = "6.0.0.M1" # logging is not used at the moment, might be enabled later -#kotlinLogging = "7.0.0" log4j = "2.24.1" -jackson = "2.18.0" +jackson = "2.18.1" versionsPlugin = "0.51.0" dokkaPlugin = "1.9.20" @@ -23,7 +22,6 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } # logging libs -#kotlin-logging = { module = "io.github.oshai:kotlin-logging", version.ref = "kotlinLogging" } log4j-slf4j2 = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version.ref = "log4j" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } diff --git a/src/commonMain/kotlin/Anthropic.kt b/src/commonMain/kotlin/Anthropic.kt index b56150f..b9129e4 100644 --- a/src/commonMain/kotlin/Anthropic.kt +++ b/src/commonMain/kotlin/Anthropic.kt @@ -1,11 +1,15 @@ package com.xemantic.anthropic +import com.xemantic.anthropic.error.AnthropicException +import com.xemantic.anthropic.error.ErrorResponse import com.xemantic.anthropic.event.Event +import com.xemantic.anthropic.cache.CacheControl import com.xemantic.anthropic.message.MessageRequest -import com.xemantic.anthropic.message.Tool -import com.xemantic.anthropic.message.ToolUse -import com.xemantic.anthropic.tool.UsableTool -import com.xemantic.anthropic.tool.toolOf +import com.xemantic.anthropic.message.MessageResponse +import com.xemantic.anthropic.tool.BuiltInTool +import com.xemantic.anthropic.tool.ToolUse +import com.xemantic.anthropic.tool.Tool +import com.xemantic.anthropic.tool.ToolInput import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.* @@ -26,11 +30,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.serialization.KSerializer -import kotlinx.serialization.json.Json -import kotlinx.serialization.serializer -import kotlin.reflect.KType -import kotlin.reflect.typeOf /** * The default Anthropic API base. @@ -42,26 +41,10 @@ const val ANTHROPIC_API_BASE: String = "https://api.anthropic.com/" */ const val DEFAULT_ANTHROPIC_VERSION: String = "2023-06-01" -/** - * An exception thrown when API requests returns error. - */ -class AnthropicException( - error: Error, - httpStatusCode: HttpStatusCode -) : RuntimeException(error.toString()) - expect val envApiKey: String? expect val missingApiKeyMessage: String -/** - * A JSON format suitable for communication with Anthropic API. - */ -val anthropicJson: Json = Json { - allowSpecialFloatingPointValues = true - explicitNulls = false - encodeDefaults = true -} /** * The public constructor function which for the Anthropic API client. @@ -82,10 +65,9 @@ fun Anthropic( defaultModel = config.defaultModel.id, defaultMaxTokens = config.defaultMaxTokens, directBrowserAccess = config.directBrowserAccess, - logLevel = if (config.logHttp) LogLevel.ALL else LogLevel.NONE - ).apply { - toolEntryMap = (config.usableTools as List>).associateBy { it.tool.name } - } + logLevel = if (config.logHttp) LogLevel.ALL else LogLevel.NONE, + toolMap = config.tools.associateBy { it.name } + ) } // TODO this can be a second constructor, then toolMap can be private class Anthropic internal constructor( @@ -96,7 +78,8 @@ class Anthropic internal constructor( val defaultModel: String, val defaultMaxTokens: Int, val directBrowserAccess: Boolean, - val logLevel: LogLevel + val logLevel: LogLevel, + private val toolMap: Map ) { class Config { @@ -110,27 +93,25 @@ class Anthropic internal constructor( var directBrowserAccess: Boolean = false var logHttp: Boolean = false - @PublishedApi - internal var usableTools: List> = emptyList() + var tools: List = emptyList() - inline fun tool( - noinline block: T.() -> Unit = {} + inline fun tool( + cacheControl: CacheControl? = null, + noinline inputInitializer: T.() -> Unit = {} ) { - val entry = ToolEntry(typeOf(), toolOf(), serializer(), block) - usableTools += entry + tools += Tool(cacheControl, initializer = inputInitializer) } - } - - @PublishedApi - internal class ToolEntry( - val type: KType, - val tool: Tool, // TODO, no cache control - val serializer: KSerializer, - val initialize: T.() -> Unit = {} - ) + inline fun builtInTool( + tool: T, + noinline inputInitializer: T.() -> Unit = {} + ) { + @Suppress("UNCHECKED_CAST") + tool.inputInitializer = inputInitializer as ToolInput.() -> Unit + tools += tool + } - internal var toolEntryMap = mapOf>() + } private val client = HttpClient { @@ -179,7 +160,7 @@ class Anthropic internal constructor( val request = MessageRequest.Builder( defaultModel, defaultMaxTokens, - toolEntryMap + toolMap ).apply(block).build() val apiResponse = client.post("/v1/messages") { @@ -191,8 +172,7 @@ class Anthropic internal constructor( is MessageResponse -> response.apply { content.filterIsInstance() .forEach { toolUse -> - val entry = toolEntryMap[toolUse.name]!! - toolUse.toolEntry = entry + toolUse.tool = toolMap[toolUse.name]!! } } is ErrorResponse -> throw AnthropicException( @@ -211,7 +191,7 @@ class Anthropic internal constructor( val request = MessageRequest.Builder( defaultModel, defaultMaxTokens, - toolEntryMap + toolMap ).apply { block(this) stream = true diff --git a/src/commonMain/kotlin/AnthropicJson.kt b/src/commonMain/kotlin/AnthropicJson.kt new file mode 100644 index 0000000..07c1501 --- /dev/null +++ b/src/commonMain/kotlin/AnthropicJson.kt @@ -0,0 +1,147 @@ +package com.xemantic.anthropic + +import com.xemantic.anthropic.batch.MessageBatchResponse +import com.xemantic.anthropic.error.ErrorResponse +import com.xemantic.anthropic.image.Image +import com.xemantic.anthropic.message.Content +import com.xemantic.anthropic.message.MessageResponse +import com.xemantic.anthropic.text.Text +import com.xemantic.anthropic.tool.BuiltInTool +import com.xemantic.anthropic.tool.DefaultTool +import com.xemantic.anthropic.tool.Tool +import com.xemantic.anthropic.tool.ToolResult +import com.xemantic.anthropic.tool.ToolUse +import com.xemantic.anthropic.tool.bash.Bash +import com.xemantic.anthropic.tool.computer.Computer +import com.xemantic.anthropic.tool.editor.TextEditor +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.descriptors.buildSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass + +// Note: surprisingly the order is important. This definition needs to go first. +private val anthropicSerializersModule = SerializersModule { + polymorphicDefaultDeserializer(Response::class) { ResponseSerializer } + polymorphicDefaultDeserializer(Content::class) { ContentSerializer } + polymorphic(Content::class) { + subclass(Text::class) + subclass(Image::class) + subclass(ToolUse::class) + subclass(ToolResult::class) + } + polymorphicDefaultDeserializer(Tool::class) { ToolSerializer } + polymorphicDefaultSerializer(Tool::class) { ToolSerializer } + polymorphicDefaultDeserializer(BuiltInTool::class) { + @Suppress("UNCHECKED_CAST") + ToolSerializer as KSerializer + } + polymorphicDefaultSerializer(BuiltInTool::class) { ToolSerializer } +} + +/** + * A JSON format suitable for communication with Anthropic API. + */ +val anthropicJson: Json = Json { + serializersModule = anthropicSerializersModule + allowSpecialFloatingPointValues = true + explicitNulls = false + encodeDefaults = true +} + +private object ResponseSerializer : JsonContentPolymorphicSerializer( + baseClass = Response::class +) { + + override fun selectDeserializer( + element: JsonElement + ) = when ( + val type = element.stringProperty("type") + ) { + "error" -> ErrorResponse.serializer() + "message" -> MessageResponse.serializer() + "message_batch" -> MessageBatchResponse.serializer() + else -> throw SerializationException( + "Unsupported Response type: $type, full response: $element" + ) + } + +} + +private object ContentSerializer : JsonContentPolymorphicSerializer( + baseClass = Content::class +) { + + override fun selectDeserializer( + element: JsonElement + ) = when ( + val type = element.stringProperty("type") + ) { + "text" -> Text.serializer() + "image" -> Image.serializer() + "tool_use" -> ToolUse.serializer() + "tool_result" -> ToolResult.serializer() + else -> throw SerializationException( + "Unsupported Content type: $type, element: $element" + ) + } + +} + +private object ToolSerializer : KSerializer { + + @OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class) + override val descriptor: SerialDescriptor = + buildSerialDescriptor("ToolSerializer", SerialKind.CONTEXTUAL) + + override fun serialize(encoder: Encoder, value: Tool) { + val serializer = when (value) { + is DefaultTool -> DefaultTool.serializer() + is BuiltInTool -> when (value) { + is Computer -> Computer.serializer() + is TextEditor -> TextEditor.serializer() + is Bash -> Bash.serializer() + else -> throw SerializationException("Unsupported BuiltInTool type: $value") + } + else -> throw SerializationException("Unsupported Tool type: $value") + } + (serializer as KSerializer).serialize(encoder, value) + } + + override fun deserialize(decoder: Decoder): Tool { + val input = decoder as JsonDecoder + val tree = input.decodeJsonElement() + val type = tree.jsonObject["type"] + val serializer = if (type == null) { + DefaultTool.serializer() + } else { + when (val name = tree.stringProperty("name")) { + "computer" -> Computer.serializer() + "str_replace_editor" -> TextEditor.serializer() + "bash" -> Bash.serializer() + else -> throw SerializationException("Unsupported Tool name: $name") + } + } as KSerializer + return input.json.decodeFromJsonElement(serializer, tree) + } + +} + +private fun JsonElement.stringProperty( + name: String +) = (jsonObject[name] ?: throw SerializationException( + "Missing '$name' attribute in element: $this" +)).jsonPrimitive.content diff --git a/src/commonMain/kotlin/Models.kt b/src/commonMain/kotlin/Models.kt index 4aa9c8b..bbaeb86 100644 --- a/src/commonMain/kotlin/Models.kt +++ b/src/commonMain/kotlin/Models.kt @@ -14,8 +14,8 @@ enum class Model( maxOutput = 8182, messageBatchesApi = true, cost = Cost( - input = 3.0, - output = 15.0 + inputTokens = 3.0, + outputTokens = 15.0 ) ), @@ -25,8 +25,8 @@ enum class Model( maxOutput = 8182, messageBatchesApi = true, cost = Cost( - input = 3.0, - output = 15.0 + inputTokens = 3.0, + outputTokens = 15.0 ) ), @@ -36,8 +36,8 @@ enum class Model( maxOutput = 8182, messageBatchesApi = true, cost = Cost( - input = 3.0, - output = 15.0 + inputTokens = 3.0, + outputTokens = 15.0 ) ), @@ -47,8 +47,8 @@ enum class Model( maxOutput = 4096, messageBatchesApi = true, cost = Cost( - input = 15.0, - output = 75.0 + inputTokens = 15.0, + outputTokens = 75.0 ) ), @@ -58,8 +58,8 @@ enum class Model( maxOutput = 4096, messageBatchesApi = true, cost = Cost( - input = 15.0, - output = 75.0 + inputTokens = 15.0, + outputTokens = 75.0 ) ), @@ -69,8 +69,8 @@ enum class Model( maxOutput = 4096, messageBatchesApi = true, cost = Cost( - input = 3.0, - output = 15.0 + inputTokens = 3.0, + outputTokens = 15.0 ) ), @@ -80,15 +80,18 @@ enum class Model( maxOutput = 4096, messageBatchesApi = true, cost = Cost( - input = .25, - output = 1.25 + inputTokens = .25, + outputTokens = 1.25 ) ); /** * Cost per MTok */ - data class Cost(val input: Double, val output: Double) + data class Cost( + val inputTokens: Double, + val outputTokens: Double + ) companion object { val DEFAULT: Model = CLAUDE_3_5_SONNET diff --git a/src/commonMain/kotlin/Responses.kt b/src/commonMain/kotlin/Responses.kt index ca0b7a5..bcfc9f0 100644 --- a/src/commonMain/kotlin/Responses.kt +++ b/src/commonMain/kotlin/Responses.kt @@ -1,73 +1,6 @@ package com.xemantic.anthropic -import com.xemantic.anthropic.batch.ProcessingStatus -import com.xemantic.anthropic.batch.RequestCounts -import com.xemantic.anthropic.message.Content -import com.xemantic.anthropic.message.Message -import com.xemantic.anthropic.message.Role -import com.xemantic.anthropic.message.StopReason -import com.xemantic.anthropic.message.Usage -import kotlinx.datetime.LocalDateTime -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonClassDiscriminator @Serializable -@JsonClassDiscriminator("type") -@OptIn(ExperimentalSerializationApi::class) -sealed class Response( - val type: String -) - -@Serializable -@SerialName("error") -data class ErrorResponse( - val error: Error -) : Response(type = "error") - -@Serializable -@SerialName("message") -data class MessageResponse( - val id: String, - val role: Role, - val content: List, // limited to Text and ToolUse - val model: String, - @SerialName("stop_reason") - val stopReason: StopReason?, - @SerialName("stop_sequence") - val stopSequence: String?, - val usage: Usage -) : Response(type = "message") { - - fun asMessage(): Message = Message { - role = Role.ASSISTANT - content += this@MessageResponse.content - } - -} - -@Serializable -@SerialName("message_batch") -data class MessageBatchResponse( - val id: String, - @SerialName("processing_status") - val processingStatus: ProcessingStatus, - @SerialName("request_counts") - val requestCounts: RequestCounts, - @SerialName("ended_at") - val endedAt: LocalDateTime?, - @SerialName("created_at") - val createdAt: LocalDateTime, - @SerialName("expires_at") - val expiresAt: LocalDateTime, - @SerialName("cancel_initiated_at") - val cancelInitiatedAt: LocalDateTime?, - @SerialName("results_url") - val resultsUrl: String? -) : Response(type = "message_batch") {} - -@Serializable -data class Error( - val type: String, val message: String -) +abstract class Response(val type: String) diff --git a/src/commonMain/kotlin/batch/Batches.kt b/src/commonMain/kotlin/batch/Batches.kt index 955f904..3465458 100644 --- a/src/commonMain/kotlin/batch/Batches.kt +++ b/src/commonMain/kotlin/batch/Batches.kt @@ -1,6 +1,8 @@ package com.xemantic.anthropic.batch +import com.xemantic.anthropic.Response import com.xemantic.anthropic.message.Message +import kotlinx.datetime.LocalDateTime import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -45,3 +47,24 @@ enum class ProcessingStatus { @SerialName("ended") ENDED } + + +@Serializable +@SerialName("message_batch") +data class MessageBatchResponse( + val id: String, + @SerialName("processing_status") + val processingStatus: ProcessingStatus, + @SerialName("request_counts") + val requestCounts: RequestCounts, + @SerialName("ended_at") + val endedAt: LocalDateTime?, + @SerialName("created_at") + val createdAt: LocalDateTime, + @SerialName("expires_at") + val expiresAt: LocalDateTime, + @SerialName("cancel_initiated_at") + val cancelInitiatedAt: LocalDateTime?, + @SerialName("results_url") + val resultsUrl: String? +) : Response(type = "message_batch") diff --git a/src/commonMain/kotlin/cache/Cache.kt b/src/commonMain/kotlin/cache/Cache.kt new file mode 100644 index 0000000..5c9ca8c --- /dev/null +++ b/src/commonMain/kotlin/cache/Cache.kt @@ -0,0 +1,16 @@ +package com.xemantic.anthropic.cache + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CacheControl( + val type: Type +) { + + enum class Type { + @SerialName("ephemeral") + EPHEMERAL + } + +} diff --git a/src/commonMain/kotlin/error/Errors.kt b/src/commonMain/kotlin/error/Errors.kt new file mode 100644 index 0000000..5d649c2 --- /dev/null +++ b/src/commonMain/kotlin/error/Errors.kt @@ -0,0 +1,25 @@ +package com.xemantic.anthropic.error + +import com.xemantic.anthropic.Response +import io.ktor.http.HttpStatusCode +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("error") +data class ErrorResponse( + val error: Error +) : Response(type = "error") + +@Serializable +data class Error( + val type: String, val message: String +) + +/** + * An exception thrown when API requests returns error. + */ +class AnthropicException( + error: Error, + httpStatusCode: HttpStatusCode +) : RuntimeException(error.toString()) diff --git a/src/commonMain/kotlin/event/Events.kt b/src/commonMain/kotlin/event/Events.kt index cef2430..6ee07d4 100644 --- a/src/commonMain/kotlin/event/Events.kt +++ b/src/commonMain/kotlin/event/Events.kt @@ -1,6 +1,6 @@ package com.xemantic.anthropic.event -import com.xemantic.anthropic.MessageResponse +import com.xemantic.anthropic.message.MessageResponse import com.xemantic.anthropic.message.StopReason import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName @@ -12,52 +12,79 @@ import kotlinx.serialization.json.JsonClassDiscriminator @Serializable @JsonClassDiscriminator("type") @OptIn(ExperimentalSerializationApi::class) -sealed class Event +sealed class Event { -@Serializable -@SerialName("message_start") -data class MessageStartEvent( - val message: MessageResponse -) : Event() + @Serializable + @SerialName("message_start") + data class MessageStart( + val message: MessageResponse + ) : Event() -@Serializable -@SerialName("message_delta") -data class MessageDeltaEvent( - val delta: Delta, - val usage: Usage -) : Event() { + @Serializable + @SerialName("message_delta") + data class MessageDelta( + val delta: Delta, + val usage: Usage + ) : Event() { + + @Serializable + data class Delta( + @SerialName("stop_reason") + val stopReason: StopReason, + @SerialName("stop_sequence") + val stopSequence: String? // TODO is that correct? + ) + + @Serializable + data class Usage( + @SerialName("output_tokens") + val outputTokens: Int + ) + + } @Serializable - data class Delta( - @SerialName("stop_reason") - val stopReason: StopReason, - @SerialName("stop_sequence") - val stopSequence: String? // TODO is that correct? - ) + @SerialName("message_stop") + class MessageStop : Event() { + override fun toString(): String = "MessageStop" + } -} + @Serializable + @SerialName("content_block_start") + data class ContentBlockStart( + val index: Int, + @SerialName("content_block") + val contentBlock: ContentBlock + ) : Event() + + @Serializable + @SerialName("content_block_stop") + data class ContentBlockStop( + val index: Int + ) : Event() + + @Serializable + @SerialName("ping") + class Ping: Event() { + override fun toString(): String = "Ping" + } + + @Serializable + @SerialName("content_block_delta") + data class ContentBlockDelta( + val index: Int, + val delta: Delta + ) : Event() -@Serializable -@SerialName("message_stop") -class MessageStopEvent : Event() { - override fun toString(): String = "MessageStop" } + + + + // TODO error event is missing, should we rename all of these to events? -@Serializable -@SerialName("content_block_start") -data class ContentBlockStartEvent( - val index: Int, - @SerialName("content_block") - val contentBlock: ContentBlock -) : Event() -@Serializable -@SerialName("content_block_stop") -data class ContentBlockStopEvent( - val index: Int -) : Event() @Serializable @JsonClassDiscriminator("type") @@ -75,21 +102,10 @@ sealed class ContentBlock { class ToolUse( val text: String // TODO tool_id ) : ContentBlock() - // TODO missing tool_use -} -@Serializable -@SerialName("ping") -class PingEvent: Event() { - override fun toString(): String = "Ping" } -@Serializable -@SerialName("content_block_delta") -data class ContentBlockDeltaEvent( - val index: Int, - val delta: Delta -) : Event() + @Serializable @JsonClassDiscriminator("type") @@ -104,8 +120,3 @@ sealed class Delta { } -@Serializable -data class Usage( - @SerialName("output_tokens") - val outputTokens: Int -) diff --git a/src/commonMain/kotlin/image/Images.kt b/src/commonMain/kotlin/image/Images.kt new file mode 100644 index 0000000..0c4b607 --- /dev/null +++ b/src/commonMain/kotlin/image/Images.kt @@ -0,0 +1,72 @@ +package com.xemantic.anthropic.image + +import com.xemantic.anthropic.cache.CacheControl +import com.xemantic.anthropic.message.Content +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +@Serializable +@SerialName("image") +data class Image( + val source: Source, + @SerialName("cache_control") + override val cacheControl: CacheControl? = null +) : Content() { + + enum class MediaType { + @SerialName("image/jpeg") + IMAGE_JPEG, + @SerialName("image/png") + IMAGE_PNG, + @SerialName("image/gif") + IMAGE_GIF, + @SerialName("image/webp") + IMAGE_WEBP + } + + @Serializable + data class Source( + val type: Type = Type.BASE64, + @SerialName("media_type") + val mediaType: MediaType, + val data: String + ) { + + enum class Type { + @SerialName("base64") + BASE64 + } + + } + + class Builder { + var data: ByteArray? = null + var mediaType: MediaType? = null + var cacheControl: CacheControl? = null + } + +} + +// TODO move image magic here from Claudine to further simplify the API + +// TODO write it functional way +fun Image(block: Image.Builder.() -> Unit): Image { + val builder = Image.Builder() + block(builder) + return Image( + source = Image.Source( + mediaType = requireNotNull(builder.mediaType) { + "Image 'mediaType' must be defined" + }, + data = + @OptIn(ExperimentalEncodingApi::class) + Base64.encode( + requireNotNull(builder.data) { + "Image 'data' must be defined" + } + ) + ) + ) +} diff --git a/src/commonMain/kotlin/message/Messages.kt b/src/commonMain/kotlin/message/Messages.kt index 5756568..154edf8 100644 --- a/src/commonMain/kotlin/message/Messages.kt +++ b/src/commonMain/kotlin/message/Messages.kt @@ -1,16 +1,17 @@ package com.xemantic.anthropic.message -import com.xemantic.anthropic.Anthropic -import com.xemantic.anthropic.MessageResponse import com.xemantic.anthropic.Model -import com.xemantic.anthropic.anthropicJson -import com.xemantic.anthropic.schema.JsonSchema -import com.xemantic.anthropic.tool.UsableTool +import com.xemantic.anthropic.Response +import com.xemantic.anthropic.cache.CacheControl +import com.xemantic.anthropic.text.Text +import com.xemantic.anthropic.tool.Tool +import com.xemantic.anthropic.tool.ToolChoice +import com.xemantic.anthropic.tool.ToolInput +import com.xemantic.anthropic.tool.toolName +import com.xemantic.anthropic.usage.Usage import kotlinx.serialization.* import kotlinx.serialization.json.JsonClassDiscriminator -import kotlinx.serialization.json.JsonObject import kotlin.collections.mutableListOf -import kotlin.reflect.typeOf /** * The roles that can be taken by entities in a conversation. @@ -53,12 +54,12 @@ data class MessageRequest( ) { class Builder internal constructor( - private val defaultModel: String, - private val defaultMaxTokens: Int, + defaultModel: String, + defaultMaxTokens: Int, @PublishedApi - internal val toolEntryMap: Map> + internal val toolMap: Map ) { - var model: String? = null + var model: String = defaultModel var maxTokens: Int = defaultMaxTokens var messages: List = emptyList() var metadata = null @@ -68,26 +69,48 @@ data class MessageRequest( var system: List? = null var temperature: Double? = null var toolChoice: ToolChoice? = null - var tools: List? = null + var tools: List = emptyList() val topK: Int? = null val topP: Int? = null - fun useTools() { - tools = toolEntryMap.values.map { it.tool } + /** + * Will fill [tools] with all the tools defined + * when creating this [com.xemantic.anthropic.Anthropic] client. + */ + fun allTools() { + tools = toolMap.values.toList() + } + + inline fun tool() { + val name = toolName() + tools += listOf(toolMap[name]!!) } /** * Sets both, the [tools] list and the [toolChoice] with - * just one tool to use, forcing the API to respond with the [ToolUse]. + * just one tool to use, forcing the API to respond with the [com.xemantic.anthropic.tool.ToolUse]. */ - inline fun useTool() { - val type = typeOf() - val toolEntry = toolEntryMap.values.find { it.type == type } - requireNotNull(toolEntry) { - "No such tool defined in Anthropic client: ${T::class.qualifiedName}" + inline fun singleTool() { + val name = toolName() + tools = listOf(toolMap[name]!!) + toolChoice = ToolChoice.Tool(name) + } + +// inline fun > useBuiltInTool() { +// this.name +// } + + /** + * Sets both, the [tools] list and the [toolChoice] with + * just one tool to use, forcing the API to respond with the + * [com.xemantic.anthropic.tool.ToolUse] instance. + */ + fun chooseTool(name: String) { + val tool = requireNotNull(toolMap[name]) { + "No tool with such name defined in Anthropic client: $name" } - tools = listOf(toolEntry.tool) - toolChoice = ToolChoice.Tool(name = toolEntry.tool.name) + tools = listOf(tool) + toolChoice = ToolChoice.Tool(name = tool.name) } fun messages(vararg messages: Message) { @@ -113,16 +136,16 @@ data class MessageRequest( } fun build(): MessageRequest = MessageRequest( - model = if (model != null) model!! else defaultModel, + model = model, maxTokens = maxTokens, messages = messages, metadata = metadata, stopSequences = stopSequences.toNullIfEmpty(), - stream = if (stream != null) stream else null, + stream = if ((stream != null) && stream!!) true else null, system = system, temperature = temperature, toolChoice = toolChoice, - tools = tools, + tools = tools.toNullIfEmpty(), topK = topK, topP = topP ) @@ -131,16 +154,17 @@ data class MessageRequest( } /** - * Used only in tests. Maybe should be internal? + * Used only in tests */ -fun MessageRequest( +internal fun MessageRequest( model: Model = Model.DEFAULT, + toolMap: Map = emptyMap(), block: MessageRequest.Builder.() -> Unit ): MessageRequest { val builder = MessageRequest.Builder( defaultModel = model.id, defaultMaxTokens = model.maxOutput, - toolEntryMap = emptyMap() + toolMap = toolMap ) block(builder) return builder.build() @@ -197,184 +221,16 @@ data class System( } -@Serializable -data class Tool( - val name: String, - val description: String?, - @SerialName("input_schema") - val inputSchema: JsonSchema, - @SerialName("cache_control") - val cacheControl: CacheControl? -) - @Serializable @JsonClassDiscriminator("type") @OptIn(ExperimentalSerializationApi::class) -sealed class Content { +abstract class Content { @SerialName("cache_control") abstract val cacheControl: CacheControl? } -@Serializable -@SerialName("text") -data class Text( - val text: String, - @SerialName("cache_control") - override val cacheControl: CacheControl? = null, -) : Content() - -@Serializable -@SerialName("image") -data class Image( - val source: Source, - @SerialName("cache_control") - override val cacheControl: CacheControl? = null -) : Content() { - - enum class MediaType { - @SerialName("image/jpeg") - IMAGE_JPEG, - @SerialName("image/png") - IMAGE_PNG, - @SerialName("image/gif") - IMAGE_GIF, - @SerialName("image/webp") - IMAGE_WEBP - } - - @Serializable - data class Source( - val type: Type = Type.BASE64, - @SerialName("media_type") - val mediaType: MediaType, - val data: String - ) { - - enum class Type { - @SerialName("base64") - BASE64 - } - - } - -} - -@SerialName("tool_use") -@Serializable -data class ToolUse( - @SerialName("cache_control") - override val cacheControl: CacheControl? = null, - val id: String, - val name: String, - val input: JsonObject -) : Content() { - - @Transient - @PublishedApi - internal lateinit var toolEntry: Anthropic.ToolEntry - - inline fun input(): T = anthropicJson.decodeFromJsonElement( - deserializer = toolEntry.serializer as KSerializer, - element = input - ) - - suspend fun use(): ToolResult { - val tool = anthropicJson.decodeFromJsonElement( - deserializer = toolEntry.serializer, - element = input - ) - val result = try { - toolEntry.initialize(tool) - tool.use(toolUseId = id) - } catch (e: Exception) { - ToolResult( - toolUseId = id, - isError = true, - content = listOf( - Text( - text = e.message ?: "Unknown error occurred" - ) - ) - ) - } - return result - } - -} - -@Serializable -@SerialName("tool_result") -data class ToolResult( - @SerialName("tool_use_id") - val toolUseId: String, - val content: List, // TODO only Text, Image allowed here, should be accessible in gthe builder - @SerialName("is_error") - val isError: Boolean = false, - @SerialName("cache_control") - override val cacheControl: CacheControl? = null -) : Content() - -fun ToolResult( - toolUseId: String, - text: String -): ToolResult = ToolResult( - toolUseId, - content = listOf(Text(text)) -) - -inline fun ToolResult( - toolUseId: String, - value: T -): ToolResult = ToolResult( - toolUseId, - content = listOf( - Text( - anthropicJson.encodeToString( - serializer = serializer(), - value = value - ) - ) - ) -) - -@Serializable -data class CacheControl( - val type: Type -) { - - enum class Type { - @SerialName("ephemeral") - EPHEMERAL - } - -} - -@Serializable -@JsonClassDiscriminator("type") -@OptIn(ExperimentalSerializationApi::class) -sealed class ToolChoice( - @SerialName("disable_parallel_tool_use") - val disableParallelToolUse: Boolean = false -) { - - @Serializable - @SerialName("auto") - class Auto : ToolChoice() - - @Serializable - @SerialName("any") - class Any : ToolChoice() - - @Serializable - @SerialName("tool") - class Tool( - val name: String - ) : ToolChoice() - -} - enum class StopReason { @SerialName("end_turn") END_TURN, @@ -386,20 +242,29 @@ enum class StopReason { TOOL_USE } -@Serializable -data class Usage( - @SerialName("input_tokens") - val inputTokens: Int, - @SerialName("cache_creation_input_tokens") - val cacheCreationInputTokens: Int? = null, - @SerialName("cache_read_input_tokens") - val cacheReadInputTokens: Int? = null, - @SerialName("output_tokens") - val outputTokens: Int -) - operator fun MutableCollection.plusAssign( response: MessageResponse ) { this += response.asMessage() } + +@Serializable +@SerialName("message") +data class MessageResponse( + val id: String, + val role: Role, + val content: List, // limited to Text and ToolUse + val model: String, + @SerialName("stop_reason") + val stopReason: StopReason?, + @SerialName("stop_sequence") + val stopSequence: String?, + val usage: Usage +) : Response(type = "message") { + + fun asMessage(): Message = Message { + role = Role.ASSISTANT + content += this@MessageResponse.content + } + +} diff --git a/src/commonMain/kotlin/schema/JsonSchemaGenerator.kt b/src/commonMain/kotlin/schema/JsonSchemaGenerator.kt index e44ed12..64a16bd 100644 --- a/src/commonMain/kotlin/schema/JsonSchemaGenerator.kt +++ b/src/commonMain/kotlin/schema/JsonSchemaGenerator.kt @@ -79,7 +79,8 @@ private fun generateSchemaProperty( private fun enumProperty( descriptor: SerialDescriptor, description: String? -) = JsonSchemaProperty( // TODO should it return type enum? +) = JsonSchemaProperty( + type = "string", enum = descriptor.elementNames(), description = description, ) diff --git a/src/commonMain/kotlin/text/Text.kt b/src/commonMain/kotlin/text/Text.kt new file mode 100644 index 0000000..f6c91da --- /dev/null +++ b/src/commonMain/kotlin/text/Text.kt @@ -0,0 +1,14 @@ +package com.xemantic.anthropic.text + +import com.xemantic.anthropic.cache.CacheControl +import com.xemantic.anthropic.message.Content +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("text") +data class Text( + val text: String, + @SerialName("cache_control") + override val cacheControl: CacheControl? = null, +) : Content() diff --git a/src/commonMain/kotlin/tool/Tools.kt b/src/commonMain/kotlin/tool/Tools.kt index 079eaa6..7b401b0 100644 --- a/src/commonMain/kotlin/tool/Tools.kt +++ b/src/commonMain/kotlin/tool/Tools.kt @@ -1,29 +1,73 @@ package com.xemantic.anthropic.tool -import com.xemantic.anthropic.message.CacheControl -import com.xemantic.anthropic.message.Tool -import com.xemantic.anthropic.message.ToolResult +import com.xemantic.anthropic.anthropicJson +import com.xemantic.anthropic.cache.CacheControl +import com.xemantic.anthropic.message.Content import com.xemantic.anthropic.schema.Description +import com.xemantic.anthropic.schema.JsonSchema import com.xemantic.anthropic.schema.jsonSchemaOf +import com.xemantic.anthropic.text.Text import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer import kotlinx.serialization.MetaSerializable +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException +import kotlinx.serialization.Transient +import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.serializer -/** - * Annotation used to mark a class extending the [UsableTool]. - * - * This annotation provides metadata for tools that can be serialized and used in the context - * of the Anthropic API. It includes a name and description for the tool. - * - * @property name The name of the tool. This name is used during serialization and should be a unique identifier for the tool. - */ +@Serializable +@JsonClassDiscriminator("name") @OptIn(ExperimentalSerializationApi::class) -@MetaSerializable -@Target(AnnotationTarget.CLASS) -annotation class AnthropicTool( - val name: String -) +abstract class Tool { + + abstract val name: String + abstract val description: String? + abstract val inputSchema: JsonSchema? + abstract val cacheControl: CacheControl? + + @Transient + @PublishedApi + internal lateinit var inputSerializer: KSerializer + + @Transient + @PublishedApi + internal lateinit var inputInitializer: ToolInput.() -> Unit + + inline fun initialize( + noinline block: T.() -> Unit + ) { + @Suppress("UNCHECKED_CAST") + inputInitializer = block as ToolInput.() -> Unit + } + +} + +@Serializable +@PublishedApi +@OptIn(ExperimentalSerializationApi::class) +internal data class DefaultTool( + override val name: String, + override val description: String? = null, + @SerialName("input_schema") + override val inputSchema: JsonSchema? = null, + @SerialName("cache_control") + override val cacheControl: CacheControl? = null +) : Tool() + +@Serializable +@OptIn(ExperimentalSerializationApi::class) +abstract class BuiltInTool( + override val name: String, + val type: String, + override val description: String? = null, + @SerialName("input_schema") + override val inputSchema: JsonSchema? = null, + @SerialName("cache_control") + override val cacheControl: CacheControl? = null +) : Tool() /** * Interface for tools that can be used in the context of the Anthropic API. @@ -32,7 +76,7 @@ annotation class AnthropicTool( * with a given tool use ID. The implementation of the [use] method should * contain the logic for executing the tool and returning the [ToolResult]. */ -interface UsableTool { +interface ToolInput { /** * Executes the tool and returns the result. @@ -45,29 +89,52 @@ interface UsableTool { } @OptIn(ExperimentalSerializationApi::class) -inline fun toolOf( - cacheControl: CacheControl? = null // TODO should it be here? +inline fun toolName(): String = serializer().name() + +@OptIn(ExperimentalSerializationApi::class) +@PublishedApi +internal inline fun KSerializer.name() = ( + descriptor + .annotations + .filterIsInstance() + .firstOrNull() ?: throw SerializationException( + "The ${T::class} must be annotated with @AnthropicTool" + ) +).name + +/** + * Annotation used to mark a class extending the [ToolInput]. + * + * This annotation provides metadata for tools that can be serialized and used in the context + * of the Anthropic API. It includes a name and description for the tool. + * + * @property name The name of the tool. This name is used during serialization and should be a unique identifier for the tool. + */ +@OptIn(ExperimentalSerializationApi::class) +@MetaSerializable +@Target(AnnotationTarget.CLASS) +annotation class AnthropicTool( + val name: String +) + +@OptIn(ExperimentalSerializationApi::class) +inline fun Tool( + cacheControl: CacheControl? = null, + noinline initializer: T.() -> Unit = {} ): Tool { val serializer = try { serializer() } catch (e: SerializationException) { throw SerializationException( - "Cannot find serializer for class ${T::class.qualifiedName}, " + + "Cannot find serializer for ${T::class}, " + "make sure that it is annotated with @AnthropicTool and " + "kotlin.serialization plugin is enabled for the project", e ) } - val anthropicTool = serializer - .descriptor - .annotations - .filterIsInstance() - .firstOrNull() ?: throw SerializationException( - "The class ${T::class.qualifiedName} must be annotated with @AnthropicTool" - ) - + val toolName = toolName() val description = serializer .descriptor .annotations @@ -75,11 +142,118 @@ inline fun toolOf( .firstOrNull() ?.value - return Tool( - name = anthropicTool.name, - // annotation description cannot be null, so we allow empty and detect it here + return DefaultTool( + name = toolName, description = description, inputSchema = jsonSchemaOf(), cacheControl = cacheControl + ).apply { + @Suppress("UNCHECKED_CAST") + inputSerializer = serializer as KSerializer + @Suppress("UNCHECKED_CAST") + inputInitializer = initializer as ToolInput.() -> Unit + } + +} + +@Serializable +@SerialName("tool_use") +data class ToolUse( + @SerialName("cache_control") + override val cacheControl: CacheControl? = null, + val id: String, + val name: String, + val input: JsonObject +) : Content() { + + @Transient + @PublishedApi + internal lateinit var tool: Tool + + @PublishedApi + internal fun decodeInput() = anthropicJson.decodeFromJsonElement( + deserializer = tool.inputSerializer, + element = input + ).apply(tool.inputInitializer) + + inline fun input(): T = (decodeInput() as T) + + suspend fun use(): ToolResult { + val toolInput = decodeInput() + return try { + toolInput.use(toolUseId = id) + } catch (e: Exception) { + e.printStackTrace() + ToolResult( + toolUseId = id, + isError = true, + content = listOf( + Text( + text = e.message ?: "Unknown error occurred" + ) + ) + ) + } + } + +} + + +@Serializable +@SerialName("tool_result") +data class ToolResult( + @SerialName("tool_use_id") + val toolUseId: String, + val content: List, // TODO only Text, Image allowed here, should be accessible in gthe builder + @SerialName("is_error") + val isError: Boolean = false, + @SerialName("cache_control") + override val cacheControl: CacheControl? = null +) : Content() + +fun ToolResult( + toolUseId: String, + text: String +): ToolResult = ToolResult( + toolUseId, + content = listOf(Text(text)) +) + +inline fun ToolResult( + toolUseId: String, + value: T +): ToolResult = ToolResult( + toolUseId, + content = listOf( + Text( + anthropicJson.encodeToString( + serializer = serializer(), + value = value + ) + ) ) +) + +@Serializable +@JsonClassDiscriminator("type") +@OptIn(ExperimentalSerializationApi::class) +sealed class ToolChoice( + @SerialName("disable_parallel_tool_use") + val disableParallelToolUse: Boolean? = false +) { + + @Serializable + @SerialName("auto") + class Auto : ToolChoice() + + @Serializable + @SerialName("any") + class Any : ToolChoice() + + @Serializable + @SerialName("tool") + class Tool( + val name: String + ) : ToolChoice() + } diff --git a/src/commonMain/kotlin/tool/bash/Bash.kt b/src/commonMain/kotlin/tool/bash/Bash.kt new file mode 100644 index 0000000..47ed43f --- /dev/null +++ b/src/commonMain/kotlin/tool/bash/Bash.kt @@ -0,0 +1,33 @@ +package com.xemantic.anthropic.tool.bash + +import com.xemantic.anthropic.cache.CacheControl +import com.xemantic.anthropic.tool.BuiltInTool +import com.xemantic.anthropic.tool.ToolInput +import com.xemantic.anthropic.tool.ToolResult +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +@SerialName("bash") +@OptIn(ExperimentalSerializationApi::class) +data class Bash( + override val cacheControl: CacheControl? = null +) : BuiltInTool( + name = "bash", + type = "bash_20241022" +) { + + @Serializable + data class Input( + val command: String, + val restart: Boolean? = false, + ) : ToolInput { + + override suspend fun use(toolUseId: String): ToolResult { + TODO("Not yet implemented") + } + + } + +} diff --git a/src/commonMain/kotlin/tool/computer/Computer.kt b/src/commonMain/kotlin/tool/computer/Computer.kt index 3c7d973..cfa45b7 100644 --- a/src/commonMain/kotlin/tool/computer/Computer.kt +++ b/src/commonMain/kotlin/tool/computer/Computer.kt @@ -1,7 +1,54 @@ package com.xemantic.anthropic.tool.computer +import com.xemantic.anthropic.cache.CacheControl +import com.xemantic.anthropic.tool.BuiltInTool +import com.xemantic.anthropic.tool.ToolResult +import com.xemantic.anthropic.tool.ToolInput +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +@SerialName("computer") +@OptIn(ExperimentalSerializationApi::class) +data class Computer( + override val cacheControl: CacheControl? = null, + @SerialName("display_width_px") + val displayWidthPx: Int, + @SerialName("display_height_px") + val displayHeightPx: Int, + @SerialName("display_number") + val displayNumber: Int? = null +) : BuiltInTool( + name = "computer", + type = "computer_20241022" +) { + + init { + inputSerializer = Input.serializer() + initialize { + service = computerService + } + } + + @Serializable + data class Input( + val action: Action, + val coordinate: Coordinate?, + val text: String + ) : ToolInput { + + @Transient + lateinit var service: ComputerService + + override suspend fun use( + toolUseId: String + ) = service.use(toolUseId, this) + + } + +} enum class Action { @SerialName("key") @@ -27,7 +74,18 @@ enum class Action { } @Serializable -data class Resolution( - val width: Int, - val height: Int -) +data class Resolution(val width: Int, val height: Int) + +@Serializable +data class Coordinate(val x: Int, val y: Int) + +interface ComputerService { + + suspend fun use( + toolUseId: String, + input: Computer.Input + ): ToolResult + +} + +expect val computerService: ComputerService diff --git a/src/commonMain/kotlin/tool/editor/TextEditor.kt b/src/commonMain/kotlin/tool/editor/TextEditor.kt new file mode 100644 index 0000000..f7a2ee7 --- /dev/null +++ b/src/commonMain/kotlin/tool/editor/TextEditor.kt @@ -0,0 +1,80 @@ +package com.xemantic.anthropic.tool.editor + +import com.xemantic.anthropic.cache.CacheControl +import com.xemantic.anthropic.tool.BuiltInTool +import com.xemantic.anthropic.tool.ToolInput +import com.xemantic.anthropic.tool.ToolResult +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +@SerialName("str_replace_editor") +@OptIn(ExperimentalSerializationApi::class) +data class TextEditor( + override val cacheControl: CacheControl? = null +) : BuiltInTool( + name = "str_replace_editor", + type = "text_editor_20241022" +) { + + init { + inputSerializer = Input.serializer() + inputInitializer = { + //service = computerService + } + } + + @Serializable + data class Input( + val command: Command, + @SerialName("file_text") + val fileText: String? = null, + @SerialName("insert_line") + val insertLine: Int? = null, + @SerialName("new_str") + val newStr: String? = null, + @SerialName("old_str") + val oldStr: String? = null, + @SerialName("path") + val path: String, + @SerialName("view_range") + val viewRange: Int? = 0 + ) : ToolInput { + + @Transient + lateinit var service: TextEditorService + + override suspend fun use( + toolUseId: String + ) = service.use(toolUseId, this) + + } + +} + +@Serializable +enum class Command { + @SerialName("view") + VIEW, + @SerialName("create") + CREATE, + @SerialName("str_replace") + STR_REPLACE, + @SerialName("insert") + INSERT, + @SerialName("undo_edit") + UNDO_EDIT +} + +interface TextEditorService { + + suspend fun use( + toolUseId: String, + input: TextEditor.Input + ): ToolResult + +} + +expect val textEditorService: TextEditorService diff --git a/src/commonMain/kotlin/usage/Usage.kt b/src/commonMain/kotlin/usage/Usage.kt new file mode 100644 index 0000000..6a2d6cc --- /dev/null +++ b/src/commonMain/kotlin/usage/Usage.kt @@ -0,0 +1,50 @@ +package com.xemantic.anthropic.usage + +import com.xemantic.anthropic.Model +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Usage( + @SerialName("input_tokens") + val inputTokens: Int, + @SerialName("output_tokens") + val outputTokens: Int, + @SerialName("cache_creation_input_tokens") + val cacheCreationInputTokens: Int? = null, + @SerialName("cache_read_input_tokens") + val cacheReadInputTokens: Int? = null, +) + +fun Usage.add(usage: Usage): Usage = Usage( + inputTokens = inputTokens + usage.inputTokens, + outputTokens = outputTokens + usage.outputTokens, + cacheReadInputTokens = (cacheReadInputTokens ?: 0) + (usage.cacheReadInputTokens ?: 0), + cacheCreationInputTokens = (cacheCreationInputTokens ?: 0) + (usage.cacheCreationInputTokens ?: 0), +) + +fun Usage.cost( + model: Model, + isBatch: Boolean = false +): Cost = Cost( + inputTokens = inputTokens * model.cost.inputTokens / 1000000.0 * (if (isBatch) .5 else 1.0), + outputTokens = outputTokens * model.cost.outputTokens / 1000000.0 * (if (isBatch) .5 else 1.0), + cacheReadInputTokens = (cacheReadInputTokens ?: 0) * model.cost.inputTokens * .1 / 1000000.0 * (if (isBatch) .5 else 1.0), + cacheCreationInputTokens = (cacheCreationInputTokens ?: 0) * model.cost.inputTokens * .25 / 1000000.0 * (if (isBatch) .5 else 1.0) +) + +data class Cost( + val inputTokens: Double, + val outputTokens: Double, + val cacheCreationInputTokens: Double, + val cacheReadInputTokens: Double +) { + + fun add(cost: Cost): Cost = Cost( + inputTokens = inputTokens + cost.inputTokens, + outputTokens = outputTokens + cost.outputTokens, + cacheCreationInputTokens = cacheCreationInputTokens + cost.cacheCreationInputTokens, + cacheReadInputTokens = cacheReadInputTokens + cost.cacheReadInputTokens + ) + +} diff --git a/src/commonTest/kotlin/AnthropicTest.kt b/src/commonTest/kotlin/AnthropicTest.kt index 94beddf..427ef59 100644 --- a/src/commonTest/kotlin/AnthropicTest.kt +++ b/src/commonTest/kotlin/AnthropicTest.kt @@ -1,18 +1,18 @@ package com.xemantic.anthropic -import com.xemantic.anthropic.event.ContentBlockDeltaEvent import com.xemantic.anthropic.event.Delta.TextDelta -import com.xemantic.anthropic.message.Image +import com.xemantic.anthropic.event.Event +import com.xemantic.anthropic.image.Image import com.xemantic.anthropic.message.Message import com.xemantic.anthropic.message.Role import com.xemantic.anthropic.message.StopReason -import com.xemantic.anthropic.message.Text -import com.xemantic.anthropic.message.ToolUse import com.xemantic.anthropic.message.plusAssign -import com.xemantic.anthropic.test.Calculator -import com.xemantic.anthropic.test.DatabaseQueryTool -import com.xemantic.anthropic.test.FibonacciTool -import com.xemantic.anthropic.test.TestDatabase +import com.xemantic.anthropic.tool.Calculator +import com.xemantic.anthropic.tool.DatabaseQuery +import com.xemantic.anthropic.tool.FibonacciTool +import com.xemantic.anthropic.tool.TestDatabase +import com.xemantic.anthropic.text.Text +import com.xemantic.anthropic.tool.ToolUse import io.kotest.assertions.assertSoftly import io.kotest.matchers.ints.shouldBeGreaterThan import io.kotest.matchers.shouldBe @@ -43,8 +43,7 @@ class AnthropicTest { // then assertSoftly(response) { - type shouldBe "message" - role shouldBe Role.ASSISTANT + role shouldBe Role.ASSISTANT model shouldBe "claude-3-5-sonnet-20241022" stopReason shouldBe StopReason.END_TURN content.size shouldBe 1 @@ -94,7 +93,7 @@ class AnthropicTest { val response = client.messages.stream { +Message { +"Say: 'The sun slowly dipped below the horizon, painting the sky in a breathtaking array of oranges, pinks, and purples.'" } } - .filterIsInstance() + .filterIsInstance() .map { (it.delta as TextDelta).text } .toList() .joinToString(separator = "") @@ -115,20 +114,19 @@ class AnthropicTest { // when val initialResponse = client.messages.create { messages = conversation - useTools() + singleTool() // we are forcing the use of this tool } conversation += initialResponse // then assertSoftly(initialResponse) { stopReason shouldBe StopReason.TOOL_USE - content.size shouldBe 2 - content[0] shouldBe instanceOf() - content[1] shouldBe instanceOf() - (content[1] as ToolUse).name shouldBe "Calculator" + content.size shouldBe 1 // and therefore there is only ToolUse without commentary + content[0] shouldBe instanceOf() + (content[0] as ToolUse).name shouldBe "Calculator" } - val toolUse = initialResponse.content[1] as ToolUse + val toolUse = initialResponse.content[0] as ToolUse val result = toolUse.use() // here we execute the tool conversation += Message { +result } @@ -136,7 +134,7 @@ class AnthropicTest { // when val resultResponse = client.messages.create { messages = conversation - useTools() + tool() // we are not forcing the use, but tool definition needs to be present } // then @@ -158,7 +156,7 @@ class AnthropicTest { // when val response = client.messages.create { +Message { +"What's fibonacci number 42" } - useTools() + allTools() } // then @@ -187,7 +185,7 @@ class AnthropicTest { val fibonacciResponse = client.messages.create { messages = conversation - useTools() + allTools() } conversation += fibonacciResponse @@ -198,7 +196,7 @@ class AnthropicTest { val calculatorResponse = client.messages.create { messages = conversation - useTools() + allTools() } conversation += calculatorResponse @@ -209,7 +207,7 @@ class AnthropicTest { val finalResponse = client.messages.create { messages = conversation - useTools() + allTools() } finalResponse.content[0] shouldBe instanceOf() @@ -222,7 +220,7 @@ class AnthropicTest { // given val testDatabase = TestDatabase() val client = Anthropic { - tool { + tool { database = testDatabase } } @@ -230,7 +228,8 @@ class AnthropicTest { // when val response = client.messages.create { +Message { +"List data in CUSTOMER table" } - useTool() + singleTool() // we are forcing the use of this tool + // could be also just tool() if we are confident that LLM will use this one } val toolUse = response.content.filterIsInstance().first() toolUse.use() diff --git a/src/commonTest/kotlin/error/ErrorResponseTest.kt b/src/commonTest/kotlin/error/ErrorResponseTest.kt new file mode 100644 index 0000000..284dd6d --- /dev/null +++ b/src/commonTest/kotlin/error/ErrorResponseTest.kt @@ -0,0 +1,38 @@ +package com.xemantic.anthropic.error + +import com.xemantic.anthropic.Response +import com.xemantic.anthropic.test.testJson +import io.kotest.assertions.assertSoftly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.instanceOf +import kotlin.test.Test + +/** + * Tests the JSON format of deserialized Anthropic API error responses. + */ +class ErrorResponseTest { + + @Test + fun shouldDeserializeToolUseMessageResponse() { + // given + val jsonResponse = """ + { + "type": "error", + "error": { + "type": "not_found_error", + "message": "The requested resource could not be found." + } + } + """.trimIndent() + + val response = testJson.decodeFromString(jsonResponse) + response shouldBe instanceOf() + assertSoftly(response as ErrorResponse) { + error shouldBe Error( + type = "not_found_error", + message = "The requested resource could not be found." + ) + } + } + +} \ No newline at end of file diff --git a/src/commonTest/kotlin/message/MessageRequestTest.kt b/src/commonTest/kotlin/message/MessageRequestTest.kt index ecce656..044962e 100644 --- a/src/commonTest/kotlin/message/MessageRequestTest.kt +++ b/src/commonTest/kotlin/message/MessageRequestTest.kt @@ -1,11 +1,37 @@ package com.xemantic.anthropic.message +import com.xemantic.anthropic.message.MessageRequestTest.TemperatureUnit +import com.xemantic.anthropic.schema.Description import com.xemantic.anthropic.test.testJson +import com.xemantic.anthropic.tool.AnthropicTool +import com.xemantic.anthropic.tool.Tool +import com.xemantic.anthropic.tool.ToolResult +import com.xemantic.anthropic.tool.ToolInput +import com.xemantic.anthropic.tool.bash.Bash +import com.xemantic.anthropic.tool.computer.Computer +import com.xemantic.anthropic.tool.editor.TextEditor import io.kotest.assertions.json.shouldEqualJson import io.kotest.matchers.shouldBe +import kotlinx.serialization.SerialName import kotlinx.serialization.encodeToString import kotlin.test.Test +// given +@AnthropicTool("get_weather") +@Description("Get the current weather in a given location") +data class GetWeather( + @Description("The city and state, e.g. San Francisco, CA") + val location: String, + @Description("The unit of temperature, either 'celsius' or 'fahrenheit'") + val unit: TemperatureUnit? = null +) : ToolInput { + + override suspend fun use( + toolUseId: String + ) = ToolResult(toolUseId, "42") + +} + /** * Tests the JSON serialization format of created Anthropic API message requests. */ @@ -51,4 +77,154 @@ class MessageRequestTest { """.trimIndent() } + enum class TemperatureUnit { + @SerialName("celsius") + CELSIUS, + @SerialName("fahrenheit") + FAHRENHEIT + } + + @Test + fun shouldCreateMessageRequestWithMultipleTools() { + // given + val request = MessageRequest { + +Message { + +"Hey Claude!?" + } + tools = listOf( + Computer( + displayWidthPx = 1024, + displayHeightPx = 768, + displayNumber = 1 + ), + TextEditor(), + Bash(), + Tool() + ) + } + + // when + val json = testJson.encodeToString(request) + + // then + json shouldEqualJson """ + { + "model": "claude-3-5-sonnet-latest", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Hey Claude!?" + } + ] + } + ], + "max_tokens": 8182, + "tools": [ + { + "type": "computer_20241022", + "name": "computer", + "display_width_px": 1024, + "display_height_px": 768, + "display_number": 1 + }, + { + "type": "text_editor_20241022", + "name": "str_replace_editor" + }, + { + "type": "bash_20241022", + "name": "bash" + }, + { + "name": "get_weather", + "description": "Get the current weather in a given location", + "input_schema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + }, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The unit of temperature, either 'celsius' or 'fahrenheit'" + } + }, + "required": ["location"] + } + } + ] + } + """.trimIndent() + } + + @Test + fun shouldDeserializeMessageRequestForExampleStoredOnDisk() { + // given + val request = """ + { + "model": "claude-3-5-sonnet-latest", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Hey Claude!?" + } + ] + } + ], + "max_tokens": 8182, + "tools": [ + { + "type": "computer_20241022", + "name": "computer", + "display_width_px": 1024, + "display_height_px": 768, + "display_number": 1 + }, + { + "type": "text_editor_20241022", + "name": "str_replace_editor" + }, + { + "type": "bash_20241022", + "name": "bash" + }, + { + "name": "get_weather", + "description": "Get the current weather in a given location", + "input_schema": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA" + }, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "The unit of temperature, either 'celsius' or 'fahrenheit'" + } + }, + "required": ["location"] + } + } + ] + } + """.trimIndent() + + // when + val messageRequest = testJson.decodeFromString(request) + + // then + // TODO assertions + println(messageRequest) + } + } diff --git a/src/commonTest/kotlin/message/MessageResponseTest.kt b/src/commonTest/kotlin/message/MessageResponseTest.kt index 638f8bd..01131a6 100644 --- a/src/commonTest/kotlin/message/MessageResponseTest.kt +++ b/src/commonTest/kotlin/message/MessageResponseTest.kt @@ -1,7 +1,9 @@ package com.xemantic.anthropic.message -import com.xemantic.anthropic.MessageResponse +import com.xemantic.anthropic.Response import com.xemantic.anthropic.test.testJson +import com.xemantic.anthropic.tool.ToolUse +import com.xemantic.anthropic.usage.Usage import io.kotest.assertions.assertSoftly import io.kotest.matchers.shouldBe import io.kotest.matchers.types.instanceOf @@ -42,10 +44,10 @@ class MessageResponseTest { } """.trimIndent() - val response = testJson.decodeFromString(jsonResponse) - assertSoftly(response) { + val response = testJson.decodeFromString(jsonResponse) + response shouldBe instanceOf() + assertSoftly(response as MessageResponse) { id shouldBe "msg_01PspkNzNG3nrf5upeTsmWLF" - type shouldBe "message" role shouldBe Role.ASSISTANT model shouldBe "claude-3-5-sonnet-20241022" content.size shouldBe 1 diff --git a/src/commonTest/kotlin/message/ToolResultTest.kt b/src/commonTest/kotlin/message/ToolResultTest.kt index 535e742..9a1d7bf 100644 --- a/src/commonTest/kotlin/message/ToolResultTest.kt +++ b/src/commonTest/kotlin/message/ToolResultTest.kt @@ -1,5 +1,7 @@ package com.xemantic.anthropic.message +import com.xemantic.anthropic.text.Text +import com.xemantic.anthropic.tool.ToolResult import io.kotest.matchers.shouldBe import kotlinx.serialization.Serializable import kotlin.test.Test diff --git a/src/commonTest/kotlin/test/AnthropicTestTools.kt b/src/commonTest/kotlin/tool/AnthropicTestTools.kt similarity index 80% rename from src/commonTest/kotlin/test/AnthropicTestTools.kt rename to src/commonTest/kotlin/tool/AnthropicTestTools.kt index 79713b4..c9a1579 100644 --- a/src/commonTest/kotlin/test/AnthropicTestTools.kt +++ b/src/commonTest/kotlin/tool/AnthropicTestTools.kt @@ -1,14 +1,11 @@ -package com.xemantic.anthropic.test +package com.xemantic.anthropic.tool -import com.xemantic.anthropic.message.ToolResult import com.xemantic.anthropic.schema.Description -import com.xemantic.anthropic.tool.AnthropicTool -import com.xemantic.anthropic.tool.UsableTool import kotlinx.serialization.Transient @AnthropicTool("FibonacciTool") @Description("Calculate Fibonacci number n") -data class FibonacciTool(val n: Int): UsableTool { +data class FibonacciTool(val n: Int): ToolInput { tailrec fun fibonacci( n: Int, a: Int = 0, b: Int = 1 @@ -28,7 +25,7 @@ data class Calculator( val operation: Operation, val a: Double, val b: Double -): UsableTool { +): ToolInput { @Suppress("unused") // it is used, but by Anthropic, so we skip the warning enum class Operation( @@ -63,18 +60,18 @@ class TestDatabase : Database { @AnthropicTool("DatabaseQuery") @Description("Executes database query") -data class DatabaseQueryTool( +data class DatabaseQuery( val query: String -) : UsableTool { +) : ToolInput { @Transient - lateinit var database: Database + internal lateinit var database: Database override suspend fun use( toolUseId: String ) = ToolResult( toolUseId, - text = database.execute(query).joinToString() + text = database.execute(query).joinToString() ) } diff --git a/src/commonTest/kotlin/tool/UsableToolTest.kt b/src/commonTest/kotlin/tool/UsableToolTest.kt index 6ff5258..3a57e6e 100644 --- a/src/commonTest/kotlin/tool/UsableToolTest.kt +++ b/src/commonTest/kotlin/tool/UsableToolTest.kt @@ -1,7 +1,6 @@ package com.xemantic.anthropic.tool -import com.xemantic.anthropic.message.CacheControl -import com.xemantic.anthropic.message.ToolResult +import com.xemantic.anthropic.cache.CacheControl import com.xemantic.anthropic.schema.Description import com.xemantic.anthropic.schema.JsonSchema import com.xemantic.anthropic.schema.JsonSchemaProperty @@ -16,10 +15,10 @@ class UsableToolTest { @AnthropicTool("TestTool") @Description("Test tool receiving a message and outputting it back") - class TestTool( + class TestToolInput( @Description("the message") val message: String - ) : UsableTool { + ) : ToolInput { override suspend fun use( toolUseId: String ) = ToolResult(toolUseId, message) @@ -28,7 +27,7 @@ class UsableToolTest { @Test fun shouldCreateToolFromUsableToolAnnotatedWithAnthropicTool() { // when - val tool = toolOf() + val tool = Tool() assertSoftly(tool) { name shouldBe "TestTool" @@ -48,7 +47,7 @@ class UsableToolTest { @Test fun shouldCreateToolWithCacheControlFromUsableToolSuppliedWithCacheControl() { // when - val tool = toolOf( + val tool = Tool( cacheControl = CacheControl(type = CacheControl.Type.EPHEMERAL) ) @@ -66,7 +65,7 @@ class UsableToolTest { } } - class NoAnnotationTool : UsableTool { + class NoAnnotationTool : ToolInput { override suspend fun use( toolUseId: String ) = ToolResult(toolUseId, "nothing") @@ -75,15 +74,15 @@ class UsableToolTest { @Test fun shouldFailToCreateToolWithoutAnthropicToolAnnotation() { shouldThrowWithMessage( - "Cannot find serializer for class com.xemantic.anthropic.tool.UsableToolTest.NoAnnotationTool, " + + "Cannot find serializer for class com.xemantic.anthropic.tool.UsableToolTest\$NoAnnotationTool, " + "make sure that it is annotated with @AnthropicTool and kotlin.serialization plugin is enabled for the project" ) { - toolOf() + Tool() } } @Serializable - class OnlySerializableAnnotationTool : UsableTool { + class OnlySerializableAnnotationTool : ToolInput { override suspend fun use( toolUseId: String ) = ToolResult(toolUseId, "nothing") @@ -92,9 +91,9 @@ class UsableToolTest { @Test fun shouldFailToCreateToolWithOnlySerializableAnnotation() { shouldThrowWithMessage( - "The class com.xemantic.anthropic.tool.UsableToolTest.OnlySerializableAnnotationTool must be annotated with @AnthropicTool" + "The class com.xemantic.anthropic.tool.UsableToolTest\$OnlySerializableAnnotationTool must be annotated with @AnthropicTool" ) { - toolOf() + Tool() } } diff --git a/src/jsMain/kotlin/JsAnthropic.kt b/src/jsMain/kotlin/JsAnthropic.kt new file mode 100644 index 0000000..105722b --- /dev/null +++ b/src/jsMain/kotlin/JsAnthropic.kt @@ -0,0 +1,7 @@ +package com.xemantic.anthropic + +actual val envApiKey: String? + get() = null + +actual val missingApiKeyMessage: String + get() = "apiKey is missing, it has to be provided as a parameter." diff --git a/src/jsMain/kotlin/tool/computer/JsComputer.kt b/src/jsMain/kotlin/tool/computer/JsComputer.kt new file mode 100644 index 0000000..08595fe --- /dev/null +++ b/src/jsMain/kotlin/tool/computer/JsComputer.kt @@ -0,0 +1,4 @@ +package com.xemantic.anthropic.tool.computer + +actual val computerService: ComputerService + get() = TODO("Not yet implemented") diff --git a/src/jsMain/kotlin/tool/editor/JsTextEditor.kt b/src/jsMain/kotlin/tool/editor/JsTextEditor.kt new file mode 100644 index 0000000..5c0319b --- /dev/null +++ b/src/jsMain/kotlin/tool/editor/JsTextEditor.kt @@ -0,0 +1,4 @@ +package com.xemantic.anthropic.tool.editor + +actual val textEditorService: TextEditorService + get() = TODO("Not yet implemented") diff --git a/src/jvmMain/kotlin/JvmAnthropic.kt b/src/jvmMain/kotlin/JvmAnthropic.kt index ba07033..036434c 100644 --- a/src/jvmMain/kotlin/JvmAnthropic.kt +++ b/src/jvmMain/kotlin/JvmAnthropic.kt @@ -1,6 +1,7 @@ package com.xemantic.anthropic import com.xemantic.anthropic.message.MessageRequest +import com.xemantic.anthropic.message.MessageResponse import kotlinx.coroutines.runBlocking import java.util.function.Consumer diff --git a/src/jvmMain/kotlin/image/JvmImages.kt b/src/jvmMain/kotlin/image/JvmImages.kt new file mode 100644 index 0000000..adda993 --- /dev/null +++ b/src/jvmMain/kotlin/image/JvmImages.kt @@ -0,0 +1,9 @@ +package com.xemantic.anthropic.image + +import java.io.File + +fun Image.Builder.path(path: String) = file(File(path)) + +fun Image.Builder.file(file: File) { + data = file.readBytes() +} diff --git a/src/jvmMain/kotlin/message/JvmMessages.kt b/src/jvmMain/kotlin/message/JvmMessages.kt deleted file mode 100644 index ab60161..0000000 --- a/src/jvmMain/kotlin/message/JvmMessages.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.xemantic.anthropic.message - -import java.io.File -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - -fun Image( - path: String, - mediaType: Image.MediaType -): Image = Image( - file = File(path), - mediaType -) - -@OptIn(ExperimentalEncodingApi::class) -fun Image( - file: File, - mediaType: Image.MediaType -): Image = Image( - source = Image.Source( - data = Base64.encode(file.readBytes()), - mediaType = mediaType - ) -) diff --git a/src/jvmMain/kotlin/tool/computer/JvmComputer.kt b/src/jvmMain/kotlin/tool/computer/JvmComputer.kt new file mode 100644 index 0000000..b6d861d --- /dev/null +++ b/src/jvmMain/kotlin/tool/computer/JvmComputer.kt @@ -0,0 +1,42 @@ +package com.xemantic.anthropic.tool.computer + +import com.xemantic.anthropic.image.Image +import com.xemantic.anthropic.tool.ToolResult +import java.awt.Rectangle +import java.awt.Robot +import java.awt.Toolkit +import java.io.ByteArrayOutputStream +import javax.imageio.ImageIO + +object JvmComputerService : ComputerService { + + override suspend fun use( + toolUseId: String, + input: Computer.Input + ): ToolResult = when (input.action) { + Action.SCREENSHOT -> ToolResult( + toolUseId = toolUseId, + content = listOf( + Image { + data = takeScreenshot() + mediaType = Image.MediaType.IMAGE_JPEG + } + ) + ) + else -> TODO() + } + +} + +fun takeScreenshot(): ByteArray { + val robot = Robot() + val screenRect = Rectangle(Toolkit.getDefaultToolkit().screenSize) + val output = ByteArrayOutputStream() + val image = robot.createScreenCapture(screenRect) + if (!ImageIO.write(image, "jpeg", output)) { + throw IllegalStateException("Failed to save screenshot") + } + return output.toByteArray() +} + +actual val computerService: ComputerService get() = JvmComputerService diff --git a/src/jvmMain/kotlin/tool/editor/JvmTextEditor.kt b/src/jvmMain/kotlin/tool/editor/JvmTextEditor.kt new file mode 100644 index 0000000..653430d --- /dev/null +++ b/src/jvmMain/kotlin/tool/editor/JvmTextEditor.kt @@ -0,0 +1,22 @@ +package com.xemantic.anthropic.tool.editor + +import com.xemantic.anthropic.tool.ToolResult +import java.io.File + +object JvmTextEditorService : TextEditorService { + + override suspend fun use( + toolUseId: String, + input: TextEditor.Input + ): ToolResult = ToolResult( + toolUseId = toolUseId, + if (input.command == Command.VIEW) { + File(input.path).readText() + } else { + "Not implemented yet" + } + ) + +} + +actual val textEditorService: TextEditorService get() = JvmTextEditorService