R/image.R

Defines functions foundry_save_image foundry_resolve_image_model foundry_match_choice foundry_validate_image_options foundry_image_edit foundry_parse_image_response foundry_image foundry_get_image_key foundry_set_image_key foundry_get_image_endpoint foundry_set_image_endpoint

Documented in foundry_get_image_endpoint foundry_get_image_key foundry_image foundry_image_edit foundry_parse_image_response foundry_save_image foundry_set_image_endpoint foundry_set_image_key

#' Set Image Generation Endpoint
#'
#' Set the Azure endpoint for image generation (DALL-E). Use this when your
#' DALL-E model is deployed on a different Azure resource than your chat/embedding models.
#'
#' @param endpoint Character. The full Azure endpoint URL for image generation.
#'
#' @return Invisibly returns the endpoint that was set.
#'
#' @details
#' If not set, `foundry_image()` will fall back to `AZURE_FOUNDRY_ENDPOINT`.
#' Use this function when DALL-E is deployed on a separate Azure resource.
#'
#' @export
#'
#' @examples
#' local({
#'   old <- Sys.getenv("AZURE_FOUNDRY_IMAGE_ENDPOINT", unset = NA_character_)
#'   on.exit({
#'     if (is.na(old)) {
#'       Sys.unsetenv("AZURE_FOUNDRY_IMAGE_ENDPOINT")
#'     } else {
#'       Sys.setenv(AZURE_FOUNDRY_IMAGE_ENDPOINT = old)
#'     }
#'   })
#'   foundry_set_image_endpoint("https://example.openai.azure.com")
#' })
foundry_set_image_endpoint <- function(endpoint) {
 if (is.null(endpoint) || endpoint == "") {
    cli::cli_abort("Image endpoint is required and cannot be empty.")
  }

  # Remove trailing slash if present
 endpoint <- sub("/$", "", endpoint)

  Sys.setenv(AZURE_FOUNDRY_IMAGE_ENDPOINT = endpoint)
  cli::cli_alert_success("Image endpoint set to {.url {endpoint}}")
  invisible(endpoint)
}


#' Get Image Generation Endpoint
#'
#' Retrieve the Azure endpoint for image generation.
#'
#' @param required Logical. If TRUE and no endpoint is set, throws an error.
#'
#' @return Character string with the endpoint, or NULL if not set and not required.
#'
#' @details
#' Checks `AZURE_FOUNDRY_IMAGE_ENDPOINT` first, then falls back to `AZURE_FOUNDRY_ENDPOINT`.
#'
#' @keywords internal
foundry_get_image_endpoint <- function(required = FALSE) {
  # First check image-specific endpoint
  endpoint <- Sys.getenv("AZURE_FOUNDRY_IMAGE_ENDPOINT")

  # Fall back to main endpoint
  if (endpoint == "") {
    endpoint <- Sys.getenv("AZURE_FOUNDRY_ENDPOINT")
  }

  if (endpoint == "") {
    if (required) {
      cli::cli_abort(c(
        "Image endpoint is required but not set.",
        "i" = "Set {.envvar AZURE_FOUNDRY_IMAGE_ENDPOINT} or use {.fun foundry_set_image_endpoint}.",
        "i" = "Alternatively, set {.envvar AZURE_FOUNDRY_ENDPOINT} if DALL-E is on your main resource."
      ))
    }
    return(NULL)
  }

  endpoint
}


#' Set Image Generation API Key
#'
#' Set the API key for image generation. Use this when your DALL-E model
#' uses a different API key than your chat/embedding models.
#'
#' @param key Character. The API key for image generation.
#'
#' @return Invisibly returns TRUE on success.
#'
#' @details
#' If not set, `foundry_image()` will fall back to `AZURE_FOUNDRY_KEY`.
#'
#' @export
#'
#' @examples
#' local({
#'   old <- Sys.getenv("AZURE_FOUNDRY_IMAGE_KEY", unset = NA_character_)
#'   on.exit({
#'     if (is.na(old)) {
#'       Sys.unsetenv("AZURE_FOUNDRY_IMAGE_KEY")
#'     } else {
#'       Sys.setenv(AZURE_FOUNDRY_IMAGE_KEY = old)
#'     }
#'   })
#'   foundry_set_image_key("example-image-key-not-a-secret")
#' })
foundry_set_image_key <- function(key) {
  if (is.null(key) || is.na(key) || key == "") {
    cli::cli_abort("Image API key cannot be empty.")
  }

  Sys.setenv(AZURE_FOUNDRY_IMAGE_KEY = key)
  cli::cli_alert_success("Image API key set successfully.")
  invisible(TRUE)
}


#' Get Image Generation API Key
#'
#' Retrieve the API key for image generation.
#'
#' @param key Character. Optional key to use directly instead of environment variable.
#' @param required Logical. If TRUE and no key is found, throws an error.
#'
#' @return Character string with the API key, or NULL if not found and not required.
#'
#' @details
#' Checks in order: provided key, `AZURE_FOUNDRY_IMAGE_KEY`, `AZURE_FOUNDRY_KEY`.
#'
#' @keywords internal
foundry_get_image_key <- function(key = NULL, required = FALSE) {
  # Use provided key first
  if (!is.null(key) && key != "") {
    return(key)
  }

  # Check image-specific key
  env_key <- Sys.getenv("AZURE_FOUNDRY_IMAGE_KEY")

  # Fall back to main key
  if (env_key == "") {
    env_key <- Sys.getenv("AZURE_FOUNDRY_KEY")
  }

  if (env_key == "") {
    if (required) {
      cli::cli_abort(c(
        "Image API key is required but not set.",
        "i" = "Set {.envvar AZURE_FOUNDRY_IMAGE_KEY} or use {.fun foundry_set_image_key}.",
        "i" = "Alternatively, set {.envvar AZURE_FOUNDRY_KEY} if using the same key as your main resource."
      ))
    }
    return(NULL)
  }

  env_key
}


#' Generate Images with DALL-E
#'
#' Generate images using an Azure AI Foundry deployed DALL-E model. Returns a
#' tibble with the generated image URLs or base64-encoded data, along with
#' metadata about the generation.
#'
#' @param prompt Character. A text description of the desired image(s).
#' @param model Character. The deployment name of a DALL-E model.
#'   Defaults to the environment variable `AZURE_FOUNDRY_IMAGE_MODEL`.
#' @param n Integer. Number of images to generate (1-10). Default: 1.
#' @param size Character. The size of the generated image(s).
#'   Modern v1 image models support `"auto"`, `"1024x1024"`, `"1536x1024"`,
#'   and `"1024x1536"`. DALL-E deployments also support older sizes such as
#'   `"1792x1024"`, `"1024x1792"`, `"512x512"`, and `"256x256"`.
#' @param quality Character. The quality of the image. Modern v1 models support
#'   `"auto"`, `"low"`, `"medium"`, and `"high"`. DALL-E 3 supports
#'   `"standard"` and `"hd"`.
#' @param style Character. Optional DALL-E 3 style, `"vivid"` or `"natural"`.
#' @param response_format Character. Optional DALL-E response format, `"url"`
#'   or `"b64_json"`. This is not supported by `gpt-image-1`-series models,
#'   which return base64 image data.
#' @param output_format Character. Optional v1 image output format, `"png"`,
#'   `"jpeg"`, or `"webp"`.
#' @param output_compression Integer. Optional v1 compression level from 0 to
#'   100 for `"jpeg"` or `"webp"` output.
#' @param background Character. Optional v1 background mode: `"transparent"`,
#'   `"opaque"`, or `"auto"`.
#' @param moderation Character. Optional v1 moderation level: `"low"` or
#'   `"auto"`.
#' @param api Character. API shape to use. `"v1"` uses
#'   `/openai/v1/images/generations`; `"deployment"` uses the legacy
#'   `/openai/deployments/{deployment}/images/generations` endpoint.
#' @param api_key Character. Optional API key override.
#' @param token Character. Optional bearer token override.
#' @param api_version Character. Optional API version override.
#'
#' @return A tibble with columns:
#'   \describe{
#'     \item{prompt}{Character. The original prompt provided.}
#'     \item{revised_prompt}{Character. DALL-E's interpretation/revision of the prompt (DALL-E 3 only).}
#'     \item{url}{Character. URL to the generated image (NA if response_format is "b64_json").}
#'     \item{b64_json}{Character. Base64-encoded image data (NA if response_format is "url").}
#'     \item{output_format}{Character. Requested or returned output format.}
#'     \item{created}{POSIXct. Timestamp when the image was created.}
#'     \item{raw_image}{List. Raw image object returned by the service.}
#'   }
#'
#' @details
#' **Model Requirements**: The `model` parameter must be an image-capable
#' deployment such as a DALL-E or `gpt-image-1`-series deployment. Chat models
#' cannot generate images.
#'
#' **Size Availability**:
#' - gpt-image-1 series: auto, 1024x1024, 1536x1024, 1024x1536
#' - DALL-E 3: 1024x1024, 1792x1024, 1024x1792
#' - DALL-E 2: 256x256, 512x512, 1024x1024
#'
#' **URL Expiration**: Image URLs returned by the API are temporary and will expire.
#' Use `foundry_save_image()` to download and save images locally.
#'
#' @export
#'
#' @examples
#' \dontrun{
#' # Requires a configured Azure image endpoint and credentials,
#' # plus a DALL-E deployment.
#' # Generate a single image
#' result <- foundry_image("A sunset over mountains", model = "dall-e-3")
#'
#' # View the image URL
#' result$url
#'
#' # Generate an image with HD quality
#' result <- foundry_image(
#'   "A futuristic cityscape",
#'   model = "dall-e-3",
#'   quality = "hd",
#'   style = "vivid"
#' )
#'
#' # Get base64-encoded images instead of URLs
#' result <- foundry_image(
#'   "An abstract painting",
#'   model = "dall-e-3",
#'   response_format = "b64_json"
#' )
#'
#' # Save an image to disk
#' result <- foundry_image("A cat wearing a hat", model = "dall-e-3")
#' local({
#'   path <- tempfile(fileext = ".png")
#'   on.exit(unlink(path))
#'   foundry_save_image(result, path)
#' })
#' }
foundry_image <- function(prompt,
                          model = NULL,
                          n = 1L,
                          size = "1024x1024",
                          quality = NULL,
                          style = NULL,
                          response_format = NULL,
                          output_format = NULL,
                          output_compression = NULL,
                          background = NULL,
                          moderation = NULL,
                          api = c("v1", "deployment"),
                          api_key = NULL,
                          token = NULL,
                          api_version = NULL) {

  # Get model/deployment
  if (is.null(model)) {
    model <- Sys.getenv("AZURE_FOUNDRY_IMAGE_MODEL")
    if (model == "") {
      cli::cli_abort(c(
        "Image model/deployment name is required.",
        "i" = "Specify {.arg model} or set the {.envvar AZURE_FOUNDRY_IMAGE_MODEL} environment variable."
      ))
    }
  }

  # Validate prompt
  if (missing(prompt) || is.null(prompt) || !is.character(prompt) || length(prompt) != 1) {
    cli::cli_abort("{.arg prompt} must be a single non-empty character string.")
  }

  if (nchar(prompt) == 0) {
    cli::cli_abort("{.arg prompt} cannot be empty.")
  }

  # Validate n
  n <- as.integer(n)
  if (is.na(n) || n < 1 || n > 10) {
    cli::cli_abort("{.arg n} must be an integer between 1 and 10.")
  }

  api <- match.arg(api)
  foundry_validate_image_options(
    size = size,
    quality = quality,
    style = style,
    response_format = response_format,
    output_format = output_format,
    background = background,
    moderation = moderation
  )

  # Build request body
  body <- list(
    model = model,
    prompt = prompt,
    n = n,
    size = size
  )
  if (!is.null(quality)) body$quality <- quality
  if (!is.null(style)) body$style <- style
  if (!is.null(response_format)) body$response_format <- response_format
  if (!is.null(output_format)) body$output_format <- output_format
  if (!is.null(output_compression)) {
    output_compression <- as.integer(output_compression)
    if (is.na(output_compression) || output_compression < 0L ||
        output_compression > 100L) {
      cli::cli_abort("{.arg output_compression} must be between 0 and 100.")
    }
    body$output_compression <- output_compression
  }
  if (!is.null(background)) body$background <- background
  if (!is.null(moderation)) body$moderation <- moderation

  # Get image-specific endpoint and use image credentials without overriding
  # shared token/key precedence.
  base_url <- foundry_get_image_endpoint(required = TRUE)

  if (identical(api, "v1")) {
    req <- foundry_build_v1_request(
      path = "images/generations",
      body = body,
      method = "POST",
      api_key = api_key,
      token = token,
      endpoint = base_url,
      api_version = api_version %||% "preview",
      key_getter = foundry_get_image_key
    )
  } else {
    body$model <- NULL
    api_version <- api_version %||% foundry_get_api_version()
    url <- paste0(
      base_url,
      "/openai/deployments/",
      model,
      "/images/generations"
    )

    req <- httr2::request(url) %>%
      httr2::req_url_query(`api-version` = api_version) %>%
      foundry_authenticate_request(
        api_key = api_key,
        token = token,
        key_getter = foundry_get_image_key
      ) %>%
      httr2::req_body_json(body) %>%
      httr2::req_retry(max_tries = 3, backoff = ~ 2) %>%
      httr2::req_error(body = foundry_error_body)
  }

  result <- foundry_perform(req)

  # Parse response into tibble
  foundry_parse_image_response(result, prompt, response_format, output_format)
}


#' Parse Image Generation Response
#'
#' Internal function to parse image generation API response into a tibble.
#'
#' @param result List. The parsed JSON response.
#' @param original_prompt Character. The original prompt provided.
#' @param response_format Character. The response format requested.
#'
#' @return A tibble with image response data.
#' @keywords internal
foundry_parse_image_response <- function(result,
                                         original_prompt,
                                         response_format = NULL,
                                         output_format = NULL) {
  # Convert Unix timestamp to POSIXct
  created_time <- as.POSIXct(result$created, origin = "1970-01-01", tz = "UTC")

  # Process each image in the data array
  images <- purrr::map_dfr(result$data, function(img) {
    tibble::tibble(
      prompt = original_prompt,
      revised_prompt = img$revised_prompt %||% NA_character_,
      url = img$url %||% NA_character_,
      b64_json = img$b64_json %||% NA_character_,
      output_format = output_format %||% img$output_format %||% NA_character_,
      created = created_time,
      raw_image = list(img)
    )
  })

  images
}


#' Edit an image with Microsoft Foundry
#'
#' @description
#' `r lifecycle::badge("experimental")`
#'
#' Use the v1 preview image edits endpoint to edit one or more input images with
#' a text prompt.
#'
#' @param image Character vector of local image paths.
#' @param prompt Character. Edit instruction.
#' @param model Character. Image model deployment name.
#' @param mask Character. Optional local mask image path.
#' @param n Integer. Number of images to generate.
#' @param size Character. Output image size.
#' @param quality Character. Optional quality value.
#' @param output_format Character. Optional output format, such as `"png"`,
#'   `"jpeg"`, or `"webp"`.
#' @param background Character. Optional background mode.
#' @param api_key Character. Optional API key override.
#' @param token Character. Optional bearer token override.
#' @param api_version Character. Optional API version. Defaults to `"preview"`.
#'
#' @return A tibble with edited image data and metadata.
#' @export
#'
#' @examples
#' \dontrun{
#' # Requires a configured Azure image endpoint and credentials,
#' # an image deployment, and your own local input.png image.
#' foundry_image_edit("input.png", "Make the sky more dramatic", model = "gpt-image-1")
#' }
foundry_image_edit <- function(image,
                               prompt,
                               model = NULL,
                               mask = NULL,
                               n = 1L,
                               size = "1024x1024",
                               quality = NULL,
                               output_format = NULL,
                               background = NULL,
                               api_key = NULL,
                               token = NULL,
                               api_version = "preview") {
  if (!is.character(image) || length(image) < 1L || anyNA(image)) {
    cli::cli_abort("{.arg image} must be one or more local image paths.")
  }
  missing_images <- image[!file.exists(image)]
  if (length(missing_images) > 0L) {
    cli::cli_abort("Image file does not exist: {.file {missing_images[[1]]}}.")
  }
  foundry_check_character_scalar(prompt, "prompt")
  model <- foundry_resolve_image_model(model)
  n <- as.integer(n)
  if (is.na(n) || n < 1L || n > 10L) {
    cli::cli_abort("{.arg n} must be an integer between 1 and 10.")
  }
  if (!is.null(mask) && !file.exists(mask)) {
    cli::cli_abort("Mask file does not exist: {.file {mask}}.")
  }

  base_url <- foundry_get_image_endpoint(required = TRUE)
  req <- foundry_build_v1_request(
    path = "images/edits",
    method = "POST",
    api_key = api_key,
    token = token,
    endpoint = base_url,
    api_version = api_version,
    key_getter = foundry_get_image_key
  )

  multipart <- list(
    prompt = prompt,
    model = model,
    n = as.character(n),
    size = size
  )
  if (length(image) == 1L) {
    multipart$image <- curl::form_file(image)
  } else {
    image_parts <- lapply(image, curl::form_file)
    names(image_parts) <- rep("image[]", length(image_parts))
    multipart <- c(multipart, image_parts)
  }
  if (!is.null(mask)) multipart$mask <- curl::form_file(mask)
  if (!is.null(quality)) multipart$quality <- quality
  if (!is.null(output_format)) multipart$output_format <- output_format
  if (!is.null(background)) multipart$background <- background

  req <- do.call(httr2::req_body_multipart, c(list(req), multipart))
  result <- foundry_perform(req)
  foundry_parse_image_response(result, prompt, NULL, output_format)
}


foundry_validate_image_options <- function(size,
                                           quality,
                                           style,
                                           response_format,
                                           output_format,
                                           background,
                                           moderation) {
  foundry_match_choice(size, c(
    "auto", "1024x1024", "1536x1024", "1024x1536", "256x256",
    "512x512", "1792x1024", "1024x1792"
  ), "size")
  if (!is.null(quality)) {
    foundry_match_choice(quality, c(
      "auto", "standard", "hd", "low", "medium", "high"
    ), "quality")
  }
  if (!is.null(style)) foundry_match_choice(style, c("vivid", "natural"), "style")
  if (!is.null(response_format)) {
    foundry_match_choice(response_format, c("url", "b64_json"), "response_format")
  }
  if (!is.null(output_format)) {
    foundry_match_choice(output_format, c("png", "jpeg", "webp"), "output_format")
  }
  if (!is.null(background)) {
    foundry_match_choice(background, c("transparent", "opaque", "auto"), "background")
  }
  if (!is.null(moderation)) {
    foundry_match_choice(moderation, c("low", "auto"), "moderation")
  }
  invisible(TRUE)
}


foundry_match_choice <- function(value, choices, arg) {
  if (!is.character(value) || length(value) != 1L || is.na(value) ||
      !value %in% choices) {
    cli::cli_abort("{.arg {arg}} must be one of {.val {choices}}.")
  }
  invisible(value)
}


foundry_resolve_image_model <- function(model) {
  if (is.null(model)) {
    model <- Sys.getenv("AZURE_FOUNDRY_IMAGE_MODEL")
    if (model == "") {
      cli::cli_abort(c(
        "Image model/deployment name is required.",
        "i" = "Specify {.arg model} or set the {.envvar AZURE_FOUNDRY_IMAGE_MODEL} environment variable."
      ))
    }
  }
  foundry_check_character_scalar(model, "model")
  model
}


#' Save Generated Image to File
#'
#' Download and save a generated image from `foundry_image()` to a local file.
#' Works with both URL and base64-encoded image results.
#'
#' @param image_result A tibble returned by `foundry_image()`.
#' @param path Character. The file path where the image should be saved.
#'   Should include the file extension (e.g., ".png").
#' @param index Integer. Which image to save if multiple were generated (1-based).
#'   Default: 1 (first image).
#'
#' @return Invisibly returns the path to the saved file.
#'
#' @details
#' This function handles both URL and base64-encoded images automatically.
#' For URL-based images, it downloads the image from the temporary Azure URL.
#' For base64-encoded images, it decodes the data and writes it to file.
#'
#' **Note**: Image URLs from Azure are temporary and expire after a short time.
#' Use this function to save images locally before the URLs expire.
#'
#' @export
#'
#' @examples
#' # Save a one-pixel PNG without calling Azure.
#' if (requireNamespace("base64enc", quietly = TRUE)) {
#'   local({
#'     image <- tibble::tibble(
#'       url = NA_character_,
#'       b64_json = paste0(
#'         "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8",
#'         "/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
#'       )
#'     )
#'     path <- tempfile(fileext = ".png")
#'     on.exit(unlink(path))
#'     foundry_save_image(image, path)
#'     file.exists(path)
#'   })
#' }
#'
#' \dontrun{
#' # Requires a configured Azure image endpoint and credentials,
#' # plus DALL-E deployments.
#' local({
#'   paths <- replicate(5, tempfile(fileext = ".png"))
#'   on.exit(unlink(paths))
#'
#'   # Generate and save an image
#'   result <- foundry_image("A beautiful landscape", model = "dall-e-3")
#'   foundry_save_image(result, paths[1])
#'
#'   # DALL-E 2 supports generating several images per request.
#'   result <- foundry_image("Colorful abstract art", model = "dall-e-2", n = 3)
#'   foundry_save_image(result, paths[2], index = 1)
#'   foundry_save_image(result, paths[3], index = 2)
#'   foundry_save_image(result, paths[4], index = 3)
#'
#'   # Save a base64-encoded image
#'   if (requireNamespace("base64enc", quietly = TRUE)) {
#'     result <- foundry_image(
#'       "A cat", model = "dall-e-3", response_format = "b64_json"
#'     )
#'     foundry_save_image(result, paths[5])
#'   }
#' })
#' }
foundry_save_image <- function(image_result, path, index = 1) {
  # Validate input
  if (!inherits(image_result, "data.frame")) {
    cli::cli_abort("{.arg image_result} must be a tibble from {.fun foundry_image}.")
  }

  required_cols <- c("url", "b64_json")
  if (!all(required_cols %in% names(image_result))) {
    cli::cli_abort("{.arg image_result} must contain {.field url} and {.field b64_json} columns from {.fun foundry_image}.")
  }

  # Validate index
  index <- as.integer(index)
  if (is.na(index) || index < 1 || index > nrow(image_result)) {
    cli::cli_abort("{.arg index} must be between 1 and {nrow(image_result)} (number of images).")
  }

  # Validate path
  if (missing(path) || is.null(path) || !is.character(path) || length(path) != 1) {
    cli::cli_abort("{.arg path} must be a single file path string.")
  }

  # Get the specified image row
  img <- image_result[index, ]

  # Determine if we have URL or base64 data
  has_url <- !is.na(img$url) && nchar(img$url) > 0
  has_b64 <- !is.na(img$b64_json) && nchar(img$b64_json) > 0

  if (!has_url && !has_b64) {
    cli::cli_abort("No image data found at index {index}. Both URL and b64_json are NA.")
  }

  if (has_b64) {
    # Check if base64enc is available
    if (!requireNamespace("base64enc", quietly = TRUE)) {
      cli::cli_abort(c(
        "Package {.pkg base64enc} is required to save base64-encoded images.",
        "i" = "Install it with: {.code install.packages(\"base64enc\")}"
      ))
    }
    # Decode base64 and write to file
    img_data <- base64enc::base64decode(img$b64_json)
    writeBin(img_data, path)
    cli::cli_alert_success("Image saved to {.file {path}} (from base64)")
  } else {
    # Download from URL
    tryCatch({
      utils::download.file(img$url, path, mode = "wb", quiet = TRUE)
      cli::cli_alert_success("Image saved to {.file {path}} (from URL)")
    }, error = function(e) {
      cli::cli_abort(c(
        "Failed to download image from URL.",
        "i" = "The URL may have expired. Generate a new image and save it promptly.",
        "x" = conditionMessage(e)
      ))
    })
  }

  invisible(path)
}

Try the foundryR package in your browser

Any scripts or data that you put into this service are public.

foundryR documentation built on Sept. 25, 2026, 1:10 a.m.