LLM4S API Specification
LLM4S API Specification
Overview
LLM4S provides a type-safe, functional, and idiomatic Scala interface for interacting with Large Language Models (LLMs). This specification defines a platform-agnostic API that abstracts away provider-specific details while maintaining the full capabilities of modern LLMs, including tool calling.
Core Design Principles
- Immutability: All data structures are immutable
- Type safety: Leverage Scala’s type system for compile-time safety
- Functional approach: Use monadic error handling with Either/Option
- Platform agnosticism: Abstract away provider-specific details
- Idiomatic Scala: Use case classes, pattern matching, and functional approaches
- Compatibility: Maintain compatibility with existing tool calling API where possible
Core API Components
1. Message Types
sealed trait Message {
def role: String
def content: String
}
case class UserMessage(content: String) extends Message {
val role = "user"
}
case class SystemMessage(content: String) extends Message {
val role = "system"
}
case class AssistantMessage(
content: String,
toolCalls: Seq[ToolCall] = Seq.empty
) extends Message {
val role = "assistant"
}
case class ToolMessage(
toolCallId: String,
content: String
) extends Message {
val role = "tool"
}
2. Conversation Model
case class Conversation(messages: Seq[Message]) {
// Add a message and return a new Conversation
def addMessage(message: Message): Conversation =
Conversation(messages :+ message)
// Add multiple messages and return a new Conversation
def addMessages(newMessages: Seq[Message]): Conversation =
Conversation(messages ++ newMessages)
}
3. Tool Calls
case class ToolCall(
id: String,
name: String,
arguments: ujson.Value
)
4. Completion Results
case class Completion(
id: String,
created: Long,
message: AssistantMessage,
usage: Option[TokenUsage] = None
)
case class TokenUsage(
promptTokens: Int,
completionTokens: Int,
totalTokens: Int
)
case class StreamedChunk(
id: String,
content: Option[String],
toolCall: Option[ToolCall] = None,
finishReason: Option[String] = None
)
5. Completion Options
case class CompletionOptions(
temperature: Double = 0.7,
topP: Double = 1.0,
maxTokens: Option[Int] = None,
presencePenalty: Double = 0.0,
frequencyPenalty: Double = 0.0,
tools: Seq[ToolFunction[_, _]] = Seq.empty
)
6. Error Types
sealed trait LLMError
case class AuthenticationError(message: String) extends LLMError
case class RateLimitError(message: String) extends LLMError
case class ServiceError(message: String, code: Int) extends LLMError
case class ValidationError(message: String) extends LLMError
case class UnknownError(throwable: Throwable) extends LLMError
LLM Client Interface
trait LLMClient {
/** Complete a conversation and get a response */
def complete(
conversation: Conversation,
options: CompletionOptions = CompletionOptions()
): Either[LLMError, Completion]
/** Stream a completion with callback for chunks */
def streamComplete(
conversation: Conversation,
options: CompletionOptions = CompletionOptions(),
onChunk: StreamedChunk => Unit
): Either[LLMError, Completion]
}
Provider Configuration
sealed trait ProviderConfig {
def model: String
}
case class OpenAIConfig(
apiKey: String,
model: String = "gpt-4o",
organization: Option[String] = None,
baseUrl: String = "https://api.openai.com/v1"
) extends ProviderConfig
case class AzureConfig(
endpoint: String,
apiKey: String,
model: String,
apiVersion: String = "2023-12-01-preview"
) extends ProviderConfig
case class AnthropicConfig(
apiKey: String,
model: String = "claude-3-opus-20240229",
baseUrl: String = "https://api.anthropic.com"
) extends ProviderConfig
Main LLM Factory
object LLM {
/** Factory method for getting a client with the right configuration */
def client(
provider: LLMProvider,
config: ProviderConfig
): LLMClient = provider match {
case LLMProvider.OpenAI => new OpenAIClient(config.asInstanceOf[OpenAIConfig])
case LLMProvider.Azure => new AzureOpenAIClient(config.asInstanceOf[AzureConfig])
case LLMProvider.Anthropic => new AnthropicClient(config.asInstanceOf[AnthropicConfig])
// Other providers...
}
/** Convenience method for quick completion */
def complete(
messages: Seq[Message],
provider: LLMProvider,
config: ProviderConfig,
options: CompletionOptions = CompletionOptions()
): Either[LLMError, Completion] = {
val conversation = Conversation(messages)
client(provider, config).complete(conversation, options)
}
}
sealed trait LLMProvider
object LLMProvider {
case object OpenAI extends LLMProvider
case object Azure extends LLMProvider
case object Anthropic extends LLMProvider
// Add more as needed
}
Integration with Existing Tool API
The existing tool calling API can be used with minimal changes. Provider-specific translation logic should be encapsulated within the provider implementations.
// Adapter to convert ToolFunction to provider-specific format
trait ToolAdapter[T] {
def convertTools(tools: Seq[ToolFunction[_, _]]): T
}
// Example adapter for Azure OpenAI
class AzureToolAdapter extends ToolAdapter[ChatCompletionsOptions] {
def convertTools(tools: Seq[ToolFunction[_, _]]): ChatCompletionsOptions = {
val chatOptions = new ChatCompletionsOptions()
AzureToolHelper.addToolsToOptions(new ToolRegistry(tools), chatOptions)
chatOptions
}
}
Tool API (Compatible with Existing Implementation)
// Integrating with existing ToolFunction and ToolRegistry
trait LLMToolIntegration {
/** Execute a tool call using the existing tool registry */
def executeToolCall(
toolCall: ToolCall,
toolRegistry: ToolRegistry
): Either[String, ujson.Value] = {
val request = ToolCallRequest(toolCall.name, toolCall.arguments)
toolRegistry.execute(request)
}
/** Process tool calls and get tool messages */
def processToolCalls(
toolCalls: Seq[ToolCall],
toolRegistry: ToolRegistry
): Seq[ToolMessage] = {
toolCalls.map { toolCall =>
val result = executeToolCall(toolCall, toolRegistry)
val content = result match {
case Right(json) => json.render()
case Left(error) => s"""{"error":"$error"}"""
}
ToolMessage(toolCall.id, content)
}
}
}
Example Provider Implementation
OpenAI Client Implementation
class OpenAIClient(config: OpenAIConfig) extends LLMClient {
private val httpClient = HttpClient.newHttpClient()
override def complete(
conversation: Conversation,
options: CompletionOptions
): Either[LLMError, Completion] = {
try {
// Convert to OpenAI format
val requestBody = createRequestBody(conversation, options)
// Make API call
val request = HttpRequest.newBuilder()
.uri(URI.create(s"${config.baseUrl}/chat/completions"))
.header("Content-Type", "application/json")
.header("Authorization", s"Bearer ${config.apiKey}")
.POST(HttpRequest.BodyPublishers.ofString(requestBody.render()))
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
// Handle response status
response.statusCode() match {
case 200 =>
// Parse successful response
val responseJson = ujson.read(response.body())
Right(parseCompletion(responseJson))
case 401 => Left(AuthenticationError("Invalid API key"))
case 429 => Left(RateLimitError("Rate limit exceeded"))
case status => Left(ServiceError(s"OpenAI API error: ${response.body()}", status))
}
} catch {
case e: Exception => Left(UnknownError(e))
}
}
// Implementation-specific helpers
private def createRequestBody(conversation: Conversation, options: CompletionOptions): ujson.Obj = {
// Convert messages to OpenAI format...
// Add tools if present...
// Return formatted request body
}
private def parseCompletion(json: ujson.Value): Completion = {
// Extract relevant fields and convert to our model
}
// ... Streaming implementation
}
Azure OpenAI Client Implementation
class AzureOpenAIClient(config: AzureConfig) extends LLMClient {
// Initialize Azure OpenAI client using existing LLMConnect utility
private val llmConnection = LLMConnect.getClient(
endpoint = config.endpoint,
apiKey = config.apiKey,
defaultModel = config.model
)
private val client = llmConnection.client
override def complete(
conversation: Conversation,
options: CompletionOptions
): Either[LLMError, Completion] = {
try {
// Convert conversation to Azure format
val chatMessages = convertToAzureMessages(conversation)
// Create chat options
val chatOptions = new ChatCompletionsOptions(chatMessages)
// Set options
chatOptions.setTemperature(options.temperature)
options.maxTokens.foreach(mt => chatOptions.setMaxTokens(mt))
// Add tools if specified
if (options.tools.nonEmpty) {
val toolRegistry = new ToolRegistry(options.tools)
AzureToolHelper.addToolsToOptions(toolRegistry, chatOptions)
}
// Make API call
val completions = client.getChatCompletions(config.model, chatOptions)
// Convert response to our model
Right(convertFromAzureCompletion(completions))
} catch {
case e: Exception => Left(UnknownError(e))
}
}
// Convert our Conversation to Azure's message format
private def convertToAzureMessages(conversation: Conversation): java.util.ArrayList[ChatRequestMessage] = {
val messages = new java.util.ArrayList[ChatRequestMessage]()
conversation.messages.foreach {
case UserMessage(content) =>
messages.add(new ChatRequestUserMessage(content))
case SystemMessage(content) =>
messages.add(new ChatRequestSystemMessage(content))
case AssistantMessage(content, toolCalls) =>
val msg = new ChatRequestAssistantMessage(content)
// Add tool calls if needed
messages.add(msg)
case ToolMessage(toolCallId, content) =>
messages.add(new ChatRequestToolMessage(content, toolCallId))
}
messages
}
// Convert Azure completion to our model
private def convertFromAzureCompletion(completions: ChatCompletions): Completion = {
val choice = completions.getChoices.get(0)
val message = choice.getMessage
// Extract tool calls if present
val toolCalls = extractToolCalls(message)
Completion(
id = completions.getId,
created = completions.getCreatedAt.toEpochSecond,
message = AssistantMessage(
content = message.getContent,
toolCalls = toolCalls
),
usage = Some(TokenUsage(
promptTokens = completions.getUsage.getPromptTokens,
completionTokens = completions.getUsage.getCompletionTokens,
totalTokens = completions.getUsage.getTotalTokens
))
)
}
private def extractToolCalls(message: ChatResponseMessage): Seq[ToolCall] = {
import scala.jdk.CollectionConverters._
Option(message.getToolCalls)
.map(_.asScala.toSeq.collect {
case ftc: ChatCompletionsFunctionToolCall =>
ToolCall(
id = ftc.getId,
name = ftc.getFunction.getName,
arguments = ujson.read(ftc.getFunction.getArguments)
)
})
.getOrElse(Seq.empty)
}
// ... Streaming implementation
}
Usage Examples
Basic Conversation
// Configure a client
val client = LLM.client(
provider = LLMProvider.OpenAI,
config = OpenAIConfig(
apiKey = "sk-..."
)
)
// Create a conversation
val conversation = Conversation(Seq(
SystemMessage("You are a helpful assistant. You will talk like a pirate."),
UserMessage("Please write a scala function to add two integers")
))
// Get a completion
val result = client.complete(conversation)
result match {
case Right(completion) =>
println(s"Assistant: ${completion.message.content}")
case Left(error) =>
println(s"Error: $error")
}
Tool Calling Example
// Create a conversation asking about weather
val weatherConversation = Conversation(Seq(
UserMessage("What's the weather like in Paris?")
))
// Use the existing weather tool
val toolRegistry = new ToolRegistry(Seq(WeatherTool.tool))
// Get completion with tools
val weatherResult = client.complete(
weatherConversation,
CompletionOptions(tools = Seq(WeatherTool.tool))
)
// Handle the completion with potential tool calls
weatherResult match {
case Right(completion) if completion.message.toolCalls.nonEmpty =>
// Process tool calls with the existing tool registry
val toolResponses = completion.message.toolCalls.map { toolCall =>
val request = ToolCallRequest(toolCall.name, toolCall.arguments)
val result = toolRegistry.execute(request)
ToolMessage(
toolCallId = toolCall.id,
content = result.fold(
error => s"""{"error":"$error"}""",
success => success.render()
)
)
}
// Add the assistant message and tool responses to conversation
val updatedConversation = weatherConversation
.addMessage(completion.message)
.addMessages(toolResponses)
// Get final response with tool results
val finalResult = client.complete(updatedConversation)
// Display final response
finalResult.foreach(fc =>
println(s"Final response: ${fc.message.content}")
)
case Right(completion) =>
// Normal response without tool calls
println(s"Response: ${completion.message.content}")
case Left(error) =>
println(s"Error: $error")
}
Streaming Example
client.streamComplete(
conversation,
CompletionOptions(),
chunk => println(s"Chunk received: ${chunk.content.getOrElse("")}")
) match {
case Right(finalCompletion) =>
println("Stream completed successfully!")
case Left(error) =>
println(s"Stream error: $error")
}
Migration from Existing API
For users of the current LLM4S API, a compatibility layer can be provided:
object LLM4SCompat {
/** Convert the current API response to the new model */
def convertFromAzureResponse(completions: ChatCompletions): Completion = {
// Implementation similar to AzureOpenAIClient.convertFromAzureCompletion
}
/** Convert new model conversation to current API messages */
def convertToAzureMessages(conversation: Conversation): java.util.ArrayList[ChatRequestMessage] = {
// Implementation similar to AzureOpenAIClient.convertToAzureMessages
}
// More compatibility helpers...
}
Conclusion
This API specification provides a clean, idiomatic Scala interface for LLM integration while maintaining compatibility with the existing tool calling implementation. The design emphasizes immutability, type safety, and a functional approach while abstracting away provider-specific details.
Provider-specific implementations handle the translation between our model and the provider’s API format, ensuring a consistent experience regardless of the underlying LLM service being used.