library(wrapify)
<- wrapper(
openai_wrapper base_url = "https://api.openai.com/v1",
auth = bearer_auth_type(),
env_var_name = "OPENAI_KEY"
)
<- super_simple_constructor(content =, role = "user")
chat_message
<- requestor(
chat_completion
openai_wrapper,"chat/completions",
method = "post",
body_args = function_args(
messages = ,
model = "gpt-3.5-turbo",
temperature = NULL
) )
Wrapify
An R package for making API wrappers declaratively
I often find myself needing to write functions to make API calls in R. This tends to involve a lot of boilerplate and repetition. The {wrapify}
package is an attempt to abstract as much as possible of that boilerplate away in a declarative way. The package code is available on Github, and the package can be installed with devtools::install_github("colin-fraser/wrapify")
.
First example: OpenAI API
Here is the full code for an OpenAI API wrapper which hits the chat completion API.
This creates three things:
- The wrapper object
openai_wrapper
which contains the base configuration for interacting with the OpenAI API, including the base URL, authentication method (bearer token), and where to find the API key (OPENAI_KEY environment variable). This is mostly used internally — you shouldn’t need to touch it. - A constructor function
chat_message
that creates properly formatted message objects for the chat API, with a required ‘content’ parameter and a default role of “user” - A requestor function
chat_completion
that makes POST requests to the chat/completions endpoint, with required ‘messages’ parameter and optional parameters for ‘model’ (defaulting to “gpt-3.5-turbo”) and ‘temperature’ arguments. These function arguments are specified asbody_args
, meaning that the values will be inserted into thebody
of the resulting request.
The requestor
function is really the meat of the whole package. This function returns a function that you can call to hit the API. This is a little bit of an unusual way of defining functions if you’re not used to it, but the upside is it abstracts away a ton of boilerplate.
To use these objects, you can do something like the following.
<- list(
messages chat_message("What is the square root of 10000? Explain your reasoning.")
)chat_completion(messages, temperature = 0.8)
$id
[1] "chatcmpl-AVnAUm9xNk36FIjuwK9JQa9y5eAAf"
$object
[1] "chat.completion"
$created
[1] 1732140534
$model
[1] "gpt-3.5-turbo-0125"
$choices
$choices[[1]]
$choices[[1]]$index
[1] 0
$choices[[1]]$message
$choices[[1]]$message$role
[1] "assistant"
$choices[[1]]$message$content
[1] "The square root of 10000 is 100. This is because when we multiply 100 by itself (100 * 100), we get 10000. Therefore, the square root of 10000 is the number that, when multiplied by itself, equals 10000, which is 100."
$choices[[1]]$message$refusal
NULL
$choices[[1]]$logprobs
NULL
$choices[[1]]$finish_reason
[1] "stop"
$usage
$usage$prompt_tokens
[1] 21
$usage$completion_tokens
[1] 62
$usage$total_tokens
[1] 83
$usage$prompt_tokens_details
$usage$prompt_tokens_details$cached_tokens
[1] 0
$usage$prompt_tokens_details$audio_tokens
[1] 0
$usage$completion_tokens_details
$usage$completion_tokens_details$reasoning_tokens
[1] 0
$usage$completion_tokens_details$audio_tokens
[1] 0
$usage$completion_tokens_details$accepted_prediction_tokens
[1] 0
$usage$completion_tokens_details$rejected_prediction_tokens
[1] 0
$system_fingerprint
NULL
I think this is pretty slick! In just a few lines of code, we have a fully functioning API wrapper.
You can make it a bit more user friendly by supplying an extractor
. This is a function that takes the httr2
response object that’s retrieved from the API and converts it into some desired format. Here’s an example.
library(httr2)
library(purrr)
<- function(resp) {
extract_chat_text |>
resp resp_body_json() |>
pluck("choices", 1, "message", "content")
}
chat_completion(messages, temperature = 0.8, .extractor = extract_chat_text)
[1] "The square root of 10000 is 100.\n\nThe square root of a number is a value that, when multiplied by itself, gives the original number. In this case, 100 multiplied by 100 equals 10000. Therefore, the square root of 10000 is 100."
If you want to set the extractor to run by default without having to specify it, you can do so when you define the requestor function.
<- requestor(
chat_completion_with_extractor
openai_wrapper,"chat/completions",
method = "post",
body_args = function_args(
messages = ,
model = "gpt-3.5-turbo",
temperature = NULL,
max_completion_tokens = 100 # note <- added this in as well
),extractor = extract_chat_text
)
# setting temperature = 2 should get us something pretty wacky
chat_completion_with_extractor(messages, temperature = 2)
[1] "The square root of 10000 is 100. In mathematics, the square root of a number \"A\" is another number \"B\" that, when multiplied by itself, equals \"A.\" Since 100 x 100 equals 10000, 100 is the square root of 10000 \t\t\t\t"
You can always choose not to run the extractor if you don’t want to as well. In that case, the raw response object is returned.
chat_completion_with_extractor(messages, temperature = 1, .extract = FALSE)
<httr2_response>
POST https://api.openai.com/v1/chat/completions
Status: 200 OK
Content-Type: application/json
Body: In memory (995 bytes)
You can even choose not to perform the request. This can be useful if you want to create a batch of requests in advance and send them all at once.
chat_completion_with_extractor(messages, temperature = 1, .perform = FALSE)
<httr2_request>
POST https://api.openai.com/v1/chat/completions
Headers:
• content-type: 'application/json'
• Authorization: '<REDACTED>'
Body: json encoded data
A second example: the Todoist API
I’m a long time user of Todoist, and they also have a developer API which works a little bit differently. Whereas the OpenAI API relies on POST requests with a JSON-formatted body, the Todoist API is a more traditional REST API.
The developer docs are here. We start by creating the wrapper object.
<- wrapper(
todoist "https://api.todoist.com/rest/v2/",
auth = bearer_auth_type(),
env_var_name = "TODOIST_KEY"
)
Now let’s look at some of the things we can do with this API. To get all active tasks, you hit the "/tasks"
endpoint with optional parameters project_id
, section_id
, label
, etc for filtering. For example if you supply a label, it will return tasks with just that label. Whereas in the previous example the function arguments went in the body_args
of the requestor, here these will go in the query_args
as they are query parameters of the GET request. Here’s how to set this up (I’ll just implement the label filtering).
<- requestor(
get_tasks
todoist,"tasks",
query_args = function_args(
label = NULL
),# simple extractor to just pick out the id and the content fields of
# the response, for demo purposes
extractor = \(x) {
resp_body_json(x) |>
map(\(y) y[c("id", "content")])
}
)get_tasks(label="blog")
[[1]]
[[1]]$id
[1] "8604768592"
[[1]]$content
[1] "Write wrapify blog post"
You can get more details on a specific task by hitting the endpoint tasks/{task_id}
. This kind of situation is handled by the resource_args
argument in requestor
as follows.
<- requestor(
get_task
todoist,"tasks/{task_id}",
resource_args = function_args(task_id=),
extractor = \(x) {
resp_body_json(x)[c("id", "content", "description", "is_completed")]
}
)get_task(8604768592)
$id
[1] "8604768592"
$content
[1] "Write wrapify blog post"
$description
[1] "Write a blog post overview of wrapify package"
$is_completed
[1] FALSE
Package tools
The intention for this package is to use it to build other packages. In general the idea would be to export all of the requestor functions, but not the wrapper
object as the end user doesn’t have much to do with that. To help with this, there’s a helper function generate_roxygen_comment
that can generate a documentation template for the requestor functions.
generate_roxygen_comment(get_task, title = "Get a task")
#' Get a task
#'
#' [Add a description here]
#'
#' @param task_id [Description of task_id]
#' @param .credentials Credentials to use, e.g. an API key
#' @param .perform Perform the request? If FALSE, an httr2 request object is returned.
#' @param .extract Extract the data? If FALSE, an httr2::response object is returned
#' @param .extractor A function which takes an httr2::response object and returns the desired data
#'
#' @return [Describe the return value here]
#' @export
Using this, the full code for a todoist
API package would look like this:
<- wrapper(
todoist "https://api.todoist.com/rest/v2/",
auth = bearer_auth_type(),
env_var_name = "TODOIST_KEY"
)
#' Get a task
#'
#' Get a task by ID
#'
#' @param task_id the task id
#' @param .credentials Credentials to use, e.g. an API key
#' @param .perform Perform the request? If FALSE, an httr2 request object is returned.
#' @param .extract Extract the data? If FALSE, an httr2::response object is returned
#' @param .extractor A function which takes an httr2::response object and returns the desired data
#'
#' @return A list with the task data
#' @export
<- requestor(
get_task
todoist,"tasks/{task_id}",
resource_args = function_args(task_id=)
)
#' Get all tasks
#'
#' Get all tasks with optional filters
#'
#' @param label Optionally filter by label
#' @param .credentials Credentials to use, e.g. an API key
#' @param .perform Perform the request? If FALSE, an httr2 request object is returned.
#' @param .extract Extract the data? If FALSE, an httr2::response object is returned
#' @param .extractor A function which takes an httr2::response object and returns the desired data
#'
#' @return The data on the returned tasks
#' @export
<- requestor(
get_tasks
todoist,"tasks",
query_args = function_args(
label = NULL
) )
This is all the code you would need to write to create a todoist API wrapper with those two functions. Again, I think this is pretty slick! It’s a very small amount of code that produces fairly powerful output. I’ve found this to be extremely useful for my own purposes and would love to hear whether others find it similarly useful.