llmberjack

package module
v0.0.0-...-35a7e6d Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 18, 2025 License: MIT Imports: 16 Imported by: 1

README

LLMberjack 🪵🪓🦊

Type-safe wrapper adapter around various LLM providers.

Note: this library is a very early preview, meaning it will have major breaking changes until 1.0, including its name and import paths.

Usage

Basic setup

LLMberjack is used by setting up an instance of it with at least one provider. An adapter can be configured with more (named) providers, which can be selected by their names when sending requests.

gpt, err := openai.New(openai.WithApiKey("..."))
gemini, err := aistudio.New()

llm, err := llmberjack.New(
	llmberjack.WithDefaultProvider(gpt),
	llmberjack.WithProvider("gemini", gemini),
	llmberjack.WithDefaultModel("gpt-4"),
)

An adapter always has a default provider that will be used when no specific provider is specified on a request. The default provider is either the one added with WithDefaultProvider(), or the first named provider given.

Each provider may offer some options for customization that are specific to it. Refer to each provider's package to know which options they offer.

Requests
Typed output

Requests are built through a series of chainable methods determining its content and behavior. A request is typed with the Go type of the expected response. When a type is given, the appropriate response format will be set on the request so the provider responds with a JSON string of the appropriate schema.

The struct tags one can add to the given type are explained in this repository.

type Output struct {
	LightColor string `json:"text" jsonschema_description:"Color of the traffic light" jsonschema:"enum=red,enum=yellow=enum=red"`
}

req, err := llmberjack.NewRequest[Output]()
req, err := llmberjack.NewUntypedRequest() // Equivalent to `NewRequest[string]()`

If you wish for your response to be serialized into a type that cannot be represented as a static struct (for example, if you build your types dynamically), you can specify the schema yourself with OverrideResponseSchema(). Note that this schema still requires to be unserializable into the provided type.

Note that if you build your own JSON schema, it is your responsibility to make one that is accepted by your provider. Notably, you should probably add AdditionalProperties: jsonschema.SchemaFalse to your object schemas.


props := jsonschema.NewProperties()
props.Set("reply", &jsonschema.Schema{
	Type: "string",
	Description: "Your response to my question",
})

schema := jsonschema.Schema{
	Type: "object",
	Properties: props,
}

req, err := NewRequest[map[string]string]().
	OverrideResponseSchema(schema)
Provider and model selection

Both provider and model used in a request can be selected with the builder methods WithProvider() and WithModel(). If not provided:

  • The default provider will be used
  • The model of the request will be used if set, or the default model for the provider if set, or the default model on the adapter
Prompting

Adding prompts is performed in a provider-agnostic way through a series of builder method on Request[T] and offer a variety of input media. So far, only text input are supported.

req.
	WithInstruction("system prompt").
	WithInstructionReader(strings.NewReader("system prompt")).
	WithInstructionFile("/etc/prompt.md").
	WithText(llmberjack.RoleUser, "user prompt").
	WithTextReader(llmberjack.RoleUser, strings.NewReader("user prompt")).
	WithJson(llmberjack.RoleUser, data). // Any JSON-serializable type
	WithSerializable(llmberjack.RoleUser, llmberjack.Serializers.Json, data) // Use a decoder implementing llmberjack.Serializer

WithSerializable accepts any type that fulfills the Serializable interface and that can write a arbitrarily-serialized input into an io.Writer. The library currently comes with two serializers, llmberjacks.Serializers.Json and llmberjack.Serializers.Csv, but you would write your own.

Executing

Executing a request is done by calling the Do() method on a request. A response will contain generic information about the response, and one or more candidate responses (depending on the configuration of the request).

To obtain the typed, deserialized output of one of the candidate, use resp.Get(idx) (idx being the index of the candidate).

resp, err := req.Do(ctx, llm)
output, err := resp.Get(0)

A few utilities are available to run multiple requests at the same time:

  • llmberjack.All[T](context.Context, *llmberjack.Llmberjack, reqs ...Request[T]) can be used to fire several requests at once, wait for all of them to return and get a slice of results.
  • llmberjack.Race[T](context.Context, *llmberjack.Llmberjack, reqs ...Request[T]) can be used to fire several requests at once, return the first successful response, and cancel the others.

Note that cancelled requests will still incur cost on most providers.

History

By default, every request will be sent with a blank context. To opt into history accumulation (building a context through the conversation), one can use threads. By starting a threads in one request, and then re-using that same thread in subsequent requests, inputs and outputs will be accumulated and sent with every request.

Each thread is represented by an opaque, non-copyable *ThreadId which is associated with the provider that created it. A thread cannot be shared across providers.

Warning: A ThreadId must not be copied, which is why it should always be handled as a pointer. Go will emit warnings if it is copied anywhere.

resp1, err := req.CreateThread().Do(ctx, llm)
resp2, err := req.InThread(resp1.ThreadId).Do(ctx, llm)

To send a new request with a clear history, either send a request without using a thread method, create a new thread, or clear the thread with resp.ThreadId.Clear(). It can be copied with resp.ThreadId.Copy().

When using thread, by default, both inputs and outputs are saved. To opt out of storing one or both of those, you can chain the SkipSaveInput() or SkipSaveOutput() on the request.

Note that starting a response from a previous candidate automatically adds that response to the relevant thread history.

Threads should be closed after you are done using them to clean associated resources. We recomment deferring a call to (*ThreadId).Close() after you create it. If you do not, threads will live on until the whole adapter is garbage collected.

Chaining

To conduct a conversation, you must select one candidate response as the basis for the next request. The first request needs to be in a thread.

resp1, err := req.CreateThread().Do(ctx, llm)
resp2, err := req.FromCandidate(resp1, 0).Do(ctx, llm)
Tool calling

Tools can be defined in a type-safe manner by using the NewTool function and refering to it in various requests. A function consists of a name, a description and a callback taking an arbitrary type as argument and returning (string, error).

type WeatherToolParams struct {
	Location string `json:"location" jsonschema_description:"The location for which to retrieve the weather forecast"`
}

weatherTool := llmberjack.NewTool[WeatherToolParams](
	"get_weather_in_location",
	"Get a weather forecast in a given location",
	llmberjack.Function(func(p WeatherToolParams) (string, error) {
		return "Weather is going to be very rainy with chance of thunderstorms", nil
	}),
)

resp1, err := llmberjack.NewUntypedRequest().CreateThread().
	WithText(llmberjack.RoleUser, "Tell me the weather in Paris.").
	WithTools(weatherTool).
	Do(ctx, llm)

resp2, err := llmberjack.NewUntypedRequest().FromCandidate(resp1, 0).
	WithToolExecution(weatherTool).
	Do(ctx, llm)

A lot is happening here:

  • A tool is defined, taking a WeatherToolParams as argument. This type will be serialized into a JSON schema to instruct the LLM how to communicate arguments.
  • A request requiring a tool is sent, in a thread.
  • A second request selects a previous candidate (joining its thread), and executes any requested function, appending the output to the request.

Tool calling only works on request that are part of a thread, since providing history is required.

Note that WithToolExecution will fail if a candidate was not selected beforehand or if the previous response is not part of a thread.

Example

See the executables in examples/ for more complete examples.

type Output struct {
	Reply  string `json:"reply" jsonschema_description:"The response you want to give me"`
	Random int    `json:"random" jsonschema_description:"A random number you must generate between 100 and 200"`
}

func main() {
	ctx := context.Background()
	systemPrompt, _ := os.Open("../prompts/system.txt")

	provider, _ := aistudio.New(
		aistudio.WithBackend(genai.BackendVertexAI),
		aistudio.WithProject(os.Getenv("GOOGLE_CLOUD_PROJECT")),
		aistudio.WithLocation("europe-west1"),
		aistudio.WithApiKey(os.Getenv("LLM_API_KEY"))
	)

	llm, _ := llmberjack.New(
		llmberjack.WithDefaultProvider(provider),
		llmberjack.WithDefaultModel("gemini-2.5-flash"),
	)

	resp, _ := llmberjack.NewRequest[Output]().
		WithInstructionReader(systemPrompt).
		WithText(llmberjack.RoleUser, "Hello, my name is Antoine!").
		Do(ctx, llm)

	obj, _ := resp.Get(0)

	fmt.Println("Reply:", obj.Reply)
	fmt.Println("Random number:", obj.Random)
}

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	// Serializers is a global object that holds singleton of library-provided
	// serializers
	Serializers = struct {
		Json jsonSerializer
		Csv  csvSerializer
	}{
		Json: jsonSerializer{},
		Csv:  csvSerializer{},
	}
)

Functions

func Function

func Function[A any](f func(args A) (string, error)) internal.FunctionBody

Function is a wrapper for the code executed in a tool.

It is generic in A, which is a type containing the tool arguments. It follows the same idioms as a response schema.

func NewTool

func NewTool[A any](name, description string, fn internal.FunctionBody) internal.Tool

NewTool creates a new tool.

It is generic in the type of the tool arguments, and takes the tool name and description.

The function body should be wrapped in `Function`.

func WithDefaultModel

func WithDefaultModel(model string) llmOption

WithDefaultModel sets the model to use if not specified in a particular request. It is the caller's responsibility to ensure the requested model is available on the configured provider.

func WithDefaultProvider

func WithDefaultProvider(provider Llm) llmOption

WithDefaultProvider sets what LLM provider to use for communication.

func WithHttpClient

func WithHttpClient(client *http.Client) llmOption

WithHttpClient sets a custom HTTP clients to be used.

If a provider does not support overriding the HTTP client, this will be ignored.

func WithProvider

func WithProvider(name string, provider Llm) llmOption

WithProvider registers a provider.

The first one to be registered will become the default, unless a default was already or is defined later with `SetDefaultProvider`.

Types

type AsyncResponse

type AsyncResponse[T any] struct {
	Response *Response[T]
	Error    error
}

func All

func All[T any](ctx context.Context, llm *Llmberjack, reqs ...Request[T]) []AsyncResponse[T]

type Candidater

type Candidater interface {
	NumCandidates() int
	Candidate(int) (*ResponseCandidate, error)
	Thread() *ThreadId
}

Candidater represents a type that can have several candidates.

type FinishReason

type FinishReason string
const (
	FinishReasonStop          FinishReason = "stop"
	FinishReasonMaxTokens     FinishReason = "max_tokens"
	FinishReasonContentFilter FinishReason = "content_filter"
)

type History

type History[T any] struct {
	// contains filtered or unexported fields
}

History manages the conversation context by storing a sequence of messages. It is generic in type `T`, where `T` represents the specific message format required by a particular LLM provider (e.g., OpenAI's Message or AIStudio's ChatMessage). This allows the adapter to maintain conversation state across multiple requests.

func (*History[T]) Clear

func (h *History[T]) Clear(threadId *ThreadId)

Clear empties the entire conversation history, effectively starting a new conversation. This also clears any system instructions that were part of the history.

func (*History[T]) Close

func (h *History[T]) Close(threadId *ThreadId)

func (*History[T]) Copy

func (h *History[T]) Copy(threadId *ThreadId) *ThreadId

func (*History[T]) Load

func (h *History[T]) Load(threadId *ThreadId) []T

Load retrieves the entire conversation history as a slice of messages. This history can then be included in subsequent requests to the LLM to maintain conversational context.

func (*History[T]) Save

func (h *History[T]) Save(threadId *ThreadId, message T)

Save appends a new message to the conversation history. The `message` parameter should be of the generic type `T`, matching the content representation expected by the LLM provider.

type InnerResponse

type InnerResponse struct {
	Id         string
	Model      string
	Candidates []ResponseCandidate
	Created    time.Time
}

InnerResponse is a response from a provider.

type Llm

type Llm interface {
	// Init initializes the LLM provider with the given adapter configuration.
	// It is called once when the provider is added to the adapter.
	Init(llm internal.Adapter) error
	// ResetContext clears the conversation history for the specific LLM provider.
	// This allows starting a new conversation without re-initializing the provider.
	ResetThread(*ThreadId)
	// CopyThread copies all history from the provided thread into a new, discrete one.
	CopyThread(*ThreadId) *ThreadId
	// CloseThread deletes a thread and associated resources.
	CloseThread(*ThreadId)
	// ChatCompletion sends a chat completion request to the LLM provider.
	// It takes a context, the adapter's internal configuration, and a Requester
	// to retrieve the request.
	ChatCompletion(context.Context, internal.Adapter, Requester) (*InnerResponse, error)
	// RequestOptionsType returns the reflect.Type of the provider-specific
	// request options struct. This is used for type checking and reflection
	// when processing custom request options.
	RequestOptionsType() reflect.Type
}

Llm defines the interface that all LLM providers must implement. It provides a contract for initializing, managing context, and performing chat completions with different language models.

type Llmberjack

type Llmberjack struct {
	// contains filtered or unexported fields
}

Llmberjack is the main entrypoint for interacting with different LLM providers. It provides a unified interface to send requests and receive responses.

func New

func New(opts ...llmOption) (*Llmberjack, error)

New creates a new Llmberjack with the given options. It initializes the specified LLM provider and returns a configured adapter.

Example usage:

adapter, err := llmberjack.New(
	llmberjack.WithDefaultProvider(provider),
	llmberjack.WithDefaultModel("gpt-4"),
	llmberjack.WithApiKey("...")
)

func (Llmberjack) DefaultModel

func (llm Llmberjack) DefaultModel() string

func (*Llmberjack) GetProvider

func (llm *Llmberjack) GetProvider(requestProvider *string) (Llm, error)

GetProvider retrieves an LLM provider based on the given provider name. It accepts the provider requested in a specific request, which will override the default provider. If the provider argument is nil, it will return the configured default provider.

func (Llmberjack) HttpClient

func (llm Llmberjack) HttpClient() *http.Client

type Message

type Message struct {
	// Type is the binary representation of the message
	Type MessageType
	// Role represent "who" (or "what") composed a message. Note that all
	// provider will not support all of the roles, but must still account for
	// them.
	Role MessageRole
	// Parts are subdivision of a specific message.
	Parts []io.Reader

	// Tool is an instruction from a tool function to be called. This only makes
	// sense in response messages.
	Tool *ResponseToolCall
}

Message is an abstraction over a "prompt".

type MessageRole

type MessageRole int
const (
	RoleSystem MessageRole = iota
	RoleUser
	RoleAi
	RoleTool
)

type MessageType

type MessageType int
const (
	TypeText MessageType = iota
)

type Request

type Request[T any] struct {
	// contains filtered or unexported fields
}

Request represent a request to be sent the a provider, in the context of the current conversation.

It contains an `innerRequest` built by the caller, but also optionally tracks which candidate it responds to, in order to link tool responses to their corresponding tool calls.

It is generic in T which it will use to unmarshal the response into a typed struct.

func NewRequest

func NewRequest[T any]() Request[T]

NewRequest creates a builder to craft a request to sent to an LLM provider.

It provides a series of methods to chain-call in order to add context, prompts and configuration.

It is generic in T, which will be used to generate a JSONSchema to be used as a response schema in the request. See [this](https://github.com/invopop/jsonschema) for more information about how to write the structs.

Example usage:

resp, err := llmberjack.NewRequest[Output]().
	WithText(llmberjack.RoleUser, "How are you today?").
	Do(ctx, llm)

func NewUntypedRequest

func NewUntypedRequest() Request[string]

NewUntypedRequest is a helper method to create a `Request` which will be a raw string, without unmarshalling the response into a struct.

func (Request[T]) CreateThread

func (r Request[T]) CreateThread() Request[T]

func (Request[T]) Do

func (r Request[T]) Do(ctx context.Context, llm *Llmberjack) (*Response[T], error)

Do executes a built request on the configured provider.

It will return a response generic over the configured typed on the Request, or an error.

func (Request[T]) FromCandidate

func (r Request[T]) FromCandidate(c Candidater, idx int) Request[T]

FromCandidate selects a candidate/choice from a previous response as the base for this Request.

Selecting a candidate will have two effects:

  • Adding the candidate to the history (if the request was in a thread)
  • Using this response tool calls as a basis for tool responses, if applicable.

Example usage:

resp, err := llmberjack.NewRequest[Output]().
	FromCandidate(previousResp, 0).
	WithText(llmberjack.RoleUser, "How are you today?").
	Do(ctx, llm)

func (Request[T]) InThread

func (r Request[T]) InThread(threadId *ThreadId) Request[T]

func (Request[T]) OverrideResponseSchema

func (r Request[T]) OverrideResponseSchema(schema jsonschema.Schema) Request[T]

func (Request[T]) ProviderRequestOptions

func (r Request[T]) ProviderRequestOptions(provider Llm) internal.ProviderRequestOptions

func (Request[T]) SkipSaveInput

func (r Request[T]) SkipSaveInput() Request[T]

func (Request[T]) SkipSaveOutput

func (r Request[T]) SkipSaveOutput() Request[T]

func (Request[T]) ToRequest

func (r Request[T]) ToRequest() innerRequest

func (Request[T]) WithInstruction

func (r Request[T]) WithInstruction(parts ...string) Request[T]

WithInstruction adds a system prompt to the request.

Note that if the adapter is configured to save history, this need only be added on the first request sent to the provider.

func (Request[T]) WithInstructionFiles

func (r Request[T]) WithInstructionFiles(files ...string) Request[T]

func (Request[T]) WithInstructionReader

func (r Request[T]) WithInstructionReader(parts ...io.Reader) Request[T]

WithInstructionReader adds a system prompt read from an io.Reader.

func (Request[T]) WithJson

func (r Request[T]) WithJson(role MessageRole, data any) Request[T]

func (Request[T]) WithMaxCandidates

func (r Request[T]) WithMaxCandidates(candidates int) Request[T]

WithMaxCandidates limits how many candidate responses the provider is able to provide.

Most providers default to 1 for this value.

func (Request[T]) WithMaxTokens

func (r Request[T]) WithMaxTokens(tokens int) Request[T]

WithMaxTokens limits how many token a provider can emit for its completion.

func (Request[T]) WithModel

func (r Request[T]) WithModel(model string) Request[T]

WithModel overrides the model used for this specific request.

If not provided, the default model set on the provider, then the adapter will be used.

func (Request[T]) WithModelFunc

func (r Request[T]) WithModelFunc(fn func(provider Llm, providerName *string) string) Request[T]

WithModelFunc executes a callback to determine the model to use.

Is it useful notably when having multiple provider, to be able to select the model depending on which provider was actually selected to execute the request. The callback is passed the actual instance of the selected provider, as well as its registered name, if applicable.

func (Request[T]) WithProvider

func (r Request[T]) WithProvider(name string) Request[T]

func (Request[T]) WithProviderOptions

func (r Request[T]) WithProviderOptions(opts internal.ProviderRequestOptions) Request[T]

WithProviderOptions set provider-specific options.

Some options are not going to be supported by all providers, so they will usually defined a type representing options specific to them. This function allows to define those. One set of option can be defined by provider type.

func (Request[T]) WithSchemaDescription

func (r Request[T]) WithSchemaDescription(name, description string) Request[T]

func (Request[T]) WithSerializable

func (r Request[T]) WithSerializable(role MessageRole, ser Serializer, input any) Request[T]

func (Request[T]) WithTemperature

func (r Request[T]) WithTemperature(temp float64) Request[T]

WithTemperature sets custom temperature value to be used.

Default value depends on the model.

func (Request[T]) WithText

func (r Request[T]) WithText(role MessageRole, parts ...string) Request[T]

WithText adds a text message to the Request.

Each provided `string` will be added as a discrete `part` in the message. The message will be declared as text content.

func (Request[T]) WithTextReader

func (r Request[T]) WithTextReader(role MessageRole, parts ...io.Reader) Request[T]

WithTextReader adds a message to the Request read from an io.Reader.

func (Request[T]) WithThinking

func (r Request[T]) WithThinking(thinking bool) Request[T]

func (Request[T]) WithToolExecution

func (r Request[T]) WithToolExecution(tools ...internal.Tool) Request[T]

WithToolExecution executes the requested tools and add their output to the Request.

It will also take care of adding the matching tool definitions to the Request, so there is not need to also call `WithTool`.

Note that this requires that a candidate from the previous response was selected by calling `FromCandidate()` before this function, to determine which function the provider asked to be called.

func (Request[T]) WithTools

func (r Request[T]) WithTools(tools ...internal.Tool) Request[T]

WithTools adds tool definitions to the request.

Tools are represented as a type-safe function taking its configuration as input, and return a string and an error. The JSONSchema sent to the provider will be generated from the input type.

Example usage:

resp, err := llmberjack.NewRequest[Output]().
	WithText(llmberjack.RoleUser, "How are you today?").
	WithTool(llmberjack.NewTool[WeatherParams]("get_weather", "Get weather at location", llmberjack.Function(func(args WeatherParams) (string, error) {
		return "Good weather!", nil
	})).
	Do(ctx, llm)

func (Request[T]) WithTopP

func (r Request[T]) WithTopP(topp float64) Request[T]

WithTopP sets the `top_p` parameter.

type Requester

type Requester interface {
	// ToRequest unwraps the actual request.
	ToRequest() innerRequest
	// ProviderRequestOptions extracts the provider-specific configuration
	// options for a given provider. This is called from each provider to
	// retrieve its specific configuration in a type-safe manner.
	ProviderRequestOptions(provider Llm) internal.ProviderRequestOptions
}

Requester represents something that can be turned into a request.

Used internally to abstract over request types across packages.

type Response

type Response[T any] struct {
	InnerResponse

	ThreadId *ThreadId
}

Response[T] is a wrapper around a provider response.

It wraps it so it can be generic without the provider's response to also be, and provide typed methods to unmarshal the response, if necessary.

func Race

func Race[T any](ctx context.Context, llm *Llmberjack, reqs ...Request[T]) (*Response[T], error)

func (Response[T]) Candidate

func (r Response[T]) Candidate(idx int) (*ResponseCandidate, error)

func (Response[T]) Get

func (r Response[T]) Get(idx int) (T, error)

Get will return the deserialized output for a candidate.

It will parse the response and deserialize it to the requested type, or return an error if it cannot.

func (Response[T]) Iterator

func (r Response[T]) Iterator() iter.Seq2[T, error]

func (Response[T]) NumCandidates

func (r Response[T]) NumCandidates() int

func (Response[T]) Thread

func (r Response[T]) Thread() *ThreadId

type ResponseCandidate

type ResponseCandidate struct {
	Text         string
	FinishReason FinishReason
	ToolCalls    []ResponseToolCall
	Grounding    *ResponseGrounding
	Thoughts     string

	// SelectCandidate is a callback that is called when a candidate is
	// "selected" (when the conversation will continue from it).
	SelectCandidate func()
}

ResponseCandidate represent a candidate response from a provider.

type ResponseGrounding

type ResponseGrounding struct {
	Searches []string
	Sources  []ResponseGroundingSource
	Snippets []string
}

type ResponseGroundingSource

type ResponseGroundingSource struct {
	Title  string
	Domain string
	Url    string
	Date   time.Time
}

type ResponseToolCall

type ResponseToolCall struct {
	Id         string
	Name       string
	Parameters []byte
}

ResponseToolCall is a request from a provider to execute a tool.

type Serializer

type Serializer interface {
	Serialize(input any, output io.Writer) error
}

type ThreadId

type ThreadId struct {
	// contains filtered or unexported fields
}

ThreadId uniquely represents a conversation with an LLM.

It is used to mark and identify a specific conversation and accumulate its history. ThreadsId inherent identifiers (their memory address is the identifier), so their value cannot be copied, only pointers should be passed around.

func (*ThreadId) Clear

func (t *ThreadId) Clear()

func (*ThreadId) Close

func (t *ThreadId) Close()

func (*ThreadId) Copy

func (t *ThreadId) Copy() *ThreadId

Directories

Path Synopsis
examples
async command
full command
grounding command
multiple command
simple command
llms

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL