diff --git a/DESCRIPTION b/DESCRIPTION index 092a284b..aa50961c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -92,11 +92,12 @@ Collate: 'params.R' 'provider-any.R' 'provider-aws.R' + 'provider-claude.R' 'provider-openai-compatible.R' 'provider-azure.R' + 'provider-azure-anthropic.R' 'provider-claude-files.R' 'provider-claude-tools.R' - 'provider-claude.R' 'provider-google.R' 'provider-cloudflare.R' 'provider-databricks.R' diff --git a/NAMESPACE b/NAMESPACE index e748db40..61198380 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -37,6 +37,7 @@ export(batch_chat_text) export(chat) export(chat_anthropic) export(chat_aws_bedrock) +export(chat_azure_anthropic) export(chat_azure_openai) export(chat_claude) export(chat_cloudflare) diff --git a/NEWS.md b/NEWS.md index 40420b0d..40006637 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,6 @@ # ellmer (development version) +* New `chat_azure_anthropic()` enables chatting with Anthropic Claude models hosted on Azure AI Foundry (`*.services.ai.azure.com/anthropic` endpoints), with the same Azure authentication options as `chat_azure_openai()`. * Fixed three bugs that caused errors when streaming web search results: Claude's `citations_delta` events were mishandled, `server_tool_use` input wasn't parsed from JSON during streaming, and OpenAI's `web_search_call` failed for non-search action types like `open_page` (#941). * `chat_aws_bedrock()` gains a `cache` parameter for prompt caching. The default, `"auto"`, enables caching for models known to support it (Anthropic Claude and Amazon Nova) and disables it otherwise (#954). * Built-in tools (e.g., `openai_tool_web_search()`, `claude_tool_web_search()`) now include `description` and `annotations` properties, making their metadata consistent with user-defined tools created by `tool()` (#942). diff --git a/R/provider-azure-anthropic.R b/R/provider-azure-anthropic.R new file mode 100644 index 00000000..71afcd99 --- /dev/null +++ b/R/provider-azure-anthropic.R @@ -0,0 +1,168 @@ +#' @include provider-azure.R +#' @include provider-claude.R +NULL + +# https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/endpoints + +#' Chat with an Anthropic Claude model hosted on Azure AI Foundry +#' +#' [Azure AI Foundry](https://azure.microsoft.com/en-us/products/ai-foundry) +#' hosts Anthropic Claude models accessible via the +#' `*.services.ai.azure.com/anthropic` endpoint, using the Anthropic Messages +#' API format. +#' +#' Unlike [chat_azure_openai()], which targets `*.openai.azure.com` endpoints +#' and uses the OpenAI chat completions format, this function targets Azure AI +#' Foundry's Anthropic-compatible endpoint. +#' +#' ## Authentication +#' +#' `chat_azure_anthropic()` supports API keys via the `AZURE_ANTHROPIC_API_KEY` +#' environment variable and the `credentials` parameter. It also supports: +#' +#' - Azure service principals (when the `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, +#' and `AZURE_CLIENT_SECRET` environment variables are set). +#' - Interactive Entra ID authentication, like the Azure CLI. +#' - Viewer-based credentials on Posit Connect. Requires the \pkg{connectcreds} +#' package. +#' +#' @param endpoint Azure AI Foundry endpoint URL with protocol and hostname, +#' i.e. `https://{your-project}.services.ai.azure.com/anthropic`. Defaults +#' to the value of the `AZURE_ANTHROPIC_ENDPOINT` environment variable. +#' @param model The **deployment name** for the model you want to use. +#' @param credentials `r api_key_param("AZURE_ANTHROPIC_API_KEY")` +#' @inheritParams chat_anthropic +#' @inherit chat_openai return +#' @family chatbots +#' @export +#' @examples +#' \dontrun{ +#' chat <- chat_azure_anthropic( +#' endpoint = "https://your-project.services.ai.azure.com/anthropic", +#' model = "your-deployment-name" +#' ) +#' chat$chat("Tell me three jokes about statisticians") +#' } +chat_azure_anthropic <- function( + endpoint = azure_anthropic_endpoint(), + model, + params = NULL, + system_prompt = NULL, + credentials = NULL, + cache = c("5m", "1h", "none"), + beta_headers = character(), + api_args = list(), + api_headers = character(), + echo = NULL +) { + check_string(endpoint) + check_string(model) + params <- params %||% params() + cache <- arg_match(cache) + echo <- check_echo(echo) + + credentials <- as_credentials( + "chat_azure_anthropic", + default_azure_anthropic_credentials(), + credentials = credentials + ) + + provider <- ProviderAzureAnthropic( + name = "Azure/Anthropic", + base_url = paste0(gsub("/$", "", endpoint), "/v1"), + model = model, + params = params, + credentials = credentials, + extra_args = api_args, + extra_headers = api_headers, + beta_headers = beta_headers, + cache = cache + ) + + Chat$new(provider = provider, system_prompt = system_prompt, echo = echo) +} + +ProviderAzureAnthropic <- new_class( + "ProviderAzureAnthropic", + parent = ProviderAnthropic +) + +azure_anthropic_endpoint <- function() { + key_get("AZURE_ANTHROPIC_ENDPOINT") +} + +default_azure_anthropic_credentials <- function() { + azure_scope <- "https://cognitiveservices.azure.com/.default" + + # Detect viewer-based credentials from Posit Connect. + if (has_connect_viewer_token(scope = azure_scope)) { + return(function() { + token <- connectcreds::connect_viewer_token(scope = azure_scope) + list(Authorization = paste("Bearer", token$access_token)) + }) + } + + # Detect Azure service principals. + tenant_id <- Sys.getenv("AZURE_TENANT_ID") + client_id <- Sys.getenv("AZURE_CLIENT_ID") + client_secret <- Sys.getenv("AZURE_CLIENT_SECRET") + if (nchar(tenant_id) && nchar(client_id) && nchar(client_secret)) { + client <- oauth_client( + client_id, + token_url = paste0( + "https://login.microsoftonline.com/", + tenant_id, + "/oauth2/v2.0/token" + ), + secret = client_secret, + auth = "body", + name = "ellmer-azure-anthropic-sp" + ) + return(function() { + token <- oauth_token_cached( + client, + oauth_flow_client_credentials, + flow_params = list(scope = azure_scope), + reauth = is_testing() + ) + list(Authorization = paste("Bearer", token$access_token)) + }) + } + + # If we have an API key, include it in the credentials. + api_key <- Sys.getenv("AZURE_ANTHROPIC_API_KEY") + if (nchar(api_key)) { + return(\() api_key) + } + + # Masquerade as the Azure CLI. + client_id <- "04b07795-8ddb-461a-bbee-02f9e1bf7b46" + if (is_interactive() && !is_hosted_session()) { + client <- oauth_client( + client_id, + token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token", + secret = "", + auth = "body", + name = paste0("ellmer-", client_id) + ) + return(function() { + token <- oauth_token_cached( + client, + oauth_flow_auth_code, + flow_params = list( + auth_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + scope = paste(azure_scope, "offline_access"), + redirect_uri = "http://localhost:8400", + auth_params = list(prompt = "select_account") + ) + ) + list(Authorization = paste("Bearer", token$access_token)) + }) + } + + if (is_testing()) { + testthat::skip("no Azure credentials available") + } + + cli::cli_abort("No Azure credentials are available.") +} diff --git a/man/chat_anthropic.Rd b/man/chat_anthropic.Rd index 66cc5738..89aa4be5 100644 --- a/man/chat_anthropic.Rd +++ b/man/chat_anthropic.Rd @@ -148,6 +148,7 @@ chat$chat("Tell me three jokes about statisticians") \seealso{ Other chatbots: \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_aws_bedrock.Rd b/man/chat_aws_bedrock.Rd index 29e1840a..ffa7a070 100644 --- a/man/chat_aws_bedrock.Rd +++ b/man/chat_aws_bedrock.Rd @@ -118,6 +118,7 @@ chat$chat("Tell me three jokes about statisticians") \seealso{ Other chatbots: \code{\link{chat_anthropic}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_azure_anthropic.Rd b/man/chat_azure_anthropic.Rd new file mode 100644 index 00000000..8b5e2c87 --- /dev/null +++ b/man/chat_azure_anthropic.Rd @@ -0,0 +1,116 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/provider-azure-anthropic.R +\name{chat_azure_anthropic} +\alias{chat_azure_anthropic} +\title{Chat with an Anthropic Claude model hosted on Azure AI Foundry} +\usage{ +chat_azure_anthropic( + endpoint = azure_anthropic_endpoint(), + model, + params = NULL, + system_prompt = NULL, + credentials = NULL, + cache = c("5m", "1h", "none"), + beta_headers = character(), + api_args = list(), + api_headers = character(), + echo = NULL +) +} +\arguments{ +\item{endpoint}{Azure AI Foundry endpoint URL with protocol and hostname, +i.e. \verb{https://\{your-project\}.services.ai.azure.com/anthropic}. Defaults +to the value of the \code{AZURE_ANTHROPIC_ENDPOINT} environment variable.} + +\item{model}{The \strong{deployment name} for the model you want to use.} + +\item{params}{Common model parameters, usually created by \code{\link[=params]{params()}}.} + +\item{system_prompt}{A system prompt to set the behavior of the assistant.} + +\item{credentials}{Override the default credentials. You generally should not need this argument; instead set the \code{AZURE_ANTHROPIC_API_KEY} environment variable. The best place to set this is in \code{.Renviron}, +which you can easily edit by calling \code{usethis::edit_r_environ()}. + +If you do need additional control, this argument takes a zero-argument function that returns either a string (the API key), or a named list (added as additional headers to every request).} + +\item{cache}{How long to cache inputs? Defaults to "5m" (five minutes). +Set to "none" to disable caching or "1h" to cache for one hour. + +See details below.} + +\item{beta_headers}{Optionally, a character vector of beta headers to opt-in +claude features that are still in beta.} + +\item{api_args}{Named list of arbitrary extra arguments appended to the body +of every chat API call. Combined with the body object generated by ellmer +with \code{\link[=modifyList]{modifyList()}}.} + +\item{api_headers}{Named character vector of arbitrary extra headers appended +to every chat API call.} + +\item{echo}{One of the following options: +\itemize{ +\item \code{none}: don't emit any output (default when running in a function). +\item \code{output}: echo text and tool-calling output as it streams in (default +when running at the console). +\item \code{all}: echo all input and output. +} + +Note this only affects the \code{chat()} method.} +} +\value{ +A \link{Chat} object. +} +\description{ +\href{https://azure.microsoft.com/en-us/products/ai-foundry}{Azure AI Foundry} +hosts Anthropic Claude models accessible via the +\verb{*.services.ai.azure.com/anthropic} endpoint, using the Anthropic Messages +API format. +} +\details{ +Unlike \code{\link[=chat_azure_openai]{chat_azure_openai()}}, which targets \verb{*.openai.azure.com} endpoints +and uses the OpenAI chat completions format, this function targets Azure AI +Foundry's Anthropic-compatible endpoint. +\subsection{Authentication}{ + +\code{chat_azure_anthropic()} supports API keys via the \code{AZURE_ANTHROPIC_API_KEY} +environment variable and the \code{credentials} parameter. It also supports: +\itemize{ +\item Azure service principals (when the \code{AZURE_TENANT_ID}, \code{AZURE_CLIENT_ID}, +and \code{AZURE_CLIENT_SECRET} environment variables are set). +\item Interactive Entra ID authentication, like the Azure CLI. +\item Viewer-based credentials on Posit Connect. Requires the \pkg{connectcreds} +package. +} +} +} +\examples{ +\dontrun{ +chat <- chat_azure_anthropic( + endpoint = "https://your-project.services.ai.azure.com/anthropic", + model = "your-deployment-name" +) +chat$chat("Tell me three jokes about statisticians") +} +} +\seealso{ +Other chatbots: +\code{\link{chat_anthropic}()}, +\code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_openai}()}, +\code{\link{chat_cloudflare}()}, +\code{\link{chat_databricks}()}, +\code{\link{chat_deepseek}()}, +\code{\link{chat_github}()}, +\code{\link{chat_google_gemini}()}, +\code{\link{chat_groq}()}, +\code{\link{chat_huggingface}()}, +\code{\link{chat_mistral}()}, +\code{\link{chat_ollama}()}, +\code{\link{chat_openai}()}, +\code{\link{chat_openai_compatible}()}, +\code{\link{chat_openrouter}()}, +\code{\link{chat_perplexity}()}, +\code{\link{chat_portkey}()} +} +\concept{chatbots} diff --git a/man/chat_azure_openai.Rd b/man/chat_azure_openai.Rd index 64fb46af..48c8ed90 100644 --- a/man/chat_azure_openai.Rd +++ b/man/chat_azure_openai.Rd @@ -89,6 +89,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, \code{\link{chat_deepseek}()}, diff --git a/man/chat_cloudflare.Rd b/man/chat_cloudflare.Rd index 139416eb..0f80ca81 100644 --- a/man/chat_cloudflare.Rd +++ b/man/chat_cloudflare.Rd @@ -78,6 +78,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_databricks}()}, \code{\link{chat_deepseek}()}, diff --git a/man/chat_databricks.Rd b/man/chat_databricks.Rd index 9bb467f2..84996ffc 100644 --- a/man/chat_databricks.Rd +++ b/man/chat_databricks.Rd @@ -89,6 +89,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_deepseek}()}, diff --git a/man/chat_deepseek.Rd b/man/chat_deepseek.Rd index a2965b7a..77a7a5d7 100644 --- a/man/chat_deepseek.Rd +++ b/man/chat_deepseek.Rd @@ -74,6 +74,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_github.Rd b/man/chat_github.Rd index 255bcf25..9f664500 100644 --- a/man/chat_github.Rd +++ b/man/chat_github.Rd @@ -83,6 +83,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_google_gemini.Rd b/man/chat_google_gemini.Rd index efbfcb8f..6ecd38c0 100644 --- a/man/chat_google_gemini.Rd +++ b/man/chat_google_gemini.Rd @@ -113,6 +113,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_groq.Rd b/man/chat_groq.Rd index 72a6ba94..d5c83d88 100644 --- a/man/chat_groq.Rd +++ b/man/chat_groq.Rd @@ -69,6 +69,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_huggingface.Rd b/man/chat_huggingface.Rd index 565cb007..0314bbe2 100644 --- a/man/chat_huggingface.Rd +++ b/man/chat_huggingface.Rd @@ -77,6 +77,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_mistral.Rd b/man/chat_mistral.Rd index f4238746..e7830686 100644 --- a/man/chat_mistral.Rd +++ b/man/chat_mistral.Rd @@ -74,6 +74,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_ollama.Rd b/man/chat_ollama.Rd index 622da336..cb472cca 100644 --- a/man/chat_ollama.Rd +++ b/man/chat_ollama.Rd @@ -90,6 +90,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_openai.Rd b/man/chat_openai.Rd index 7f660b04..60a6de65 100644 --- a/man/chat_openai.Rd +++ b/man/chat_openai.Rd @@ -97,6 +97,7 @@ chat$chat("Tell me three funny jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_openai_compatible.Rd b/man/chat_openai_compatible.Rd index 794d29b4..94637b1f 100644 --- a/man/chat_openai_compatible.Rd +++ b/man/chat_openai_compatible.Rd @@ -81,6 +81,7 @@ chat$chat("What is the difference between a tibble and a data frame?") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_openrouter.Rd b/man/chat_openrouter.Rd index f5ceac9e..ef3f5ac0 100644 --- a/man/chat_openrouter.Rd +++ b/man/chat_openrouter.Rd @@ -66,6 +66,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_perplexity.Rd b/man/chat_perplexity.Rd index dc2e9364..8c4b627e 100644 --- a/man/chat_perplexity.Rd +++ b/man/chat_perplexity.Rd @@ -75,6 +75,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/man/chat_portkey.Rd b/man/chat_portkey.Rd index 20d11a40..cb5c500f 100644 --- a/man/chat_portkey.Rd +++ b/man/chat_portkey.Rd @@ -81,6 +81,7 @@ chat$chat("Tell me three jokes about statisticians") Other chatbots: \code{\link{chat_anthropic}()}, \code{\link{chat_aws_bedrock}()}, +\code{\link{chat_azure_anthropic}()}, \code{\link{chat_azure_openai}()}, \code{\link{chat_cloudflare}()}, \code{\link{chat_databricks}()}, diff --git a/tests/testthat/test-provider-azure-anthropic.R b/tests/testthat/test-provider-azure-anthropic.R new file mode 100644 index 00000000..215b450d --- /dev/null +++ b/tests/testthat/test-provider-azure-anthropic.R @@ -0,0 +1,104 @@ +# Defaults ---------------------------------------------------------------- + +test_that("model is required", { + withr::local_envvar(AZURE_ANTHROPIC_API_KEY = "key") + expect_error( + chat_azure_anthropic("https://example.services.ai.azure.com/anthropic"), + "model" + ) +}) + +test_that("trailing slash in endpoint is handled correctly", { + withr::local_envvar(AZURE_ANTHROPIC_API_KEY = "key") + chat <- chat_azure_anthropic( + endpoint = "https://example.services.ai.azure.com/anthropic/", + model = "claude-sonnet-4-6" + ) + expect_no_match(chat$get_provider()@base_url, "//v1") + expect_match(chat$get_provider()@base_url, "/v1$") +}) + +# Authentication ---------------------------------------------------------- + +test_that("Azure Anthropic request headers are generated correctly", { + turn <- UserTurn( + contents = list(ContentText("What is 1 + 1?")) + ) + endpoint <- "https://example.services.ai.azure.com/anthropic" + + p <- ProviderAzureAnthropic( + name = "Azure/Anthropic", + base_url = paste0(endpoint, "/v1"), + model = "claude-sonnet-4-6", + params = list(), + extra_args = list(), + extra_headers = character(), + credentials = \() "key", + beta_headers = character(), + cache = "none" + ) + req <- chat_request(p, FALSE, list(turn)) + headers <- req_get_headers(req, "reveal") + + # Uses x-api-key and anthropic-version, same as standard Anthropic API + expect_equal(headers$`x-api-key`, "key") + expect_equal(headers$`anthropic-version`, "2023-06-01") + expect_null(headers$`api-key`) + # No api-version query parameter + expect_no_match(req$url, "api-version") +}) + +test_that("beta headers are forwarded correctly", { + endpoint <- "https://example.services.ai.azure.com/anthropic" + + p <- ProviderAzureAnthropic( + name = "Azure/Anthropic", + base_url = paste0(endpoint, "/v1"), + model = "claude-sonnet-4-6", + params = list(), + extra_args = list(), + extra_headers = character(), + credentials = \() "key", + beta_headers = c("feature-a", "feature-b"), + cache = "none" + ) + req <- base_request(p) + headers <- req_get_headers(req) + expect_equal(headers$`anthropic-beta`, "feature-a,feature-b") +}) + +test_that("service principal authentication requests look correct", { + withr::local_envvar( + AZURE_TENANT_ID = "aaaa0a0a-bb1b-cc2c-dd3d-eeeeee4e4e4e", + AZURE_CLIENT_ID = "id", + AZURE_CLIENT_SECRET = "secret" + ) + local_mocked_responses(function(req) { + summary <- request_summary(req) + expect_match( + summary$url, + "login.microsoftonline.com/aaaa0a0a-bb1b-cc2c-dd3d-eeeeee4e4e4e" + ) + expect_match(summary$body$grant_type, "client_credentials") + expect_match(summary$body$scope, "cognitiveservices.azure.com") + expect_match(summary$body$client_id, "id") + expect_match(summary$body$client_secret, "secret") + response_json(body = list(access_token = "token")) + }) + source <- default_azure_anthropic_credentials() + expect_equal(source(), list(Authorization = "Bearer token")) +}) + +test_that("tokens can be requested from a Connect server", { + skip_if_not_installed("connectcreds") + + connectcreds::local_mocked_connect_responses(token = "token") + credentials <- default_azure_anthropic_credentials() + expect_equal(credentials(), list(Authorization = "Bearer token")) +}) + +test_that("API key is read from AZURE_ANTHROPIC_API_KEY", { + withr::local_envvar(AZURE_ANTHROPIC_API_KEY = "my-key") + credentials <- default_azure_anthropic_credentials() + expect_equal(credentials(), "my-key") +})