From 8b67cc8cb871dff8437a57e1c112ab3f3f9a66e9 Mon Sep 17 00:00:00 2001 From: JBGruber Date: Mon, 28 Apr 2025 17:43:48 +0200 Subject: [PATCH 1/2] very rough draft on offering more LLM providers --- R/chat.r | 45 ++++++++--- R/embedding.r | 4 +- R/engine_ollama.r | 87 ++++++++++++++++++++++ R/engine_openai.r | 7 ++ R/{lib.R => lib.r} | 74 ------------------ R/{rollama-package.R => rollama-package.r} | 0 R/utils.r | 31 ++++---- man/ping_ollama.Rd | 2 +- man/query.Rd | 4 + man/rollama-options.Rd | 2 +- man/rollama-package.Rd | 2 +- 11 files changed, 150 insertions(+), 108 deletions(-) create mode 100644 R/engine_ollama.r create mode 100644 R/engine_openai.r rename R/{lib.R => lib.r} (74%) rename R/{rollama-package.R => rollama-package.r} (100%) diff --git a/R/chat.r b/R/chat.r index c6920b4..c48e774 100644 --- a/R/chat.r +++ b/R/chat.r @@ -45,6 +45,8 @@ #' value is `"json"`. #' @param template the prompt template to use (overrides what is defined in the #' Modelfile). +#' @param engine which service serves the model. See details for possible +#' options. #' @param verbose Whether to print status messages to the Console. Either #' `TRUE`/`FALSE` or see [httr2::progress_bars]. The default is to have status #' messages in interactive sessions. Can be changed with @@ -149,12 +151,19 @@ query <- function(q, output = c("response", "text", "list", "data.frame", "httr2_response", "httr2_request"), format = NULL, template = NULL, + engine = "Ollama", verbose = getOption("rollama_verbose", default = interactive())) { if (!is.function(output)) { output <- match.arg(output) } + switch ( + tolower(engine), + ollama = ping_ollama(), + openai = check_auth_openai() + ) + # q can be a string, a data.frame, or list of data.frames if (is.character(q)) { config <- getOption("rollama_config", default = NULL) @@ -177,13 +186,18 @@ query <- function(q, msg <- purrr::map(q, check_conversation) } - reqs <- build_req(model = model, - msg = msg, - server = server, - images = images, - model_params = model_params, - format = format, - template = template) + req_fun <- switch ( + tolower(engine), + ollama = build_req_ollama + ) + + reqs <- req_fun(model = model, + msg = msg, + server = server, + images = images, + model_params = model_params, + format = format, + template = template) if (identical(output, "httr2_request")) return(invisible(reqs)) @@ -194,10 +208,19 @@ query <- function(q, } res <- NULL + message_path <- switch ( + tolower(engine), + ollama = c("message", "content") + ) + if (screen) { + screen_fun <- switch ( + tolower(engine), + ollama = screen_ollama + ) res <- purrr::map(resps, httr2::resp_body_json) purrr::walk(res, function(r) { - screen_answer(purrr::pluck(r, "message", "content"), + screen_answer(purrr::pluck(r, message_path), purrr::pluck(r, "model")) }) } @@ -214,9 +237,9 @@ query <- function(q, out <- switch(output, "response" = res, - "text" = purrr::map_chr(res, c("message", "content")), - "list" = process2list(res, reqs), - "data.frame" = process2df(res) + "text" = purrr::map_chr(res, message_path), + "list" = process2list(res, reqs, engine), + "data.frame" = process2df(res, message_path) ) invisible(out) } diff --git a/R/embedding.r b/R/embedding.r index 8322406..3171119 100644 --- a/R/embedding.r +++ b/R/embedding.r @@ -48,8 +48,8 @@ embed_text <- function(text, stream = FALSE, model_params = model_params) |> purrr::compact() |> - make_req(server = server, - endpoint = "/api/embeddings") + make_req_ollama(server = server, + endpoint = "/api/embeddings") }) resps <- httr2::req_perform_parallel(reqs, progress = pb) diff --git a/R/engine_ollama.r b/R/engine_ollama.r new file mode 100644 index 0000000..633565d --- /dev/null +++ b/R/engine_ollama.r @@ -0,0 +1,87 @@ +build_req_ollama <- function(model, + msg, + server, + images, + model_params, + format, + template) { + + if (is.null(model)) model <- getOption("rollama_model", default = "llama3.1") + if (is.null(server)) server <- getOption("rollama_server", + default = "http://localhost:11434") + seed <- getOption("rollama_seed") + if (!is.null(seed) && !purrr::pluck_exists(model_params, "seed")) { + model_params <- append(model_params, list(seed = seed)) + } + check_model_installed(model, server = server) + if (length(msg) != length(model)) { + if (length(model) > 1L) + cli::cli_alert_info(c( + "The number of queries is unequal to the number of models you supplied.", + "We assume you want to run each query with each model" + )) + req_data <- purrr::map(msg, function(ms) { + purrr::map(model, function(m) { + list( + model = m, + messages = ms, + stream = FALSE, + options = model_params, + format = format, + template = template + ) |> + purrr::compact() |> # remove NULL values + make_req_ollama_ollama( + server = sample(server, 1, prob = as_prob(names(server))), + endpoint = "/api/chat" + ) + }) + }) |> + unlist(recursive = FALSE) + } else { + req_data <- purrr::map2(msg, model, function(ms, m) { + list( + model = m, + messages = ms, + stream = FALSE, + options = model_params, + format = format, + template = template + ) |> + purrr::compact() |> # remove NULL values + make_req_ollama_ollama( + server = sample(server, 1, prob = as_prob(names(server))), + endpoint = "/api/chat" + ) + }) + } + + return(req_data) +} + + +make_req_ollama <- function(req_data, server, endpoint) { + r <- httr2::request(server) |> + httr2::req_url_path_append(endpoint) |> + httr2::req_body_json(prep_req_data(req_data), auto_unbox = FALSE) |> + # see https://github.com/JBGruber/rollama/issues/23 + httr2::req_options(timeout_ms = 1000 * 60 * 60 * 24, + connecttimeout_ms = 1000 * 60 * 60 * 24) |> + httr2::req_headers(!!!get_headers()) + return(r) +} + +processitem2list_ollama <- function(resp, req) { + list( + request = list( + model = purrr::pluck(req, "body", "data", "model"), + role = purrr::pluck(req, "body", "data", "messages", "role"), + message = purrr::pluck(req, "body", "data", "messages", "content") + ), + response = list( + model = purrr::pluck(resp, "model"), + role = purrr::pluck(resp, "message", "role"), + message = purrr::pluck(resp, "message", "content") + ) + ) +} diff --git a/R/engine_openai.r b/R/engine_openai.r new file mode 100644 index 0000000..9e4818f --- /dev/null +++ b/R/engine_openai.r @@ -0,0 +1,7 @@ +auth_openai <- function() { + +} + +check_auth_openai <- function() { + +} diff --git a/R/lib.R b/R/lib.r similarity index 74% rename from R/lib.R rename to R/lib.r index 97e963d..7a7216e 100644 --- a/R/lib.R +++ b/R/lib.r @@ -36,80 +36,6 @@ ping_ollama <- function(server = NULL, silent = FALSE, version = FALSE) { } -build_req <- function(model, - msg, - server, - images, - model_params, - format, - template) { - - if (is.null(model)) model <- getOption("rollama_model", default = "llama3.1") - if (is.null(server)) server <- getOption("rollama_server", - default = "http://localhost:11434") - seed <- getOption("rollama_seed") - if (!is.null(seed) && !purrr::pluck_exists(model_params, "seed")) { - model_params <- append(model_params, list(seed = seed)) - } - check_model_installed(model, server = server) - if (length(msg) != length(model)) { - if (length(model) > 1L) - cli::cli_alert_info(c( - "The number of queries is unequal to the number of models you supplied.", - "We assume you want to run each query with each model" - )) - req_data <- purrr::map(msg, function(ms) { - purrr::map(model, function(m) { - list( - model = m, - messages = ms, - stream = FALSE, - options = model_params, - format = format, - template = template - ) |> - purrr::compact() |> # remove NULL values - make_req( - server = sample(server, 1, prob = as_prob(names(server))), - endpoint = "/api/chat" - ) - }) - }) |> - unlist(recursive = FALSE) - } else { - req_data <- purrr::map2(msg, model, function(ms, m) { - list( - model = m, - messages = ms, - stream = FALSE, - options = model_params, - format = format, - template = template - ) |> - purrr::compact() |> # remove NULL values - make_req( - server = sample(server, 1, prob = as_prob(names(server))), - endpoint = "/api/chat" - ) - }) - } - - return(req_data) -} - - -make_req <- function(req_data, server, endpoint) { - r <- httr2::request(server) |> - httr2::req_url_path_append(endpoint) |> - httr2::req_body_json(prep_req_data(req_data), auto_unbox = FALSE) |> - # see https://github.com/JBGruber/rollama/issues/23 - httr2::req_options(timeout_ms = 1000 * 60 * 60 * 24, - connecttimeout_ms = 1000 * 60 * 60 * 24) |> - httr2::req_headers(!!!get_headers()) - return(r) -} - - perform_reqs <- function(reqs, verbose) { model <- purrr::map_chr(reqs, c("body", "data", "model")) |> diff --git a/R/rollama-package.R b/R/rollama-package.r similarity index 100% rename from R/rollama-package.R rename to R/rollama-package.r diff --git a/R/utils.r b/R/utils.r index 189571c..ccffd17 100644 --- a/R/utils.r +++ b/R/utils.r @@ -54,30 +54,25 @@ check_model_installed <- function(model, # process responses to list -process2list <- function(resps, reqs) { - purrr::map2(resps, reqs, function(resp, req) { - list( - request = list( - model = purrr::pluck(req, "body", "data", "model"), - role = purrr::pluck(req, "body", "data", "messages", "role"), - message = purrr::pluck(req, "body", "data", "messages", "content") - ), - response = list( - model = purrr::pluck(resp, "model"), - role = purrr::pluck(resp, "message", "role"), - message = purrr::pluck(resp, "message", "content") - ) - ) - }) +process2list <- function(resps, reqs, engine) { + process_fun <- req_fun <- switch ( + tolower(engine), + ollama = processitem2list_ollama + ) + purrr::map2(resps, reqs, process_fun) } # process responses to data.frame -process2df <- function(resps) { +process2df <- function(resps, message_path, engine) { + role_path <- switch ( + tolower(engine), + ollama = c("message", "role") + ) tibble::tibble( model = purrr::map_chr(resps, "model"), - role = purrr::map_chr(resps, c("message", "role")), - response = purrr::map_chr(resps, c("message", "content")) + role = purrr::map_chr(resps, role_path), + response = purrr::map_chr(resps, message_path) ) } diff --git a/man/ping_ollama.Rd b/man/ping_ollama.Rd index e4fa175..d32c3d0 100644 --- a/man/ping_ollama.Rd +++ b/man/ping_ollama.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/lib.R +% Please edit documentation in R/lib.r \name{ping_ollama} \alias{ping_ollama} \title{Ping server to see if Ollama is reachable} diff --git a/man/query.Rd b/man/query.Rd index 80abd55..28814b6 100644 --- a/man/query.Rd +++ b/man/query.Rd @@ -15,6 +15,7 @@ query( output = c("response", "text", "list", "data.frame", "httr2_response", "httr2_request"), format = NULL, template = NULL, + engine = "Ollama", verbose = getOption("rollama_verbose", default = interactive()) ) @@ -59,6 +60,9 @@ value is \code{"json"}.} \item{template}{the prompt template to use (overrides what is defined in the Modelfile).} +\item{engine}{which service serves the model. See details for possible +options.} + \item{verbose}{Whether to print status messages to the Console. Either \code{TRUE}/\code{FALSE} or see \link[httr2:progress_bars]{httr2::progress_bars}. The default is to have status messages in interactive sessions. Can be changed with diff --git a/man/rollama-options.Rd b/man/rollama-options.Rd index 40450f0..c2e0acc 100644 --- a/man/rollama-options.Rd +++ b/man/rollama-options.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/rollama-package.R +% Please edit documentation in R/rollama-package.r \name{rollama-options} \alias{rollama-options} \title{rollama Options} diff --git a/man/rollama-package.Rd b/man/rollama-package.Rd index 083020f..90aa3f0 100644 --- a/man/rollama-package.Rd +++ b/man/rollama-package.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/rollama-package.R +% Please edit documentation in R/rollama-package.r \docType{package} \name{rollama-package} \alias{rollama} From c10a798cafff66bc8d4690eac915cdda7f9fe1c3 Mon Sep 17 00:00:00 2001 From: JBGruber Date: Sat, 23 Aug 2025 11:03:16 +0200 Subject: [PATCH 2/2] add authentication vignette (#37 #43) --- vignettes/authentication.Rmd | 178 +++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 vignettes/authentication.Rmd diff --git a/vignettes/authentication.Rmd b/vignettes/authentication.Rmd new file mode 100644 index 0000000..0dcb2b5 --- /dev/null +++ b/vignettes/authentication.Rmd @@ -0,0 +1,178 @@ +--- +title: "authentication" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{authentication} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r, include = FALSE} +knitr::opts_chunk$set( + eval = FALSE, + collapse = TRUE, + comment = "#>" +) +``` + +You can use rollama with services that offer a Ollama-compatible API (e.g., [Ollama Turbo](https://ollama.com/turbo) or [Open WebUI](https://openwebui.com/)). +The only difference to using Ollama directly is that you have to authenticate to use these services, which you can do by adding your token to requests as shown below. + +## Ollama Turbo Example + +To use [Ollama Turbo](https://ollama.com/turbo), you have to set the `rollama_server` option to `https://ollama.com` and provide your api key as a header: + +```{r} +library(rollama) +Sys.setenv(api_key = "") +options( + rollama_server = "https://ollama.com", + rollama_headers = list( + Authorization = paste("Bearer", Sys.getenv("api_key")) + ) +) +chat(q = "Why is the sky blue?", model = "gpt-oss:120b") +#> +#> ── Answer from gpt-oss:120b ──────────────────────────────────────────────────── +#> The sky looks blue because of a phenomenon called **Rayleigh scattering**. +#> +#> ### 1. Sunlight is a mixture of colors +#> Sunlight contains all visible wavelengths (roughly 380 nm – 750 nm). When it +#> reaches Earth, it’s essentially white light made up of the colors of the +#> rainbow. +#> +#> ### 2. The atmosphere is full of tiny particles +#> The air is filled with gas molecules (nitrogen, oxygen, etc.) and tiny +#> particles that are **much smaller than the wavelength of visible light**. +#> +#> ### 3. Short wavelengths scatter more strongly +#> When light encounters particles that are much smaller than its wavelength, it +#> is scattered in all directions. The scattering efficiency follows an +#> inverse‑fourth‑power law: +#> +#> \[ +#> \text{Intensity of scattered light} \propto \frac{1}{\lambda^4} +#> \] +#> +#> So a wavelength that is half as long (e.g., blue at ~450 nm) scatters about +#> 16 times more than a wavelength that is twice as long (e.g., red at ~650 nm). +#> +#> Because blue and violet light are scattered far more than the other colors, a +#> lot of that short‑wavelength light is redirected toward our eyes from all parts +#> of the sky. +#> +#> ### 4. Why we see blue rather than violet +#> - **Human vision:** Our eyes are more sensitive to blue than to violet. +#> - **Solar spectrum:** There’s slightly less violet light from the Sun to begin +#> with. +#> - **Atmospheric absorption:** A small amount of violet is absorbed by the upper +#> atmosphere. +#> +#> The combination of these factors makes the scattered light we perceive as +#> predominantly **blue**. +#> +#> ### 5. Sunrise and sunset colors +#> When the Sun is low on the horizon, its light must travel through a much +#> thicker layer of atmosphere. The short‑wavelength blue light gets scattered out +#> of the direct line of sight long before the light reaches you, leaving the +#> longer‑wavelength reds and oranges to dominate the sky’s color. That’s why +#> sunrises and sunsets appear reddish. +#> +#> ### Quick recap +#> +#> | Process | Effect on light | +#> |---------|-----------------| +#> | **Rayleigh scattering** | Scatters shorter wavelengths (blue/violet) much +#> more than longer ones | +#> | **Human eye sensitivity** | More responsive to blue than violet | +#> | **Atmospheric path length** | Determines which wavelengths reach you directly +#> (short → scattered, long → direct) | +#> +#> So the sky is blue because the atmosphere preferentially scatters the +#> shorter‑wavelength (blue) portion of sunlight toward us, while the longer +#> wavelengths pass through relatively unchanged. +``` + +## Open WebUI Example + +For hosting Ollama (and other providers) on a lab or organisation server, [Open WebUI](https://openwebui.com/) is a good option. +To use the Open WebUI API with rollama, you have to set the `rollama_server` option to the URL of the hosted instance + the path `/ollama/` and provide your api key as a header. +For example, my employer hosts an instance of at `https://ai-openwebui.gesis.org/` so the server address is: + + +```{r} +library(rollama) +Sys.setenv(api_key = "") +options( + rollama_server = "https://ai-openwebui.gesis.org/ollama/", + rollama_headers = list( + Authorization = paste("Bearer", Sys.getenv("api_key")) + ) +) +chat(q = "Why is the sky blue?", model = "llama4:latest") +#> +#> ── Answer from llama4:latest ─────────────────────────────────────────────────── +#> The sky appears blue because of a phenomenon called Rayleigh scattering, which +#> occurs when sunlight interacts with the Earth's atmosphere. Here's a simplified +#> explanation: +#> +#> 1. **Sunlight**: The sun emits a wide range of electromagnetic radiation, +#> including visible light, which is made up of different colors (wavelengths). +#> 2. **Atmosphere**: When sunlight enters Earth's atmosphere, it encounters tiny +#> molecules of gases like nitrogen (N2) and oxygen (O2). +#> 3. **Scattering**: These gas molecules scatter the sunlight in all directions. +#> The amount of scattering that occurs depends on the wavelength of the light. +#> 4. **Wavelength and scattering**: Shorter wavelengths (like blue and violet) +#> are scattered more than longer wavelengths (like red and orange). This is known +#> as Rayleigh scattering. +#> 5. **Blue dominance**: As a result of this scattering, the blue light is +#> dispersed throughout the atmosphere, making it visible from all directions. +#> This is why the sky typically appears blue during the daytime. +#> +#> However, there are some additional factors that can affect the color of the +#> sky, such as: +#> +#> * **Dust and water vapor**: Tiny particles in the air can scatter light in +#> different ways, changing the apparent color of the sky. +#> * **Time of day**: During sunrise and sunset, the light travels through more of +#> the atmosphere, scattering shorter wavelengths and making the sky appear more +#> red. +#> * **Atmospheric conditions**: Pollution, dust, and water vapor can alter the +#> color of the sky. +#> +#> So, to summarize, the sky appears blue because of the scattering of sunlight by +#> the tiny molecules in the Earth's atmosphere, with shorter wavelengths (like +#> blue) being scattered more than longer wavelengths. +``` + +# Keeping your keys save + +You should not keep your API keys in an R file like above and ideally also never enter it in the Console. +This way, you never have to double check before sending an R file or your history to someone else. + +However, as per [Hadley Wickham](https://cran.r-project.org/web/packages/httr/vignettes/secrets.html) + +> Asking each time is a hassle, so you might want to store the secret across sessions. One easy way to do that is with environment variables. Environment variables, or envvars for short, are a cross platform way of passing information to processes. + +> For passing envvars to R, you can list name-value pairs in a file called .Renviron in your home directory. + +You can open this file to edit it with `usethis::edit_r_environ()`. +The file should then contain your keys, which should look something like this: + +``` +rollama_key=sk-b3d60321e62eb364e9c8dc12ffeec242 +``` + +When you then start a new session, `Sys.getenv("rollama_key")` returns the `sk-b3...` value. +So the start of your script could look like this: + +```r +library(rollama) +options( + rollama_server = "https://ai-openwebui.gesis.org/ollama/", + rollama_headers = list( + Authorization = paste("Bearer", Sys.getenv("rollama_key")) + ) +) +``` +