Skip to content

Commit

Permalink
feat(BE-174): integrate anthropic api
Browse files Browse the repository at this point in the history
  • Loading branch information
hanrw committed Mar 14, 2024
1 parent 70b176e commit b3d3277
Show file tree
Hide file tree
Showing 18 changed files with 509 additions and 0 deletions.
2 changes: 2 additions & 0 deletions anthropic-client/anthropic-client-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ kotlin {
// put your Multiplatform dependencies here
api(libs.kotlinx.serialization.json)
api(libs.bundles.ktor.client)
api(projects.common)
}

commonTest.dependencies {
implementation(libs.ktor.client.mock)
api(projects.common)
}

macosMain.dependencies {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.tddworks.anthropic.api

import kotlinx.serialization.Serializable
import kotlin.jvm.JvmInline

/**
* https://docs.anthropic.com/claude/docs/models-overview
* Claude is a family of state-of-the-art large language models developed by Anthropic. Our models are designed to provide you with the best possible experience when interacting with AI, offering a range of capabilities and performance levels to suit your needs and make it easy to deploy high performing, safe, and steerable models. In this guide, we'll introduce you to our latest and greatest models, the Claude 3 family, as well as our legacy models, which are still available for those who need them.
*
*/
@Serializable
@JvmInline
value class Model(val value: String) {
companion object {
/**
* Most powerful model for highly complex tasks
* Max output length: 4096 tokens
* Cost (Input / Output per MTok^) $15.00 / $75.00
*/
val CLAUDE_3_OPUS = Model("claude-3-opus-20240229")

/**
* Ideal balance of intelligence and speed for enterprise workloads
* Max output length: 4096 tokens
* Cost (Input / Output per MTok^) $3.00 / $15.00
*/
val CLAUDE_3_Sonnet = Model("claude-3-sonnet-20240229")

/**
* Fastest and most compact model for near-instant responsiveness
* Max output length: 4096 tokens
* Cost (Input / Output per MTok^) $0.25 / $1.25
*/
val CLAUDE_3_HAIKU = Model("claude-3-haiku-20240307")

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.tddworks.anthropic.api.messages.api

import com.tddworks.openllm.api.ChatRequest
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic

@Serializable
@ExperimentalSerializationApi
data class CreateMessageRequest(
val messages: List<Message>,
val systemPrompt: String? = null,
) : ChatRequest


Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.tddworks.anthropic.api.messages.api

import com.tddworks.openllm.api.ChatResponse
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* {
* "content": [
* {
* "text": "Hi! My name is Claude.",
* "type": "text"
* }
* ],
* "id": "msg_013Zva2CMHLNnXjNJJKqJ2EF",
* "model": "claude-3-opus-20240229",
* "role": "assistant",
* "stop_reason": "end_turn",
* "stop_sequence": null,
* "type": "message",
* "usage": {
* "input_tokens": 10,
* "output_tokens": 25
* }
* }
*/
@Serializable
data class CreateMessageResponse(
val content: List<ContentMessage>,
val id: String,
val model: String,
val role: String,
@SerialName("stop_reason")
val stopReason: String,
@SerialName("stop_sequence")
val stopSequence: String?,
val type: String,
val usage: Usage,
) : ChatResponse


@Serializable
data class ContentMessage(val text: String, val type: String)

@Serializable
data class Usage(
@SerialName("input_tokens")
val inputTokens: Int,
@SerialName("output_tokens")
val outputTokens: Int,
)


Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.tddworks.anthropic.api.messages.api

import kotlinx.serialization.Serializable

/**
* https://docs.anthropic.com/claude/reference/messages_post
* Our models are trained to operate on alternating user and assistant conversational turns. When creating a new Message, you specify the prior conversational turns with the messages parameter, and the model then generates the next Message in the conversation.
*
* Each input message must be an object with a role and content. You can specify a single user-role message, or you can include multiple user and assistant messages. The first message must always use the user role.
*
* If the final message uses the assistant role, the response content will continue immediately from the content in that message. This can be used to constrain part of the model's response.
*
* Example with a single user message:
*
* [{"role": "user", "content": "Hello, Claude"}]
*/
@Serializable
data class Message(
val role: Role,
val content: String,
) {
companion object {
fun user(content: String) = Message(Role.User, content)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.tddworks.anthropic.api.messages.api

import kotlinx.serialization.Serializable
import kotlin.jvm.JvmInline

/**
* https://docs.anthropic.com/claude/reference/messages_post
* Our models are trained to operate on alternating user and assistant conversational turns. When creating a new Message, you specify the prior conversational turns with the messages parameter, and the model then generates the next Message in the conversation.
*/
@JvmInline
@Serializable
value class Role(val name: String) {
companion object {
val User: Role = Role("user")
val Assistant: Role = Role("assistant")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.tddworks.anthropic.api.messages.api.internal

import com.tddworks.common.network.api.ktor.api.HttpRequester
import com.tddworks.common.network.api.ktor.api.performRequest
import com.tddworks.openllm.api.ChatApi
import com.tddworks.openllm.api.ChatRequest
import com.tddworks.openllm.api.ChatResponse
import io.ktor.client.request.*
import io.ktor.http.*

/**
* * Anthropic Messages API -https://docs.anthropic.com/claude/reference/messages_post
*/
class DefaultMessagesApi(private val requester: HttpRequester) : ChatApi {
/**
* Create a message.
* @param request Send a structured list of input messages with text and/or image content, and the model will generate the next message in the conversation.
* @return The chat completion.
*/
override suspend fun chat(request: ChatRequest): ChatResponse {
return requester.performRequest<ChatResponse> {
method = HttpMethod.Post
url(path = CHAT_COMPLETIONS_PATH)
setBody(request)
contentType(ContentType.Application.Json)
}
}

companion object {
const val CHAT_COMPLETIONS_PATH = "/v1/messages"
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.tddworks.anthropic.api.messages.api.internal.json

import com.tddworks.anthropic.api.messages.api.CreateMessageRequest
import com.tddworks.anthropic.api.messages.api.CreateMessageResponse
import com.tddworks.openllm.api.ChatRequest
import com.tddworks.openllm.api.ChatResponse
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic

@OptIn(ExperimentalSerializationApi::class)
val chatModule = SerializersModule {
polymorphic(ChatRequest::class) {
subclass(CreateMessageRequest::class, CreateMessageRequest.serializer())
defaultDeserializer { CreateMessageRequest.serializer() }
}
polymorphic(ChatResponse::class) {
subclass(CreateMessageResponse::class, CreateMessageResponse.serializer())
defaultDeserializer { ChatMessageSerializer }
}

// polymorphicDefaultSerializer(ChatResponse::class) { instance ->
// @Suppress("UNCHECKED_CAST")
// when (instance) {
// is CreateMessageResponse -> CreateMessageResponse.serializer() as SerializationStrategy<ChatResponse>
// else -> null
// }
// }
}

object ChatMessageSerializer :
JsonContentPolymorphicSerializer<ChatResponse>(ChatResponse::class) {
override fun selectDeserializer(element: JsonElement): KSerializer<out ChatResponse> {
val content = element.jsonObject["content"]

return when {
content != null -> CreateMessageResponse.serializer()
else -> throw IllegalArgumentException("Unknown type of message")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.tddworks.anthropic.api

import com.tddworks.anthropic.api.messages.api.internal.json.chatModule
import kotlinx.serialization.json.Json

val prettyJson = Json { // this returns the JsonBuilder
prettyPrint = true
ignoreUnknownKeys = true
// optional: specify indent
prettyPrintIndent = " "
}

/**
* Represents a JSON object that allows for leniency and ignores unknown keys.
*
* @property isLenient Removes JSON specification restriction (RFC-4627) and makes parser more liberal to the malformed input. In lenient mode quoted boolean literals, and unquoted string literals are allowed.
* Its relaxations can be expanded in the future, so that lenient parser becomes even more permissive to invalid value in the input, replacing them with defaults.
* false by default.
* @property ignoreUnknownKeys Specifies whether encounters of unknown properties in the input JSON should be ignored instead of throwing SerializationException. false by default..
*/
internal val JsonLenient = Json {
isLenient = true
ignoreUnknownKeys = true
// https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md#class-discriminator-for-polymorphism
classDiscriminator = "#class"
serializersModule = chatModule
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.tddworks.anthropic.api


import io.ktor.client.*
import io.ktor.client.engine.mock.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.*

/**
* See https://ktor.io/docs/http-client-testing.html#usage
*/
fun mockHttpClient(mockResponse: String) = HttpClient(MockEngine) {

val headers = headersOf("Content-Type" to listOf(ContentType.Application.Json.toString()))

install(ContentNegotiation) {
register(ContentType.Application.Json, KotlinxSerializationConverter(JsonLenient))
}

engine {
addHandler { request ->
if (request.url.encodedPath == "/v1/messages") {
respond(mockResponse, HttpStatusCode.OK, headers)
} else {
error("Unhandled ${request.url.encodedPath}")
}
}
}

defaultRequest {
url {
protocol = URLProtocol.HTTPS
host = "api.lemonsqueezy.com"
}

header(HttpHeaders.ContentType, ContentType.Application.Json)
contentType(ContentType.Application.Json)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.tddworks.anthropic.api

import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test

class ModelTest {
@Test
fun `should return correct latest API model name`() {
assertEquals("claude-3-opus-20240229", Model.CLAUDE_3_OPUS.value)
assertEquals("claude-3-sonnet-20240229", Model.CLAUDE_3_Sonnet.value)
assertEquals("claude-3-haiku-20240307", Model.CLAUDE_3_HAIKU.value)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.tddworks.anthropic.api.messages.api

import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test

class MessageTest {

@Test
fun `should return assistant message name`() {
val assistantMessage = Message(role = Role.Assistant, content = "message")
assertEquals(Role.Assistant, assistantMessage.role)
assertEquals("message", assistantMessage.content)
}

@Test
fun `should return user message name`() {
val userMessage = Message.user("message")
assertEquals(Role.User, userMessage.role)
assertEquals("message", userMessage.content)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.tddworks.anthropic.api.messages.api

import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test


class RoleTest {

@Test
fun `should return correct role name`() {
assertEquals("user", Role.User.name)
assertEquals("assistant", Role.Assistant.name)
}
}
Loading

0 comments on commit b3d3277

Please sign in to comment.