tidyllm is an R package providing a unified interface for interacting with various large language model APIs. This vignette guides you through the basic setup and usage of tidyllm.
To install tidyllm from CRAN:
Or install the development version from GitHub:
Set API keys as environment variables. The easiest way is to add them
to your .Renviron file (run
usethis::edit_r_environ()) and restart R:
ANTHROPIC_API_KEY="your-key-here"
OPENAI_API_KEY="your-key-here"
Or set them temporarily in your session:
| Provider | Environment Variable | Where to get a key |
|---|---|---|
| Claude (Anthropic) | ANTHROPIC_API_KEY |
Anthropic Console |
| OpenAI | OPENAI_API_KEY |
OpenAI API Keys |
| Google Gemini | GOOGLE_API_KEY |
Google AI Studio |
| Mistral | MISTRAL_API_KEY |
Mistral Console |
| Groq | GROQ_API_KEY |
Groq Console |
| Perplexity | PERPLEXITY_API_KEY |
Perplexity API Settings |
| DeepSeek | DEEPSEEK_API_KEY |
DeepSeek Platform |
| Voyage AI | VOYAGE_API_KEY |
Voyage AI Dashboard |
| OpenRouter | OPENROUTER_API_KEY |
OpenRouter Dashboard |
| Azure OpenAI | AZURE_OPENAI_API_KEY |
Azure Portal |
tidyllm supports local inference via Ollama and llama.cpp. Both run entirely on your machine; no API key required.
For Ollama, install from ollama.com and pull a model:
For llama.cpp, see the Local Models article on the tidyllm website for a step-by-step setup guide.
tidyllm is built around a
message-centric interface. Interactions work through a
message history created by llm_message() and passed to
verbs like chat():
library(tidyllm)
conversation <- llm_message("What is the capital of France?") |>
chat(claude())
conversation## Message History:
## system:
## You are a helpful assistant
## --------------------------------------------------------------
## user:
## What is the capital of France?
## --------------------------------------------------------------
## assistant:
## The capital of France is Paris.
## --------------------------------------------------------------
# Continue the conversation with a different provider
conversation <- conversation |>
llm_message("What's a famous landmark in this city?") |>
chat(openai())
get_reply(conversation)## [1] "A famous landmark in Paris is the Eiffel Tower."
All API interactions follow the verb + provider pattern:
chat(),
embed(), send_batch(),
check_batch(), fetch_batch(),
list_batches(), list_models(),
deep_research(), check_job(),
fetch_job()openai(),
claude(), gemini(), ollama(),
mistral(), groq(), perplexity(),
deepseek(), voyage(),
openrouter(), llamacpp(),
azure_openai()Provider-specific functions like openai_chat() or
claude_chat() also work directly and expose the full range
of parameters for each API.
tidyllm supports sending images to multimodal models. Here we let a model describe a photo:
image_description <- llm_message("Describe this picture. Can you guess where it was taken?",
.imagefile = "picture.jpeg") |>
chat(openai(.model = "gpt-5.4"))
get_reply(image_description)## [1] "The picture shows a beautiful landscape with a lake, mountains, and a town nestled below. The area appears lush and green, with agricultural fields visible. This scenery is reminiscent of northern Italy, particularly around Lake Garda."
Pass a PDF path to the .pdf argument of
llm_message(). The package extracts the text and wraps it
in <pdf> tags in the prompt:
llm_message("Summarize the key points from this document.",
.pdf = "die_verwandlung.pdf") |>
chat(openai(.model = "gpt-5.4"))## Message History:
## system:
## You are a helpful assistant
## --------------------------------------------------------------
## user:
## Summarize the key points from this document.
## -> Attached Media Files: die_verwandlung.pdf
## --------------------------------------------------------------
## assistant:
## The story centres on Gregor Samsa, who wakes up transformed
## into a giant insect. Unable to work, he becomes isolated
## while his family struggles. Eventually Gregor dies, and his
## relieved family looks ahead to a better future.
## --------------------------------------------------------------
Specify page ranges with
.pdf = list(filename = "doc.pdf", start_page = 1, end_page = 5).
Use .f to capture console output from a function and
.capture_plot to include the last plot:
## Warning: Paket 'ggplot2' wurde unter R Version 4.4.3 erstellt
## Warning: Paket 'tibble' wurde unter R Version 4.4.3 erstellt
## Warning: Paket 'purrr' wurde unter R Version 4.4.3 erstellt
## Warning: Paket 'lubridate' wurde unter R Version 4.4.3 erstellt
ggplot(mtcars, aes(wt, mpg)) +
geom_point() +
geom_smooth(method = "lm", formula = "y ~ x") +
labs(x = "Weight", y = "Miles per gallon")llm_message("Analyze this plot and data summary:",
.capture_plot = TRUE,
.f = ~{summary(mtcars)}) |>
chat(claude())## Message History:
## system:
## You are a helpful assistant
## --------------------------------------------------------------
## user:
## Analyze this plot and data summary:
## -> Attached Media Files: file1568f6c1b4565.png, RConsole.txt
## --------------------------------------------------------------
## assistant:
## The scatter plot shows a clear negative correlation between
## weight and fuel efficiency. Heavier cars consistently
## achieve lower mpg, with the linear trend confirming this
## relationship. Variability around the line suggests other
## factors, engine size and transmission, also play a role.
## --------------------------------------------------------------
get_reply() retrieves assistant text from a message
history. Use an index to access a specific reply, or omit it to get the
last:
conversation <- llm_message("Imagine a German address.") |>
chat(groq()) |>
llm_message("Imagine another address.") |>
chat(claude())
conversation## Message History:
## system:
## You are a helpful assistant
## --------------------------------------------------------------
## user:
## Imagine a German address.
## --------------------------------------------------------------
## assistant:
## Herr Müller
## Musterstraße 12
## 53111 Bonn
## --------------------------------------------------------------
## user:
## Imagine another address.
## --------------------------------------------------------------
## assistant:
## Frau Schmidt
## Fichtenweg 78
## 42103 Wuppertal
## --------------------------------------------------------------
## [1] "Herr Müller\nMusterstraße 12\n53111 Bonn"
## [1] "Frau Schmidt\nFichtenweg 78\n42103 Wuppertal"
Convert the message history (without attachments) to a tibble with
as_tibble():
## # A tibble: 5 × 2
## role content
## <chr> <chr>
## 1 system "You are a helpful assistant"
## 2 user "Imagine a German address."
## 3 assistant "Herr Müller\nMusterstraße 12\n53111 Bonn"
## 4 user "Imagine another address."
## 5 assistant "Frau Schmidt\nFichtenweg 78\n42103 Wuppertal"
get_metadata() returns token counts, model names, and
API-specific metadata for all assistant replies:
## # A tibble: 2 × 6
## model timestamp prompt_tokens completion_tokens
## <chr> <dttm> <int> <int>
## 1 groq-… 2025-11-08 14:25:43 20 45
## 2 claud… 2025-11-08 14:26:02 80 40
## # ℹ 2 more variables: total_tokens <int>,
## # api_specific <list>
The api_specific list column holds provider-only
metadata such as citations (Perplexity), thinking traces (Claude,
Gemini, DeepSeek), or grounding information (Gemini).
tidyllm supports JSON schema enforcement so models
return structured, machine-readable data. Use
tidyllm_schema() with field helpers to define your
schema:
field_chr(): textfield_dbl(): numericfield_lgl(): booleanfield_fct(.levels = ...): enumerationfield_object(...): nested object (supports
.vector = TRUE for arrays)person_schema <- tidyllm_schema(
first_name = field_chr("A male first name"),
last_name = field_chr("A common last name"),
occupation = field_chr("A quirky occupation"),
address = field_object(
"The person's home address",
street = field_chr("Street name"),
number = field_dbl("House number"),
city = field_chr("A large city"),
zip = field_dbl("Postal code"),
country = field_fct("Country", .levels = c("Germany", "France"))
)
)
profile <- llm_message("Imagine a person profile matching the schema.") |>
chat(openai(), .json_schema = person_schema)
profile## Message History:
## system:
## You are a helpful assistant
## --------------------------------------------------------------
## user:
## Imagine a person profile matching the schema.
## --------------------------------------------------------------
## assistant:
## {"first_name":"Julien","last_name":"Martin","occupation":"Gondola
## Repair Specialist","address":{"street":"Rue de
## Rivoli","number":112,"city":"Paris","zip":75001,"country":"France"}}
## --------------------------------------------------------------
Extract the parsed result as an R list with
get_reply_data():
## List of 4
## $ first_name: chr "Julien"
## $ last_name : chr "Martin"
## $ occupation: chr "Gondola Repair Specialist"
## $ address :List of 5
## ..$ street : chr "Rue de Rivoli"
## ..$ number : int 112
## ..$ city : chr "Paris"
## ..$ zip : int 75001
## ..$ country: chr "France"
Set .vector = TRUE on field_object() to get
outputs that unpack directly into a data frame:
llm_message("Imagine five people.") |>
chat(openai(),
.json_schema = tidyllm_schema(
person = field_object(
first_name = field_chr(),
last_name = field_chr(),
occupation = field_chr(),
.vector = TRUE
)
)) |>
get_reply_data() |>
bind_rows()## first_name last_name occupation
## 1 Alice Johnson Software Developer
## 2 Robert Anderson Graphic Designer
## 3 Maria Gonzalez Data Scientist
## 4 Liam O'Connor Mechanical Engineer
## 5 Sophia Lee Content Writer
If the ellmer package is installed, you can use
ellmer type objects (ellmer::type_string(),
ellmer::type_object(), etc.) directly as field definitions
in tidyllm_schema() or as the .json_schema
argument.
Common arguments like .model, .temperature,
and .json_schema can be set directly in verbs like
chat(). Provider-specific arguments go in the provider
function:
# Common args in the verb
llm_message("Write a haiku about tidyllm.") |>
chat(ollama(), .temperature = 0)
# Provider-specific args in the provider
llm_message("Hello") |>
chat(ollama(.ollama_server = "http://my-server:11434"), .temperature = 0)When an argument appears in both chat() and the provider
function, chat() takes precedence. If a common argument is
not supported by the chosen provider, chat() raises an
error rather than silently ignoring it.
Define R functions that the model can call during a conversation.
Wrap them with tidyllm_tool():
get_current_time <- function(tz, format = "%Y-%m-%d %H:%M:%S") {
format(Sys.time(), tz = tz, format = format, usetz = TRUE)
}
time_tool <- tidyllm_tool(
.f = get_current_time,
.description = "Returns the current time in a specified timezone.",
tz = field_chr("The timezone identifier, e.g. 'Europe/Berlin'."),
format = field_chr("strftime format string. Default: '%Y-%m-%d %H:%M:%S'.")
)
llm_message("What time is it in Stuttgart right now?") |>
chat(openai(), .tools = time_tool)## Message History:
## system:
## You are a helpful assistant
## --------------------------------------------------------------
## user:
## What time is it in Stuttgart right now?
## --------------------------------------------------------------
## assistant:
## The current time in Stuttgart (Europe/Berlin) is 2025-03-03
## 09:51:22 CET.
## --------------------------------------------------------------
When a tool is provided, the model can request its execution, tidyllm runs it in your R session, and the result is fed back automatically. Multi-turn tool loops (where the model makes several tool calls) are handled transparently.
If you use ellmer, convert existing ellmer tool
definitions with ellmer_tool():
library(ellmer)
btw_tool <- ellmer_tool(btw::btw_tool_files_list_files)
llm_message("List files in the R/ folder.") |>
chat(claude(), .tools = btw_tool)For a detailed guide, see the Tool Use article on the tidyllm website.
Generate vector representations of text with embed().
The output is a tibble with one row per input:
c("What is the meaning of life?",
"How much wood would a woodchuck chuck?",
"How does the brain work?") |>
embed(ollama())## # A tibble: 3 × 2
## input embeddings
## <chr> <list>
## 1 What is the meaning of life? <dbl [384]>
## 2 How much wood would a woodchuck chuck? <dbl [384]>
## 3 How does the brain work? <dbl [384]>
Voyage AI supports multimodal embeddings, meaning text and images share the same vector space:
voyage_rerank() re-orders documents by relevance to a
query:
Batch APIs (Claude, OpenAI, Mistral, Groq, Gemini) process large
numbers of requests overnight at roughly half the cost of standard
calls. Use send_batch() to submit,
check_batch() to poll status, and
fetch_batch() to retrieve results:
# Submit a batch and save the job handle to disk
glue::glue("Write a poem about {x}", x = c("cats", "dogs", "hamsters")) |>
purrr::map(llm_message) |>
send_batch(claude()) |>
saveRDS("claude_batch.rds")## # A tibble: 1 × 8
## batch_id status created_at expires_at req_succeeded
## <chr> <chr> <dttm> <dttm> <int>
## 1 msgbatch_02A1B2C… ended 2025-11-01 10:30:00 2025-11-02 10:30:00 3
## # ℹ 3 more variables: req_errored <int>, req_expired <int>, req_canceled <int>
# Fetch completed results
conversations <- readRDS("claude_batch.rds") |>
fetch_batch(claude())
poems <- purrr::map_chr(conversations, get_reply)check_job() and fetch_job() are
type-dispatching aliases; they work on both batch objects and Perplexity
research jobs (see deep_research() below).
For long-horizon research tasks, deep_research() runs an
extended web search and synthesis. Perplexity’s
sonar-deep-research model is currently supported:
Stream reply tokens to the console as they are generated with
.stream = TRUE:
Streaming is useful for monitoring long responses interactively. For production data-analysis workflows the non-streaming mode is preferred because it provides complete metadata and is more reliable.
| Provider | Strengths and tidyllm-specific features |
|---|---|
openai() |
Top benchmark performance across coding, math, and reasoning;
o3/o4 reasoning models |
claude() |
Best-in-class coding (SWE-bench leader);
.thinking = TRUE for extended reasoning; Files API for
document workflows; batch |
gemini() |
1M-token context window; video and audio via file upload API; search
grounding; .thinking_budget for reasoning; batch |
mistral() |
EU-hosted, GDPR-friendly; Magistral reasoning models; embeddings; batch |
groq() |
Fastest available inference (300-1200 tokens/s);
groq_transcribe() for Whisper audio;
.json_schema; batch |
perplexity() |
Real-time web search with citations in metadata;
deep_research() for long-horizon research; search domain
filter |
deepseek() |
Top math and reasoning benchmarks at very low cost;
.thinking = TRUE auto-switches to
deepseek-reasoner |
voyage() |
State-of-the-art retrieval embeddings; voyage_rerank();
multimodal embeddings (text + images);
.output_dimension |
openrouter() |
500+ models via one API key; automatic fallback routing;
openrouter_credits() and per-generation cost tracking |
ollama() |
Local models with full data privacy; no API costs;
ollama_download_model() for model management |
llamacpp() |
Often the most performant local inference stack; BNF grammar
constraints (.grammar); logprobs via
get_logprobs(); llamacpp_rerank(); model
management |
azure_openai() |
Enterprise Azure deployments of OpenAI models; batch support |
For getting started with local models (Ollama and llama.cpp), see the Local Models article on the tidyllm website.
Avoid specifying a provider on every call by setting options:
options(tidyllm_chat_default = openai(.model = "gpt-5.4"))
options(tidyllm_embed_default = ollama())
options(tidyllm_sbatch_default = claude(.temperature = 0))
options(tidyllm_cbatch_default = claude())
options(tidyllm_fbatch_default = claude())
options(tidyllm_lbatch_default = claude())
# Now the provider argument can be omitted
llm_message("Hello!") |> chat()
c("text one", "text two") |> embed()