Skip to content

Commit

Permalink
Add support for authenticating with gargle to chat_gemini(). (#320)
Browse files Browse the repository at this point in the history
This commit expands `chat_gemini()` to support non-API key
authentication, very similar to what we've done for other cloud
providers.

As a bonus I threw in viewer-based credential support for Connect.

I don't have an environment to test this out yet, so this is mostly
conjectural.

Closes #317.

Signed-off-by: Aaron Jacobs <[email protected]>
  • Loading branch information
atheriel authored Feb 17, 2025
1 parent 3e30821 commit 236376b
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 11 deletions.
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Suggests:
bslib,
connectcreds,
curl (>= 6.0.1),
gargle,
gitcreds,
knitr,
magick,
Expand Down
9 changes: 9 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# ellmer (development version)

* `chat_gemini()` can now authenticate with Google default application
credentials (including service accounts, etc). This requires the `gargle`
package (#317, @atheriel).

* `chat_gemini()` now detects viewer-based credentials when running on Posit
Connect (#320, @atheriel).

# ellmer 0.1.1

## Lifecycle changes
Expand Down
102 changes: 93 additions & 9 deletions R/provider-gemini.R
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,23 @@ NULL

#' Chat with a Google Gemini model
#'
#' @description
#'
#' ## Authentication
#' To authenticate, we recommend saving your
#' [API key](https://aistudio.google.com/app/apikey) to
#' the `GOOGLE_API_KEY` env var in your `.Renviron`
#' (which you can easily edit by calling `usethis::edit_r_environ()`).
#'
#' By default, `chat_gemini()` will use Google's default application credentials
#' if there is no API key provided. This requires the \pkg{gargle} package.
#'
#' It can also pick up on viewer-based credentials on Posit Connect. This in
#' turn requires the \pkg{connectcreds} package.
#'
#' @param api_key The API key to use for authentication. You generally should
#' not supply this directly, but instead set the `GOOGLE_API_KEY` environment
#' variable.
#' variable. Or leave it as `NULL` to use ambient credentials.
#' @inheritParams chat_openai
#' @inherit chat_openai return
#' @family chatbots
Expand All @@ -27,19 +35,26 @@ NULL
chat_gemini <- function(system_prompt = NULL,
turns = NULL,
base_url = "https://generativelanguage.googleapis.com/v1beta/",
api_key = gemini_key(),
api_key = NULL,
model = NULL,
api_args = list(),
echo = NULL) {
turns <- normalize_turns(turns, system_prompt)
model <- set_default(model, "gemini-2.0-flash")
echo <- check_echo(echo)
check_string(api_key, allow_null = TRUE)
api_key <- api_key %||% Sys.getenv("GOOGLE_API_KEY")
credentials <- NULL
if (!nchar(api_key)) {
credentials <- default_google_credentials()
}

provider <- ProviderGemini(
base_url = base_url,
model = model,
extra_args = api_args,
api_key = api_key
api_key = api_key,
credentials = credentials
)
Chat$new(provider = provider, turns = turns, echo = echo)
}
Expand All @@ -48,15 +63,12 @@ ProviderGemini <- new_class(
"ProviderGemini",
parent = Provider,
properties = list(
api_key = prop_string(),
api_key = prop_string(allow_null = TRUE),
credentials = class_function | NULL,
model = prop_string()
)
)

gemini_key <- function() {
key_get("GOOGLE_API_KEY")
}

method(chat_request, ProviderGemini) <- function(provider,
stream = TRUE,
turns = list(),
Expand All @@ -65,7 +77,16 @@ method(chat_request, ProviderGemini) <- function(provider,


req <- request(provider@base_url)
req <- req_headers_redacted(req, "x-goog-api-key" = provider@api_key)
if (nchar(provider@api_key)) {
req <- req_headers_redacted(req, "x-goog-api-key" = provider@api_key)
} else {
# TODO: Can use req_headers_redacted() when !!! is supported.
req <- req_headers(
req,
!!!provider@credentials(),
.redact = "Authorization"
)
}
req <- req_retry(req, max_tries = 2)
req <- ellmer_req_timeout(req, stream)
req <- req_error(req, body = function(resp) {
Expand Down Expand Up @@ -401,3 +422,66 @@ merge_gemini_chunks <- merge_objects(
promptFeedback = merge_last(),
usageMetadata = merge_last()
)

default_google_credentials <- function() {
gemini_scope <- "https://www.googleapis.com/auth/generative-language.retriever"

# Detect viewer-based credentials from Posit Connect.
if (has_connect_viewer_token(scope = gemini_scope)) {
return(function() {
token <- connectcreds::connect_viewer_token(scope = gemini_scope)
list(Authorization = paste("Bearer", token$access_token))
})
}

if (is_testing()) {
testthat::skip_if_not_installed("gargle")
}

check_installed("gargle", "for Google authentication")
gargle::with_cred_funs(
funs = list(
# We don't want to use *all* of gargle's default credential functions --
# in particular, we don't want to try and authenticate using the bundled
# OAuth client -- so winnow down the list.
credentials_app_default = gargle::credentials_app_default
),
{
token <- gargle::token_fetch(scopes = gemini_scope)
},
action = "replace"
)

if (is.null(token) && is_testing()) {
testthat::skip("no Google credentials available")
}

if (is.null(token)) {
cli::cli_abort(
c(
"No Google credentials are available.",
"i" = "Try suppling an API key or configuring Google's application default credentials."
)
)
}

# gargle emits an httr-style token, which we awkwardly shim into something
# httr2 can work with.

if (!token$can_refresh()) {
# TODO: Not really sure what to do in this case when the token expires.
return(function() {
list(Authorization = paste("Bearer", token$credentials$access_token))
})
}

# gargle tokens don't track the expiry time, so we do it ourselves (with a
# grace period).
expiry <- Sys.time() + token$credentials$expires_in - 5
return(function() {
if (expiry < Sys.time()) {
token$refresh()
}
list(Authorization = paste("Bearer", token$credentials$access_token))
})
}
10 changes: 8 additions & 2 deletions man/chat_gemini.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 236376b

Please sign in to comment.