Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,10 @@ val outputFormat = OutputFormat.JsonSchema(schema)

### Claude Tool Calling

#### Custom Tools

Define your own tools that Claude calls and your application executes:

```scala
import sttp.ai.claude.models.{Tool, ToolInputSchema, PropertySchema}

Expand All @@ -411,7 +415,7 @@ val weatherTool = Tool(
`type` = "object",
properties = Map(
"location" -> PropertySchema(`type` = "string", description = Some("City name")),
"unit" -> PropertySchema(`type` = "string", enum = Some(List("celsius", "fahrenheit")))
"unit" -> PropertySchema(`type` = "string", `enum` = Some(List("celsius", "fahrenheit")))
),
required = Some(List("location"))
)
Expand All @@ -425,6 +429,42 @@ val request = MessageRequest.withTools(
)
```

#### Predefined Tools

Currently supported:

- **`Tool.WebSearch`** (`web_search_20250305`)

```scala
import sttp.ai.claude.models.{ContentBlock, Message, Tool}
import sttp.ai.claude.requests.MessageRequest

val request = MessageRequest.withTools(
model = "claude-sonnet-4-5-20250514",
messages = List(Message.user(List(ContentBlock.text("What was the most recent SpaceX launch?")))),
maxTokens = 1024,
tools = List(Tool.WebSearch.default)
)

val response = client.createMessage(request)

response.content.foreach {
case t: ContentBlock.TextContent => println(t.text)
case s: ContentBlock.ServerToolUseContent =>
println(s"Searched for: ${s.input.get("query").map(_.str).getOrElse("")}")
case r: ContentBlock.WebSearchToolResultContent =>
r.content match {
case ContentBlock.WebSearchToolResult.Results(items) =>
items.foreach(it => println(s"- ${it.title} — ${it.url}"))
case ContentBlock.WebSearchToolResult.Error(code) =>
println(s"Web search failed: $code")
}
case _ => ()
}
```

Both custom and predefined tools can be passed in the same `tools` list.

### Claude Streaming

#### Using fs2 (cats-effect)
Expand Down
68 changes: 67 additions & 1 deletion claude/src/main/scala/sttp/ai/claude/models/ContentBlock.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package sttp.ai.claude.models

import sttp.ai.core.json.SnakePickle
import sttp.ai.core.json.SnakePickle.{macroRW, ReadWriter}
import ujson.Value
import upickle.implicits.key
Expand Down Expand Up @@ -52,6 +53,67 @@ object ContentBlock {
val `type`: String = "document"
}

@key("server_tool_use")
case class ServerToolUseContent(
id: String,
name: String,
input: Map[String, Value]
) extends ContentBlock {
val `type`: String = "server_tool_use"
}

@key("web_search_tool_result")
case class WebSearchToolResultContent(
toolUseId: String,
content: WebSearchToolResult,
caller: Option[Value] = None
) extends ContentBlock {
val `type`: String = "web_search_tool_result"
}

case class WebSearchResult(
url: String,
title: String,
pageAge: Option[String] = None,
encryptedContent: Option[String] = None
) {
val `type`: String = "web_search_result"
}

object WebSearchResult {
implicit val rw: ReadWriter[WebSearchResult] = macroRW
}

sealed trait WebSearchToolResult

object WebSearchToolResult {
case class Results(items: List[WebSearchResult]) extends WebSearchToolResult

case class Error(errorCode: String) extends WebSearchToolResult

private val ErrorTypeValue = "web_search_tool_result_error"

implicit val rw: ReadWriter[WebSearchToolResult] = SnakePickle
.readwriter[Value]
.bimap[WebSearchToolResult](
{
case Results(items) => SnakePickle.writeJs(items)
case Error(code) =>
ujson.Obj(
"type" -> ujson.Str(ErrorTypeValue),
"error_code" -> ujson.Str(code)
)
},
{
case arr: ujson.Arr => Results(SnakePickle.read[List[WebSearchResult]](arr))
case obj: ujson.Obj if obj.value.get("type").contains(ujson.Str(ErrorTypeValue)) =>
Error(obj("error_code").str)
case other =>
throw new IllegalArgumentException(s"Unrecognised web_search_tool_result content: $other")
}
)
}

sealed trait DocumentSource {
def `type`: String
}
Expand Down Expand Up @@ -120,13 +182,17 @@ object ContentBlock {
implicit val toolUseContentRW: ReadWriter[ToolUseContent] = macroRW
implicit val toolResultContentRW: ReadWriter[ToolResultContent] = macroRW
implicit val documentContentRW: ReadWriter[DocumentContent] = macroRW
implicit val serverToolUseContentRW: ReadWriter[ServerToolUseContent] = macroRW
implicit val webSearchToolResultContentRW: ReadWriter[WebSearchToolResultContent] = macroRW

implicit val rw: ReadWriter[ContentBlock] = ReadWriter.merge(
textContentRW,
thinkingContentRW,
imageContentRW,
toolUseContentRW,
toolResultContentRW,
documentContentRW
documentContentRW,
serverToolUseContentRW,
webSearchToolResultContentRW
)
}
97 changes: 91 additions & 6 deletions claude/src/main/scala/sttp/ai/claude/models/Tool.scala
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
package sttp.ai.claude.models

import sttp.ai.core.json.SnakePickle
import sttp.ai.core.json.SnakePickle.{macroRW, ReadWriter}
import ujson.Value

case class Tool(
name: String,
description: String,
inputSchema: ToolInputSchema
)
sealed trait Tool

case class ToolInputSchema(
`type`: String,
Expand Down Expand Up @@ -58,6 +56,93 @@ object ToolInputSchema {
implicit val rw: ReadWriter[ToolInputSchema] = macroRW
}

@upickle.implicits.serializeDefaults(true)
case class UserLocation(
`type`: String = UserLocation.ApproximateType,
city: Option[String] = None,
region: Option[String] = None,
country: Option[String] = None,
timezone: Option[String] = None
)

object UserLocation {
val ApproximateType = "approximate"

def approximate(
city: Option[String] = None,
region: Option[String] = None,
country: Option[String] = None,
timezone: Option[String] = None
): UserLocation = UserLocation(ApproximateType, city, region, country, timezone)

implicit val rw: ReadWriter[UserLocation] = macroRW
}

object Tool {
implicit val rw: ReadWriter[Tool] = macroRW
case class Custom(
name: String,
description: String,
inputSchema: ToolInputSchema
) extends Tool

@upickle.implicits.key("web_search_20250305")
case class WebSearch(
maxUses: Option[Int] = None,
allowedDomains: Option[List[String]] = None,
blockedDomains: Option[List[String]] = None,
userLocation: Option[UserLocation] = None
) extends Tool

object WebSearch {
final val ToolType = "web_search_20250305"
final val ToolName = "web_search"

val default: WebSearch = WebSearch()
}

def apply(name: String, description: String, inputSchema: ToolInputSchema): Custom =
Custom(name, description, inputSchema)

// manual rw so custom JSON has no `type` field, matching Anthropic documented format
private val customRW: ReadWriter[Custom] = SnakePickle
.readwriter[Value]
.bimap[Custom](
c =>
ujson.Obj(
"name" -> ujson.Str(c.name),
"description" -> ujson.Str(c.description),
"input_schema" -> SnakePickle.writeJs(c.inputSchema)
),
json =>
Custom(
name = json("name").str,
description = json("description").str,
inputSchema = SnakePickle.read[ToolInputSchema](json("input_schema"))
)
)

private val webSearchRW: ReadWriter[WebSearch] = macroRW

private def withName(json: Value, toolName: String): Value = {
val obj = scala.collection.mutable.LinkedHashMap[String, Value]()
json.obj.foreach { case (k, v) =>
obj.update(k, v)
if (k == SnakePickle.tagName) obj.update("name", ujson.Str(toolName))
}
ujson.Obj.from(obj)
}

implicit val toolRW: ReadWriter[Tool] = SnakePickle
.readwriter[Value]
.bimap[Tool](
{
case c: Custom => SnakePickle.writeJs(c)(customRW)
case ws: WebSearch => withName(SnakePickle.writeJs(ws)(webSearchRW), WebSearch.ToolName)
},
json =>
json.obj.get(SnakePickle.tagName).map(_.str) match {
case Some(WebSearch.ToolType) => SnakePickle.read[WebSearch](json)(webSearchRW)
case _ => SnakePickle.read[Custom](json)(customRW)
}
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,35 @@ class ClaudeIntegrationSpec extends AnyFlatSpec with Matchers with BeforeAndAfte
()
}

it should "handle web search predefined tool successfully" in
withClient { client =>
// given
val request = MessageRequest.withTools(
model = testModel,
messages = List(Message.user("What was the most recent SpaceX launch? Use web search to find out.")),
maxTokens = 1024,
tools = List(Tool.WebSearch(maxUses = Some(1)))
)

// when
val response = client.createMessage(request)

// then
response should not be null
response.role shouldBe "assistant"
response.content should not be empty

val serverToolUse = response.content.collectFirst { case s: ContentBlock.ServerToolUseContent => s }
serverToolUse should be(defined)
serverToolUse.get.name shouldBe "web_search"

val toolResult = response.content.collectFirst { case r: ContentBlock.WebSearchToolResultContent => r }
toolResult should be(defined)
toolResult.get.toolUseId shouldBe serverToolUse.get.id
toolResult.get.content shouldBe a[ContentBlock.WebSearchToolResult.Results]
()
}

"Claude Error Handling" should "throw AuthenticationException for invalid API key" in {
// given
val invalidConfig = ClaudeConfig(
Expand Down
Loading