Skip to content

LLM

The wippy/llm module provides a unified interface for working with Large Language Models from multiple providers (OpenAI, Anthropic, Google, local models). It supports text generation, tool calling, structured output, embeddings, and streaming.

Add the module to your project:

Terminal window
wippy add wippy/llm
wippy install

Declare the dependency in your _index.yaml. The LLM module requires an environment storage (for API keys) and a process host:

version: "1.0"
namespace: app
entries:
- name: os_env
kind: env.storage.os
- name: processes
kind: process.host
lifecycle:
auto_start: true
- name: dep.llm
kind: ns.dependency
component: wippy/llm
version: "*"
parameters:
- name: env_storage
value: app:os_env
- name: process_host
value: app:processes

The env.storage.os entry exposes OS environment variables to the LLM providers. Set your API keys as environment variables (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY).

Import the llm library into your entry and call generate():

entries:
- name: ask
kind: function.lua
source: file://ask.lua
method: handler
imports:
llm: wippy.llm:llm
local llm = require("llm")
local function handler()
local response, err = llm.generate("What are the three laws of robotics?", {
model = "gpt-4o"
})
if err then
return nil, err
end
return response.result
end
return { handler = handler }

The first argument to generate() can be a string prompt, a prompt builder, or a table of messages. The second argument is an options table.

OptionTypeDescription
modelstringModel name or class (required)
temperaturenumberRandomness control, 0-1
max_tokensnumberMaximum tokens to generate
top_pnumberNucleus sampling parameter
top_knumberTop-k filtering
thinking_effortnumberThinking depth 0-100 (models with thinking capability)
toolstableArray of tool definitions
tool_choicestring"auto", "none", "any", or tool name
streamtableStreaming config: { reply_to, topic, buffer_size }
timeoutnumberRequest timeout in seconds (default 600)
FieldTypeDescription
resultstringGenerated text content
tokenstableToken usage: prompt_tokens, completion_tokens, thinking_tokens, total_tokens, plus optional cache_read_input_tokens, cache_read_tokens, cache_creation_input_tokens, cache_write_tokens
finish_reasonstringWhy generation stopped: "stop", "length", "tool_call", "filtered", "error"
tool_callstable?Array of tool calls (if model invoked tools)
metadatatableProvider-specific metadata
usage_recordtable?Usage tracking record

For multi-turn conversations and complex prompts, use the prompt builder:

imports:
llm: wippy.llm:llm
prompt: wippy.llm:prompt
local llm = require("llm")
local prompt = require("prompt")
local conversation = prompt.new()
conversation:add_system("You are a helpful assistant.")
conversation:add_user("What is the capital of France?")
local response, err = llm.generate(conversation, {
model = "gpt-4o",
temperature = 0.7,
max_tokens = 500
})
MethodDescription
prompt.new()Create empty builder
prompt.with_system(content)Create builder with system message
:add_system(content, meta?)Add system message
:add_user(content, meta?)Add user message
:add_assistant(content, meta?)Add assistant message
:add_developer(content, meta?)Add developer message
:add_message(role, content_parts, name?, meta?)Add message with role and content parts
:add_function_call(name, arguments, id?, options?)Add tool call from assistant (arguments is the raw JSON string)
:add_function_result(name, result, id?)Add tool execution result
:add_cache_marker(id?)Mark cache boundary (Claude models)
:get_messages()Get message array
:build()Get { messages = ... } table for llm.generate()
:clone()Deep copy the builder
:clear()Remove all messages

All add_* methods return the builder for chaining.

Build up context across turns by appending messages:

local conversation = prompt.new()
conversation:add_system("You are a helpful assistant.")
-- first turn
conversation:add_user("What is Lua?")
local r1 = llm.generate(conversation, { model = "gpt-4o" })
conversation:add_assistant(r1.result)
-- second turn with full context
conversation:add_user("What makes it different from Python?")
local r2 = llm.generate(conversation, { model = "gpt-4o" })

Combine text and images in a single message:

local conversation = prompt.new()
conversation:add_message(prompt.ROLE.USER, {
prompt.text("What's in this image?"),
prompt.image("https://example.com/photo.jpg")
})
FunctionDescription
prompt.text(content)Text content part
prompt.image(url, mime_type?)Image from URL
prompt.image_base64(mime_type, data)Base64-encoded image
ConstantValue
prompt.ROLE.SYSTEM"system"
prompt.ROLE.USER"user"
prompt.ROLE.ASSISTANT"assistant"
prompt.ROLE.DEVELOPER"developer"
prompt.ROLE.FUNCTION_CALL"function_call"
prompt.ROLE.FUNCTION_RESULT"function_result"
prompt.ROLE.CACHE_MARKER"cache_marker"

Clone a builder to create variations without modifying the original:

local base = prompt.new()
base:add_system("You are a helpful assistant.")
local conv1 = base:clone()
conv1:add_user("What is AI?")
local conv2 = base:clone()
conv2:add_user("What is ML?")

Stream responses in real-time using process communication. This requires a process.lua entry:

local llm = require("llm")
local TOPIC = "llm_stream"
local function main()
local stream_ch = process.listen(TOPIC)
local response = llm.generate("Write a short story", {
model = "gpt-4o",
stream = {
reply_to = process.pid(),
topic = TOPIC,
},
})
while true do
local chunk, ok = stream_ch:receive()
if not ok then break end
if chunk.type == "chunk" then
io.write(chunk.content)
elseif chunk.type == "thinking" then
io.write(chunk.content)
elseif chunk.type == "error" then
io.print("Error: " .. chunk.error.message)
break
elseif chunk.type == "done" then
break
end
end
process.unlisten(stream_ch)
end
TypeFieldsDescription
"chunk"contentText content fragment
"thinking"contentModel thinking process
"tool_call"name, arguments, idTool invocation
"error"error.message, error.typeStream error
"done"metaStream complete
Streaming requires a process.lua entry because it uses Wippy's process communication system (process.pid(), process.listen()).

Define tools as inline schemas and pass them to generate():

local llm = require("llm")
local prompt = require("prompt")
local json = require("json")
local tools = {
{
name = "get_weather",
description = "Get current weather for a location",
schema = {
type = "object",
properties = {
location = { type = "string", description = "City name" },
},
required = { "location" },
},
},
}
local conversation = prompt.new()
conversation:add_user("What's the weather in Tokyo?")
local response = llm.generate(conversation, {
model = "gpt-4o",
tools = tools,
tool_choice = "auto",
})
if response.tool_calls and #response.tool_calls > 0 then
for _, tc in ipairs(response.tool_calls) do
-- execute the tool and get a result
local result = { temperature = 22, condition = "sunny" }
-- add the exchange to the conversation
conversation:add_function_call(tc.name, tc.arguments, tc.id)
conversation:add_function_result(tc.name, json.encode(result), tc.id)
end
-- continue generation with tool results
local final = llm.generate(conversation, { model = "gpt-4o" })
print(final.result)
end
FieldTypeDescription
idstringUnique call identifier
namestringTool name
argumentstableParsed arguments matching the schema
ValueBehavior
"auto"Model decides when to use tools (default)
"none"Never use tools
"any"Must use at least one tool
"tool_name"Must use the specified tool

Generate validated JSON matching a schema:

local llm = require("llm")
local schema = {
type = "object",
properties = {
name = { type = "string" },
age = { type = "number" },
hobbies = {
type = "array",
items = { type = "string" },
},
},
required = { "name", "age", "hobbies" },
additionalProperties = false,
}
local response, err = llm.structured_output(schema, "Describe a fictional character", {
model = "gpt-4o",
})
if not err then
print(response.result.name)
print(response.result.age)
end
For OpenAI models, all properties must be in the required array. Use union types for optional fields: type = {"string", "null"}. Set additionalProperties = false.

Models are defined as registry entries with meta.type: llm.model:

entries:
- name: gpt-4o
kind: registry.entry
meta:
name: gpt-4o
type: llm.model
title: GPT-4o
comment: OpenAI's flagship model
capabilities:
- generate
- tool_use
- structured_output
- vision
class:
- balanced
priority: 100
max_tokens: 128000
output_tokens: 16384
pricing:
input: 2.5
output: 10
providers:
- id: wippy.llm.openai:provider
provider_model: gpt-4o
FieldDescription
meta.nameModel identifier used in API calls
meta.typeMust be llm.model
meta.capabilitiesFeature list: generate, tool_use, structured_output, embed, thinking, vision, caching
meta.classClass membership: fast, balanced, reasoning, etc.
meta.priorityNumeric priority for class-based resolution (higher wins)
max_tokensMaximum context window
output_tokensMaximum output tokens
pricingCost per million tokens: input, output
providersArray with id (provider entry) and provider_model (provider-specific model name)

For locally hosted models (LM Studio, Ollama), define a separate provider entry with a custom base_url:

- name: local_provider
kind: registry.entry
meta:
name: ollama
type: llm.provider
title: Ollama Local
driver:
id: wippy.llm.openai:driver
options:
api_key_env: none
base_url: http://127.0.0.1:11434/v1
- name: local-llama
kind: registry.entry
meta:
name: local-llama
type: llm.model
title: Local Llama
capabilities:
- generate
max_tokens: 4096
output_tokens: 4096
pricing:
input: 0
output: 0
providers:
- id: app:local_provider
provider_model: llama-3.2

Models can be referenced by exact name, class, or explicit class prefix:

-- exact model name
llm.generate("Hello", { model = "gpt-4o" })
-- model class (picks highest priority in that class)
llm.generate("Hello", { model = "fast" })
-- explicit class syntax
llm.generate("Hello", { model = "class:reasoning" })

Resolution order:

  1. Match by exact meta.name
  2. Match by class name (highest meta.priority wins)
  3. With class: prefix, search only in that class

Query available models and their capabilities at runtime:

local llm = require("llm")
-- all models
local models = llm.available_models()
-- filter by capability
local tool_models = llm.available_models("tool_use")
local embed_models = llm.available_models("embed")
-- list model classes
local classes = llm.get_classes()
for _, c in ipairs(classes) do
print(c.name .. ": " .. c.title)
end

Generate vector embeddings for semantic search:

local llm = require("llm")
-- single text
local response = llm.embed("The quick brown fox", {
model = "text-embedding-3-small",
dimensions = 512,
})
-- response.result is a float array
-- multiple texts
local response = llm.embed({
"First document",
"Second document",
}, { model = "text-embedding-3-small" })
-- response.result is an array of float arrays

Probe a provider before sending work. Useful for readiness checks and lightweight health monitoring:

local status, err = llm.status({
model = "gpt-4o",
})
OptionDescription
modelRequired. Model to check.
provider_idOptional. Skip model resolution and target a specific provider.

Returns the provider’s StatusResponse (contents are provider-dependent).

Errors are returned as the second return value. On error, the first return value is nil:

local response, err = llm.generate("Hello", { model = "gpt-4o" })
if err then
io.print("Error: " .. tostring(err))
return
end
io.print(response.result)
ConstantDescription
llm.ERROR_TYPE.INVALID_REQUESTMalformed request
llm.ERROR_TYPE.AUTHENTICATIONInvalid API key
llm.ERROR_TYPE.RATE_LIMITProvider rate limit exceeded
llm.ERROR_TYPE.SERVER_ERRORProvider server error
llm.ERROR_TYPE.CONTEXT_LENGTHInput exceeds context window
llm.ERROR_TYPE.CONTENT_FILTERContent filtered by safety systems
llm.ERROR_TYPE.TIMEOUTRequest timed out
llm.ERROR_TYPE.MODEL_ERRORInvalid or unavailable model
ConstantDescription
llm.FINISH_REASON.STOPNormal completion
llm.FINISH_REASON.LENGTHReached max tokens
llm.FINISH_REASON.CONTENT_FILTERContent filtered
llm.FINISH_REASON.TOOL_CALLModel made a tool call
llm.FINISH_REASON.ERRORError during generation
ConstantDescription
llm.CAPABILITY.GENERATEText generation
llm.CAPABILITY.TOOL_USETool/function calling
llm.CAPABILITY.STRUCTURED_OUTPUTJSON structured output
llm.CAPABILITY.EMBEDVector embeddings
llm.CAPABILITY.THINKINGExtended thinking
llm.CAPABILITY.VISIONImage understanding
llm.CAPABILITY.CACHINGPrompt caching