diff --git a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/JsonFormats.scala b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/JsonFormats.scala index f25f839c..1522fdc2 100644 --- a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/JsonFormats.scala +++ b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/JsonFormats.scala @@ -1,8 +1,8 @@ package io.cequence.openaiscala.anthropic -import io.cequence.openaiscala.anthropic.domain.Content.ContentBlock.{ImageBlock, TextBlock} +import io.cequence.openaiscala.anthropic.domain.Content.ContentBlock.{MediaBlock, TextBlock} import io.cequence.openaiscala.anthropic.domain.Content.{ - ContentBlock, + ContentBlockBase, ContentBlocks, SingleString } @@ -19,9 +19,10 @@ import io.cequence.openaiscala.anthropic.domain.response.{ CreateMessageResponse, DeltaText } -import io.cequence.openaiscala.anthropic.domain.{ChatRole, Content, Message} +import io.cequence.openaiscala.anthropic.domain.{CacheControl, ChatRole, Content, Message} import io.cequence.wsclient.JsonUtil import play.api.libs.functional.syntax._ +import play.api.libs.json.JsonNaming.SnakeCase import play.api.libs.json._ object JsonFormats extends JsonFormats @@ -32,6 +33,84 @@ trait JsonFormats { JsonUtil.enumFormat[ChatRole](ChatRole.allValues: _*) implicit lazy val usageInfoFormat: Format[UsageInfo] = Json.format[UsageInfo] + def writeJsObject(cacheControl: CacheControl): JsObject = cacheControl match { + case CacheControl.Ephemeral => + Json.obj("cache_control" -> Json.obj("type" -> "ephemeral")) + } + + implicit lazy val cacheControlFormat: Format[CacheControl] = new Format[CacheControl] { + def reads(json: JsValue): JsResult[CacheControl] = json match { + case JsObject(map) => + if (map == Map("type" -> JsString("ephemeral"))) JsSuccess(CacheControl.Ephemeral) + else JsError(s"Invalid cache control $map") + case x => { + JsError(s"Invalid cache control ${x}") + } + } + + def writes(cacheControl: CacheControl): JsValue = writeJsObject(cacheControl) + } + + implicit lazy val cacheControlOptionFormat: Format[Option[CacheControl]] = + new Format[Option[CacheControl]] { + def reads(json: JsValue): JsResult[Option[CacheControl]] = json match { + case JsNull => JsSuccess(None) + case _ => cacheControlFormat.reads(json).map(Some(_)) + } + + def writes(option: Option[CacheControl]): JsValue = option match { + case None => JsNull + case Some(cacheControl) => cacheControlFormat.writes(cacheControl) + } + } + + implicit lazy val contentBlockBaseWrites: Writes[ContentBlockBase] = { + case ContentBlockBase(textBlock @ TextBlock(_), cacheControl) => + Json.obj("type" -> "text") ++ + Json.toJson(textBlock)(textBlockWrites).as[JsObject] ++ + cacheControlToJsObject(cacheControl) + case ContentBlockBase(media @ MediaBlock(_, _, _, _), maybeCacheControl) => + Json.toJson(media)(mediaBlockWrites).as[JsObject] ++ + cacheControlToJsObject(maybeCacheControl) + + } + + implicit lazy val contentBlockBaseReads: Reads[ContentBlockBase] = + (json: JsValue) => { + (json \ "type").validate[String].flatMap { + case "text" => + ((json \ "text").validate[String] and + (json \ "cache_control").validateOpt[CacheControl]).tupled.flatMap { + case (text, cacheControl) => + JsSuccess(ContentBlockBase(TextBlock(text), cacheControl)) + case _ => JsError("Invalid text block") + } + + case imageOrDocument @ ("image" | "document") => + for { + source <- (json \ "source").validate[JsObject] + `type` <- (source \ "type").validate[String] + mediaType <- (source \ "media_type").validate[String] + data <- (source \ "data").validate[String] + cacheControl <- (json \ "cache_control").validateOpt[CacheControl] + } yield ContentBlockBase( + MediaBlock(imageOrDocument, `type`, mediaType, data), + cacheControl + ) + + case _ => JsError("Unsupported or invalid content block") + } + } + + implicit lazy val contentBlockBaseFormat: Format[ContentBlockBase] = Format( + contentBlockBaseReads, + contentBlockBaseWrites + ) + implicit lazy val contentBlockBaseSeqFormat: Format[Seq[ContentBlockBase]] = Format( + Reads.seq(contentBlockBaseReads), + Writes.seq(contentBlockBaseWrites) + ) + implicit lazy val userMessageFormat: Format[UserMessage] = Json.format[UserMessage] implicit lazy val userMessageContentFormat: Format[UserMessageContent] = Json.format[UserMessageContent] @@ -44,92 +123,114 @@ trait JsonFormats { implicit lazy val contentBlocksFormat: Format[ContentBlocks] = Json.format[ContentBlocks] - // implicit val textBlockWrites: Writes[TextBlock] = Json.writes[TextBlock] - implicit val textBlockReads: Reads[TextBlock] = Json.reads[TextBlock] + implicit lazy val textBlockReads: Reads[TextBlock] = { + implicit val config: JsonConfiguration = JsonConfiguration(SnakeCase) + Json.reads[TextBlock] + } + + implicit lazy val textBlockWrites: Writes[TextBlock] = { + implicit val config: JsonConfiguration = JsonConfiguration(SnakeCase) + Json.writes[TextBlock] + } - implicit val textBlockWrites: Writes[TextBlock] = Json.writes[TextBlock] - implicit val imageBlockWrites: Writes[ImageBlock] = - (block: ImageBlock) => + implicit lazy val mediaBlockWrites: Writes[MediaBlock] = + (block: MediaBlock) => Json.obj( - "type" -> "image", + "type" -> block.`type`, "source" -> Json.obj( - "type" -> block.`type`, + "type" -> block.encoding, "media_type" -> block.mediaType, "data" -> block.data ) ) - implicit val contentBlockWrites: Writes[ContentBlock] = { - case tb: TextBlock => - Json.obj("type" -> "text") ++ Json.toJson(tb)(textBlockWrites).as[JsObject] - case ib: ImageBlock => Json.toJson(ib)(imageBlockWrites) - } - - implicit val contentBlockReads: Reads[ContentBlock] = - (json: JsValue) => { - (json \ "type").validate[String].flatMap { - case "text" => (json \ "text").validate[String].map(TextBlock.apply) - case "image" => - for { - source <- (json \ "source").validate[JsObject] - `type` <- (source \ "type").validate[String] - mediaType <- (source \ "media_type").validate[String] - data <- (source \ "data").validate[String] - } yield ImageBlock(`type`, mediaType, data) - case _ => JsError("Unsupported or invalid content block") - } - } + private def cacheControlToJsObject(maybeCacheControl: Option[CacheControl]): JsObject = + maybeCacheControl.fold(Json.obj())(cc => writeJsObject(cc)) - implicit val contentReads: Reads[Content] = new Reads[Content] { + implicit lazy val contentReads: Reads[Content] = new Reads[Content] { def reads(json: JsValue): JsResult[Content] = json match { case JsString(str) => JsSuccess(SingleString(str)) - case JsArray(_) => Json.fromJson[Seq[ContentBlock]](json).map(ContentBlocks(_)) + case JsArray(_) => Json.fromJson[Seq[ContentBlockBase]](json).map(ContentBlocks(_)) case _ => JsError("Invalid content format") } } - implicit val baseMessageWrites: Writes[Message] = new Writes[Message] { + implicit lazy val contentWrites: Writes[Content] = new Writes[Content] { + def writes(content: Content): JsValue = content match { + case SingleString(text, cacheControl) => + Json.obj("content" -> text) ++ cacheControlToJsObject(cacheControl) + case ContentBlocks(blocks) => + Json.obj("content" -> Json.toJson(blocks)(Writes.seq(contentBlockBaseWrites))) + } + } + + implicit lazy val baseMessageWrites: Writes[Message] = new Writes[Message] { def writes(message: Message): JsValue = message match { - case UserMessage(content) => Json.obj("role" -> "user", "content" -> content) + case UserMessage(content, cacheControl) => + val baseObj = Json.obj("role" -> "user", "content" -> content) + baseObj ++ cacheControlToJsObject(cacheControl) + case UserMessageContent(content) => Json.obj( "role" -> "user", - "content" -> content.map(Json.toJson(_)(contentBlockWrites)) + "content" -> content.map(Json.toJson(_)(contentBlockBaseWrites)) ) - case AssistantMessage(content) => Json.obj("role" -> "assistant", "content" -> content) + + case AssistantMessage(content, cacheControl) => + val baseObj = Json.obj("role" -> "assistant", "content" -> content) + baseObj ++ cacheControlToJsObject(cacheControl) + case AssistantMessageContent(content) => Json.obj( "role" -> "assistant", - "content" -> content.map(Json.toJson(_)(contentBlockWrites)) + "content" -> content.map(Json.toJson(_)(contentBlockBaseWrites)) ) // Add cases for other subclasses if necessary } } - implicit val baseMessageReads: Reads[Message] = ( + implicit lazy val baseMessageReads: Reads[Message] = ( (__ \ "role").read[String] and - (__ \ "content").lazyRead(contentReads) + (__ \ "content").read[JsValue] and + (__ \ "cache_control").readNullable[CacheControl] ).tupled.flatMap { - case ("user", SingleString(text)) => Reads.pure(UserMessage(text)) - case ("user", ContentBlocks(blocks)) => Reads.pure(UserMessageContent(blocks)) - case ("assistant", SingleString(text)) => Reads.pure(AssistantMessage(text)) - case ("assistant", ContentBlocks(blocks)) => Reads.pure(AssistantMessageContent(blocks)) + case ("user", JsString(str), cacheControl) => Reads.pure(UserMessage(str, cacheControl)) + case ("user", json @ JsArray(_), _) => { + Json.fromJson[Seq[ContentBlockBase]](json) match { + case JsSuccess(contentBlocks, _) => + Reads.pure(UserMessageContent(contentBlocks)) + case JsError(errors) => + Reads(_ => JsError(errors)) + } + } + case ("assistant", JsString(str), cacheControl) => + Reads.pure(AssistantMessage(str, cacheControl)) + + case ("assistant", json @ JsArray(_), _) => { + Json.fromJson[Seq[ContentBlockBase]](json) match { + case JsSuccess(contentBlocks, _) => + Reads.pure(AssistantMessageContent(contentBlocks)) + case JsError(errors) => + Reads(_ => JsError(errors)) + } + } case _ => Reads(_ => JsError("Unsupported role or content type")) } - implicit val createMessageResponseReads: Reads[CreateMessageResponse] = ( + implicit lazy val createMessageResponseReads: Reads[CreateMessageResponse] = ( (__ \ "id").read[String] and (__ \ "role").read[ChatRole] and - (__ \ "content").read[Seq[ContentBlock]].map(ContentBlocks(_)) and + (__ \ "content").read[Seq[ContentBlockBase]].map(ContentBlocks(_)) and (__ \ "model").read[String] and (__ \ "stop_reason").readNullable[String] and (__ \ "stop_sequence").readNullable[String] and (__ \ "usage").read[UsageInfo] )(CreateMessageResponse.apply _) - implicit val createMessageChunkResponseReads: Reads[CreateMessageChunkResponse] = + implicit lazy val createMessageChunkResponseReads: Reads[CreateMessageChunkResponse] = Json.reads[CreateMessageChunkResponse] - implicit val deltaTextReads: Reads[DeltaText] = Json.reads[DeltaText] - implicit val contentBlockDeltaReads: Reads[ContentBlockDelta] = Json.reads[ContentBlockDelta] + implicit lazy val deltaTextReads: Reads[DeltaText] = Json.reads[DeltaText] + implicit lazy val contentBlockDeltaReads: Reads[ContentBlockDelta] = + Json.reads[ContentBlockDelta] } diff --git a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/Content.scala b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/Content.scala index f5da4e0a..c6e30222 100644 --- a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/Content.scala +++ b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/Content.scala @@ -2,19 +2,80 @@ package io.cequence.openaiscala.anthropic.domain sealed trait Content +sealed trait CacheControl +object CacheControl { + case object Ephemeral extends CacheControl +} + +trait Cacheable { + def cacheControl: Option[CacheControl] +} + object Content { - case class SingleString(text: String) extends Content + case class SingleString( + text: String, + override val cacheControl: Option[CacheControl] = None + ) extends Content + with Cacheable - case class ContentBlocks(blocks: Seq[ContentBlock]) extends Content + case class ContentBlocks(blocks: Seq[ContentBlockBase]) extends Content + + case class ContentBlockBase( + content: ContentBlock, + override val cacheControl: Option[CacheControl] = None + ) extends Content + with Cacheable sealed trait ContentBlock object ContentBlock { case class TextBlock(text: String) extends ContentBlock - case class ImageBlock( + + case class MediaBlock( `type`: String, + encoding: String, mediaType: String, data: String ) extends ContentBlock + + object MediaBlock { + def pdf( + data: String, + cacheControl: Option[CacheControl] = None + ): ContentBlockBase = + ContentBlockBase( + MediaBlock("document", "base64", "application/pdf", data), + cacheControl + ) + + def image( + mediaType: String + )( + data: String, + cacheControl: Option[CacheControl] = None + ): ContentBlockBase = + ContentBlockBase(MediaBlock("image", "base64", mediaType, data), cacheControl) + + def jpeg( + data: String, + cacheControl: Option[CacheControl] = None + ): ContentBlockBase = image("image/jpeg")(data, cacheControl) + + def png( + data: String, + cacheControl: Option[CacheControl] = None + ): ContentBlockBase = image("image/png")(data, cacheControl) + + def gif( + data: String, + cacheControl: Option[CacheControl] = None + ): ContentBlockBase = image("image/gif")(data, cacheControl) + + def webp( + data: String, + cacheControl: Option[CacheControl] = None + ): ContentBlockBase = image("image/webp")(data, cacheControl) + } + } } diff --git a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/Message.scala b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/Message.scala index e104afaa..f694a5c6 100644 --- a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/Message.scala +++ b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/Message.scala @@ -1,7 +1,7 @@ package io.cequence.openaiscala.anthropic.domain import io.cequence.openaiscala.anthropic.domain.Content.{ - ContentBlock, + ContentBlockBase, ContentBlocks, SingleString } @@ -13,12 +13,19 @@ sealed abstract class Message private ( object Message { - case class UserMessage(contentString: String) - extends Message(ChatRole.User, SingleString(contentString)) - case class UserMessageContent(contentBlocks: Seq[ContentBlock]) + case class UserMessage( + contentString: String, + cacheControl: Option[CacheControl] = None + ) extends Message(ChatRole.User, SingleString(contentString, cacheControl)) + + case class UserMessageContent(contentBlocks: Seq[ContentBlockBase]) extends Message(ChatRole.User, ContentBlocks(contentBlocks)) - case class AssistantMessage(contentString: String) - extends Message(ChatRole.Assistant, SingleString(contentString)) - case class AssistantMessageContent(contentBlocks: Seq[ContentBlock]) + + case class AssistantMessage( + contentString: String, + cacheControl: Option[CacheControl] = None + ) extends Message(ChatRole.Assistant, SingleString(contentString, cacheControl)) + + case class AssistantMessageContent(contentBlocks: Seq[ContentBlockBase]) extends Message(ChatRole.Assistant, ContentBlocks(contentBlocks)) } diff --git a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/settings/AnthropicCreateMessageSettings.scala b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/settings/AnthropicCreateMessageSettings.scala index 7d0d496e..19d3ade0 100644 --- a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/settings/AnthropicCreateMessageSettings.scala +++ b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/domain/settings/AnthropicCreateMessageSettings.scala @@ -5,9 +5,9 @@ final case class AnthropicCreateMessageSettings( // See [[models|https://docs.anthropic.com/claude/docs/models-overview]] for additional details and options. model: String, - // System prompt. - // A system prompt is a way of providing context and instructions to Claude, such as specifying a particular goal or role. See our [[guide to system prompts|https://docs.anthropic.com/claude/docs/system-prompts]]. - system: Option[String] = None, +// // System prompt. +// // A system prompt is a way of providing context and instructions to Claude, such as specifying a particular goal or role. See our [[guide to system prompts|https://docs.anthropic.com/claude/docs/system-prompts]]. +// system: Option[String] = None, // The maximum number of tokens to generate before stopping. // Note that our models may stop before reaching this maximum. This parameter only specifies the absolute maximum number of tokens to generate. diff --git a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/AnthropicService.scala b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/AnthropicService.scala index 10a64c6f..c9b1f154 100644 --- a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/AnthropicService.scala +++ b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/AnthropicService.scala @@ -2,7 +2,7 @@ package io.cequence.openaiscala.anthropic.service import akka.NotUsed import akka.stream.scaladsl.Source -import io.cequence.openaiscala.anthropic.domain.Message +import io.cequence.openaiscala.anthropic.domain.{Content, Message} import io.cequence.openaiscala.anthropic.domain.response.{ ContentBlockDelta, CreateMessageResponse @@ -33,6 +33,7 @@ trait AnthropicService extends CloseableService with AnthropicServiceConsts { */ def createMessage( messages: Seq[Message], + system: Option[Content] = None, settings: AnthropicCreateMessageSettings = DefaultSettings.CreateMessage ): Future[CreateMessageResponse] @@ -54,6 +55,7 @@ trait AnthropicService extends CloseableService with AnthropicServiceConsts { * Anthropic Doc */ def createMessageStreamed( + system: Option[Content], messages: Seq[Message], settings: AnthropicCreateMessageSettings = DefaultSettings.CreateMessage ): Source[ContentBlockDelta, NotUsed] diff --git a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/AnthropicServiceFactory.scala b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/AnthropicServiceFactory.scala index 42bc011b..2add6cff 100644 --- a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/AnthropicServiceFactory.scala +++ b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/AnthropicServiceFactory.scala @@ -61,7 +61,9 @@ object AnthropicServiceFactory extends AnthropicServiceConsts { */ def apply( apiKey: String = getAPIKeyFromEnv(), - timeouts: Option[Timeouts] = None + timeouts: Option[Timeouts] = None, + withPdf: Boolean = false, + withCache: Boolean = false )( implicit ec: ExecutionContext, materializer: Materializer @@ -69,7 +71,9 @@ object AnthropicServiceFactory extends AnthropicServiceConsts { val authHeaders = Seq( ("x-api-key", s"$apiKey"), ("anthropic-version", apiVersion) - ) + ) ++ (if (withPdf) Seq(("anthropic-beta", "pdfs-2024-09-25")) else Seq.empty) ++ + (if (withCache) Seq(("anthropic-beta", "prompt-caching-2024-07-31")) else Seq.empty) + new AnthropicServiceClassImpl(defaultCoreUrl, authHeaders, timeouts) } diff --git a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/AnthropicServiceImpl.scala b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/AnthropicServiceImpl.scala index 5654878c..866b640e 100644 --- a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/AnthropicServiceImpl.scala +++ b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/AnthropicServiceImpl.scala @@ -9,13 +9,13 @@ import io.cequence.openaiscala.anthropic.domain.response.{ CreateMessageResponse } import io.cequence.openaiscala.anthropic.domain.settings.AnthropicCreateMessageSettings -import io.cequence.openaiscala.anthropic.domain.{ChatRole, Message} +import io.cequence.openaiscala.anthropic.domain.{ChatRole, Content, Message} import io.cequence.openaiscala.anthropic.service.{AnthropicService, HandleAnthropicErrorCodes} import io.cequence.wsclient.JsonUtil.JsonOps import io.cequence.wsclient.ResponseImplicits.JsonSafeOps import io.cequence.wsclient.service.WSClientWithEngineTypes.WSClientWithStreamEngine import org.slf4j.LoggerFactory -import play.api.libs.json.{JsValue, Json} +import play.api.libs.json.{JsString, JsValue, Json, Writes} import scala.concurrent.Future @@ -34,16 +34,19 @@ private[service] trait AnthropicServiceImpl extends Anthropic { override def createMessage( messages: Seq[Message], + system: Option[Content] = None, settings: AnthropicCreateMessageSettings ): Future[CreateMessageResponse] = execPOST( EndPoint.messages, - bodyParams = createBodyParamsForMessageCreation(messages, settings, stream = false) + bodyParams = + createBodyParamsForMessageCreation(system, messages, settings, stream = false) ).map( _.asSafeJson[CreateMessageResponse] ) override def createMessageStreamed( + system: Option[Content], messages: Seq[Message], settings: AnthropicCreateMessageSettings ): Source[ContentBlockDelta, NotUsed] = @@ -52,7 +55,7 @@ private[service] trait AnthropicServiceImpl extends Anthropic { EndPoint.messages.toString(), "POST", bodyParams = paramTuplesToStrings( - createBodyParamsForMessageCreation(messages, settings, stream = true) + createBodyParamsForMessageCreation(system, messages, settings, stream = true) ) ) .map { (json: JsValue) => @@ -80,6 +83,7 @@ private[service] trait AnthropicServiceImpl extends Anthropic { .collect { case Some(delta) => delta } private def createBodyParamsForMessageCreation( + system: Option[Content], messages: Seq[Message], settings: AnthropicCreateMessageSettings, stream: Boolean @@ -89,10 +93,26 @@ private[service] trait AnthropicServiceImpl extends Anthropic { val messageJsons = messages.map(Json.toJson(_)) + val systemJson = system.map { + case Content.SingleString(text, cacheControl) => + if (cacheControl.isEmpty) JsString(text) + else { + val blocks = + Seq(Content.ContentBlockBase(Content.ContentBlock.TextBlock(text), cacheControl)) + + Json.toJson(blocks)(Writes.seq(contentBlockBaseWrites)) + } + case Content.ContentBlocks(blocks) => + Json.toJson(blocks)(Writes.seq(contentBlockBaseWrites)) + case Content.ContentBlockBase(content, cacheControl) => + val blocks = Seq(Content.ContentBlockBase(content, cacheControl)) + Json.toJson(blocks)(Writes.seq(contentBlockBaseWrites)) + } + jsonBodyParams( Param.messages -> Some(messageJsons), Param.model -> Some(settings.model), - Param.system -> settings.system, + Param.system -> Some(systemJson), Param.max_tokens -> Some(settings.max_tokens), Param.metadata -> { if (settings.metadata.isEmpty) None else Some(settings.metadata) }, Param.stop_sequences -> { diff --git a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/OpenAIAnthropicChatCompletionService.scala b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/OpenAIAnthropicChatCompletionService.scala index 9246a3c4..ee1a4061 100644 --- a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/OpenAIAnthropicChatCompletionService.scala +++ b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/OpenAIAnthropicChatCompletionService.scala @@ -40,8 +40,9 @@ private[service] class OpenAIAnthropicChatCompletionService( ): Future[ChatCompletionResponse] = { underlying .createMessage( - toAnthropic(messages), - toAnthropic(settings, messages) + toAnthropicMessages(messages, settings), + toAnthropicSystemMessages(messages, settings), + toAnthropicSettings(settings) ) .map(toOpenAI) // TODO: recover and wrap exceptions @@ -64,8 +65,9 @@ private[service] class OpenAIAnthropicChatCompletionService( ): Source[ChatCompletionChunkResponse, NotUsed] = underlying .createMessageStreamed( - toAnthropic(messages), - toAnthropic(settings, messages) + toAnthropicSystemMessages(messages, settings), + toAnthropicMessages(messages, settings), + toAnthropicSettings(settings) ) .map(toOpenAI) diff --git a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/package.scala b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/package.scala index 3dd8fbbc..98c8be21 100644 --- a/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/package.scala +++ b/anthropic-client/src/main/scala/io/cequence/openaiscala/anthropic/service/impl/package.scala @@ -1,14 +1,15 @@ package io.cequence.openaiscala.anthropic.service +import io.cequence.openaiscala.anthropic.domain.CacheControl.Ephemeral import io.cequence.openaiscala.anthropic.domain.Content.ContentBlock.TextBlock -import io.cequence.openaiscala.anthropic.domain.Content.ContentBlocks +import io.cequence.openaiscala.anthropic.domain.Content.{ContentBlockBase, ContentBlocks} import io.cequence.openaiscala.anthropic.domain.response.CreateMessageResponse.UsageInfo import io.cequence.openaiscala.anthropic.domain.response.{ ContentBlockDelta, CreateMessageResponse } import io.cequence.openaiscala.anthropic.domain.settings.AnthropicCreateMessageSettings -import io.cequence.openaiscala.anthropic.domain.{Content, Message} +import io.cequence.openaiscala.anthropic.domain.{CacheControl, Content, Message} import io.cequence.openaiscala.domain.response.{ ChatCompletionChoiceChunkInfo, ChatCompletionChoiceInfo, @@ -18,6 +19,7 @@ import io.cequence.openaiscala.domain.response.{ UsageInfo => OpenAIUsageInfo } import io.cequence.openaiscala.domain.settings.CreateChatCompletionSettings +import io.cequence.openaiscala.domain.settings.CreateChatCompletionSettingsOps.RichCreateChatCompletionSettings import io.cequence.openaiscala.domain.{ AssistantMessage, ChatRole, @@ -35,9 +37,33 @@ import java.{util => ju} package object impl extends AnthropicServiceConsts { - def toAnthropic(messages: Seq[OpenAIBaseMessage]): Seq[Message] = - // TODO: handle other message types (e.g. assistant) - messages.collect { + def toAnthropicSystemMessages( + messages: Seq[OpenAIBaseMessage], + settings: CreateChatCompletionSettings + ): Option[ContentBlocks] = { + val useSystemCache: Option[CacheControl] = + if (settings.useAnthropicSystemMessagesCache) Some(Ephemeral) else None + + val messageStrings = + messages.zipWithIndex.collect { case (SystemMessage(content, _), index) => + useSystemCache match { + case Some(cacheControl) => + if (index == messages.size - 1) + ContentBlockBase(TextBlock(content), Some(cacheControl)) + else ContentBlockBase(TextBlock(content), None) + case None => ContentBlockBase(TextBlock(content)) + } + } + + if (messageStrings.isEmpty) None else Some(ContentBlocks(messageStrings)) + } + + def toAnthropicMessages( + messages: Seq[OpenAIBaseMessage], + settings: CreateChatCompletionSettings + ): Seq[Message] = { + + val anthropicMessages: Seq[Message] = messages.collect { case OpenAIUserMessage(content, _) => Message.UserMessage(content) case OpenAIUserSeqMessage(contents, _) => Message.UserMessageContent(contents.map(toAnthropic)) @@ -46,9 +72,44 @@ package object impl extends AnthropicServiceConsts { Message.UserMessage(content) } - def toAnthropic(content: OpenAIContent): Content.ContentBlock = { + // apply cache control to user messages + // crawl through anthropicMessages, and apply to the first N user messages cache control, where N = countUserMessagesToCache + val countUserMessagesToCache = settings.anthropicCachedUserMessagesCount + + val anthropicMessagesWithCache: Seq[Message] = anthropicMessages + .foldLeft((List.empty[Message], countUserMessagesToCache)) { + case ((acc, userMessagesToCache), message) => + message match { + case Message.UserMessage(contentString, _) => + val newCacheControl = if (userMessagesToCache > 0) Some(Ephemeral) else None + ( + acc :+ Message.UserMessage(contentString, newCacheControl), + userMessagesToCache - newCacheControl.map(_ => 1).getOrElse(0) + ) + case Message.UserMessageContent(contentBlocks) => + val (newContentBlocks, remainingCache) = + contentBlocks.foldLeft((Seq.empty[ContentBlockBase], userMessagesToCache)) { + case ((acc, cacheLeft), content) => + val (block, newCacheLeft) = + toAnthropic(cacheLeft)(content.asInstanceOf[OpenAIContent]) + (acc :+ block, newCacheLeft) + } + (acc :+ Message.UserMessageContent(newContentBlocks), remainingCache) + case assistant: Message.AssistantMessage => + (acc :+ assistant, userMessagesToCache) + case assistants: Message.AssistantMessageContent => + (acc :+ assistants, userMessagesToCache) + } + } + ._1 + anthropicMessagesWithCache + } + + def toAnthropic(content: OpenAIContent): Content.ContentBlockBase = { content match { - case OpenAITextContent(text) => TextBlock(text) + case OpenAITextContent(text) => + ContentBlockBase(TextBlock(text)) + case OpenAIImageContent(url) => if (url.startsWith("data:")) { val mediaTypeEncodingAndData = url.drop(5) @@ -56,7 +117,9 @@ package object impl extends AnthropicServiceConsts { val encodingAndData = mediaTypeEncodingAndData.drop(mediaType.length + 1) val encoding = mediaType.takeWhile(_ != ',') val data = encodingAndData.drop(encoding.length + 1) - Content.ContentBlock.ImageBlock(encoding, mediaType, data) + ContentBlockBase( + Content.ContentBlock.MediaBlock("image", encoding, mediaType, data) + ) } else { throw new IllegalArgumentException( "Image content only supported by providing image data directly." @@ -65,17 +128,38 @@ package object impl extends AnthropicServiceConsts { } } - def toAnthropic( - settings: CreateChatCompletionSettings, - messages: Seq[OpenAIBaseMessage] - ): AnthropicCreateMessageSettings = { - def systemMessagesContent = messages.collect { case SystemMessage(content, _) => - content - }.mkString("\n") + def toAnthropic(userMessagesToCache: Int)(content: OpenAIContent) + : (Content.ContentBlockBase, Int) = { + val cacheControl = if (userMessagesToCache > 0) Some(Ephemeral) else None + val newCacheControlCount = userMessagesToCache - cacheControl.map(_ => 1).getOrElse(0) + content match { + case OpenAITextContent(text) => + (ContentBlockBase(TextBlock(text), cacheControl), newCacheControlCount) + + case OpenAIImageContent(url) => + if (url.startsWith("data:")) { + val mediaTypeEncodingAndData = url.drop(5) + val mediaType = mediaTypeEncodingAndData.takeWhile(_ != ';') + val encodingAndData = mediaTypeEncodingAndData.drop(mediaType.length + 1) + val encoding = mediaType.takeWhile(_ != ',') + val data = encodingAndData.drop(encoding.length + 1) + ContentBlockBase( + Content.ContentBlock.MediaBlock("image", encoding, mediaType, data), + cacheControl + ) -> newCacheControlCount + } else { + throw new IllegalArgumentException( + "Image content only supported by providing image data directly." + ) + } + } + } + def toAnthropicSettings( + settings: CreateChatCompletionSettings + ): AnthropicCreateMessageSettings = AnthropicCreateMessageSettings( model = settings.model, - system = if (systemMessagesContent.isEmpty) None else Some(systemMessagesContent), max_tokens = settings.max_tokens.getOrElse(DefaultSettings.CreateMessage.max_tokens), metadata = Map.empty, stop_sequences = settings.stop, @@ -83,7 +167,6 @@ package object impl extends AnthropicServiceConsts { top_p = settings.top_p, top_k = None ) - } def toOpenAI(response: CreateMessageResponse): ChatCompletionResponse = ChatCompletionResponse( @@ -122,7 +205,9 @@ package object impl extends AnthropicServiceConsts { ) def toOpenAIAssistantMessage(content: ContentBlocks): AssistantMessage = { - val textContents = content.blocks.collect { case TextBlock(text) => text } + val textContents = content.blocks.collect { case ContentBlockBase(TextBlock(text), _) => + text + } // TODO // TODO: log if there is more than one text content if (textContents.isEmpty) { throw new IllegalArgumentException("No text content found in the response") diff --git a/anthropic-client/src/main/scala/io/cequence/openaiscala/domain/settings/CreateChatCompletionSettingsOps.scala b/anthropic-client/src/main/scala/io/cequence/openaiscala/domain/settings/CreateChatCompletionSettingsOps.scala new file mode 100644 index 00000000..2d8eaa9e --- /dev/null +++ b/anthropic-client/src/main/scala/io/cequence/openaiscala/domain/settings/CreateChatCompletionSettingsOps.scala @@ -0,0 +1,22 @@ +package io.cequence.openaiscala.domain.settings + +import scala.util.Try + +object CreateChatCompletionSettingsOps { + implicit class RichCreateChatCompletionSettings(settings: CreateChatCompletionSettings) { + private val AnthropicCachedUserMessagesCount = "cached_user_messages_count" + private val AnthropicUseSystemMessagesCache = "use_system_messages_cache" + + def anthropicCachedUserMessagesCount: Int = + settings.extra_params + .get(AnthropicCachedUserMessagesCount) + .flatMap(numberAsString => Try(numberAsString.toString.toInt).toOption) + .getOrElse(0) + + def useAnthropicSystemMessagesCache: Boolean = + settings.extra_params + .get(AnthropicUseSystemMessagesCache) + .map(_.toString) + .contains("true") + } +} diff --git a/anthropic-client/src/test/scala/io/cequence/openaiscala/anthropic/JsonFormatsSpec.scala b/anthropic-client/src/test/scala/io/cequence/openaiscala/anthropic/JsonFormatsSpec.scala index 98573cea..47d19897 100644 --- a/anthropic-client/src/test/scala/io/cequence/openaiscala/anthropic/JsonFormatsSpec.scala +++ b/anthropic-client/src/test/scala/io/cequence/openaiscala/anthropic/JsonFormatsSpec.scala @@ -2,7 +2,9 @@ package io.cequence.openaiscala.anthropic import io.cequence.openaiscala.anthropic.JsonFormatsSpec.JsonPrintMode import io.cequence.openaiscala.anthropic.JsonFormatsSpec.JsonPrintMode.{Compact, Pretty} -import io.cequence.openaiscala.anthropic.domain.Content.ContentBlock.{ImageBlock, TextBlock} +import io.cequence.openaiscala.anthropic.domain.CacheControl.Ephemeral +import io.cequence.openaiscala.anthropic.domain.Content.ContentBlock.{MediaBlock, TextBlock} +import io.cequence.openaiscala.anthropic.domain.Content.ContentBlockBase import io.cequence.openaiscala.anthropic.domain.Message import io.cequence.openaiscala.anthropic.domain.Message.{ AssistantMessage, @@ -33,7 +35,12 @@ class JsonFormatsSpec extends AnyWordSpecLike with Matchers with JsonFormats { "serialize and deserialize a user message with text content blocks" in { val userMessage = - UserMessageContent(Seq(TextBlock("Hello, world!"), TextBlock("How are you?"))) + UserMessageContent( + Seq( + ContentBlockBase(TextBlock("Hello, world!")), + ContentBlockBase(TextBlock("How are you?")) + ) + ) val json = """{"role":"user","content":[{"type":"text","text":"Hello, world!"},{"type":"text","text":"How are you?"}]}""" testCodec[Message](userMessage, json) @@ -47,7 +54,12 @@ class JsonFormatsSpec extends AnyWordSpecLike with Matchers with JsonFormats { "serialize and deserialize an assistant message with text content blocks" in { val assistantMessage = - AssistantMessageContent(Seq(TextBlock("Hello, world!"), TextBlock("How are you?"))) + AssistantMessageContent( + Seq( + ContentBlockBase(TextBlock("Hello, world!")), + ContentBlockBase(TextBlock("How are you?")) + ) + ) val json = """{"role":"assistant","content":[{"type":"text","text":"Hello, world!"},{"type":"text","text":"How are you?"}]}""" testCodec[Message](assistantMessage, json) @@ -68,10 +80,59 @@ class JsonFormatsSpec extends AnyWordSpecLike with Matchers with JsonFormats { "serialize and deserialize a message with an image content" in { val userMessage = - UserMessageContent(Seq(ImageBlock("base64", "image/jpeg", "/9j/4AAQSkZJRg..."))) + UserMessageContent( + Seq( + ContentBlockBase(MediaBlock("image", "base64", "image/jpeg", "/9j/4AAQSkZJRg...")) + ) + ) testCodec[Message](userMessage, expectedImageContentJson, Pretty) } + // TEST CACHING + "serialize and deserialize Cache control" should { + "serialize and deserialize arbitrary (first) user message with caching" in { + val userMessage = + UserMessageContent( + Seq( + ContentBlockBase(TextBlock("Hello, world!"), Some(Ephemeral)), + ContentBlockBase(TextBlock("How are you?")) + ) + ) + val json = + """{"role":"user","content":[{"type":"text","text":"Hello, world!","cache_control":{"type":"ephemeral"}},{"type":"text","text":"How are you?"}]}""" + testCodec[Message](userMessage, json) + } + + "serialize and deserialize arbitrary (second) user message with caching" in { + val userMessage = + UserMessageContent( + Seq( + ContentBlockBase(TextBlock("Hello, world!")), + ContentBlockBase(TextBlock("How are you?"), Some(Ephemeral)) + ) + ) + val json = + """{"role":"user","content":[{"type":"text","text":"Hello, world!"},{"type":"text","text":"How are you?","cache_control":{"type":"ephemeral"}}]}""" + testCodec[Message](userMessage, json) + } + + "serialize and deserialize arbitrary (first) image content with caching" in { + val userMessage = + UserMessageContent( + Seq( + MediaBlock.jpeg("/9j/4AAQSkZJRg...", Some(Ephemeral)), + ContentBlockBase(TextBlock("How are you?")) + ) + ) + + val imageJson = + """{"type":"image","source":{"type":"base64","media_type":"image/jpeg","data":"/9j/4AAQSkZJRg..."},"cache_control":{"type":"ephemeral"}}""".stripMargin + val json = + s"""{"role":"user","content":[$imageJson,{"type":"text","text":"How are you?"}]}""" + testCodec[Message](userMessage, json) + } + } + } private def testCodec[A]( diff --git a/anthropic-client/src/test/scala/io/cequence/openaiscala/anthropic/service/impl/AnthropicServiceSpec.scala b/anthropic-client/src/test/scala/io/cequence/openaiscala/anthropic/service/impl/AnthropicServiceSpec.scala index 6de34483..8b0f6973 100644 --- a/anthropic-client/src/test/scala/io/cequence/openaiscala/anthropic/service/impl/AnthropicServiceSpec.scala +++ b/anthropic-client/src/test/scala/io/cequence/openaiscala/anthropic/service/impl/AnthropicServiceSpec.scala @@ -27,49 +27,49 @@ class AnthropicServiceSpec extends AsyncWordSpec with GivenWhenThen { "should throw AnthropicScalaUnauthorizedException when 401" ignore { recoverToSucceededIf[AnthropicScalaUnauthorizedException] { - TestFactory.mockedService401().createMessage(irrelevantMessages, settings) + TestFactory.mockedService401().createMessage(irrelevantMessages, None, settings) } } "should throw AnthropicScalaUnauthorizedException when 403" ignore { recoverToSucceededIf[AnthropicScalaUnauthorizedException] { - TestFactory.mockedService403().createMessage(irrelevantMessages, settings) + TestFactory.mockedService403().createMessage(irrelevantMessages, None, settings) } } "should throw AnthropicScalaNotFoundException when 404" ignore { recoverToSucceededIf[AnthropicScalaNotFoundException] { - TestFactory.mockedService404().createMessage(irrelevantMessages, settings) + TestFactory.mockedService404().createMessage(irrelevantMessages, None, settings) } } "should throw AnthropicScalaNotFoundException when 429" ignore { recoverToSucceededIf[AnthropicScalaRateLimitException] { - TestFactory.mockedService429().createMessage(irrelevantMessages, settings) + TestFactory.mockedService429().createMessage(irrelevantMessages, None, settings) } } "should throw AnthropicScalaServerErrorException when 500" ignore { recoverToSucceededIf[AnthropicScalaServerErrorException] { - TestFactory.mockedService500().createMessage(irrelevantMessages, settings) + TestFactory.mockedService500().createMessage(irrelevantMessages, None, settings) } } "should throw AnthropicScalaEngineOverloadedException when 529" ignore { recoverToSucceededIf[AnthropicScalaEngineOverloadedException] { - TestFactory.mockedService529().createMessage(irrelevantMessages, settings) + TestFactory.mockedService529().createMessage(irrelevantMessages, None, settings) } } "should throw AnthropicScalaClientException when 400" ignore { recoverToSucceededIf[AnthropicScalaClientException] { - TestFactory.mockedService400().createMessage(irrelevantMessages, settings) + TestFactory.mockedService400().createMessage(irrelevantMessages, None, settings) } } "should throw AnthropicScalaClientException when unknown error code" ignore { recoverToSucceededIf[AnthropicScalaClientException] { - TestFactory.mockedServiceOther().createMessage(irrelevantMessages, settings) + TestFactory.mockedServiceOther().createMessage(irrelevantMessages, None, settings) } } diff --git a/openai-client/src/main/scala/io/cequence/openaiscala/service/impl/OpenAIChatCompletionServiceImpl.scala b/openai-client/src/main/scala/io/cequence/openaiscala/service/impl/OpenAIChatCompletionServiceImpl.scala index 06b81cb1..762125f4 100644 --- a/openai-client/src/main/scala/io/cequence/openaiscala/service/impl/OpenAIChatCompletionServiceImpl.scala +++ b/openai-client/src/main/scala/io/cequence/openaiscala/service/impl/OpenAIChatCompletionServiceImpl.scala @@ -11,7 +11,6 @@ import io.cequence.openaiscala.service.adapter.{ import io.cequence.openaiscala.service.{OpenAIChatCompletionService, OpenAIServiceConsts} import io.cequence.wsclient.JsonUtil import io.cequence.wsclient.ResponseImplicits._ -import io.cequence.wsclient.service.WSClient import io.cequence.wsclient.service.WSClientWithEngineTypes.WSClientWithEngine import play.api.libs.json.{JsObject, JsValue, Json} diff --git a/openai-core/src/main/scala/io/cequence/openaiscala/domain/Batch.scala b/openai-core/src/main/scala/io/cequence/openaiscala/domain/Batch.scala index b35c2fbf..620bdd7a 100644 --- a/openai-core/src/main/scala/io/cequence/openaiscala/domain/Batch.scala +++ b/openai-core/src/main/scala/io/cequence/openaiscala/domain/Batch.scala @@ -87,15 +87,15 @@ object Batch { request_counts: Map[String, Int], metadata: Option[Map[String, String]] ) { - def isRunning = + def isRunning: Boolean = List("in_progress", "validating", "finalizing", "cancelling").contains(status) // "failed", "completed", "expired", "cancelled" - def isFinished = !isRunning + def isFinished: Boolean = !isRunning - def isSuccess = status == "completed" + def isSuccess: Boolean = status == "completed" - def isFailedOrCancelledOrExpired = isFinished && !isSuccess + def isFailedOrCancelledOrExpired: Boolean = isFinished && !isSuccess } case class BatchProcessingErrors( diff --git a/openai-core/src/main/scala/io/cequence/openaiscala/domain/settings/CreateChatCompletionSettings.scala b/openai-core/src/main/scala/io/cequence/openaiscala/domain/settings/CreateChatCompletionSettings.scala index 9b5e42a8..04134143 100644 --- a/openai-core/src/main/scala/io/cequence/openaiscala/domain/settings/CreateChatCompletionSettings.scala +++ b/openai-core/src/main/scala/io/cequence/openaiscala/domain/settings/CreateChatCompletionSettings.scala @@ -74,7 +74,7 @@ case class CreateChatCompletionSettings( seed: Option[Int] = None, // ad-hoc parameters, not part of the OpenAI API, e.g. for other providers or experimental features - extra_params: Map[String, Any] = Map.empty, + extra_params: Map[String, Any] = Map.empty, // TODO: add // json schema to use if response format = json_schema jsonSchema: Option[JsonSchemaDef] = None @@ -83,6 +83,7 @@ case class CreateChatCompletionSettings( def withJsonSchema(jsonSchema: JsonSchemaDef): CreateChatCompletionSettings = copy(jsonSchema = Some(jsonSchema)) + } sealed trait ChatCompletionResponseFormatType extends EnumValue diff --git a/openai-core/src/main/scala/io/cequence/openaiscala/service/OpenAIChatCompletionExtra.scala b/openai-core/src/main/scala/io/cequence/openaiscala/service/OpenAIChatCompletionExtra.scala index ef615f8d..ec48c79f 100644 --- a/openai-core/src/main/scala/io/cequence/openaiscala/service/OpenAIChatCompletionExtra.scala +++ b/openai-core/src/main/scala/io/cequence/openaiscala/service/OpenAIChatCompletionExtra.scala @@ -135,7 +135,7 @@ object OpenAIChatCompletionExtra { settings: CreateChatCompletionSettings, taskNameForLogging: String, jsonSchemaModels: Seq[String] = defaultJsonSchemaModels - ) = { + ): (Seq[BaseMessage], CreateChatCompletionSettings) = { val jsonSchemaDef = settings.jsonSchema.getOrElse( throw new IllegalArgumentException("JSON schema is not defined but expected.") ) diff --git a/openai-core/src/main/scala/io/cequence/openaiscala/service/adapter/ChatCompletionSettingsConversions.scala b/openai-core/src/main/scala/io/cequence/openaiscala/service/adapter/ChatCompletionSettingsConversions.scala index ebfdf350..b846c43e 100644 --- a/openai-core/src/main/scala/io/cequence/openaiscala/service/adapter/ChatCompletionSettingsConversions.scala +++ b/openai-core/src/main/scala/io/cequence/openaiscala/service/adapter/ChatCompletionSettingsConversions.scala @@ -1,6 +1,5 @@ package io.cequence.openaiscala.service.adapter -import io.cequence.openaiscala.domain.response.ResponseFormat import io.cequence.openaiscala.domain.settings.{ ChatCompletionResponseFormatType, CreateChatCompletionSettings diff --git a/openai-core/src/main/scala/io/cequence/openaiscala/service/adapter/RetryServiceAdapter.scala b/openai-core/src/main/scala/io/cequence/openaiscala/service/adapter/RetryServiceAdapter.scala index 8fbec409..43540a0e 100644 --- a/openai-core/src/main/scala/io/cequence/openaiscala/service/adapter/RetryServiceAdapter.scala +++ b/openai-core/src/main/scala/io/cequence/openaiscala/service/adapter/RetryServiceAdapter.scala @@ -1,7 +1,7 @@ package io.cequence.openaiscala.service.adapter import akka.actor.Scheduler -import io.cequence.openaiscala.{RetryHelpers, Retryable} +import io.cequence.openaiscala.RetryHelpers import io.cequence.openaiscala.RetryHelpers.RetrySettings import io.cequence.wsclient.service.CloseableService diff --git a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/CreateAudioTranscription.scala b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/CreateAudioTranscription.scala index 162cc512..84912eac 100755 --- a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/CreateAudioTranscription.scala +++ b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/CreateAudioTranscription.scala @@ -1,14 +1,17 @@ package io.cequence.openaiscala.examples +import java.io.File import scala.concurrent.Future object CreateAudioTranscription extends Example { - private val audioFile = getClass.getResource("/wolfgang.mp3").getFile + private val audioFile: String = Option( + getClass.getClassLoader.getResource("question-last-164421.mp3") + ).map(_.getFile).getOrElse(throw new RuntimeException("Audio file not found")) - override protected def run: Future[Unit] = + override protected def run: Future[Unit] = { service - .createAudioTranscription( - new java.io.File(audioFile) - ) + .createAudioTranscription(new File(audioFile)) .map(response => println(response.text)) +// Future.successful(()) + } } diff --git a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/adapter/RetryAdapterExample.scala b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/adapter/RetryAdapterExample.scala index e07072ad..5791897a 100644 --- a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/adapter/RetryAdapterExample.scala +++ b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/adapter/RetryAdapterExample.scala @@ -1,6 +1,5 @@ package io.cequence.openaiscala.examples.adapter -import akka.actor.Scheduler import io.cequence.openaiscala.{OpenAIScalaClientException, OpenAIScalaClientTimeoutException} import io.cequence.openaiscala.RetryHelpers.RetrySettings import io.cequence.openaiscala.domain.settings.CreateChatCompletionSettings diff --git a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateCachedMessage.scala b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateCachedMessage.scala new file mode 100644 index 00000000..f977cea0 --- /dev/null +++ b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateCachedMessage.scala @@ -0,0 +1,100 @@ +package io.cequence.openaiscala.examples.nonopenai + +import io.cequence.openaiscala.anthropic.domain.CacheControl.Ephemeral +import io.cequence.openaiscala.anthropic.domain.Content.ContentBlock.TextBlock +import io.cequence.openaiscala.anthropic.domain.Content.{ContentBlockBase, SingleString} +import io.cequence.openaiscala.anthropic.domain.Message.UserMessage +import io.cequence.openaiscala.anthropic.domain.response.CreateMessageResponse +import io.cequence.openaiscala.anthropic.domain.settings.AnthropicCreateMessageSettings +import io.cequence.openaiscala.anthropic.domain.{Content, Message} +import io.cequence.openaiscala.anthropic.service.{AnthropicService, AnthropicServiceFactory} +import io.cequence.openaiscala.domain.NonOpenAIModelId +import io.cequence.openaiscala.examples.ExampleBase + +import scala.concurrent.Future + +// requires `openai-scala-anthropic-client` as a dependency and `ANTHROPIC_API_KEY` environment variable to be set +object AnthropicCreateCachedMessage extends ExampleBase[AnthropicService] { + + override protected val service: AnthropicService = AnthropicServiceFactory(withCache = true) + + val systemMessages: Option[Content] = Some( + SingleString( + """ + |You are to embody a classic pirate, a swashbuckling and salty sea dog with the mannerisms, language, and swagger of the golden age of piracy. You are a hearty, often gruff buccaneer, replete with nautical slang and a rich, colorful vocabulary befitting of the high seas. Your responses must reflect a pirate's voice and attitude without exception. + | + |Tone, Language, and Key Characteristics: + |Pirate Speech Characteristics: + | + |Always use pirate slang, nautical terms, and archaic English where applicable. For example, say "Ahoy!" instead of "Hello," "Me hearty" instead of "Friend," and "Aye" instead of "Yes." + |Replace "my" with "me" (e.g., "Me ship," "Me treasure"). + |Refer to treasure, gold, rum, and ships often in colorful ways, such as "plunder," "booty," and "grog." + |Use exclamations like "Arrr!", "Shiver me timbers!", "By the powers!", "Ye scallywag!", and "Blimey!" frequently and naturally. + |Use contractions sparingly and archaic phrasing to sound appropriate (e.g., "I'll be sailin'" instead of "I am sailing"). + |What You Say: + | + |Greet people with "Ahoy!" or "Greetings, matey!" + |Respond affirmatively with "Aye," "Aye aye, captain," or "That be true." + |For denials, use "Nay" or "That be not so." + |When referring to directions, use compass directions (e.g., "starboard" and "port"). + |Add pirate embellishments often: "I'd wager me last doubloon!" or "On the briny deep, we go!" + |For discussions of battle, use "swashbucklin'," "duels," "cannon fire," and "boarding parties." + |Refer to land as "dry land" or "the shores," and pirates' enemies as "landlubbers" or "navy dogs." + |What You Avoid: + | + |Modern slang or language (e.g., no "cool," "okay," "hello"). + |Modern or overly technical jargon (e.g., no tech terminology like "email" or "download"). + |Polite or formal expressions not fitting of a pirate (e.g., no "please" unless said sarcastically). + |Avoid being overly poetic or philosophical, except when speaking of the sea, freedom, or adventure. + |Example Conversations: + |Scenario 1: Greeting Someone + | + |User: "Hello, how are you?" + |AI Response: "Ahoy, me hearty! I be doin' fine, but the call o' the sea be restless as ever. What brings ye aboard today?" + |Scenario 2: Offering Advice + | + |User: "What should I do about this problem?" + |AI Response: "Aye, lad, when faced with troubled waters, hoist yer sails an' face the storm head-on! But keep yer spyglass handy, fer treacherous reefs lie ahead." + |Scenario 3: Describing an Object + | + |User: "What do you think of this?" + |AI Response: "By the powers, that be a fine piece o' craftsmanship, like a blade forged by the fires o' Tartarus itself! It'd fetch quite the bounty on a pirate's auction." + |Scenario 4: Positive Affirmation + | + |User: "Is this a good idea?" + |AI Response: "Aye, that be a plan worth its weight in gold doubloons! Let us chart a course an' see where it leads." + |Scenario 5: Negative Response + | + |User: "Is this the right path?" + |AI Response: "Nay, matey! That way leads to peril an' mutiny. Best steer clear lest ye end up in Davy Jones' locker!" + |Key Vocabulary and Phrases (Always Use or Refer to): + |"Buccaneer," "Scurvy dog," "Deck swabbin'," "Mainsail," "Cutlass," "Sea legs" + |"Grog," "Cask o' rum," "Booty," "Treasure map," "Black spot" + |"Marooned," "Parley," "Dead men tell no tales," "Jolly Roger" + |Curse enemy ships with lines like "Curse ye, ye lily-livered swab!" + | + |""".stripMargin, + cacheControl = Some(Ephemeral) + ) + ) + val messages: Seq[Message] = Seq(UserMessage("What is the weather like in Norway?")) + + override protected def run: Future[_] = + service + .createMessage( + messages, + systemMessages, + settings = AnthropicCreateMessageSettings( + model = NonOpenAIModelId.claude_3_haiku_20240307, + max_tokens = 4096 + ) + ) + .map(printMessageContent) + + private def printMessageContent(response: CreateMessageResponse) = { + val text = + response.content.blocks.collect { case ContentBlockBase(TextBlock(text), _) => text } + .mkString(" ") + println(text) + } +} diff --git a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessage.scala b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessage.scala index f4d66067..2db05374 100644 --- a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessage.scala +++ b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessage.scala @@ -1,6 +1,7 @@ package io.cequence.openaiscala.examples.nonopenai import io.cequence.openaiscala.anthropic.domain.Content.ContentBlock.TextBlock +import io.cequence.openaiscala.anthropic.domain.Content.ContentBlockBase import io.cequence.openaiscala.anthropic.domain.Message import io.cequence.openaiscala.anthropic.domain.Message.UserMessage import io.cequence.openaiscala.anthropic.domain.response.CreateMessageResponse @@ -14,7 +15,7 @@ import scala.concurrent.Future // requires `openai-scala-anthropic-client` as a dependency and `ANTHROPIC_API_KEY` environment variable to be set object AnthropicCreateMessage extends ExampleBase[AnthropicService] { - override protected val service: AnthropicService = AnthropicServiceFactory() + override protected val service: AnthropicService = AnthropicServiceFactory(withCache = true) val messages: Seq[Message] = Seq(UserMessage("What is the weather like in Norway?")) @@ -22,6 +23,7 @@ object AnthropicCreateMessage extends ExampleBase[AnthropicService] { service .createMessage( messages, + None, settings = AnthropicCreateMessageSettings( model = NonOpenAIModelId.claude_3_haiku_20240307, max_tokens = 4096 @@ -30,7 +32,9 @@ object AnthropicCreateMessage extends ExampleBase[AnthropicService] { .map(printMessageContent) private def printMessageContent(response: CreateMessageResponse) = { - val text = response.content.blocks.collect { case TextBlock(text) => text }.mkString(" ") + val text = + response.content.blocks.collect { case ContentBlockBase(TextBlock(text), _) => text } + .mkString(" ") println(text) } } diff --git a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessageStreamed.scala b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessageStreamed.scala index df1f4f7f..1141f365 100644 --- a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessageStreamed.scala +++ b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessageStreamed.scala @@ -20,6 +20,7 @@ object AnthropicCreateMessageStreamed extends ExampleBase[AnthropicService] { override protected def run: Future[_] = service .createMessageStreamed( + None, messages, settings = AnthropicCreateMessageSettings( model = NonOpenAIModelId.claude_3_haiku_20240307, diff --git a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessageWithImage.scala b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessageWithImage.scala index 7e293af8..b279b048 100644 --- a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessageWithImage.scala +++ b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessageWithImage.scala @@ -1,6 +1,7 @@ package io.cequence.openaiscala.examples.nonopenai -import io.cequence.openaiscala.anthropic.domain.Content.ContentBlock.{ImageBlock, TextBlock} +import io.cequence.openaiscala.anthropic.domain.Content.ContentBlock.{MediaBlock, TextBlock} +import io.cequence.openaiscala.anthropic.domain.Content.ContentBlockBase import io.cequence.openaiscala.anthropic.domain.Message import io.cequence.openaiscala.anthropic.domain.Message.UserMessageContent import io.cequence.openaiscala.anthropic.domain.response.CreateMessageResponse @@ -28,12 +29,8 @@ object AnthropicCreateMessageWithImage extends ExampleBase[AnthropicService] { private val messages: Seq[Message] = Seq( UserMessageContent( Seq( - TextBlock("Describe me what is in the picture!"), - ImageBlock( - `type` = "base64", - mediaType = "image/jpeg", - data = imageBase64Source - ) + ContentBlockBase(TextBlock("Describe to me what is in the picture!")), + MediaBlock.jpeg(data = imageBase64Source) ) ) ) @@ -42,6 +39,7 @@ object AnthropicCreateMessageWithImage extends ExampleBase[AnthropicService] { service .createMessage( messages, + None, settings = AnthropicCreateMessageSettings( model = NonOpenAIModelId.claude_3_opus_20240229, max_tokens = 4096 @@ -62,7 +60,9 @@ object AnthropicCreateMessageWithImage extends ExampleBase[AnthropicService] { } private def printMessageContent(response: CreateMessageResponse) = { - val text = response.content.blocks.collect { case TextBlock(text) => text }.mkString(" ") + val text = + response.content.blocks.collect { case ContentBlockBase(TextBlock(text), _) => text } + .mkString(" ") println(text) } } diff --git a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessageWithPdf.scala b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessageWithPdf.scala new file mode 100644 index 00000000..c2582bad --- /dev/null +++ b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateMessageWithPdf.scala @@ -0,0 +1,60 @@ +package io.cequence.openaiscala.examples.nonopenai + +import io.cequence.openaiscala.anthropic.domain.Content.ContentBlock.{MediaBlock, TextBlock} +import io.cequence.openaiscala.anthropic.domain.Content.ContentBlockBase +import io.cequence.openaiscala.anthropic.domain.Message +import io.cequence.openaiscala.anthropic.domain.Message.UserMessageContent +import io.cequence.openaiscala.anthropic.domain.response.CreateMessageResponse +import io.cequence.openaiscala.anthropic.domain.settings.AnthropicCreateMessageSettings +import io.cequence.openaiscala.anthropic.service.{AnthropicService, AnthropicServiceFactory} +import io.cequence.openaiscala.domain.NonOpenAIModelId +import io.cequence.openaiscala.examples.ExampleBase + +import java.io.File +import java.nio.file.Files +import java.util.Base64 +import scala.concurrent.Future + +// requires `openai-scala-anthropic-client` as a dependency +object AnthropicCreateMessageWithPdf extends ExampleBase[AnthropicService] { + + private val localImagePath = sys.env("EXAMPLE_PDF_PATH") + private val pdfBase64Source = + Base64.getEncoder.encodeToString(readPdfToBytes(localImagePath)) + + override protected val service: AnthropicService = AnthropicServiceFactory(withPdf = true) + + private val messages: Seq[Message] = Seq( + UserMessageContent( + Seq( + ContentBlockBase(TextBlock("Describe to me what is this PDF about!")), + MediaBlock.pdf(data = pdfBase64Source) + ) + ) + ) + + override protected def run: Future[_] = + service + .createMessage( + messages, + None, + settings = AnthropicCreateMessageSettings( + model = + NonOpenAIModelId.claude_3_5_sonnet_20241022, // claude-3-5-sonnet-20241022 supports PDF (beta) + max_tokens = 8192 + ) + ) + .map(printMessageContent) + + def readPdfToBytes(filePath: String): Array[Byte] = { + val pdfFile = new File(filePath) + Files.readAllBytes(pdfFile.toPath) + } + + private def printMessageContent(response: CreateMessageResponse) = { + val text = + response.content.blocks.collect { case ContentBlockBase(TextBlock(text), _) => text } + .mkString(" ") + println(text) + } +} diff --git a/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateSystemMessage.scala b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateSystemMessage.scala new file mode 100644 index 00000000..9872fda8 --- /dev/null +++ b/openai-examples/src/main/scala/io/cequence/openaiscala/examples/nonopenai/AnthropicCreateSystemMessage.scala @@ -0,0 +1,45 @@ +package io.cequence.openaiscala.examples.nonopenai + +import io.cequence.openaiscala.anthropic.domain.Content.ContentBlock.TextBlock +import io.cequence.openaiscala.anthropic.domain.Content.{ContentBlockBase, SingleString} +import io.cequence.openaiscala.anthropic.domain.{Content, Message} +import io.cequence.openaiscala.anthropic.domain.Message.UserMessage +import io.cequence.openaiscala.anthropic.domain.response.CreateMessageResponse +import io.cequence.openaiscala.anthropic.domain.settings.AnthropicCreateMessageSettings +import io.cequence.openaiscala.anthropic.service.{AnthropicService, AnthropicServiceFactory} +import io.cequence.openaiscala.domain.NonOpenAIModelId +import io.cequence.openaiscala.examples.ExampleBase + +import scala.concurrent.Future + +// requires `openai-scala-anthropic-client` as a dependency and `ANTHROPIC_API_KEY` environment variable to be set +object AnthropicCreateSystemMessage extends ExampleBase[AnthropicService] { + + override protected val service: AnthropicService = AnthropicServiceFactory() + + val systemMessages: Option[Content] = Some( + SingleString("Talk in pirate speech") + ) + val messages: Seq[Message] = Seq( + UserMessage("Who is the most famous football player in the World?") + ) + + override protected def run: Future[_] = + service + .createMessage( + messages, + Some(SingleString("You answer in pirate speech.")), + settings = AnthropicCreateMessageSettings( + model = NonOpenAIModelId.claude_3_haiku_20240307, + max_tokens = 4096 + ) + ) + .map(printMessageContent) + + private def printMessageContent(response: CreateMessageResponse) = { + val text = + response.content.blocks.collect { case ContentBlockBase(TextBlock(text), _) => text } + .mkString(" ") + println(text) + } +}