Scala Tool Calling API Design
Scala Tool Calling API Design
This document outlines the design for a type-safe Scala API for defining, validating, and executing tool calls for LLMs. It provides a clean, functional approach to tool function definitions with JSON Schema validation.
Core Components
1. Schema Definitions
The design uses a hierarchy of schema classes to define parameter types and constraints:
sealed trait SchemaDefinition[T] {
def toJsonSchema: ujson.Value
}
String Schema
case class StringSchema(
description: String,
enumValues: Option[Seq[String]] = None,
pattern: Option[String] = None,
minLength: Option[Int] = None,
maxLength: Option[Int] = None
) extends SchemaDefinition[String]
Number Schema
case class NumberSchema(
description: String,
isInteger: Boolean = false,
minimum: Option[Double] = None,
maximum: Option[Double] = None,
exclusiveMinimum: Option[Double] = None,
exclusiveMaximum: Option[Double] = None,
multipleOf: Option[Double] = None
) extends SchemaDefinition[Double]
Integer Schema
case class IntegerSchema(
description: String,
minimum: Option[Int] = None,
maximum: Option[Int] = None,
exclusiveMinimum: Option[Int] = None,
exclusiveMaximum: Option[Int] = None,
multipleOf: Option[Int] = None
) extends SchemaDefinition[Int]
Boolean Schema
case class BooleanSchema(
description: String
) extends SchemaDefinition[Boolean]
Array Schema
case class ArraySchema[A](
description: String,
itemSchema: SchemaDefinition[A],
minItems: Option[Int] = None,
maxItems: Option[Int] = None,
uniqueItems: Boolean = false
) extends SchemaDefinition[Seq[A]]
Object Schema
case class PropertyDefinition[T](
name: String,
schema: SchemaDefinition[T],
required: Boolean = true
)
case class ObjectSchema[T](
description: String,
properties: Seq[PropertyDefinition[_]],
additionalProperties: Boolean = false
) extends SchemaDefinition[T]
Nullable Schema
case class NullableSchema[T](
underlying: SchemaDefinition[T]
) extends SchemaDefinition[Option[T]]
2. Safe Parameter Extraction
To ensure type-safe parameter extraction:
case class SafeParameterExtractor(params: ujson.Value) {
def getString(path: String): Either[String, String]
def getInt(path: String): Either[String, Int]
def getDouble(path: String): Either[String, Double]
def getBoolean(path: String): Either[String, Boolean]
def getArray(path: String): Either[String, ujson.Arr]
def getObject(path: String): Either[String, ujson.Obj]
}
3. Tool Function Definition
case class ToolFunction[T, R: ReadWriter](
name: String,
description: String,
schema: SchemaDefinition[T],
handler: SafeParameterExtractor => Either[String, R]
) {
def toOpenAITool(strict: Boolean = true): ujson.Value
def execute(args: ujson.Value): Either[ToolCallError, R]
}
4. Tool Builder (Fluent API)
class ToolBuilder[T, R: ReadWriter] private (
name: String,
description: String,
schema: SchemaDefinition[T],
handler: Option[SafeParameterExtractor => Either[String, R]] = None
) {
def withHandler(handler: SafeParameterExtractor => Either[String, R]): ToolBuilder[T, R]
def build(): ToolFunction[T, R]
}
5. Tool Registry
class ToolRegistry(tools: Seq[ToolFunction[_, _]]) {
def getTool(name: String): Option[ToolFunction[_, _]]
def execute(request: ToolCallRequest): Either[ToolCallError, ujson.Value]
def getOpenAITools(strict: Boolean = true): ujson.Arr
def getToolDefinitions(provider: String): ujson.Value
}
6. Error Handling
sealed trait ToolCallError
object ToolCallError {
case class UnknownFunction(name: String) extends ToolCallError
case class InvalidArguments(errors: List[String]) extends ToolCallError
case class ExecutionError(cause: Throwable) extends ToolCallError
}
Schema Builder API
A fluent API for building schemas:
object Schema {
// String schemas
def string(description: String): StringSchema
// Number schemas
def number(description: String): NumberSchema
def integer(description: String): IntegerSchema
// Boolean schemas
def boolean(description: String): BooleanSchema
// Array schemas
def array[A](description: String, itemSchema: SchemaDefinition[A]): ArraySchema[A]
// Object schemas
def `object`[T](description: String): ObjectSchema[T]
// Nullable schemas
def nullable[T](schema: SchemaDefinition[T]): NullableSchema[T]
// Properties
def property[T](name: String, schema: SchemaDefinition[T], required: Boolean = true): PropertyDefinition[T]
}
Complete Implementation
import upickle.default._
import ujson._
/**
* Safe parameter extraction
*/
case class SafeParameterExtractor(params: ujson.Value) {
def getString(path: String): Either[String, String] =
extract(path, _.strOpt, "string")
def getInt(path: String): Either[String, Int] =
extract(path, _.numOpt.map(_.toInt), "integer")
def getDouble(path: String): Either[String, Double] =
extract(path, _.numOpt, "number")
def getBoolean(path: String): Either[String, Boolean] =
extract(path, _.boolOpt, "boolean")
def getArray(path: String): Either[String, ujson.Arr] =
extract(path, v => Option(v).collect { case arr: ujson.Arr => arr }, "array")
def getObject(path: String): Either[String, ujson.Obj] =
extract(path, v => Option(v).collect { case obj: ujson.Obj => obj }, "object")
// Generic extractor with type validation
private def extract[T](path: String, extractor: ujson.Value => Option[T], expectedType: String): Either[String, T] = {
try {
val pathParts = path.split('.')
var current = params
// Navigate through nested path
for (part <- pathParts.dropRight(1)) {
current.obj.get(part) match {
case Some(value) => current = value
case None => return Left(s"Path '$path' not found: missing '$part' segment")
}
}
// Extract the final value
val finalPart = pathParts.last
current.obj.get(finalPart) match {
case Some(value) =>
extractor(value) match {
case Some(result) => Right(result)
case None => Left(s"Value at '$path' is not of expected type '$expectedType'")
}
case None => Left(s"Parameter '$finalPart' not found")
}
} catch {
case e: Exception => Left(s"Error extracting parameter at '$path': ${e.getMessage}")
}
}
}
/**
* Core model for tool function definitions
*/
case class ToolFunction[T, R: ReadWriter](
name: String,
description: String,
schema: SchemaDefinition[T],
handler: SafeParameterExtractor => Either[String, R]
) {
/**
* Converts the tool definition to the format expected by OpenAI's API
*/
def toOpenAITool(strict: Boolean = true): ujson.Value = {
ujson.Obj(
"type" -> ujson.Str("function"),
"function" -> ujson.Obj(
"name" -> ujson.Str(name),
"description" -> ujson.Str(description),
"parameters" -> schema.toJsonSchema,
"strict" -> ujson.Bool(strict)
)
)
}
/**
* Executes the tool with the given arguments
*/
def execute(args: ujson.Value): Either[ToolCallError, R] = {
val extractor = SafeParameterExtractor(args)
handler(extractor) match {
case Right(result) => Right(result)
case Left(error) => Left(ToolCallError.InvalidArguments(List(error)))
}
}
}
/**
* Schema definitions
*/
sealed trait SchemaDefinition[T] {
def toJsonSchema: ujson.Value
}
/**
* Schema building blocks
*/
case class StringSchema(
description: String,
enumValues: Option[Seq[String]] = None,
pattern: Option[String] = None,
minLength: Option[Int] = None,
maxLength: Option[Int] = None
) extends SchemaDefinition[String] {
def toJsonSchema: ujson.Value = {
val base = ujson.Obj(
"type" -> ujson.Str("string"),
"description" -> ujson.Str(description)
)
enumValues.foreach(values => base("enum") = ujson.Arr.from(values.map(ujson.Str(_))))
pattern.foreach(p => base("pattern") = ujson.Str(p))
minLength.foreach(min => base("minLength") = ujson.Num(min))
maxLength.foreach(max => base("maxLength") = ujson.Num(max))
base
}
def withEnum(values: Seq[String]): StringSchema = copy(enumValues = Some(values))
def withPattern(regex: String): StringSchema = copy(pattern = Some(regex))
def withLengthConstraints(min: Option[Int] = None, max: Option[Int] = None): StringSchema =
copy(minLength = min, maxLength = max)
}
case class NumberSchema(
description: String,
isInteger: Boolean = false,
minimum: Option[Double] = None,
maximum: Option[Double] = None,
exclusiveMinimum: Option[Double] = None,
exclusiveMaximum: Option[Double] = None,
multipleOf: Option[Double] = None
) extends SchemaDefinition[Double] {
def toJsonSchema: ujson.Value = {
val base = ujson.Obj(
"type" -> ujson.Str(if (isInteger) "integer" else "number"),
"description" -> ujson.Str(description)
)
minimum.foreach(min => base("minimum") = ujson.Num(min))
maximum.foreach(max => base("maximum") = ujson.Num(max))
exclusiveMinimum.foreach(min => base("exclusiveMinimum") = ujson.Num(min))
exclusiveMaximum.foreach(max => base("exclusiveMaximum") = ujson.Num(max))
multipleOf.foreach(multiple => base("multipleOf") = ujson.Num(multiple))
base
}
def withRange(min: Option[Double] = None, max: Option[Double] = None): NumberSchema =
copy(minimum = min, maximum = max)
def withExclusiveRange(min: Option[Double] = None, max: Option[Double] = None): NumberSchema =
copy(exclusiveMinimum = min, exclusiveMaximum = max)
def withMultipleOf(multiple: Double): NumberSchema =
copy(multipleOf = Some(multiple))
}
case class IntegerSchema(
description: String,
minimum: Option[Int] = None,
maximum: Option[Int] = None,
exclusiveMinimum: Option[Int] = None,
exclusiveMaximum: Option[Int] = None,
multipleOf: Option[Int] = None
) extends SchemaDefinition[Int] {
def toJsonSchema: ujson.Value = {
val base = ujson.Obj(
"type" -> ujson.Str("integer"),
"description" -> ujson.Str(description)
)
minimum.foreach(min => base("minimum") = ujson.Num(min))
maximum.foreach(max => base("maximum") = ujson.Num(max))
exclusiveMinimum.foreach(min => base("exclusiveMinimum") = ujson.Num(min))
exclusiveMaximum.foreach(max => base("exclusiveMaximum") = ujson.Num(max))
multipleOf.foreach(multiple => base("multipleOf") = ujson.Num(multiple))
base
}
def withRange(min: Option[Int] = None, max: Option[Int] = None): IntegerSchema =
copy(minimum = min, maximum = max)
def withExclusiveRange(min: Option[Int] = None, max: Option[Int] = None): IntegerSchema =
copy(exclusiveMinimum = min, exclusiveMaximum = max)
def withMultipleOf(multiple: Int): IntegerSchema =
copy(multipleOf = Some(multiple))
}
case class BooleanSchema(
description: String
) extends SchemaDefinition[Boolean] {
def toJsonSchema: ujson.Value = {
ujson.Obj(
"type" -> ujson.Str("boolean"),
"description" -> ujson.Str(description)
)
}
}
case class ArraySchema[A](
description: String,
itemSchema: SchemaDefinition[A],
minItems: Option[Int] = None,
maxItems: Option[Int] = None,
uniqueItems: Boolean = false
) extends SchemaDefinition[Seq[A]] {
def toJsonSchema: ujson.Value = {
val base = ujson.Obj(
"type" -> ujson.Str("array"),
"description" -> ujson.Str(description),
"items" -> itemSchema.toJsonSchema
)
minItems.foreach(min => base("minItems") = ujson.Num(min))
maxItems.foreach(max => base("maxItems") = ujson.Num(max))
if (uniqueItems) base("uniqueItems") = ujson.Bool(true)
base
}
def withSizeConstraints(min: Option[Int] = None, max: Option[Int] = None): ArraySchema[A] =
copy(minItems = min, maxItems = max)
def withUniqueItems(unique: Boolean = true): ArraySchema[A] =
copy(uniqueItems = unique)
}
case class PropertyDefinition[T](
name: String,
schema: SchemaDefinition[T],
required: Boolean = true
)
case class ObjectSchema[T](
description: String,
properties: Seq[PropertyDefinition[_]],
additionalProperties: Boolean = false
) extends SchemaDefinition[T] {
def toJsonSchema: ujson.Value = {
val props = ujson.Obj()
val required = properties.filter(_.required).map(_.name)
properties.foreach { prop =>
props(prop.name) = prop.schema.toJsonSchema
}
ujson.Obj(
"type" -> ujson.Str("object"),
"description" -> ujson.Str(description),
"properties" -> props,
"required" -> ujson.Arr.from(required.map(ujson.Str(_))),
"additionalProperties" -> ujson.Bool(additionalProperties)
)
}
def withProperty[P](property: PropertyDefinition[P]): ObjectSchema[T] = {
copy(properties = properties :+ property)
}
}
case class NullableSchema[T](
underlying: SchemaDefinition[T]
) extends SchemaDefinition[Option[T]] {
def toJsonSchema: ujson.Value = {
val schema = underlying.toJsonSchema.obj
val typeField = schema.get("type")
typeField match {
case Some(ujson.Str(typeValue)) =>
// Replace type field with array of types
schema("type") = ujson.Arr(ujson.Str(typeValue), ujson.Str("null"))
case Some(arr: ujson.Arr) =>
// Add null to existing type array
schema("type") = arr.value :+ ujson.Str("null")
case _ =>
// Create new type array if none exists
schema("type") = ujson.Arr(ujson.Str("null"))
}
ujson.Obj.from(schema)
}
}
/**
* Schema builder - fluent API
*/
object Schema {
// String schemas
def string(description: String): StringSchema = StringSchema(description)
// Number schemas
def number(description: String): NumberSchema = NumberSchema(description)
def integer(description: String): IntegerSchema = IntegerSchema(description)
// Boolean schemas
def boolean(description: String): BooleanSchema = BooleanSchema(description)
// Array schemas
def array[A](description: String, itemSchema: SchemaDefinition[A]): ArraySchema[A] =
ArraySchema(description, itemSchema)
// Object schemas
def `object`[T](description: String): ObjectSchema[T] =
ObjectSchema[T](description, Seq.empty)
// Nullable schemas
def nullable[T](schema: SchemaDefinition[T]): NullableSchema[T] =
NullableSchema(schema)
// Properties
def property[T](name: String, schema: SchemaDefinition[T], required: Boolean = true): PropertyDefinition[T] =
PropertyDefinition(name, schema, required)
}
/**
* Builder for tool definitions
*/
class ToolBuilder[T, R: ReadWriter] private (
name: String,
description: String,
schema: SchemaDefinition[T],
handler: Option[SafeParameterExtractor => Either[String, R]] = None
) {
def withHandler(handler: SafeParameterExtractor => Either[String, R]): ToolBuilder[T, R] =
new ToolBuilder(name, description, schema, Some(handler))
def build(): ToolFunction[T, R] = handler match {
case Some(h) => ToolFunction(name, description, schema, h)
case None => throw new IllegalStateException("Handler not defined")
}
}
object ToolBuilder {
def apply[T, R: ReadWriter](name: String, description: String, schema: SchemaDefinition[T]): ToolBuilder[T, R] =
new ToolBuilder(name, description, schema)
}
/**
* Request/Response handling
*/
case class ToolCallRequest(
functionName: String,
arguments: ujson.Value
)
sealed trait ToolCallError
object ToolCallError {
case class UnknownFunction(name: String) extends ToolCallError
case class InvalidArguments(errors: List[String]) extends ToolCallError
case class ExecutionError(cause: Throwable) extends ToolCallError
}
/**
* Tool registry and executor
*/
class ToolRegistry(tools: Seq[ToolFunction[_, _]]) {
// Get a specific tool by name
def getTool(name: String): Option[ToolFunction[_, _]] = tools.find(_.name == name)
// Execute a tool call
def execute(request: ToolCallRequest): Either[ToolCallError, ujson.Value] = {
tools.find(_.name == request.functionName) match {
case Some(tool) =>
try {
tool.execute(request.arguments) match {
case Right(result) => Right(writeJs(result))
case Left(error) => Left(error)
}
} catch {
case e: Exception => Left(ToolCallError.ExecutionError(e))
}
case None => Left(ToolCallError.UnknownFunction(request.functionName))
}
}
// Generate OpenAI tool definitions for all tools
def getOpenAITools(strict: Boolean = true): ujson.Arr = {
ujson.Arr.from(tools.map(_.toOpenAITool(strict)))
}
// Generate a specific format of tool definitions for a particular LLM provider
def getToolDefinitions(provider: String): ujson.Value = provider.toLowerCase match {
case "openai" => getOpenAITools()
case "anthropic" => getOpenAITools() // Currently using the same format
case "gemini" => getOpenAITools() // May need adjustment for Google's format
case _ => throw new IllegalArgumentException(s"Unsupported LLM provider: $provider")
}
}
Usage Example
Here’s an example of how to use the library to define and execute tools:
// Define result type
case class WeatherResult(
location: String,
temperature: Double,
units: String,
conditions: String
)
// Provide implicit reader/writer
implicit val weatherResultRW: ReadWriter[WeatherResult] = macroRW
// Define weather parameter schema
val weatherParamsSchema = Schema.`object`[Map[String, Any]]("Weather request parameters")
.withProperty(Schema.property(
"location",
Schema.string("City and country e.g. Bogotá, Colombia")
.withPattern("^[A-Za-z\\s,]+$") // Pattern for city, country format
))
.withProperty(Schema.property(
"units",
Schema.string("Units the temperature will be returned in.")
.withEnum(Seq("celsius", "fahrenheit"))
))
// Define type-safe handler function
def weatherHandler(params: SafeParameterExtractor): Either[String, WeatherResult] = {
for {
location <- params.getString("location")
units <- params.getString("units")
} yield {
// In a real implementation, this would call an actual weather service
WeatherResult(
location = location,
temperature = 22.5,
units = units,
conditions = "sunny"
)
}
}
// Build the weather tool
val weatherTool = ToolBuilder[Map[String, Any], WeatherResult](
"get_weather",
"Retrieves current weather for the given location.",
weatherParamsSchema
).withHandler(weatherHandler).build()
// Define a complex nested schema
val addressSchema = Schema.`object`[Map[String, Any]]("Address information")
.withProperty(Schema.property("street", Schema.string("Street address")))
.withProperty(Schema.property("city", Schema.string("City name")))
.withProperty(Schema.property(
"zipcode",
Schema.string("ZIP/Postal code").withPattern("^\\d{5}(-\\d{4})?$")
))
.withProperty(Schema.property("country", Schema.string("Country name")))
// Create a tool registry with the weather tool
val toolRegistry = new ToolRegistry(Seq(weatherTool))
// Example execution
val weatherRequest = ToolCallRequest(
functionName = "get_weather",
arguments = ujson.Obj("location" -> "London, UK", "units" -> "celsius")
)
// Execute the tool call
val result = toolRegistry.execute(weatherRequest)
// Generate tool definitions for OpenAI
val openaiTools = toolRegistry.getToolDefinitions("openai")
// Use in an API request
val openaiRequest = ujson.Obj(
"model" -> "gpt-4-turbo",
"messages" -> ujson.Arr(
ujson.Obj(
"role" -> "user",
"content" -> "What's the weather in Paris?"
)
),
"tools" -> openaiTools
)
Key Advantages
- Type Safety: Both schema definitions and return types are fully type-safe
- Safe Parameter Extraction: Path-based parameter extraction with type checking
- Pattern Validation: Comprehensive support for validation constraints
- Composable Design: Schemas can be built from reusable components
- Error Handling: Clear, detailed error messages for invalid parameters
- LLM Provider Support: Easy generation of tool definitions for different LLM APIs
- Functional Style: Uses Scala’s functional features like Either for error handling
Dependencies
upickle
library for JSON serialization/deserializationujson
for working with JSON data structures
Implementation Notes
The code uses the following Scala patterns:
- Case classes for immutable data models
- Builder pattern for fluent schema construction
- Composition over inheritance for schema building
- Functional error handling with Either
- Path-based parameter extraction for nested objects