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
caseclassConversation(messages:Seq[Message]){// Add a message and return a new ConversationdefaddMessage(message:Message):Conversation=Conversation(messages:+message)// Add multiple messages and return a new ConversationdefaddMessages(newMessages:Seq[Message]):Conversation=Conversation(messages++newMessages)// Get the last message (if any)deflastMessage:Option[Message]// Get count of messagesdefmessageCount:Int// Filter messages by roledeffilterByRole(role:MessageRole):Seq[Message]}objectConversation{// Create conversation from system and user prompts (most common pattern)deffromPrompts(systemPrompt:String,userPrompt:String):Result[Conversation]// Create single user message conversationdefuserOnly(prompt:String):Result[Conversation]// Create single system message conversationdefsystemOnly(prompt:String):Result[Conversation]// Create conversation from validated messagesdefcreate(messages:Message*):Result[Conversation]// Create empty conversationdefempty():Conversation}
// Enhanced error hierarchy in org.llm4s.errorsealedtraitLLMError{defmessage:Stringdefformatted:String}objectLLMError{caseclassAuthenticationError(message:String,provider:String,details:Map[String, String]=Map.empty)extendsLLMErrorcaseclassRateLimitError(message:String,retryAfter:Option[Duration],provider:String,limit:Option[Int]=None,remaining:Option[Int]=None)extendsLLMErrorcaseclassServiceError(message:String,statusCode:Int,provider:String,requestId:Option[String]=None)extendsLLMErrorcaseclassValidationError(message:String,field:String,constraints:Map[String, String]=Map.empty)extendsLLMErrorcaseclassNetworkError(message:String,cause:Option[Throwable],retryable:Boolean=true)extendsLLMErrorcaseclassConfigurationError(message:String,missingFields:List[String])extendsLLMErrorcaseclassUnknownError(message:String,cause:Option[Throwable]=None)extendsLLMError// Factory method for exceptionsdeffromThrowable(throwable:Throwable):LLMError={UnknownError(throwable.getMessage,Some(throwable))}}// Result type alias for cleaner APIstypeResult[+A]=Either[LLMError, A]
importorg.llm4s.types.ResulttraitLLMClient{/** Complete a conversation and get a response */defcomplete(conversation:Conversation,options:CompletionOptions=CompletionOptions()):Result[Completion]/** Stream a completion with callback for chunks */defstreamComplete(conversation:Conversation,options:CompletionOptions=CompletionOptions(),onChunk:StreamedChunk=>Unit):Result[Completion]/** Validate client configuration */defvalidate():Result[Unit]=Result.success(())/** Close client and cleanup resources */defclose():Unit=()}
objectLLM{/** Factory method for getting a client with the right configuration */defclient(provider:LLMProvider,config:ProviderConfig):LLMClient=providermatch{caseLLMProvider.OpenAI=>newOpenAIClient(config.asInstanceOf[OpenAIConfig])caseLLMProvider.Azure=>newOpenAIClient(config.asInstanceOf[AzureConfig])// OpenAIClient handles bothcaseLLMProvider.Anthropic=>newAnthropicClient(config.asInstanceOf[AnthropicConfig])// Other providers...}/** Convenience method for quick completion */defcomplete(messages:Seq[Message],provider:LLMProvider,config:ProviderConfig,options:CompletionOptions=CompletionOptions()):Result[Completion]={valconversation=Conversation(messages)client(provider,config).complete(conversation,options)}}sealedtraitLLMProviderobjectLLMProvider{caseobjectOpenAIextendsLLMProvidercaseobjectAzureextendsLLMProvidercaseobjectAnthropicextendsLLMProvider// 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.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Adapter to convert ToolFunction to provider-specific formattraitToolAdapter[T]{defconvertTools(tools:Seq[ToolFunction[_, _]]):T}// Example adapter for Azure OpenAIclassAzureToolAdapterextendsToolAdapter[ChatCompletionsOptions]{defconvertTools(tools:Seq[ToolFunction[_, _]]):ChatCompletionsOptions={valchatOptions=newChatCompletionsOptions()AzureToolHelper.addToolsToOptions(newToolRegistry(tools),chatOptions)chatOptions}}
Tool API (Compatible with Existing Implementation)
// Integrating with existing ToolFunction and ToolRegistrytraitLLMToolIntegration{/** Execute a tool call using the existing tool registry */defexecuteToolCall(toolCall:ToolCall,toolRegistry:ToolRegistry):Either[String, ujson.Value]={valrequest=ToolCallRequest(toolCall.name,toolCall.arguments)toolRegistry.execute(request)}/** Process tool calls and get tool messages */defprocessToolCalls(toolCalls:Seq[ToolCall],toolRegistry:ToolRegistry):Seq[ToolMessage]={toolCalls.map{toolCall=>valresult=executeToolCall(toolCall,toolRegistry)valcontent=resultmatch{caseRight(json)=>json.render()caseLeft(error)=>s"""{"error":"$error"}"""}ToolMessage(toolCall.id,content)}}}
classOpenAIClientprivate(privatevalmodel:String,privatevalclient:AzureOpenAIClient)extendsLLMClient{// Constructor for OpenAIdefthis(config:OpenAIConfig)=this(config.model,newOpenAIClientBuilder().credential(newKeyCredential(config.apiKey)).endpoint(config.baseUrl).buildClient())// Constructor for Azuredefthis(config:AzureConfig)=this(config.model,newOpenAIClientBuilder().credential(newAzureKeyCredential(config.apiKey)).endpoint(config.endpoint).serviceVersion(OpenAIServiceVersion.valueOf(config.apiVersion)).buildClient())overridedefcomplete(conversation:Conversation,options:CompletionOptions):Either[LLMError, Completion]={try{// Convert conversation to Azure formatvalchatMessages=convertToAzureMessages(conversation)// Create chat optionsvalchatOptions=newChatCompletionsOptions(chatMessages)// Set optionschatOptions.setTemperature(options.temperature)options.maxTokens.foreach(mt=>chatOptions.setMaxTokens(mt))// Add tools if specifiedif(options.tools.nonEmpty){valtoolRegistry=newToolRegistry(options.tools)AzureToolHelper.addToolsToOptions(toolRegistry,chatOptions)}// Make API call (model passed from constructor)valcompletions=client.getChatCompletions(model,chatOptions)// Convert response to our modelRight(convertFromAzureCompletion(completions))}catch{casee:Exception=>Left(UnknownError(e))}}// Convert our Conversation to Azure's message formatprivatedefconvertToAzureMessages(conversation:Conversation):java.util.ArrayList[ChatRequestMessage]={valmessages=newjava.util.ArrayList[ChatRequestMessage]()conversation.messages.foreach{caseUserMessage(content)=>messages.add(newChatRequestUserMessage(content))caseSystemMessage(content)=>messages.add(newChatRequestSystemMessage(content))caseAssistantMessage(content,toolCalls)=>valmsg=newChatRequestAssistantMessage(content)// Add tool calls if neededmessages.add(msg)caseToolMessage(toolCallId,content)=>messages.add(newChatRequestToolMessage(content,toolCallId))}messages}// Convert Azure completion to our modelprivatedefconvertFromAzureCompletion(completions:ChatCompletions):Completion={valchoice=completions.getChoices.get(0)valmessage=choice.getMessage// Extract tool calls if presentvaltoolCalls=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)))}privatedefextractToolCalls(message:ChatResponseMessage):Seq[ToolCall]={importscala.jdk.CollectionConverters._Option(message.getToolCalls).map(_.asScala.toSeq.collect{caseftc:ChatCompletionsFunctionToolCall=>ToolCall(id=ftc.getId,name=ftc.getFunction.getName,arguments=ujson.read(ftc.getFunction.getArguments))}).getOrElse(Seq.empty)}// ... Streaming implementation}
// Configure a clientvalclient=LLM.client(provider=LLMProvider.OpenAI,config=OpenAIConfig(apiKey="sk-..."))// Create a conversation using convenience method (recommended)valresult=for{conversation<-Conversation.fromPrompts("You are a helpful assistant. You will talk like a pirate.","Please write a scala function to add two integers")completion<-client.complete(conversation)}yieldcompletionresultmatch{caseRight(completion)=>println(s"Assistant: ${completion.message.content}")caseLeft(error)=>println(s"Error: $error")}// Alternative: Manual constructionvalmanualConversation=Conversation(Seq(SystemMessage("You are a helpful assistant."),UserMessage("What is 2+2?")))
// Create a single-user-message conversation (using convenience method)valconversationResult=Conversation.userOnly("What's the weather like in Paris?")// Use the existing weather toolvaltoolRegistry=newToolRegistry(Seq(WeatherTool.tool))// Get completion with toolsvalweatherResult=conversationResult.flatMap{weatherConversation=>client.complete(weatherConversation,CompletionOptions(tools=Seq(WeatherTool.tool)))}// Handle the completion with potential tool callsweatherResultmatch{caseRight(completion)ifcompletion.message.toolCalls.nonEmpty=>// Process tool calls with the existing tool registryvaltoolResponses=completion.message.toolCalls.map{toolCall=>valrequest=ToolCallRequest(toolCall.name,toolCall.arguments)valresult=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 conversationvalupdatedConversation=weatherConversation.addMessage(completion.message).addMessages(toolResponses)// Get final response with tool resultsvalfinalResult=client.complete(updatedConversation)// Display final responsefinalResult.foreach(fc=>println(s"Final response: ${fc.message.content}"))caseRight(completion)=>// Normal response without tool callsprintln(s"Response: ${completion.message.content}")caseLeft(error)=>println(s"Error: $error")}
For users of the current LLM4S API, a compatibility layer can be provided:
1
2
3
4
5
6
7
8
9
10
11
12
13
objectLLM4SCompat{/** Convert the current API response to the new model */defconvertFromAzureResponse(completions:ChatCompletions):Completion={// Implementation similar to OpenAIClient.convertFromAzureCompletion}/** Convert new model conversation to current API messages */defconvertToAzureMessages(conversation:Conversation):java.util.ArrayList[ChatRequestMessage]={// Implementation similar to OpenAIClient.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.