The hardware and bandwidth for this mirror is donated by METANET, the Webhosting and Full Service-Cloud Provider.
If you wish to report a bug, or if you are interested in having us mirror your free-software or open-source project, please feel free to contact us at mirror[@]metanet.ch.

tidyllm 0.7.0

This release adds a provider that talks to a locally installed Claude CLI, a web search tool that works with every provider, and provider_capabilities() to see what each provider accepts. It also moves the default models to the autumn 2026 lineup and rewrites the classifier article for models that no longer accept a temperature.

claude_cli(): chat through the Claude CLI you already have

claude_cli() is a provider that sends nothing over the network itself. It runs the claude command line tool installed on your own machine and reads its JSON output back, using the login that tool already has. There is no API key to set, and the usage counts against whatever plan the CLI is signed in to.

llm_message("Explain R's S7 classes in three sentences.") |>
  chat(claude_cli())

Everything the CLI reports comes back through the usual accessors: get_metadata() carries the token counts including cache reads, and its api_specific column adds the session id, the stop reason, the number of turns and total_cost_usd, which is the real dollar cost of that one call.

Streaming and send_chat() both work, so a CLI call can print as it arrives or run in the background of a session that keeps going.

The CLI is an agent rather than a plain completion endpoint: left alone it can read files, edit them and run shell commands. tidyllm turns all of that off, because a call to chat() that quietly edits files in your working directory is not what the rest of the package does. Pass .cli_tools to allow specific tools back, or .cli_tools = TRUE to hand over to the CLI’s own configuration.

llm_message("Summarise the DESCRIPTION file here.") |>
  chat(claude_cli(.cli_tools = c("Read", "Glob")))

.stateful = TRUE leaves the conversation on the CLI’s side: the first call records a session id, and later calls resume it instead of replaying the whole history.

.json_schema maps onto the CLI’s own structured-output flag, so tidyllm_schema() works here as it does everywhere else.

claude_cli() does not take .tools. The CLI runs its own tool loop and never exposes tool-call blocks to a caller, so tidyllm’s tool loop has nothing to act on; chat(claude_cli(), .tools = ...) says so rather than silently ignoring it.

claude_cli() looks for the CLI on the PATH and, failing that, in the places the installers write to. A GUI R session does not inherit the PATH from your shell profile, so RStudio in particular can miss a perfectly good install in ~/.local/bin. To point at it yourself, set

options(tidyllm_claude_cli_path = "/path/to/claude")

in your .Rprofile, or the TIDYLLM_CLAUDE_CLI environment variable, or pass claude_cli(.binary = "/path/to/claude") for a single call.

The provider needs processx, which is in Suggests and checked where it is used, so nothing changes for anyone who does not call it.

websearch_tool(): web search for any model

Until now only some providers could search the web, each through its own built-in tool. websearch_tool() gives the same ability to every provider that supports tools, including local models through ollama() and llamacpp():

llm_message("Which central banks changed interest rates this week?") |>
  chat(ollama(), .tools = websearch_tool())

The model decides when to search and chooses the query; nothing else is up to it. The number of results, whether full page text is included and how long that text may be are fixed when you create the tool, so a model cannot run up the search bill. Each search returns numbered results with title, URL, publication date and an excerpt, and the tool asks the model to cite the URLs it uses.

The first search service is Tavily. It needs a TAVILY_API_KEY; the free plan gives 1,000 credits a month without a credit card, and a basic search costs one. Tavily’s own options, such as topic = "news", time_range = "week" or include_domains, pass through ..., and a misspelled option is caught when the tool is created. A search that fails, for instance because the monthly credits are used up, comes back to the model as a message rather than stopping the conversation.

The second search service is SearXNG, a free search engine you run yourself. Choose it with .backend = "searxng" and give the address of your server with .server or the SEARXNG_SERVER environment variable. The server’s settings.yml must allow JSON output (and, for a server only you use, turn the limiter off). SearXNG returns no page text, so .include_content = TRUE is an error there. Its options, such as engines, language or time_range, pass through ... like Tavily’s.

websearch() runs the same search directly and returns a tibble with one row per result: query, title, URL, publication date, snippet and, if asked for, the page text. It takes the same arguments as websearch_tool(), so you can check what a model would see, or collect search results as data:

c("ZEW Mannheim", "ifo Institut") |>
  purrr::map(websearch, .max_results = 3) |>
  purrr::list_rbind()

Tool results reach Claude and Gemini as plain text

A tool that returns text used to reach claude() and gemini() in R’s printed form, [1] "...", with every line break and quote escaped. They now get the text as it is. Tools that return other values, such as a data frame or a named vector, are still printed, as before.

Streaming is no longer tied to HTTP

The stream pump used to ask httr2 directly whether a connection was finished and how to close it. Those two questions now go through the provider, alongside the reader that was already there, which is what lets a stream come from a local process instead of an HTTP response. send_chat() likewise asks the provider how to start, rather than always building an httr2 promise. No behaviour changes for the twelve HTTP providers.

provider_capabilities(): what each provider supports

provider_capabilities() returns a tibble of every verb a provider implements and the arguments of each verb, with each argument’s default (for .model, the provider’s default model) and the function that implements the verb. provider_capabilities(claude(), .verb = "chat") shows what chat(claude()) accepts; .what = "media" shows which of image, pdf, audio, video and remote files each provider takes in a message. It reads the registry the verbs already use, so it cannot drift from the code.

The attachment check in chat() now reads the same media registry instead of lists of provider names. send_chat() and parallel_chat() passed a provider call where that check expected a name, so a message with audio, video or a file failed there with an internal error; it is now checked like in chat().

Perplexity is deprecated

perplexity() and the perplexity_*() functions warn since 0.7.0 and are removed in 0.8.0. The maintainer cannot test them against a funded account, and Perplexity is moving its Sonar models to a new Agent API. The same models are available as openrouter(.model = "perplexity/sonar") (also perplexity/sonar-pro and perplexity/sonar-deep-research), and websearch_tool() gives any provider web search.

Tools without arguments

A tool with no arguments made claude() reject the whole request, because the empty argument list was sent as [] where the API needs {}. All three tool schema builders (Claude, OpenAI and the chat completions providers) now send {}. This also makes MCP servers usable: mcptools::mcp_tools() |> lapply(ellmer_tool) hands their tools to any provider, and the mcptools helper tools that take no arguments no longer break Claude.

.capture_plot no longer warns

llm_message(.capture_plot = TRUE) shared a code path with the deprecated .imagefile, so it printed a warning that pointed to .media = img(path), advice that cannot capture a plot. It now saves the current plot to a temporary PNG and attaches it as an img() in the message’s media, with no warning.

New default models

Several providers retired or replaced models over the summer, so the defaults moved.

Mistral reasoning models

Models that think before they answer (Magistral, Mistral Medium 3.5, GLM on Mistral) send their reply as separate thinking and text pieces. get_reply() now returns the text alone, streaming works, and the thinking is kept under thinking in the api_specific metadata. Streamed tool calls also survive servers that repeat an empty function name on later fragments of the same call.

Bug fixes

Documentation

The classifier article no longer relies on a temperature of zero, which most current models reject. It now runs the model several times with a fixed answer schema, measures agreement, sends disagreements to a second review, validates against hand-checked labels and prices the run, and it ships the cached runs so the article builds without an API key. The Get Started article, the PDF questions, Shiny, video, local models and tools articles use current models and links.

tidyllm 0.6.0

tidyllm no longer has to block. A script can fire a request and keep working, several prompts can run at once, and a Shiny app can stream tokens into its UI without freezing itself or anyone else’s session. The headline verbs are send_chat() and parallel_chat(); underneath them sit a shared streaming pump and a chat pipeline split that every provider now goes through. Streaming and tool calls also stop being mutually exclusive.

No new required dependency: later and promises are in Suggests and checked where they are used, and the Shiny path needs neither promises nor coro.

send_chat(): a chat that does not block the session

There are now three ways to run a chat. chat() when you want the answer now. send_batch() when you have thousands of prompts and want them at half price overnight. And new in 0.6.0, send_chat() when you have one slow request and a session you would rather keep using. All three end in an LLMMessage, and the last two share the same check_job() / fetch_job() vocabulary.

job <- llm_message("Summarise this 400-page report") |>
  send_chat(claude(), .stream = TRUE)

while (check_job(job) == "running") {
  do_something_else()
  cat("\r", nchar(get_partial(job)), "characters so far")
}

reply <- fetch_job(job)          # the LLMMessage chat() would have returned

get_partial() is the text so far and cancel_job() stops the request. .on_chunk is the push form of the same thing: a function called with each delta as it arrives, which is all a Shiny app needs to render a reply token-by-token into a reactiveVal, with no promises and no coro involved.

Nothing runs on a thread or in a second process. The request is driven from R’s own event loop, waiting on curl’s file descriptors rather than on a timer, in the gaps between whatever else the session is doing. Two consequences follow from that and are worth knowing: several jobs run genuinely concurrently, and a blocking call of your own pauses them all for its duration.

Requires the later package, and promises as well for .stream = FALSE. Neither is a new hard dependency; both are checked at the point of use.

Known limits of the first cut: a job with .tools performs its tool rounds without yielding, so the session pauses for their duration, and a streamed job is not retried after a transient 429 the way chat() is.

check_job() and fetch_job() are S3 generics now rather than a chain of ifs, so batch jobs, background research jobs and chat jobs are one vocabulary reached by one mechanism.

parallel_chat(): many prompts at once

answers <- parallel_chat(list(physics = llm_message("What is a photon?"),
                              biology = llm_message("What is a ribosome?")),
                         claude())

Performs a list of messages concurrently against one provider and returns their replies in the same order under the same names. Measured on three one-sentence questions to claude(): 2.1 seconds against 5.4 for the same three in a loop.

.max_active bounds how many are in flight and .throttle caps requests per second; both matter more than they look, because httr2 applies retries across the whole set rather than per request, so a high .max_active against a rate-limited provider is a good way to collect 429s.

A failed request is returned in its own slot as the condition that failed, rather than as a hole that would silently shorten a downstream map(). Streaming and tool calls are refused rather than quietly ignored: use send_chat(), which can hold several conversations at once.

Shiny: an example app and an article

tidyllm_example_app("model_explainer") runs a small Shiny app that ships with the package. It fits a linear model to a public dataset and streams two explanations of the coefficients side by side, one in plain English and one from a sceptical referee, while the app stays responsive. Every number the model sees is computed in R and pasted into the prompt verbatim; the model does the narrating and none of the arithmetic.

It defaults to a local ollama() model, so it runs with no API key and no spend, and a dropdown switches it to Claude, OpenAI or Gemini. The source is a single file, and it is the reference implementation for the things a real app needs: .on_chunk into a reactiveVal, a status observer for the failures that carry no delta, cancel_job() on a button and on session$onSessionEnded(), and a follow-up turn on the immutable LLMMessage.

The new article Using tidyllm in Shiny walks through those patterns and closes with what to watch out for, including one worth knowing before you design a UI: a streaming send_chat() returns when the response headers arrive, so a server that answers one request at a time (a stock Ollama) makes a second concurrent job wait, while cloud providers stream both at once.

shiny and wooldridge are new in Suggests, for the app and one of its datasets.

Streaming and tool calls work together

.stream = TRUE and .tools used to be mutually exclusive: every provider raised “Streaming is not supported for requests with tool calls” if both were given. That restriction is gone for claude(), openai(), gemini(), ollama(), groq(), mistral(), deepseek(), openrouter(), llamacpp(), azure_openai() and chat_completions(). The reply streams to the console, the tool calls run when the stream ends, and each follow-up round streams too.

llm_message("What is the weather in Berlin and Reykjavik?") |>
  chat(claude(), .tools = weather_tool, .stream = TRUE)

The reason it was blocked is that the tool loop reads tool calls out of a complete response body, which a stream never produced; it produced a list of events instead. A new assemble_stream_body() generic folds those events back into the body shape, so has_tool_calls(), extract_tool_calls(), run_tool_calls() and append_tool_messages() are reused without a single streaming-specific branch. Streamed and blocking responses now carry the same raw$content, which is also what the async driver needs.

Only Claude requires real reassembly: it streams tool arguments as JSON fragments that split mid-token and interleave between two concurrent calls, so they are accumulated per content block rather than into one buffer. OpenAI’s response.completed event already carries fully-formed calls, and Gemini and Ollama send their calls parsed.

Details worth knowing, all of them cases that only exist because the two can now be combined:

Internal: the chat pipeline

Nothing user-visible changed here, but it is the largest structural change in the release. Every *_chat() used to be one function body welding request construction, the HTTP call, the tool loop and add_message() together, which meant nothing but *_chat() itself could reach the middle of it.

Bug fixes (this development cycle)

Streaming

Credential handling

Bug fixes

tidyllm 0.5.2

A bugfix release. No new providers, verbs, or media types.

JSON schemas (structured output and tool definitions)

Error messages

Token metadata

Provider fixes

tidyllm 0.5.1

Anthropic API migration

The Anthropic Messages API changed for current-generation models (Claude Sonnet 5, Opus 4.7 and newer): the fixed-budget thinking interface and the sampling parameters temperature, top_k, and top_p are rejected with a 400 error. tidyllm 0.5.1 tracks these changes:

Prompt caching

Other changes

tidyllm 0.5.0

Unified Media Interface

.media argument on llm_message()

All non-text content now attaches to messages through a single .media argument that accepts any combination of media types. Pass a single object or a list:

# Single image
llm_message("What is in this image?",
            .media = img("photo.jpg")) |>
  chat(claude())

# Multiple images in one message
llm_message("Describe the difference between these two images.",
            .media = list(img("before.jpg"), img("after.jpg"))) |>
  chat(openai())

# Mixed types: image + PDF together
llm_message("Does this figure match what is reported in Table 2?",
            .media = list(img("figure_3.png"),
                          pdf_file("paper.pdf", pages = 1:8))) |>
  chat(gemini())

New media constructors

Three new constructors join img():

# Transcribe a recording
llm_message("Summarise what is discussed in this interview.",
            .media = audio_file("bosch_interview.mp3")) |>
  chat(gemini())

# Analyse a video clip with a JSON schema
video_schema <- tidyllm_schema(
  title      = field_chr("Title or subject of the clip"),
  era        = field_chr("Approximate decade or period depicted"),
  key_people = field_chr("Names mentioned, semicolon-separated")
)

llm_message("Analyse this video clip.",
            .media = video_file("documentary.mp4")) |>
  chat(gemini(), .json_schema = video_schema)

# Extract references from a scanned PDF (binary path, no OCR needed)
llm_message("Extract all references in APA format.",
            .media = pdf_file("1995_Neal_Industry_Specific.pdf")) |>
  chat(claude(), .json_schema = ref_schema)

# Force text extraction for any provider
llm_message("Summarise this report.",
            .media = pdf_file("annual_report.pdf", .text_extract = TRUE)) |>
  chat(openai())

Multi-image support

All providers that accept images now handle multiple images per message. Pass them as a list inside .media. Claude supports up to 600 images per message; Gemini up to 3,600.

Provider Files API

A unified set of verbs manages files stored on provider servers. Upload once, reuse across many requests:

# Upload; returns a tidyllm_file handle
report <- upload_file(gemini(), .path = "quarterly_report.pdf")

# Attach the handle to any message via .files
llm_message("What were the key results this quarter?",
            .files = report) |>
  chat(gemini())

llm_message("List the top three risks in the document.",
            .files = report) |>
  chat(gemini())

# Inspect and manage uploaded files
list_files(gemini())
file_info(gemini(), report)      # accepts a tidyllm_file or a plain ID string
delete_file(gemini(), report)

The same pattern works with claude() and openai(). Provider support:

A tidyllm_file is provider-specific: a file uploaded to Claude cannot be sent to Gemini. tidyllm validates provider match before every request.

OpenAI Provider Rewrite

Responses API

openai() now uses the Responses API (POST /v1/responses). All existing workflows continue to work unchanged. New capabilities unlocked by the rewrite:

# Reasoning effort for o-series models
llm_message("Prove that there are infinitely many primes.") |>
  chat(openai(.model = "o4-mini"), .reasoning_effort = "high")

# Stateful multi-turn conversations (server retains context by ID)
first  <- llm_message("My name is Alex.") |>
  chat(openai(), .stateful = TRUE)

second <- llm_message("What is my name?") |>
  chat(openai(), .previous_response_id = first)

Batch processing (send_batch(openai())) continues to use the Chat Completions endpoint internally.

Built-in server-executed tools

# Web search: the server runs the search, results appear in the reply
llm_message("What happened in AI research this week?") |>
  chat(openai(), .tools = openai_websearch())

# Code interpreter
llm_message("Plot a histogram of 1,000 standard-normal samples.") |>
  chat(openai(), .tools = openai_code_interpreter())

# Mix built-in and custom tools in one call
llm_message("Find today's EUR/USD rate and convert 500 EUR.") |>
  chat(openai(), .tools = list(openai_websearch(), my_converter_tool))

OpenAI deep research

# Background research job (slow; typically 5 to 30 minutes)
job <- llm_message("Survey the literature on causal inference with LLMs.") |>
  deep_research(openai(.model = "o4-mini-deep-research"), .background = TRUE)

check_job(job)
result <- fetch_job(job)

New Provider: chat_completions()

A chat_completions() provider for any OpenAI-compatible endpoint (vLLM, LiteLLM, Together AI, Anyscale, and others), without having to repurpose openai():

llm_message("Hello!") |>
  chat(chat_completions(
    .api_url        = "https://api.together.xyz/v1/",
    .api_key_env_var = "TOGETHER_API_KEY",
    .model          = "meta-llama/Llama-3-8b-chat-hf"
  ))

Provider Enhancements

Mistral

OpenRouter

Deprecations

The following are soft-deprecated with warnings in 0.5.0 and will remain as permanent aliases:

Small Changes


tidyllm 0.4.0

New Providers

OpenRouter (openrouter())

Access to 300+ models from a single API key via OpenRouter. Supports chat, embeddings, model listing, and fallback routing across providers:

# Chat with any model on OpenRouter
llm_message("What is the capital of France?") |>
  chat(openrouter(.model = "anthropic/claude-3.5-sonnet"))

# List available models
list_models(openrouter())

# Check account credits
openrouter_credits()

# Retrieve generation metadata (tokens, cost) for a completed request
openrouter_generation(generation_id)

OpenRouter also supports provider fallback routing — specify a list of fallback providers to use if the primary model is unavailable.

llama.cpp (llamacpp())

Full support for local llama.cpp servers, including chat, embeddings, reranking, and model management:

# Chat with a local llama.cpp server
llm_message("Explain R to a Python developer") |>
  chat(llamacpp())

# Generate embeddings
c("text one", "text two") |> embed(llamacpp())

# Rerank documents by relevance
llamacpp_rerank("best R package for LLMs", c("tidyllm", "ellmer", "httr2"))

# Model management
llamacpp_list_local_models()          # list models in the model directory
list_hf_gguf_files("Qwen/Qwen2.5-7B-Instruct-GGUF")  # browse HuggingFace GGUF files
llamacpp_download_model("Qwen/Qwen2.5-7B-Instruct-GGUF", "qwen2.5-7b-instruct-q4_k_m.gguf")
llamacpp_delete_model("path/to/model.gguf")
llamacpp_health()                     # check server status

New Verbs

deep_research(), check_job(), fetch_job()

A new deep_research() verb for running long-horizon research tasks. Currently supported by perplexity() via the sonar-deep-research model:

# Blocking — waits for completion and returns an LLMMessage
result <- llm_message("Compare Rust and Go for systems programming") |>
  deep_research(perplexity())

get_reply(result)
get_metadata(result)$api_specific[[1]]$citations

# Background — returns immediately, poll with check_job() / fetch_job()
job <- llm_message("Summarize the latest EU AI Act developments") |>
  deep_research(perplexity(), .background = TRUE)

check_job(job)   # poll status
result <- fetch_job(job)  # retrieve when complete

check_job() and fetch_job() are type-dispatching aliases — they delegate to check_batch()/fetch_batch() for batch objects, or to perplexity_check_research()/perplexity_fetch_research() for research jobs.

Provider Enhancements

Perplexity

Thinking modes

Extended thinking is now available for two additional providers:

Tool use improvements

Ellmer compatibility

Groq

Voyage AI

OpenRouter

Azure OpenAI

Small Changes / Housekeeping

Bug Fixes

Version 0.3.5

Key Improvements

example_file <- here::here("vignettes","die_verwandlung.pdf") |> 
  claude_upload_file()

llm_message("Summarize the document in 100 words") |>
  chat(claude(.file_ids = example_file$file_id)) 
  
#> Message History:
#> system:
#> You are a helpful assistant
#> --------------------------------------------------------------
#> user:
#> Summarize the document in 100 words
#> --------------------------------------------------------------
#> assistant:
#> This document is the German text of Franz Kafka's novella
#> "Die Verwandlung" (The Metamorphosis), published through
#> Project Gutenberg. The story follows Gregor Samsa, a
#> traveling salesman who wakes up one morning transformed into
#> a monstrous insect-like creature. Unable to work and support
#> his family, Gregor becomes isolated in his room while his
#> family struggles with the burden of his transformation.
#> His sister Grete initially cares for him, bringing food
#> and cleaning his room, but over time the family's situation
#> deteriorates financially and emotionally. The story explores
#> themes of alienation, family duty, and dehumanization as
#> Gregor gradually loses his human identity and connection to
#> his family. Eventually, Gregor dies, and his family, though
#> initially grief-stricken, ultimately feels relieved and
#> optimistic about their future without the burden of caring
#> for him. The text includes the complete three-part novella
#> along with Project Gutenberg licensing information.
#> --------------------------------------------------------------  

Version 0.3.4

This release marks a major internal refactor accompanied by a suite of subtle yet impactful improvements. While many changes occur under the hood, they collectively deliver a more robust, flexible, and maintainable framework.

Key Improvements

Bug Fixes

Dev-Version 0.3.3

Thinking support in Claude

Claude now supports reasoning:

conversation <- llm_message("Are there an infinite number of prime numbers such that n mod 4 == 3?") |>
   chat(claude(.thinking=TRUE)) |>
  print()
   
#> Message History:
#> system:
#> You are a helpful assistant
#> --------------------------------------------------------------
#> user:
#> Are there an infinite number of prime numbers such that n
#> mod 4 == 3?
#> --------------------------------------------------------------
#> assistant:
#> # Infinitude of Primes Congruent to 3 mod 4
#> 
#> Yes, there are infinitely many prime numbers $p$ such
#> that $p \equiv 3 \pmod{4}$ (when $p$ divided by 4 leaves
#> remainder 3).
#> 
#> ## Proof by Contradiction
#> 
#> I'll use a proof technique similar to Euclid's classic proof
#> of the infinitude of primes:
#> 
#> 1) Assume there are only finitely many primes $p$ such that
#> $p \equiv 3 \pmod{4}$. Let's call them $p_1, p_2, ..., p_k$.
#> 
#> 2) Consider the number $N = 4p_1p_2...p_k - 1$
#> 
#> 3) Note that $N \equiv 3 \pmod{4}$ since $4p_1p_2...p_k
#> \equiv 0 \pmod{4}$ and $4p_1p_2...p_k - 1 \equiv -1 \equiv 3
#> \pmod{4}$
#> 
#> 4) $N$ must have at least one prime factor $q$
#> 
#> 5) For any $i$ between 1 and $k$, we have $N \equiv -1
#> \pmod{p_i}$, so $N$ is not divisible by any of the primes
#> $p_1, p_2, ..., p_k$
#> 
#> 6) Therefore, $q$ is a prime not in our original list
#> 
#> 7) Furthermore, $q$ must be congruent to 3 modulo 4:
#> - $q$ cannot be 2 because $N$ is odd
#> - If $q \equiv 1 \pmod{4}$, then $\frac{N}{q} \equiv 3
#> \pmod{4}$ would need another prime factor congruent to 3
#> modulo 4
#> - So $q \equiv 3 \pmod{4}$
#> 
#> 8) This contradicts our assumption that we listed all primes
#> of the form $p \equiv 3 \pmod{4}$
#> 
#> Therefore, there must be infinitely many primes of the form
#> $p \equiv 3 \pmod{4}$.
#> --------------------------------------------------------------

#Thinking process is stored in API-specific metadata
conversation |> 
   get_metadata() |>
   dplyr::pull(api_specific) |>
   purrr::map_chr("thinking") |>
   cat()
   
#> The question is asking if there are infinitely many prime numbers $p$ such that $p \equiv 3 \pmod{4}$, i.e., when divided by 4, the remainder is 3.
#> 
#> I know that there are infinitely many prime numbers overall. The classic proof is Euclid's proof by contradiction: if there were only finitely many primes, we could multiply them all together, add 1, and get a new number not divisible by any of the existing primes, which gives us a contradiction.
#> 
#> For primes of the form $p \equiv 3 \pmod{4}$, we can use a similar proof strategy. 
#> 
#> Let's assume there are only finitely many primes $p_1, p_2, \ldots, p_k$ such that $p_i \equiv 3 \pmod{4}$ for all $i$. 
#> 
#> Now, consider the number $N = 4 \cdot p_1 \cdot p_2 \cdot \ldots \cdot p_k - 1$. 
#> 
#> Note that $N \equiv -1 \equiv 3 \pmod{4}$. 
#> 
#> Now, let's consider the prime factorization of $N$. If $N$ is itself prime, then we have found a new prime $N$ such that $N \equiv 3 \pmod{4}$, which contradicts our assumption that we enumerated all such primes.
#> 
> ...

Bugfixes

Version 0.3.2

Tool usage introduced to tidyllm

A first tool usage system inspired by a similar system in ellmer has been introduced to tidyllm. At the moment tool use is available for claude(), openai(), mistral(), ollama(), gemini() and groq():

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. Use this to determine the current time in any location.",
  tz = field_chr("The time zone identifier (e.g., 'Europe/Berlin', 'America/New_York', 'Asia/Tokyo', 'UTC'). Required."),
  format = field_chr("Format string for the time output. Default is '%Y-%m-%d %H:%M:%S'.")
)


llm_message("What's the exact time in Stuttgart?") |>
  chat(openai,.tools=time_tool)
  
#> Message History:
#> system:
#> You are a helpful assistant
#> --------------------------------------------------------------
#> user:
#> What's the exact time in Stuttgart?
#> --------------------------------------------------------------
#> assistant:
#> The current time in Stuttgart (Europe/Berlin timezone) is
#> 2025-03-03 09:51:22 CET.
#> --------------------------------------------------------------  

You can use the tidyllm_tool() function to define tools available to a large language model. Once a tool or a list of tools is passed to a model, it can request to run these these functions in your current session and use their output for further generation context.

Support for DeepSeek added

tidyllm now supports the deepseek API as provider via deepseek_chat() or the deepseek() provider function. Deepseek supports logprobs just like openai(), which you can get via get_logprobs(). At the moment tool usage for deepseek is very inconsistent.

Support for Voyage.ai and Multimodal Embeddings Added

Voyage.ai introduces a unique multimodal embeddings feature, allowing you to generate embeddings not only for text but also for images. The new voyage_embedding() function in tidyllm enables this functionality by seamlessly handling different input types, working with both the new feature as well as the same inputs as for other embedding functions.

The new img() function lets you create image objects for embedding. You can mix text and img() objects in a list and send them to Voyage AI for multimodal embeddings:

list("tidyllm", img(here::here("docs", "logo.png"))) |>
  embed(voyage)
#> # A tibble: 2 × 2
#>   input          embeddings   
#>   <chr>          <list>       
#> 1 tidyllm        <dbl [1,024]>
#> 2 [IMG] logo.png <dbl [1,024]>

In this example, both text ("tidyllm") and an image (logo.png) are embedded together. The function returns a tibble where the input column contains the text and labeled image names, and the embeddings column contains the corresponding embedding vectors.

New Tests and Bugfixes

Version 0.3.1

⚠️ There is a bad bug in the latest CRAN release in the fetch_openai_batch() function that is only fixed in version 0.3.2. For the release 0.3.1. the fetch_openai_batch() function throws errors if the logprobs are turned off.

Changes compared to last release

 ellmer_adress <-ellmer::type_object(
    street = ellmer::type_string("A famous street"),
    houseNumber = ellmer::type_number("a 3 digit number"),
    postcode = ellmer::type_string(),
    city = ellmer::type_string("A large city"),
    region = ellmer::type_string(),
    country = ellmer::type_enum(values = c("Germany", "France"))
  ) 

person_schema <-  tidyllm_schema(
                person_name = "string",
                age = field_dbl("An age between 25 and 40"),
                is_employed = field_lgl("Employment Status in the last year")
                occupation = field_fct(.levels=c("Lawyer","Butcher")),
                address = ellmer_adress
                )

address_message <- llm_message("imagine an address") |>
  chat(openai,.json_schema = ellmer_adress)
  
person_message  <- llm_message("imagine a person profile") |>
  chat(openai,.json_schema = person_schema)
badger_poem <- llm_message("Write a haiku about badgers") |>
    chat(openai(.logprobs=TRUE,.top_logprobs=5))

 badger_poem |> get_logprobs()
#> # A tibble: 19 × 5
#>   reply_index token          logprob bytes     top_logprobs
#>          <int> <chr>            <dbl> <list>    <list>      
#>  1           1 "In"       -0.491      <int [2]> <list [5]>  
#>  2           1 " moon"    -1.12       <int [5]> <list [5]>  
#>  3           1 "lit"      -0.00489    <int [3]> <list [5]>  
#>  4           1 " forest"  -1.18       <int [7]> <list [5]>  
#>  5           1 ","        -0.00532    <int [1]> <list [5]>  
list_models(openai)
#> # A tibble: 52 × 3
#>    id                                   created             owned_by
#>    <chr>                                <chr>               <chr>   
#>  1 gpt-4o-mini-audio-preview-2024-12-17 2024-12-13 18:52:00 system  
#>  2 gpt-4-turbo-2024-04-09               2024-04-08 18:41:17 system  
#>  3 dall-e-3                             2023-10-31 20:46:29 system  
#>  4 dall-e-2                             2023-11-01 00:22:57 system  

Version 0.3.0

tidyllm 0.3.0 represents a major milestone for tidyllm

The largest changes compared to 0.2.0 are:

New Verb-Based Interface

Each verb and provider combination routes the interaction to provider-specific functions like openai_chat() or claude_chat() that do the work in the background. These functions can also be called directly as an alternative more verbose and provider-specific interface.

Old Usage:

llm_message("Hello World") |>
  openai(.model = "gpt-4o")

New Usage:

# Recommended Verb-Based Approach
llm_message("Hello World") |>
  chat(openai(.model = "gpt-4o"))
  
# Or even configuring a provider outside
my_ollama <- ollama(.model = "llama3.2-vision:90B",
       .ollama_server = "https://ollama.example-server.de",
       .temperature = 0)

llm_message("Hello World") |>
  chat(my_ollama)

# Alternative Approach is to use more verbose specific functions:
llm_message("Hello World") |>
  openai_chat(.model = "gpt-4o")

Backward Compatibility:

Breaking Changes:

Other Major Features:

Improvements:

Version 0.2.7

Major Features

llm_message("What is tidyllm and who maintains this package?") |>
  gemini_chat(.grounding_threshold = 0.3)

Improvements

Version 0.2.6

Large Refactor of package internals

Breaking Changes

Minor Features

here::here("local_wip","example.mp3") |> gemini_upload_file()
here::here("local_wip","legrille.mp4") |> gemini_upload_file()

file_tibble <- gemini_list_files()

llm_message("What are these two files about?") |>
  gemini_chat(.fileid=file_tibble$name)

Version 0.2.5

Major Features

Better embedding functions with improved output and error handling and new documentation. New article on using embeddings with tidyllm. Support for embedding models on azure with azure_openai_embedding()

Breaking Changes

Version 0.2.4

Refinements of the new interface

One disadvantage of the first iteration of the new interface was that all arguements that needed to be passed to provider-specific functions, were going through the provider function. This feels, unintuitive, because users expect common arguments (e.g., .model, .temperature) to be set directly in main verbs like chat() or send_batch().Moreover, provider functions don’t expose arguments for autocomplete, making it harder for users to explore options. Therefore, the main API verbs now directly accept common arguements, and check them against the available arguements for each API.

Bug-fixes

Version 0.2.3

Major Interface Overhaul

tidyllm has introduced a verb-based interface overhaul to provide a more intuitive and flexible user experience. Previously, provider-specific functions like claude(), openai(), and others were directly used for chat-based workflows. Now, these functions primarily serve as provider configuration for some general verbs like chat().

Key Changes:

Each verb and provider combination routes the interaction to provider-specific functions like openai_chat() or claude_chat() that do the work in the background. These functions can also be called directly as an alternative more verbose and provider-specific interface.

Old Usage:

llm_message("Hello World") |>
  openai(.model = "gpt-4o")

New Usage:

# Recommended Verb-Based Approach
llm_message("Hello World") |>
  chat(openai(.model = "gpt-4o"))
  
# Or even configuring a provider outside
my_ollama <- ollama(.model = "llama3.2-vision:90B",
       .ollama_server = "https://ollama.example-server.de",
       .temperature = 0)

llm_message("Hello World") |>
  chat(my_ollama)

# Alternative Approach is to use more verbose specific functions:
llm_message("Hello World") |>
  openai_chat(.model = "gpt-4o")

Version 0.2.2

Major Features

#Upload a file for use with gemini
upload_info <- gemini_upload_file("example.mp3")

#Make the file available during a Gemini API call
llm_message("Summarize this speech") |>
  gemini(.fileid = upload_info$name)
  
#Delte the file from the Google servers
gemini_delete_file(upload_info$name)

Version 0.2.1

Major Features:

conversation <- llm_message("Write a short poem about software development") |>
  claude()
  
#Get metdata on token usage and model as tibble  
get_metadata(conversation)

#or print it with the message
print(conversation,.meta=TRUE)

#Or allways print it
options(tidyllm_print_metadata=TRUE)

Bug-fixes:

Version 0.2.0

New CRAN release. Largest changes compared to 0.1.0:

Major Features:

Improvements:

Breaking Changes:

Minor Updates and Bug Fixes:

Version 0.1.11

Major Features

Improvements

Version 0.1.10

Breaking Changes

Improvements

Version 0.1.9

Major Features

Breaking Changes

Improvements


Version 0.1.8

Major Features

Improvements


Version 0.1.7

Major Features


Version 0.1.6

Major Features


Version 0.1.5

Major Features

Improvements


Version 0.1.4

Major Features

Improvements


Version 0.1.3

Major Features

Breaking Changes


Version 0.1.2

Improvements


Version 0.1.1

Major Features

Breaking Changes

These binaries (installable software) and packages are in development.
They may not be fully stable and should be used with caution. We make no claims about them.