diff --git a/.gitattributes b/.gitattributes
deleted file mode 100644
index 807d598..0000000
--- a/.gitattributes
+++ /dev/null
@@ -1,3 +0,0 @@
-
-# Use bd merge for beads JSONL files
-.beads/issues.jsonl merge=beads
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 574b02b..77a97cc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,6 +15,7 @@ jobs:
matrix:
ruby:
- "3.4.5"
+ - "4.0.5"
steps:
- uses: actions/checkout@v5
diff --git a/.gitignore b/.gitignore
index e04dc6e..3a18bf6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,7 +44,6 @@ doc/
.yardoc/
.claude
-.beads
# Agent instructions (local only)
AGENTS.md
diff --git a/.rubocop.yml b/.rubocop.yml
index c3f9dcb..d315005 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -22,6 +22,17 @@ Metrics/BlockLength:
Metrics/MethodLength:
Max: 20
+ Exclude:
+ - "test/**/*"
+
+# API wrapper methods naturally accept many optional keyword arguments
+Metrics/ParameterLists:
+ CountKeywordArgs: false
+
+# WebMock stub setup makes test methods branch-heavy by nature
+Metrics/AbcSize:
+ Exclude:
+ - "test/**/*"
Metrics/ClassLength:
Max: 150
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c62e77..8ca470e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.2.0] - Unreleased
+
+### Added
+
+- API v2 image creation via `client.v2.images.create` (`POST /v2/image/create`): ordered `references`
+ (`{ data: }` / `{ ref: }` objects addressed from the prompt as `N`), the full
+ 18-value aspect-ratio set including `auto`, prompts up to 4000 characters, and up to 8 references
+- Experimental layout endpoints under `client.v2.layouts`: `extract` (`POST /v2/image/extract_layout`),
+ `create` (`POST /v2/image/create_layout`), and `render` (`POST /v2/image/render_layout`)
+- Effects listing via `client.effects.list` (`GET /v1/image/effect`) with an optional `source` filter
+- `postprocessing:`, `test_time_scaling:`, `accept:`, and `breadcrumb:` options on the v1
+ `create`/`edit`/`remix` and v2 endpoints
+- Binary image responses: `accept: "image/png"`, `"image/jpeg"`, or `"image/webp"` returns raw image
+ bytes with metadata in `X-Reve-*` headers
+- `ImageResponse#layout` and `ReveAI::LayoutResponse` for the structured layouts returned by v2 endpoints
+- `APIError#params` for the error-specific `params` object returned by the API, and `APIError#error_code`
+ now falls back to the `X-Reve-Error-Code` header (binary error responses)
+- GET support in the HTTP layer; retries now cover GET requests as well as POST
+
+### Fixed
+
+- Remix `
N` YARD documentation corrected to 0-based indexing, matching the API
+
## [0.1.0] - 2026-01-04
### Added
diff --git a/README.md b/README.md
index 0a32214..fca4761 100644
--- a/README.md
+++ b/README.md
@@ -88,7 +88,7 @@ response.image # => "base64editeddata..."
response.version # => "reve-edit@20250915"
```
-Available versions for edit: `latest`, `latest-fast`, `reve-edit@20250915`, `reve-edit-fast@20251030`
+Available versions for edit: `latest`, `latest-fast`, `reve-edit@20250915`, `reve-edit-fast@20251030`, `reve-edit-passthrough@20260625`
### Remix Images
@@ -116,6 +116,227 @@ response.version # => "reve-remix@20250915"
Available versions for remix: `latest`, `latest-fast`, `reve-remix@20250915`, `reve-remix-fast@20251030`
+### Postprocessing and Effects
+
+The v1 `create`/`edit`/`remix` methods and `client.v2.images.create` accept these optional keyword arguments (`client.v2.layouts.render` also supports `postprocessing:` and `accept:`, and every endpoint supports `breadcrumb:`):
+
+| Option | Values | Description |
+|--------|--------|-------------|
+| `postprocessing:` | Array of operation Hashes | Operations applied to the generated image (see below) |
+| `test_time_scaling:` | `1`-`15` | Spend more effort (and credits) on the request; clamped server-side. Not recommended for v2 models |
+| `accept:` | `"image/png"`, `"image/jpeg"`, `"image/webp"` | Return raw image bytes instead of JSON; metadata moves to `X-Reve-*` headers |
+| `breadcrumb:` | String | Request-tracking tag, searchable on the Reve Usage page; ignored by the API |
+
+Supported postprocessing operations:
+
+| Operation | Parameters | Notes |
+|-----------|------------|-------|
+| `upscale` | `upscale_factor` (integer, 1-4) | Adds credits cost; a 4x upscale is large |
+| `remove_background` | none | Adds credits cost; works best with a clear subject |
+| `fit_image` | `max_dim`, `max_width`, and/or `max_height` (max 4096) | Free; scales down preserving aspect ratio |
+| `effect` | `effect_name`, optional `effect_parameters` | Applies an effect saved in your project |
+
+```ruby
+response = client.images.create(
+ prompt: "A beautiful sunset over mountains",
+ postprocessing: [
+ { process: "upscale", upscale_factor: 2 },
+ { process: "fit_image", max_dim: 2048 }
+ ],
+ test_time_scaling: 3,
+ breadcrumb: "homepage-hero"
+)
+```
+
+Effect parameter overrides use a nested `{ filterId => { uniformId => value } }` format; omitted parameters fall back to the effect's saved defaults:
+
+```ruby
+response = client.images.create(
+ prompt: "A high-quality photo of a wine bottle",
+ postprocessing: [
+ {
+ process: "effect",
+ effect_name: "adjustments",
+ effect_parameters: {
+ "adjustments" => {
+ "u_exposure" => 42,
+ "u_contrast" => -38,
+ "u_vibrance" => 64
+ }
+ }
+ }
+ ]
+)
+```
+
+List the effects available to your project — the returned `name` values are valid `effect_name` arguments:
+
+```ruby
+response = client.effects.list
+
+response.body[:effects].each do |effect|
+ puts "#{effect[:name]} (#{effect[:source]})" # source: "saved" (project) or "builtin" (preset)
+end
+
+# Optional source filter: "all" (default), "project" (saved only), "preset" (builtin only)
+presets = client.effects.list(source: "preset")
+```
+
+### API v2
+
+The v2 API collapses the three v1 workflows into a single endpoint, `client.v2.images.create`, which takes ordered reference images and returns a structured `layout` alongside the image. The v1 endpoints above remain live and unchanged.
+
+- Prompts up to 4000 characters, and up to 8 reference images
+- The full 18-value aspect-ratio set, including `auto` (the default) — see [Validation Constraints](#validation-constraints)
+- References are objects with exactly one of `data:` (base64-encoded image) or `ref:` (`"id:"` for a stored image or generation, `"reference:@"` for a named project reference)
+- Address a reference from the prompt as `N` (0-based: the first reference is `0`)
+- v2 images are significantly larger than v1 — cap the output with a free `fit_image` step: `postprocessing: [{ process: "fit_image", max_dim: 2048 }]`
+
+Generate an image from text:
+
+```ruby
+response = client.v2.images.create(prompt: "A beautiful sunset over mountains")
+
+response.image # => "base64encodeddata..."
+response.version # => "latest" (v2 responses currently always report "latest")
+```
+
+Edit an image (pass it as the first reference and address it as `0`):
+
+```ruby
+require "base64"
+
+image_data = Base64.strict_encode64(File.read("my-image.png"))
+
+response = client.v2.images.create(
+ prompt: "Add dramatic clouds to the sky of 0",
+ references: [{ data: image_data }]
+)
+```
+
+Combine multiple references:
+
+```ruby
+person = Base64.strict_encode64(File.read("person.png"))
+background = Base64.strict_encode64(File.read("background.png"))
+
+response = client.v2.images.create(
+ prompt: "The person from 0 standing in the scene from 1",
+ references: [{ data: person }, { data: background }],
+ aspect_ratio: "21:9"
+)
+```
+
+Every v2 JSON response includes the layout the model generated for the image:
+
+```ruby
+response = client.v2.images.create(prompt: "A serene mountain landscape at sunset")
+
+response.layout # => { prompt: "...", regions: [...], width: 4672, height: 3520 }
+
+response.layout[:regions].each do |region|
+ puts "#{region[:label]}: #{region[:prompt]}"
+end
+```
+
+Request a binary image instead of JSON with `accept:` — `response.image` then holds the raw bytes, and the metadata arrives via `X-Reve-*` headers:
+
+```ruby
+response = client.v2.images.create(
+ prompt: "A beautiful sunset over mountains",
+ accept: "image/webp"
+)
+
+File.binwrite("mountains.webp", response.image) # no Base64 decoding needed
+
+response.credits_used # => 150
+```
+
+Pass `version:` to pin a model alias — v2 aliases observed: `latest` (default), `reve-v2-create@260601`.
+
+#### Migrating from v1 to v2
+
+All three v1 workflows map to `client.v2.images.create`:
+
+Create — keep `prompt` and `aspect_ratio`:
+
+```ruby
+# v1
+client.images.create(prompt: "A serene mountain landscape at sunset",
+ aspect_ratio: "16:9", version: "latest")
+
+# v2
+client.v2.images.create(prompt: "A serene mountain landscape at sunset",
+ aspect_ratio: "16:9")
+```
+
+Edit — the edited image becomes `references[0]`, addressed as `0`:
+
+```ruby
+# v1
+client.images.edit(edit_instruction: "Remove the people in the background.",
+ reference_image: image_b64)
+
+# v2
+client.v2.images.create(prompt: "Remove the people in the background of 0.",
+ references: [{ data: image_b64 }])
+```
+
+Remix — `
N` tags become `N` (both 0-based) and bare base64 strings become `{ data: }` objects:
+
+```ruby
+# v1
+client.images.remix(prompt: "The woman from
0 driving the car from
1.",
+ reference_images: [woman_b64, car_b64], aspect_ratio: "1:1")
+
+# v2
+client.v2.images.create(prompt: "The woman from 0 driving the car from 1.",
+ references: [{ data: woman_b64 }, { data: car_b64 }], aspect_ratio: "1:1")
+```
+
+#### Experimental Layout Endpoints
+
+The layout endpoints are **experimental**: they require care and experimentation to achieve good results, are best suited for agents and custom tooling, and may change. For simple generation and prompt-based editing, use `client.v2.images.create`. They return JSON only (`render` also supports `accept:`) and commonly take 10-40 seconds (`render`: 40-80 seconds) — keep timeouts at 120 seconds or more.
+
+A layout is a Hash with an optional overall `prompt`, optional `width`/`height`, and a `regions` array; each region carries a `label`, a regional `prompt`, and a normalized `bbox` (`x0`, `y0`, `x1`, `y1` in 0.0-1.0, top-left origin).
+
+Extract a layout from an image:
+
+```ruby
+image = { data: Base64.strict_encode64(File.read("photo.jpg")) }
+
+response = client.v2.layouts.extract(image: image)
+response.layout # => { prompt: "...", regions: [...], width: 4672, height: 3520 }
+```
+
+Create a layout from a prompt, without rendering an image:
+
+```ruby
+response = client.v2.layouts.create(prompt: "a person at a cafe", aspect_ratio: "3:2")
+layout = response.layout
+```
+
+Render an image from a layout:
+
+```ruby
+response = client.v2.layouts.render(layout: layout)
+response.image # => "base64encodeddata..."
+```
+
+Unlike v2 create, the layout endpoints take compound references — each entry may contain any subset of `image:`, `layout:`, and `prompt:`:
+
+```ruby
+response = client.v2.layouts.create(
+ prompt: "Put the bottle on a wooden table",
+ references: [
+ { image: { data: bottle_b64 }, prompt: "the product to feature" },
+ { layout: saved_layout }
+ ]
+)
+```
+
+`create` also accepts `commands:` — ordered imperative edits (`add`, `place`, `shift`, `remove`, `keep`, `change`) with normalized positions; see the [API docs](https://api.reve.com/console/docs) for the command shapes.
+
### Rails
Create `config/initializers/reve_ai.rb`:
@@ -167,6 +388,19 @@ rescue ReveAI::ConnectionError => e
end
```
+Every API error also exposes the details returned by the API:
+
+```ruby
+rescue ReveAI::APIError => e
+ e.error_code # => "PROMPT_TOO_LONG"
+ e.params # => Hash of error-specific parameters, or nil
+ e.status # => 400
+ e.request_id # => "rsid-..."
+end
+```
+
+For binary image requests (`accept: "image/*"`), error responses carry a small grey image and the error code arrives in the `X-Reve-Error-Code` header — `#error_code` reads it from either the response body or the header.
+
### Content Moderation
The API may flag content violations:
@@ -185,20 +419,38 @@ end
|--------|---------|-------------|
| `api_key` | `ENV["REVE_AI_API_KEY"]` | Your Reve API key |
| `base_url` | `https://api.reve.com` | API base URL |
-| `timeout` | `120` | Request timeout in seconds |
+| `timeout` | `120` | Request timeout in seconds (the API requires at least 120) |
| `open_timeout` | `30` | Connection timeout in seconds |
| `max_retries` | `2` | Number of retries for failed requests |
| `logger` | `nil` | Logger instance for debugging |
| `debug` | `false` | Enable debug logging |
+The API requires client timeouts of at least 120 seconds: image generation and rendering commonly take 40-80 seconds, and the layout endpoints 10-40 seconds. The gem's default of 120 seconds complies. Treat shorter client-side timeouts as cancellations — the server may still finish the request after the client disconnects.
+
### Validation Constraints
+v1 endpoints (`create`, `edit`, `remix`):
+
| Constraint | Value |
|------------|-------|
| Max prompt length | 2560 characters |
| Max reference images (remix) | 6 |
| Valid aspect ratios | 16:9, 9:16, 3:2, 2:3, 4:3, 3:4, 1:1 |
+v2 endpoints (`client.v2`):
+
+| Constraint | Value |
+|------------|-------|
+| Max prompt length | 4000 characters |
+| Max reference images | 8 |
+| Valid aspect ratios | 4:1, 3:1, 21:9, 2:1, 17:9, 16:9, 3:2, 4:3, 5:4, 1:1, 4:5, 3:4, 2:3, 9:16, 1:2, 1:3, 1:4, auto (default: auto) |
+
+Input image limits (any endpoint that accepts images):
+
+- Formats: WEBP, JPEG, PNG, GIF, TIFF, AVIF — base64-encoded in JSON
+- Per image: at most 40 MB and 33,554,432 pixels, with neither dimension exceeding 8192 pixels
+- Per call: at most 100 MB and 50,331,648 pixels across all images
+
## Development
```
diff --git a/lib/reve_ai.rb b/lib/reve_ai.rb
index 724e16f..49b5f46 100644
--- a/lib/reve_ai.rb
+++ b/lib/reve_ai.rb
@@ -7,6 +7,10 @@
require_relative "reve_ai/http/client"
require_relative "reve_ai/resources/base"
require_relative "reve_ai/resources/images"
+require_relative "reve_ai/resources/effects"
+require_relative "reve_ai/resources/v2"
+require_relative "reve_ai/resources/v2/images"
+require_relative "reve_ai/resources/v2/layouts"
require_relative "reve_ai/client"
# Ruby client for the Reve image generation API.
diff --git a/lib/reve_ai/client.rb b/lib/reve_ai/client.rb
index 4fe5ba7..3049623 100644
--- a/lib/reve_ai/client.rb
+++ b/lib/reve_ai/client.rb
@@ -64,13 +64,40 @@ def initialize(api_key: nil, **options)
#
# @example Remix images
# result = client.images.remix(
- # prompt: "Combine
1 and
2 into one scene",
+ # prompt: "Combine
0 and
1 into one scene",
# reference_images: [image1_base64, image2_base64]
# )
def images
@images ||= Resources::Images.new(self)
end
+ # Returns the Effects resource for listing available effects.
+ #
+ # @return [Resources::Effects] Effects listing interface
+ # @see Resources::Effects
+ #
+ # @example List all effects available to the project
+ # result = client.effects.list
+ # result.body[:effects].each { |effect| puts effect[:name] }
+ def effects
+ @effects ||= Resources::Effects.new(self)
+ end
+
+ # Returns the v2 API namespace (image create and layout endpoints).
+ #
+ # @return [Resources::V2] v2 API operations interface
+ # @see Resources::V2
+ #
+ # @example Generate an image with the v2 API
+ # result = client.v2.images.create(
+ # prompt: "Remove the people in the background of 0.",
+ # references: [{ data: base64_encoded_image }]
+ # )
+ # result.layout # => { prompt: "...", regions: [...] }
+ def v2
+ @v2 ||= Resources::V2.new(self)
+ end
+
# Returns the HTTP client for making API requests.
#
# @return [HTTP::Client] HTTP client instance
diff --git a/lib/reve_ai/configuration.rb b/lib/reve_ai/configuration.rb
index eff91f6..e88600c 100644
--- a/lib/reve_ai/configuration.rb
+++ b/lib/reve_ai/configuration.rb
@@ -34,7 +34,7 @@ class Configuration
# @return [Integer] Default number of retry attempts for failed requests
DEFAULT_MAX_RETRIES = 2
- # @return [Array] Valid aspect ratios for image generation
+ # @return [Array] Valid aspect ratios for legacy v1 image generation
VALID_ASPECT_RATIOS = %w[16:9 9:16 3:2 2:3 4:3 3:4 1:1].freeze
# @return [Integer] Maximum allowed prompt length in characters
@@ -43,6 +43,15 @@ class Configuration
# @return [Integer] Maximum number of reference images for remix operations
MAX_REFERENCE_IMAGES = 6
+ # @return [Array] Valid aspect ratios for image generation
+ ASPECT_RATIOS = %w[4:1 3:1 21:9 2:1 17:9 16:9 3:2 4:3 5:4 1:1 4:5 3:4 2:3 9:16 1:2 1:3 1:4 auto].freeze
+
+ # @return [Integer] Maximum allowed prompt length in characters for v2 endpoints
+ V2_MAX_PROMPT_LENGTH = 4000
+
+ # @return [Integer] Maximum number of references for v2 create operations
+ V2_MAX_REFERENCES = 8
+
# @return [String, nil] Reve API key for authentication
attr_accessor :api_key
diff --git a/lib/reve_ai/errors.rb b/lib/reve_ai/errors.rb
index b715dea..fbb1254 100644
--- a/lib/reve_ai/errors.rb
+++ b/lib/reve_ai/errors.rb
@@ -72,22 +72,27 @@ class APIError < Error
# @return [Integer, nil] HTTP status code
attr_reader :status
- # @return [Hash] Response body parsed as Hash
+ # @return [Hash, String] Response body parsed as Hash, or raw String
+ # for binary error responses (e.g., grey image bodies)
attr_reader :body
# @return [Hash] Response headers
attr_reader :headers
+ # @return [Hash, nil] Additional error details from the response body
+ attr_reader :params
+
# Creates a new API error instance.
#
# @param message [String, nil] Error message
# @param status [Integer, nil] HTTP status code
- # @param body [Hash, nil] Response body
+ # @param body [Hash, String, nil] Response body
# @param headers [Hash, nil] Response headers
def initialize(message = nil, status: nil, body: nil, headers: nil)
@status = status
@body = body || {}
@headers = headers || {}
+ @params = @body.is_a?(Hash) ? @body[:params] : nil
super(message)
end
@@ -100,11 +105,17 @@ def request_id
headers["x-reve-request-id"]
end
- # Returns the error code from the response body.
+ # Returns the error code for this error.
+ #
+ # Read from the response body when present, falling back to the
+ # X-Reve-Error-Code header: with an image Accept header, the API answers
+ # errors with a small grey image body and no JSON error code.
#
# @return [String, nil] Error code (e.g., "PROMPT_TOO_LONG", "INVALID_API_KEY")
def error_code
- body[:error_code]
+ return body[:error_code] if body.is_a?(Hash) && body[:error_code]
+
+ headers["x-reve-error-code"]
end
end
diff --git a/lib/reve_ai/http/client.rb b/lib/reve_ai/http/client.rb
index d2ced7d..43fc77a 100644
--- a/lib/reve_ai/http/client.rb
+++ b/lib/reve_ai/http/client.rb
@@ -39,12 +39,46 @@ def initialize(configuration)
@configuration = configuration
end
+ # Makes a GET request to the API.
+ #
+ # @param path [String] API endpoint path (e.g., "/v1/image/effect")
+ # @param params [Hash, nil] Query parameters to merge into the URL
+ # (e.g., { source: "project" })
+ #
+ # @return [Response] Parsed API response
+ #
+ # @raise [TimeoutError] if request times out
+ # @raise [ConnectionError] if connection fails
+ # @raise [NetworkError] for other network errors
+ # @raise [BadRequestError] on 400 responses
+ # @raise [UnauthorizedError] on 401 responses
+ # @raise [InsufficientCreditsError] on 402 responses
+ # @raise [ForbiddenError] on 403 responses
+ # @raise [NotFoundError] on 404 responses
+ # @raise [UnprocessableEntityError] on 422 responses
+ # @raise [RateLimitError] on 429 responses
+ # @raise [ServerError] on 5xx responses
+ #
+ # @api private
+ def get(path, params: nil)
+ normalized_path = path.sub(%r{^/}, "")
+ response = perform_request do |conn|
+ conn.get(normalized_path) { |req| req.params.update(params) if params }
+ end
+ handle_response(response)
+ end
+
# Makes a POST request to the API.
#
# @param path [String] API endpoint path (e.g., "/v1/image/create")
# @param body [Hash] Request body to send as JSON
+ # @param params [Hash, nil] Query parameters to merge into the URL
+ # (e.g., { breadcrumb: "my-tracking-value" })
+ # @param accept [String, nil] Per-request Accept header override
+ # (e.g., "image/webp"). The connection default stays "application/json".
#
- # @return [Response] Parsed API response
+ # @return [Response] Parsed API response. When the response Content-Type
+ # is +image/*+, the body is the raw image String (bytes), not a Hash.
#
# @raise [TimeoutError] if request times out
# @raise [ConnectionError] if connection fails
@@ -59,10 +93,31 @@ def initialize(configuration)
# @raise [ServerError] on 5xx responses
#
# @api private
- def post(path, body = {})
+ def post(path, body = {}, params: nil, accept: nil)
normalized_path = path.sub(%r{^/}, "")
- response = connection.post(normalized_path) { |req| req.body = JSON.generate(body) }
+ response = perform_request do |conn|
+ conn.post(normalized_path) do |req|
+ req.params.update(params) if params
+ req.headers["Accept"] = accept if accept
+ req.body = JSON.generate(body)
+ end
+ end
handle_response(response)
+ end
+
+ private
+
+ # Performs an HTTP request, mapping Faraday errors to gem errors.
+ #
+ # @yieldparam connection [Faraday::Connection] Connection to perform the request on
+ # @return [Faraday::Response] Raw Faraday response
+ #
+ # @raise [TimeoutError] if request times out
+ # @raise [ConnectionError] if connection fails
+ # @raise [NetworkError] for other network errors
+ # @api private
+ def perform_request
+ yield connection
rescue Faraday::TimeoutError => e
raise TimeoutError, "Request timed out: #{e.message}"
rescue Faraday::ConnectionFailed => e
@@ -71,8 +126,6 @@ def post(path, body = {})
raise NetworkError, "Network error: #{e.message}"
end
- private
-
# Handles connection failed errors.
#
# Distinguishes between timeout errors (which may appear as connection failures)
@@ -116,7 +169,7 @@ def build_connection
# @api private
def configure_retry(conn)
conn.request :retry, max: configuration.max_retries, interval: 0.5,
- backoff_factor: 2, retry_statuses: RETRY_STATUSES, methods: [:post]
+ backoff_factor: 2, retry_statuses: RETRY_STATUSES, methods: %i[post get]
end
# Configures request headers.
@@ -162,7 +215,7 @@ def user_agent
# @raise [APIError] on error responses
# @api private
def handle_response(response)
- body = parse_body(response.body)
+ body = parse_body(response)
return build_success_response(response, body) if response.status.between?(200, 299)
raise_api_error(response.status, body, response.headers.to_h)
@@ -171,37 +224,66 @@ def handle_response(response)
# Builds a successful response object.
#
# @param response [Faraday::Response] Raw response
- # @param body [Hash] Parsed body
+ # @param body [Hash, String] Parsed body, or raw bytes for binary responses
# @return [Response] Response wrapper
# @api private
def build_success_response(response, body)
Response.new(status: response.status, headers: response.headers.to_h, body: body)
end
- # Parses the response body as JSON.
+ # Parses the response body.
+ #
+ # Bodies with an +image/*+ Content-Type are raw image bytes and are
+ # returned as-is; anything else is parsed as JSON.
#
- # @param body [String, nil] Raw response body
- # @return [Hash] Parsed body, or empty hash if nil/empty
+ # @param response [Faraday::Response] Raw Faraday response
+ # @return [Hash, String] Parsed body, raw bytes String for image
+ # responses, or empty hash if body is nil/empty
# @api private
- def parse_body(body)
- return {} if body.nil? || body.empty?
+ def parse_body(response)
+ raw = response.body
+ return {} if raw.nil? || raw.empty?
+ return raw if image_content?(response)
- JSON.parse(body, symbolize_names: true)
+ JSON.parse(raw, symbolize_names: true)
rescue JSON::ParserError
- { raw: body }
+ { raw: raw }
+ end
+
+ # Checks whether the response carries raw image bytes.
+ #
+ # @param response [Faraday::Response] Raw Faraday response
+ # @return [Boolean] true if Content-Type starts with "image/"
+ # @api private
+ def image_content?(response)
+ response.headers["content-type"].to_s.start_with?("image/")
end
# Raises the appropriate API error for a status code.
#
# @param status [Integer] HTTP status code
- # @param body [Hash] Parsed response body
+ # @param body [Hash, String] Parsed response body
# @param headers [Hash] Response headers
# @raise [APIError] Appropriate error subclass
# @api private
def raise_api_error(status, body, headers)
error_class = ERROR_CODE_MAP[status] || (status >= 500 ? ServerError : APIError)
- message = body[:message] || body[:error] || "Unknown error"
- raise error_class.new(message, status: status, body: body, headers: headers)
+ raise error_class.new(extract_error_message(body, headers), status: status, body: body, headers: headers)
+ end
+
+ # Extracts the error message from the response body.
+ #
+ # Falls back to the X-Reve-Error-Code header when the body carries no
+ # message: with an image Accept header, the API answers errors with a
+ # small grey image instead of a JSON error body.
+ #
+ # @param body [Hash, String] Parsed response body
+ # @param headers [Hash] Response headers
+ # @return [String] Error message
+ # @api private
+ def extract_error_message(body, headers)
+ from_body = body.is_a?(Hash) ? body[:message] || body[:error] : nil
+ from_body || headers["x-reve-error-code"] || "Unknown error"
end
end
end
diff --git a/lib/reve_ai/resources/base.rb b/lib/reve_ai/resources/base.rb
index 85ac6b9..661909e 100644
--- a/lib/reve_ai/resources/base.rb
+++ b/lib/reve_ai/resources/base.rb
@@ -41,26 +41,41 @@ def configuration
client.configuration
end
+ # Makes a GET request to the API.
+ #
+ # @param path [String] API endpoint path
+ # @param params [Hash, nil] Query parameters to merge into the URL
+ # @return [Response] API response
+ # @api private
+ def get(path, params: nil)
+ http_client.get(path, params: params)
+ end
+
# Makes a POST request to the API.
#
# @param path [String] API endpoint path
# @param body [Hash] Request body
+ # @param params [Hash, nil] Query parameters to merge into the URL
+ # (e.g., { breadcrumb: "my-tracking-value" })
+ # @param accept [String, nil] Per-request Accept header override
+ # (e.g., "image/webp")
# @return [Response] API response
# @api private
- def post(path, body = {})
- http_client.post(path, body)
+ def post(path, body = {}, params: nil, accept: nil)
+ http_client.post(path, body, params: params, accept: accept)
end
# Validates a text prompt.
#
# @param prompt [String] The prompt to validate
# @param field_name [String] Name for error messages (default: "Prompt")
+ # @param max_length [Integer] Maximum allowed length in characters
+ # (default: v1 limit of {Configuration::MAX_PROMPT_LENGTH})
# @raise [ValidationError] if prompt is nil, empty, or exceeds max length
# @api private
- def validate_prompt!(prompt, field_name: "Prompt")
+ def validate_prompt!(prompt, field_name: "Prompt", max_length: Configuration::MAX_PROMPT_LENGTH)
raise ValidationError, "#{field_name} is required" if prompt.nil? || prompt.empty?
- max_length = Configuration::MAX_PROMPT_LENGTH
return unless prompt.length > max_length
raise ValidationError, "#{field_name} exceeds maximum length of #{max_length} characters"
@@ -69,17 +84,58 @@ def validate_prompt!(prompt, field_name: "Prompt")
# Validates an aspect ratio value.
#
# @param aspect_ratio [String, nil] The aspect ratio to validate
+ # @param valid_ratios [Array] Allowed aspect ratios
+ # (default: v1 set {Configuration::VALID_ASPECT_RATIOS})
# @raise [ValidationError] if aspect ratio is invalid
# @api private
- def validate_aspect_ratio!(aspect_ratio)
+ def validate_aspect_ratio!(aspect_ratio, valid_ratios = Configuration::VALID_ASPECT_RATIOS)
return if aspect_ratio.nil?
-
- valid_ratios = Configuration::VALID_ASPECT_RATIOS
return if valid_ratios.include?(aspect_ratio)
raise ValidationError, "Invalid aspect_ratio '#{aspect_ratio}'. Must be one of: #{valid_ratios.join(", ")}"
end
+ # Validates a postprocessing steps array.
+ #
+ # @param postprocessing [Array, nil] Postprocessing steps, each
+ # requiring a +process+ key (e.g., { process: "upscale", upscale_factor: 2 })
+ # @raise [ValidationError] if not an Array of Hashes, or a step lacks a +process+ key
+ # @api private
+ def validate_postprocessing!(postprocessing)
+ return if postprocessing.nil?
+
+ unless postprocessing_steps?(postprocessing)
+ raise ValidationError, "Postprocessing must be an Array of Hashes with a 'process' key"
+ end
+
+ postprocessing.each_with_index do |step, index|
+ next if step.key?(:process) || step.key?("process")
+
+ raise ValidationError, "Postprocessing step at index #{index} must include a 'process' key"
+ end
+ end
+
+ # Checks whether a value is an Array of Hashes (postprocessing shape).
+ #
+ # @param value [Object] The value to check
+ # @return [Boolean] true if value is an Array of Hashes
+ # @api private
+ def postprocessing_steps?(value)
+ value.is_a?(Array) && value.all?(Hash)
+ end
+
+ # Validates a test_time_scaling value.
+ #
+ # @param value [Numeric, nil] Scaling factor (1-15)
+ # @raise [ValidationError] if not a Numeric between 1 and 15
+ # @api private
+ def validate_test_time_scaling!(value)
+ return if value.nil?
+ return if value.is_a?(Numeric) && value.between?(1, 15)
+
+ raise ValidationError, "test_time_scaling must be a number between 1 and 15"
+ end
+
# Validates a single reference image.
#
# @param image [String] Base64 encoded image data
diff --git a/lib/reve_ai/resources/effects.rb b/lib/reve_ai/resources/effects.rb
new file mode 100644
index 0000000..5975f5c
--- /dev/null
+++ b/lib/reve_ai/resources/effects.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+module ReveAI
+ module Resources
+ # Effects listing operations.
+ #
+ # Lists the effects available to the project associated with the API key,
+ # including saved project effects and built-in presets. Names from this
+ # list can be applied to any generation via the +postprocessing+ parameter
+ # of the image resources (see Images#create).
+ #
+ # @example List all effects
+ # client = ReveAI::Client.new(api_key: "your-key")
+ # result = client.effects.list
+ # result.body[:effects].each { |effect| puts effect[:name] }
+ #
+ # @note The list returns effect names only — effect parameter definitions
+ # are not included. Configure effect presets in the Reve application,
+ # save them with a name, and apply that name from the API.
+ # @see https://api.reve.com/console/docs Reve API Documentation
+ class Effects < Base
+ # @return [String] API endpoint for listing effects
+ LIST_ENDPOINT = "/v1/image/effect"
+
+ # @return [Array] Valid values for the +source+ filter
+ VALID_SOURCES = %w[all project preset].freeze
+
+ # Lists effects available to the project.
+ #
+ # The default response includes both saved project effects (+source+
+ # "saved") and built-in presets (+source+ "builtin"). Each entry in
+ # +body[:effects]+ carries +name+ and +source+, plus optional
+ # +description+ and +category+ (e.g., "color", "textures") when
+ # available. Use a returned +name+ as +effect_name+ in postprocessing
+ # requests.
+ #
+ # @param source [String, nil] Filter by effect origin: "all" (default),
+ # "project" (saved project effects only), or "preset" (builtin only)
+ # @param breadcrumb [String, nil] Request tracking label sent as the
+ # +breadcrumb+ query param; ignored by the API, searchable in the
+ # Usage page
+ #
+ # @return [Response] Response whose +body[:effects]+ is an Array of
+ # effect Hashes with +name+, +source+, and optional +description+
+ # and +category+ keys
+ #
+ # @raise [ValidationError] if source is not one of "all", "project", "preset"
+ # @raise [UnauthorizedError] if API key is invalid
+ # @raise [RateLimitError] if rate limit is exceeded
+ #
+ # @example List all effects
+ # result = client.effects.list
+ # result.body[:effects].map { |effect| effect[:name] }
+ #
+ # @example List only saved project effects
+ # result = client.effects.list(source: "project")
+ #
+ # @see https://api.reve.com/console/docs Reve API Documentation
+ def list(source: nil, breadcrumb: nil)
+ validate_source!(source)
+
+ params = {}
+ params[:source] = source if source
+ params[:breadcrumb] = breadcrumb if breadcrumb
+
+ get(LIST_ENDPOINT, params: params.empty? ? nil : params)
+ end
+
+ private
+
+ # Validates the source filter.
+ #
+ # @param source [String, nil] The source filter to validate
+ # @raise [ValidationError] if source is not one of {VALID_SOURCES}
+ # @api private
+ def validate_source!(source)
+ return if source.nil? || VALID_SOURCES.include?(source)
+
+ raise ValidationError, "Invalid source '#{source}'. Must be one of: #{VALID_SOURCES.join(", ")}"
+ end
+ end
+ end
+end
diff --git a/lib/reve_ai/resources/images.rb b/lib/reve_ai/resources/images.rb
index 456a190..99d6c71 100644
--- a/lib/reve_ai/resources/images.rb
+++ b/lib/reve_ai/resources/images.rb
@@ -23,11 +23,13 @@ module Resources
#
# @example Remix multiple images
# result = client.images.remix(
- # prompt: "Combine the style of
1 with the subject of
2",
+ # prompt: "Combine the style of
0 with the subject of
1",
# reference_images: [style_image_base64, subject_image_base64]
# )
#
- # @note All images are returned as base64 encoded PNG data.
+ # @note By default all images are returned as base64 encoded PNG data;
+ # pass accept: "image/png", "image/jpeg", or "image/webp" to any method
+ # for raw image bytes instead (see method docs).
# @see https://api.reve.com/console/docs Reve API Documentation
class Images < Base
# @return [String] API endpoint for image creation
@@ -44,6 +46,22 @@ class Images < Base
# @param prompt [String] Text description of the desired image (max 2560 chars)
# @param aspect_ratio [String, nil] Output aspect ratio (defaults to API default)
# @param version [String, nil] Model version to use (defaults to "latest")
+ # @param postprocessing [Array, nil] Postprocessing steps applied after
+ # generation; each step requires a +process+ key:
+ # +upscale+ (+upscale_factor+ 2-4, adds credits cost),
+ # +remove_background+,
+ # +fit_image+ (+max_dim+/+max_width+/+max_height+, max 4096, free),
+ # +effect+ (+effect_name+, optional +effect_parameters+ overrides nested
+ # as +{ filter_id: { uniform_id: value } }+)
+ # @param test_time_scaling [Numeric, nil] Effort scaling factor 1-15
+ # (default 1); values above 1 add credits cost, values above 5 only
+ # occasionally improve results
+ # @param accept [String, nil] Response format: "application/json" (default)
+ # or "image/png", "image/jpeg", "image/webp" for raw image bytes, e.g.
+ # accept: "image/webp" returns the raw image via result.image (metadata
+ # moves to the X-Reve-* response headers)
+ # @param breadcrumb [String, nil] Request tracking label sent as the
+ # +breadcrumb+ query param; ignored by the API, searchable in the Usage page
#
# @option aspect_ratio [String] "16:9" Widescreen landscape
# @option aspect_ratio [String] "9:16" Portrait (phone)
@@ -53,10 +71,13 @@ class Images < Base
# @option aspect_ratio [String] "3:4" Standard portrait
# @option aspect_ratio [String] "1:1" Square
#
- # @return [ImageResponse] Response containing base64 encoded image
+ # @return [ImageResponse] Response containing base64 encoded image,
+ # or raw image bytes when +accept+ is an image format
#
# @raise [ValidationError] if prompt is empty or exceeds max length
# @raise [ValidationError] if aspect_ratio is invalid
+ # @raise [ValidationError] if postprocessing is not an Array of Hashes with a +process+ key
+ # @raise [ValidationError] if test_time_scaling is not a number between 1 and 15
# @raise [BadRequestError] if API rejects the request
# @raise [UnauthorizedError] if API key is invalid
# @raise [InsufficientCreditsError] if account has no credits
@@ -71,20 +92,38 @@ class Images < Base
# aspect_ratio: "16:9"
# )
#
+ # @example With postprocessing (upscale, then fit within 2048px)
+ # result = client.images.create(
+ # prompt: "A panoramic mountain landscape",
+ # postprocessing: [{ process: "upscale", upscale_factor: 2 },
+ # { process: "fit_image", max_dim: 2048 }]
+ # )
+ #
+ # @example Request raw WebP bytes instead of JSON
+ # result = client.images.create(prompt: "A sunset", accept: "image/webp")
+ # File.binwrite("sunset.webp", result.image) # raw bytes, no Base64 decode
+ #
# @example Save to file
# result = client.images.create(prompt: "A sunset")
# File.binwrite("image.png", Base64.decode64(result.base64))
#
# @see https://api.reve.com/console/docs#/Image/create_v1_image_create_post
- def create(prompt:, aspect_ratio: nil, version: nil)
+ def create(prompt:, aspect_ratio: nil, version: nil, postprocessing: nil, test_time_scaling: nil,
+ accept: nil, breadcrumb: nil)
validate_prompt!(prompt)
validate_aspect_ratio!(aspect_ratio)
+ validate_postprocessing!(postprocessing)
+ validate_test_time_scaling!(test_time_scaling)
body = { prompt: prompt }
body[:aspect_ratio] = aspect_ratio if aspect_ratio
body[:version] = version if version
+ body[:postprocessing] = postprocessing if postprocessing
+ body[:test_time_scaling] = test_time_scaling if test_time_scaling
+
+ params = breadcrumb ? { breadcrumb: breadcrumb } : nil
- response = post(CREATE_ENDPOINT, body)
+ response = post(CREATE_ENDPOINT, body, params: params, accept: accept)
ImageResponse.new(status: response.status, headers: response.headers, body: response.body)
end
@@ -94,12 +133,24 @@ def create(prompt:, aspect_ratio: nil, version: nil)
# @param reference_image [String] Base64 encoded image to edit
# @param aspect_ratio [String, nil] Output aspect ratio (defaults to reference image ratio)
# @param version [String, nil] Model version to use (defaults to "latest")
+ # @param postprocessing [Array, nil] Postprocessing steps applied after
+ # generation; see {#create} for the supported step shapes
+ # @param test_time_scaling [Numeric, nil] Effort scaling factor 1-15
+ # (default 1); values above 1 add credits cost
+ # @param accept [String, nil] Response format: "application/json" (default)
+ # or "image/png", "image/jpeg", "image/webp" for raw image bytes via
+ # result.image (metadata moves to the X-Reve-* response headers)
+ # @param breadcrumb [String, nil] Request tracking label sent as the
+ # +breadcrumb+ query param; ignored by the API, searchable in the Usage page
#
- # @return [ImageResponse] Response containing base64 encoded edited image
+ # @return [ImageResponse] Response containing base64 encoded edited image,
+ # or raw image bytes when +accept+ is an image format
#
# @raise [ValidationError] if edit_instruction is empty or exceeds max length
# @raise [ValidationError] if reference_image is empty
# @raise [ValidationError] if aspect_ratio is invalid
+ # @raise [ValidationError] if postprocessing is not an Array of Hashes with a +process+ key
+ # @raise [ValidationError] if test_time_scaling is not a number between 1 and 15
# @raise [UnprocessableEntityError] if reference_image is not valid base64
# @raise [BadRequestError] if API rejects the request
# @raise [UnauthorizedError] if API key is invalid
@@ -116,67 +167,100 @@ def create(prompt:, aspect_ratio: nil, version: nil)
# reference_image: landscape_base64
# )
#
+ # @example Remove the background after editing
+ # result = client.images.edit(
+ # edit_instruction: "Change the car color from red to blue",
+ # reference_image: original_image_base64,
+ # postprocessing: [{ process: "remove_background" }]
+ # )
+ #
# @see https://api.reve.com/console/docs#/Image/edit_v1_image_edit_post
- def edit(edit_instruction:, reference_image:, aspect_ratio: nil, version: nil)
+ def edit(edit_instruction:, reference_image:, aspect_ratio: nil, version: nil, postprocessing: nil,
+ test_time_scaling: nil, accept: nil, breadcrumb: nil)
validate_prompt!(edit_instruction, field_name: "Edit instruction")
validate_reference_image!(reference_image)
+ validate_postprocessing!(postprocessing)
+ validate_test_time_scaling!(test_time_scaling)
body = { edit_instruction: edit_instruction, reference_image: reference_image }
body[:aspect_ratio] = aspect_ratio if aspect_ratio
body[:version] = version if version
+ body[:postprocessing] = postprocessing if postprocessing
+ body[:test_time_scaling] = test_time_scaling if test_time_scaling
+
+ params = breadcrumb ? { breadcrumb: breadcrumb } : nil
- response = post(EDIT_ENDPOINT, body)
+ response = post(EDIT_ENDPOINT, body, params: params, accept: accept)
ImageResponse.new(status: response.status, headers: response.headers, body: response.body)
end
# Creates a new image by remixing multiple reference images.
#
# Use `
N` tags in the prompt to reference specific images,
- # where N is the 1-based index into the reference_images array.
+ # where N is the 0-based index into the reference_images array.
#
# @param prompt [String] Text description with optional image references (max 2560 chars)
# @param reference_images [Array] Array of base64 encoded images (1-6 images)
# @param aspect_ratio [String, nil] Output aspect ratio (defaults to model's choice)
# @param version [String, nil] Model version to use (defaults to "latest")
+ # @param postprocessing [Array, nil] Postprocessing steps applied after
+ # generation; see {#create} for the supported step shapes
+ # @param test_time_scaling [Numeric, nil] Effort scaling factor 1-15
+ # (default 1); values above 1 add credits cost
+ # @param accept [String, nil] Response format: "application/json" (default)
+ # or "image/png", "image/jpeg", "image/webp" for raw image bytes via
+ # result.image (metadata moves to the X-Reve-* response headers)
+ # @param breadcrumb [String, nil] Request tracking label sent as the
+ # +breadcrumb+ query param; ignored by the API, searchable in the Usage page
#
- # @return [ImageResponse] Response containing base64 encoded remixed image
+ # @return [ImageResponse] Response containing base64 encoded remixed image,
+ # or raw image bytes when +accept+ is an image format
#
# @raise [ValidationError] if prompt is empty or exceeds max length
# @raise [ValidationError] if reference_images is empty or exceeds 6 images
# @raise [ValidationError] if any reference image is empty
# @raise [ValidationError] if aspect_ratio is invalid
+ # @raise [ValidationError] if postprocessing is not an Array of Hashes with a +process+ key
+ # @raise [ValidationError] if test_time_scaling is not a number between 1 and 15
# @raise [BadRequestError] if API rejects the request
#
# @example Combine two images
# result = client.images.remix(
- # prompt: "Combine the landscape from
1 with the sky from
2",
+ # prompt: "Combine the landscape from
0 with the sky from
1",
# reference_images: [landscape_base64, sky_base64]
# )
#
# @example Style transfer
# result = client.images.remix(
- # prompt: "Apply the artistic style of
1 to the photo
2",
+ # prompt: "Apply the artistic style of
0 to the photo
1",
# reference_images: [artwork_base64, photo_base64]
# )
#
# @example Multiple references
# result = client.images.remix(
- # prompt: "Create a scene with the dog from
1, " \
- # "the background from
2, and lighting from
3",
+ # prompt: "Create a scene with the dog from
0, " \
+ # "the background from
1, and lighting from
2",
# reference_images: [dog_base64, background_base64, lighting_ref_base64]
# )
#
# @see https://api.reve.com/console/docs#/Image/remix_v1_image_remix_post
- def remix(prompt:, reference_images:, aspect_ratio: nil, version: nil)
+ def remix(prompt:, reference_images:, aspect_ratio: nil, version: nil, postprocessing: nil,
+ test_time_scaling: nil, accept: nil, breadcrumb: nil)
validate_prompt!(prompt)
validate_reference_images!(reference_images)
validate_aspect_ratio!(aspect_ratio)
+ validate_postprocessing!(postprocessing)
+ validate_test_time_scaling!(test_time_scaling)
body = { prompt: prompt, reference_images: reference_images }
body[:aspect_ratio] = aspect_ratio if aspect_ratio
body[:version] = version if version
+ body[:postprocessing] = postprocessing if postprocessing
+ body[:test_time_scaling] = test_time_scaling if test_time_scaling
+
+ params = breadcrumb ? { breadcrumb: breadcrumb } : nil
- response = post(REMIX_ENDPOINT, body)
+ response = post(REMIX_ENDPOINT, body, params: params, accept: accept)
ImageResponse.new(status: response.status, headers: response.headers, body: response.body)
end
end
diff --git a/lib/reve_ai/resources/v2.rb b/lib/reve_ai/resources/v2.rb
new file mode 100644
index 0000000..96e404e
--- /dev/null
+++ b/lib/reve_ai/resources/v2.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module ReveAI
+ module Resources
+ # Namespace for Reve API v2 resources.
+ #
+ # Accessed via `client.v2`. Groups the v2 image create endpoint and the
+ # experimental layout pipeline endpoints.
+ #
+ # @example Generate an image with the v2 API
+ # result = client.v2.images.create(prompt: "A sunset over mountains")
+ # result.layout # => { prompt: "...", regions: [...] }
+ #
+ # @see https://api.reve.com/console/docs Reve API Documentation
+ class V2
+ # @return [Client] The client instance for this namespace
+ attr_reader :client
+
+ # Creates a new v2 namespace.
+ #
+ # @param client [Client] The API client
+ # @api private
+ def initialize(client)
+ @client = client
+ end
+
+ # Returns the v2 images resource.
+ #
+ # @return [V2::Images] v2 image generation operations
+ def images
+ @images ||= Images.new(client)
+ end
+
+ # Returns the v2 layouts resource (experimental endpoints).
+ #
+ # @return [V2::Layouts] v2 layout pipeline operations
+ def layouts
+ @layouts ||= Layouts.new(client)
+ end
+ end
+ end
+end
diff --git a/lib/reve_ai/resources/v2/images.rb b/lib/reve_ai/resources/v2/images.rb
new file mode 100644
index 0000000..2f1652d
--- /dev/null
+++ b/lib/reve_ai/resources/v2/images.rb
@@ -0,0 +1,188 @@
+# frozen_string_literal: true
+
+require_relative "../base"
+
+module ReveAI
+ module Resources
+ class V2
+ # v2 image generation operations.
+ #
+ # The v2 create endpoint unifies the v1 create, edit, and remix
+ # workflows: a single prompt plus an ordered list of reference images.
+ # JSON responses include a structured +layout+ alongside the image.
+ #
+ # @example Generate an image from text
+ # result = client.v2.images.create(prompt: "A sunset over mountains")
+ # result.base64 # Base64 encoded PNG
+ # result.layout # => { prompt: "...", regions: [...] }
+ #
+ # @note Image generation requests commonly take 40-80 seconds; the API
+ # documentation mandates request timeouts of at least 120 seconds
+ # (the gem default).
+ #
+ # @see https://api.reve.com/console/docs Reve API Documentation
+ class Images < Base
+ # @return [String] API endpoint for v2 image creation
+ CREATE_ENDPOINT = "/v2/image/create"
+
+ # Generates an image from a text prompt with optional reference images.
+ #
+ # Reference images are addressed from the prompt with `N`
+ # tags, where N is the 0-based index into the +references+ array (so
+ # the first reference is `0`).
+ #
+ # @param prompt [String] Text description of the desired image
+ # (max 4000 chars); may contain `N` reference tags
+ # @param references [Array, nil] Up to 8 reference images
+ # ({Configuration::V2_MAX_REFERENCES}). Each entry is a Hash with
+ # exactly one of +data+ (base64 encoded image String) or +ref+
+ # (identifier String: +"id:"+ for a previously stored image or
+ # generation, +"reference:@"+ for a named reference in your
+ # project). Entries are serialized as given.
+ # @param aspect_ratio [String, nil] Output aspect ratio (defaults to
+ # the API default of "auto", which lets the model pick); v2 supports
+ # the full set in {Configuration::ASPECT_RATIOS}, including "auto"
+ # and "4:1"
+ # @param postprocessing [Array, nil] Postprocessing steps, each
+ # with a +process+ key (e.g., { process: "upscale", upscale_factor: 2 })
+ # @param test_time_scaling [Numeric, nil] Scaling factor (1-15);
+ # values above 1 cost more credits
+ # @param version [String, nil] Optional public model version alias,
+ # passed through as-is (e.g., "latest", "reve-v2-create@260601")
+ # @param accept [String, nil] Per-request Accept header: "image/png",
+ # "image/jpeg", or "image/webp" for a binary image response, or
+ # "application/json" (default)
+ # @param breadcrumb [String, nil] Request tracking value sent as the
+ # +breadcrumb+ query parameter; ignored by the API
+ #
+ # @return [ImageResponse] Response containing the image and, on JSON
+ # responses, the generated +layout+
+ #
+ # @raise [ValidationError] if prompt is empty or exceeds 4000 characters
+ # @raise [ValidationError] if references is malformed or exceeds 8 entries
+ # @raise [ValidationError] if aspect_ratio is invalid
+ # @raise [ValidationError] if postprocessing or test_time_scaling is invalid
+ # @raise [BadRequestError] if API rejects the request
+ # @raise [UnauthorizedError] if API key is invalid
+ # @raise [InsufficientCreditsError] if account has no credits
+ # @raise [RateLimitError] if rate limit is exceeded
+ #
+ # @example Text-to-image (no references)
+ # result = client.v2.images.create(
+ # prompt: "A serene mountain landscape at sunset",
+ # aspect_ratio: "16:9"
+ # )
+ #
+ # @example Edit-style: one reference addressed as 0
+ # result = client.v2.images.create(
+ # prompt: "Remove the people in the background of 0.",
+ # references: [{ data: original_image_base64 }]
+ # )
+ #
+ # @example Remix-style: combine two references
+ # result = client.v2.images.create(
+ # prompt: "The woman from 0 driving the car from 1.",
+ # references: [{ data: woman_base64 }, { data: car_base64 }]
+ # )
+ #
+ # @note v2 images are significantly larger than v1 images; the API
+ # documentation suggests capping the output size with
+ # +postprocessing: [{ process: "fit_image", max_dim: 2048 }]+.
+ # @note The API documentation does not recommend +test_time_scaling+
+ # for v2 models.
+ #
+ # @see https://api.reve.com/console/docs Reve API Documentation
+ def create(prompt:, references: nil, aspect_ratio: nil, postprocessing: nil,
+ test_time_scaling: nil, version: nil, accept: nil, breadcrumb: nil)
+ validate_prompt!(prompt, max_length: Configuration::V2_MAX_PROMPT_LENGTH)
+ validate_references!(references)
+ validate_aspect_ratio!(aspect_ratio, Configuration::ASPECT_RATIOS)
+ validate_postprocessing!(postprocessing)
+ validate_test_time_scaling!(test_time_scaling)
+
+ body = build_create_body(prompt: prompt, references: references, aspect_ratio: aspect_ratio,
+ postprocessing: postprocessing, test_time_scaling: test_time_scaling,
+ version: version)
+ params = breadcrumb ? { breadcrumb: breadcrumb } : nil
+
+ response = post(CREATE_ENDPOINT, body, params: params, accept: accept)
+ ImageResponse.new(status: response.status, headers: response.headers, body: response.body)
+ end
+
+ private
+
+ # Builds the request body for the create endpoint.
+ #
+ # @return [Hash] Request body with only the provided options
+ # @api private
+ def build_create_body(prompt:, references:, aspect_ratio:, postprocessing:, test_time_scaling:, version:)
+ body = { prompt: prompt }
+ body[:references] = references if references
+ body[:aspect_ratio] = aspect_ratio if aspect_ratio
+ body[:postprocessing] = postprocessing if postprocessing
+ body[:test_time_scaling] = test_time_scaling if test_time_scaling
+ body[:version] = version if version
+ body
+ end
+
+ # Validates the references array.
+ #
+ # @param references [Array, nil] Reference image entries
+ # @raise [ValidationError] if not an Array, exceeds the maximum, or
+ # contains a malformed entry
+ # @api private
+ def validate_references!(references)
+ return if references.nil?
+
+ unless references.is_a?(Array)
+ raise ValidationError, "References must be an Array of Hashes with exactly one of 'data' or 'ref'"
+ end
+
+ max = Configuration::V2_MAX_REFERENCES
+ raise ValidationError, "Maximum #{max} references allowed" if references.length > max
+
+ references.each_with_index { |reference, index| validate_reference!(reference, index) }
+ end
+
+ # Validates a single reference entry.
+ #
+ # @param reference [Hash] Reference entry with exactly one of +data+ or +ref+
+ # @param index [Integer] Position in the references array (for error messages)
+ # @raise [ValidationError] if the entry is not a Hash, has both or
+ # neither of +data+/+ref+, or has a non-String value
+ # @api private
+ def validate_reference!(reference, index)
+ unless reference.is_a?(Hash)
+ raise ValidationError, "Reference at index #{index} must be a Hash with exactly one of 'data' or 'ref'"
+ end
+
+ data = reference[:data] || reference["data"]
+ ref = reference[:ref] || reference["ref"]
+
+ if data.nil? == ref.nil?
+ raise ValidationError, "Reference at index #{index} must include exactly one of 'data' or 'ref'"
+ end
+
+ if data.nil?
+ validate_reference_value!(ref, "ref", index)
+ else
+ validate_reference_value!(data, "data", index)
+ end
+ end
+
+ # Validates a reference +data+ or +ref+ value.
+ #
+ # @param value [Object] The data or ref value
+ # @param key [String] Which key the value came from ("data" or "ref")
+ # @param index [Integer] Position in the references array
+ # @raise [ValidationError] if value is not a non-empty String
+ # @api private
+ def validate_reference_value!(value, key, index)
+ return if value.is_a?(String) && !value.empty?
+
+ raise ValidationError, "Reference at index #{index} '#{key}' must be a non-empty String"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/reve_ai/resources/v2/layouts.rb b/lib/reve_ai/resources/v2/layouts.rb
new file mode 100644
index 0000000..8dfce9a
--- /dev/null
+++ b/lib/reve_ai/resources/v2/layouts.rb
@@ -0,0 +1,309 @@
+# frozen_string_literal: true
+
+require_relative "../base"
+
+module ReveAI
+ module Resources
+ class V2
+ # v2 layout pipeline operations (experimental).
+ #
+ # The layout endpoints expose lower-level control over image
+ # composition: extracting structured layouts from images, generating
+ # layouts from prompts, and rendering images from layouts.
+ #
+ # @note Experimental: these endpoints require care and experimentation
+ # to achieve good results. For simple image generation and
+ # prompt-based editing, prefer {V2::Images#create}.
+ #
+ # @see https://api.reve.com/console/docs Reve API Documentation
+ class Layouts < Base
+ # @return [String] API endpoint for layout extraction
+ EXTRACT_ENDPOINT = "/v2/image/extract_layout"
+
+ # @return [String] API endpoint for layout generation
+ CREATE_ENDPOINT = "/v2/image/create_layout"
+
+ # @return [String] API endpoint for layout rendering
+ RENDER_ENDPOINT = "/v2/image/render_layout"
+
+ # Extracts a structured layout from an image.
+ #
+ # @param image [Hash] Source image: exactly one of +data+ (base64
+ # encoded image String) or +ref+ (identifier String: +"id:"+
+ # or +"reference:@"+)
+ # @param prompt [String, nil] Optional instruction for transforming
+ # the extracted layout (max 4000 chars)
+ # @param version [String, nil] Optional public model version alias
+ # @param breadcrumb [String, nil] Request tracking value sent as the
+ # +breadcrumb+ query parameter; ignored by the API
+ #
+ # @return [LayoutResponse] Response containing the extracted +layout+
+ #
+ # @raise [ValidationError] if image is malformed or prompt exceeds max length
+ #
+ # @example Extract a layout from an image
+ # result = client.v2.layouts.extract(image: { data: photo_base64 })
+ # result.layout # => { prompt: "...", regions: [...], width: 4672, height: 3520 }
+ #
+ # @note Experimental: layout extraction commonly takes 10-40 seconds.
+ def extract(image:, prompt: nil, version: nil, breadcrumb: nil)
+ validate_raw_image!(image, "Image")
+ validate_prompt!(prompt, max_length: Configuration::V2_MAX_PROMPT_LENGTH) if prompt
+
+ body = { image: image }
+ body[:prompt] = prompt if prompt
+ body[:version] = version if version
+
+ response = post(EXTRACT_ENDPOINT, body, params: breadcrumb_params(breadcrumb))
+ LayoutResponse.new(status: response.status, headers: response.headers, body: response.body)
+ end
+
+ # Generates (or edits) a structured layout without rendering an image.
+ #
+ # @param prompt [String, nil] Description of the desired layout
+ # (max 4000 chars); at least one of +prompt+ or +references+ is required
+ # @param references [Array, nil] Up to 8 ordered compound
+ # references ({Configuration::V2_MAX_REFERENCES}); each entry may
+ # contain +image+ (a raw { data: }/{ ref: } image Hash), +layout+
+ # (a layout Hash), and/or +prompt+ (String) — at least one per entry
+ # @param commands [Array, nil] Ordered imperative layout edits;
+ # each entry is a Hash with an +op+ key (add, place, shift, remove,
+ # keep, change) plus op-specific fields
+ # @param aspect_ratio [String, nil] Layout aspect ratio; supported set
+ # ({Configuration::ASPECT_RATIOS}), default "auto"
+ # @param version [String, nil] Optional public model version alias
+ # @param breadcrumb [String, nil] Request tracking value sent as the
+ # +breadcrumb+ query parameter; ignored by the API
+ #
+ # @return [LayoutResponse] Response containing the generated +layout+
+ #
+ # @raise [ValidationError] if neither prompt nor references is given,
+ # or any argument is malformed
+ #
+ # @example Free-form layout from a prompt
+ # result = client.v2.layouts.create(prompt: "a person at a cafe")
+ # result.layout[:regions] # => [{ label: "person", bbox: {...}, ... }]
+ #
+ # @note Experimental: layout generation commonly takes 10-40 seconds.
+ def create(prompt: nil, references: nil, commands: nil, aspect_ratio: nil, version: nil, breadcrumb: nil)
+ validate_prompt_or_references!(prompt, references)
+ validate_prompt!(prompt, max_length: Configuration::V2_MAX_PROMPT_LENGTH) if prompt
+ validate_compound_references!(references)
+ validate_commands!(commands)
+ validate_aspect_ratio!(aspect_ratio, Configuration::ASPECT_RATIOS)
+
+ body = build_create_body(prompt: prompt, references: references, commands: commands,
+ aspect_ratio: aspect_ratio, version: version)
+
+ response = post(CREATE_ENDPOINT, body, params: breadcrumb_params(breadcrumb))
+ LayoutResponse.new(status: response.status, headers: response.headers, body: response.body)
+ end
+
+ # Renders a final image from a target layout.
+ #
+ # @param layout [Hash] The layout to render; must include a non-empty
+ # +regions+ Array (each region: +label+, +prompt+, +bbox+ with
+ # normalized x0/y0/x1/y1; optional +parent+, +region_type+,
+ # +image_index+, +image_region_index+)
+ # @param references [Array, nil] Up to 8 ordered compound
+ # references; same shape as {#create}
+ # @param postprocessing [Array, nil] Postprocessing steps, each
+ # with a +process+ key (e.g., { process: "fit_image", max_dim: 2048 })
+ # @param version [String, nil] Optional public model version alias
+ # @param accept [String, nil] Per-request Accept header: "image/png",
+ # "image/jpeg", or "image/webp" for a binary image response, or
+ # "application/json" (default)
+ # @param breadcrumb [String, nil] Request tracking value sent as the
+ # +breadcrumb+ query parameter; ignored by the API
+ #
+ # @return [ImageResponse] Response containing the rendered image and
+ # the produced +layout+
+ #
+ # @raise [ValidationError] if layout is missing or malformed
+ #
+ # @example Render a layout to an image
+ # layout = { regions: [{ label: "cat", prompt: "a tabby cat",
+ # bbox: { x0: 0.2, y0: 0.2, x1: 0.8, y1: 0.8 } }] }
+ # result = client.v2.layouts.render(layout: layout)
+ # File.binwrite("cat.png", Base64.decode64(result.image))
+ #
+ # @note Experimental: rendering commonly takes 40-80 seconds.
+ def render(layout:, references: nil, postprocessing: nil, version: nil, accept: nil, breadcrumb: nil)
+ validate_layout!(layout)
+ validate_compound_references!(references)
+ validate_postprocessing!(postprocessing)
+
+ body = { layout: layout }
+ body[:references] = references if references
+ body[:postprocessing] = postprocessing if postprocessing
+ body[:version] = version if version
+
+ response = post(RENDER_ENDPOINT, body, params: breadcrumb_params(breadcrumb), accept: accept)
+ ImageResponse.new(status: response.status, headers: response.headers, body: response.body)
+ end
+
+ private
+
+ # Builds the request body for the create_layout endpoint.
+ #
+ # @return [Hash] Request body with only the provided options
+ # @api private
+ def build_create_body(prompt:, references:, commands:, aspect_ratio:, version:)
+ body = {}
+ body[:prompt] = prompt if prompt
+ body[:references] = references if references
+ body[:commands] = commands if commands
+ body[:aspect_ratio] = aspect_ratio if aspect_ratio
+ body[:version] = version if version
+ body
+ end
+
+ # Returns the query params Hash for a breadcrumb, or nil.
+ #
+ # @param breadcrumb [String, nil] Breadcrumb value
+ # @return [Hash, nil] Query params
+ # @api private
+ def breadcrumb_params(breadcrumb)
+ breadcrumb ? { breadcrumb: breadcrumb } : nil
+ end
+
+ # Reads a Hash value accepting symbol or string keys.
+ #
+ # @param hash [Hash] The Hash to read
+ # @param key [Symbol] The key to look up (symbol or string form)
+ # @return [Object, nil] The value, or nil when absent
+ # @api private
+ def fetch_value(hash, key)
+ hash[key] || hash[key.to_s]
+ end
+
+ # Validates that at least one of prompt or references is present.
+ #
+ # @raise [ValidationError] if both are nil/empty
+ # @api private
+ def validate_prompt_or_references!(prompt, references)
+ return if prompt && !prompt.empty?
+ return if references && !references.empty?
+
+ raise ValidationError, "At least one of prompt or references is required"
+ end
+
+ # Validates a raw image object ({ data: } or { ref: } shape).
+ #
+ # @param image [Object] The value to validate
+ # @param label [String] Label for error messages
+ # @raise [ValidationError] if not a Hash with exactly one non-empty
+ # String +data+ or +ref+ value
+ # @api private
+ def validate_raw_image!(image, label)
+ raise ValidationError, "#{label} must be a Hash with exactly one of 'data' or 'ref'" unless image.is_a?(Hash)
+
+ data = fetch_value(image, :data)
+ ref = fetch_value(image, :ref)
+
+ raise ValidationError, "#{label} must include exactly one of 'data' or 'ref'" if data.nil? == ref.nil?
+
+ value = data || ref
+ return if value.is_a?(String) && !value.empty?
+
+ raise ValidationError, "#{label} '#{data ? "data" : "ref"}' must be a non-empty String"
+ end
+
+ # Validates compound layout references.
+ #
+ # @param references [Array, nil] Compound reference entries
+ # @raise [ValidationError] if not an Array of valid compound entries
+ # @api private
+ def validate_compound_references!(references)
+ return if references.nil?
+
+ unless references.is_a?(Array)
+ raise ValidationError, "References must be an Array of compound reference Hashes"
+ end
+
+ max = Configuration::V2_MAX_REFERENCES
+ raise ValidationError, "Maximum #{max} references allowed" if references.length > max
+
+ references.each_with_index { |reference, index| validate_compound_reference!(reference, index) }
+ end
+
+ # Validates a single compound reference entry.
+ #
+ # @param reference [Hash] Entry with at least one of image/layout/prompt
+ # @param index [Integer] Position in the references array
+ # @raise [ValidationError] if the entry carries none of the allowed
+ # fields, or carries a malformed image/layout
+ # @api private
+ def validate_compound_reference!(reference, index)
+ raise ValidationError, "Reference at index #{index} must be a Hash" unless reference.is_a?(Hash)
+
+ image = fetch_value(reference, :image)
+ layout = fetch_value(reference, :layout)
+ prompt = fetch_value(reference, :prompt)
+
+ if image.nil? && layout.nil? && prompt.nil?
+ raise ValidationError,
+ "Reference at index #{index} must include at least one of 'image', 'layout', 'prompt'"
+ end
+
+ validate_raw_image!(image, "Reference at index #{index} 'image'") if image
+ validate_reference_layout!(layout, index)
+ validate_reference_prompt!(prompt, index)
+ end
+
+ # Validates the layout value inside a compound reference.
+ #
+ # @param layout [Object] The value to check
+ # @param index [Integer] Position in the references array
+ # @raise [ValidationError] if present and not a Hash
+ # @api private
+ def validate_reference_layout!(layout, index)
+ return if layout.nil? || layout.is_a?(Hash)
+
+ raise ValidationError, "Reference at index #{index} 'layout' must be a Hash"
+ end
+
+ # Validates the prompt value inside a compound reference.
+ #
+ # @param prompt [Object] The value to check
+ # @param index [Integer] Position in the references array
+ # @raise [ValidationError] if present and not a non-empty String
+ # @api private
+ def validate_reference_prompt!(prompt, index)
+ return if prompt.nil?
+ return if prompt.is_a?(String) && !prompt.empty?
+
+ raise ValidationError, "Reference at index #{index} 'prompt' must be a non-empty String"
+ end
+
+ # Validates the target layout for rendering.
+ #
+ # @param layout [Object] The layout to validate
+ # @raise [ValidationError] if not a Hash with a non-empty regions Array
+ # @api private
+ def validate_layout!(layout)
+ regions = fetch_value(layout, :regions) if layout.is_a?(Hash)
+ return if regions.is_a?(Array) && !regions.empty?
+
+ raise ValidationError, "Layout must be a Hash with a non-empty 'regions' Array"
+ end
+
+ # Validates layout commands.
+ #
+ # @param commands [Array, nil] Command entries
+ # @raise [ValidationError] if not an Array of Hashes with an +op+ key
+ # @api private
+ def validate_commands!(commands)
+ return if commands.nil?
+ raise ValidationError, "Commands must be an Array of Hashes with an 'op' key" unless commands.is_a?(Array)
+
+ commands.each_with_index do |command, index|
+ next if command.is_a?(Hash) && (command.key?(:op) || command.key?("op"))
+
+ raise ValidationError, "Command at index #{index} must be a Hash with an 'op' key"
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/reve_ai/response.rb b/lib/reve_ai/response.rb
index f94bc46..c3b99dc 100644
--- a/lib/reve_ai/response.rb
+++ b/lib/reve_ai/response.rb
@@ -3,9 +3,13 @@
module ReveAI
# Base response wrapper for API responses.
#
- # Provides access to HTTP status, headers, and parsed response body.
+ # Provides access to HTTP status, headers, and response body. The body is a
+ # parsed Hash for JSON responses, or the raw image String (bytes) when the
+ # API answers with a binary body (Accept: image/*); in that case all
+ # metadata is carried by the X-Reve-* response headers.
#
# @see ImageResponse
+ # @see LayoutResponse
class Response
# @return [Integer] HTTP status code
attr_reader :status
@@ -13,14 +17,15 @@ class Response
# @return [Hash] Response headers
attr_reader :headers
- # @return [Hash] Parsed response body
+ # @return [Hash, String] Parsed response body, or raw bytes String
+ # for binary (image/*) responses
attr_reader :body
# Creates a new response wrapper.
#
# @param status [Integer] HTTP status code
# @param headers [Hash] Response headers
- # @param body [Hash] Parsed response body
+ # @param body [Hash, String] Parsed response body, or raw bytes String
def initialize(status:, headers:, body:)
@status = status
@headers = headers
@@ -34,20 +39,42 @@ def success?
status >= 200 && status < 300
end
+ # Checks if the response body is raw binary data (e.g., image bytes).
+ #
+ # @return [Boolean] true if body is not a parsed JSON Hash
+ def binary?
+ !body.is_a?(Hash)
+ end
+
# Returns the request ID for this response.
#
# Useful for debugging and support requests.
#
# @return [String, nil] Request ID from body or headers
def request_id
- body[:request_id] || headers["x-reve-request-id"]
+ body_value(:request_id) || headers["x-reve-request-id"]
+ end
+
+ private
+
+ # Reads a key from the body when it is a Hash (JSON response).
+ #
+ # Binary (String) bodies have no keys; header fallbacks apply instead.
+ #
+ # @param key [Symbol] Body key to read
+ # @return [Object, nil] Body value, or nil for binary bodies
+ # @api private
+ def body_value(key)
+ body[key] if body.is_a?(Hash)
end
end
# Response wrapper for image generation API responses.
#
# Provides convenient accessors for image data, version info,
- # content policy status, and credit usage.
+ # content policy status, and credit usage. All accessors work for both
+ # JSON responses (values from the parsed body) and binary responses
+ # (values from the X-Reve-* headers).
#
# @example Accessing image data
# result = client.images.create(prompt: "A sunset")
@@ -66,28 +93,48 @@ def request_id
#
# @see Response
class ImageResponse < Response
- # Returns the base64 encoded image data.
+ # Returns the image data.
#
- # The image is in PNG format. Use Base64.decode64 to get raw bytes.
+ # For JSON responses this is the base64 encoded image; for binary
+ # responses (Accept: image/*) it is the raw image bytes in the
+ # negotiated format, ready to write to disk without decoding.
#
- # @return [String, nil] Base64 encoded PNG image data
+ # @return [String, nil] Base64 encoded image data (JSON response) or
+ # raw image bytes (binary response)
#
- # @example Save to file
+ # @example Save a JSON (base64) response to file
# require "base64"
- # png_bytes = Base64.decode64(result.image)
- # File.binwrite("output.png", png_bytes)
+ # File.binwrite("output.png", Base64.decode64(result.image))
+ #
+ # @example Save a binary response to file (no Base64 decode needed)
+ # File.binwrite("output.webp", result.image)
def image
- body[:image]
+ binary? ? body : body[:image]
end
# Alias for {#image}.
#
- # @return [String, nil] Base64 encoded PNG image data
+ # @note Despite the name, binary responses (Accept: image/*) return raw
+ # image bytes here, not base64 data.
+ #
+ # @return [String, nil] Base64 encoded image data (JSON response) or
+ # raw image bytes (binary response)
# @see #image
def base64
image
end
+ # Returns the layout object for this generation.
+ #
+ # Present on v2 create/render JSON responses; nil on v1 responses and
+ # on binary (Accept: image/*) responses.
+ #
+ # @return [Hash, nil] Layout Hash (e.g., +prompt+, +regions+, +width+,
+ # +height+), or nil when absent
+ def layout
+ body_value(:layout)
+ end
+
# Returns the model version used for generation.
#
# @return [String, nil] Model version (e.g., "reve-create@20250915")
@@ -95,7 +142,7 @@ def base64
# @example
# result.version # => "reve-create@20250915"
def version
- body[:version] || headers["x-reve-version"]
+ body_value(:version) || headers["x-reve-version"]
end
# Checks if the generated image violates content policy.
@@ -107,7 +154,7 @@ def version
# puts "Warning: Content policy violated"
# end
def content_violation?
- body[:content_violation] == true ||
+ body_value(:content_violation) == true ||
headers["x-reve-content-violation"] == "true"
end
@@ -118,7 +165,7 @@ def content_violation?
# @example
# puts "This request used #{result.credits_used} credits"
def credits_used
- body[:credits_used] || headers["x-reve-credits-used"]&.to_i
+ body_value(:credits_used) || headers["x-reve-credits-used"]&.to_i
end
# Returns the number of credits remaining after this request.
@@ -130,7 +177,51 @@ def credits_used
# puts "Warning: Low credit balance"
# end
def credits_remaining
- body[:credits_remaining] || headers["x-reve-credits-remaining"]&.to_i
+ body_value(:credits_remaining) || headers["x-reve-credits-remaining"]&.to_i
+ end
+ end
+
+ # Response wrapper for layout-only API responses.
+ #
+ # Returned by the v2 extract_layout and create_layout endpoints, which
+ # produce a layout object but no image. Accessors fall back to the
+ # X-Reve-* headers when the body carries no value.
+ #
+ # @example Inspecting a layout
+ # result = client.v2.layouts.extract(image: base64_image)
+ # result.layout # => { prompt: "...", regions: [...], width: 4096, height: 2560 }
+ #
+ # @see Response
+ # @see ImageResponse
+ class LayoutResponse < Response
+ # Returns the layout object.
+ #
+ # @return [Hash, nil] Layout Hash (e.g., +prompt+, +regions+, +width+,
+ # +height+), or nil when absent
+ def layout
+ body_value(:layout)
+ end
+
+ # Checks if the request violates content policy.
+ #
+ # @return [Boolean] true if content policy was violated
+ def content_violation?
+ body_value(:content_violation) == true ||
+ headers["x-reve-content-violation"] == "true"
+ end
+
+ # Returns the number of credits used for this request.
+ #
+ # @return [Integer, nil] Credits consumed by this request
+ def credits_used
+ body_value(:credits_used) || headers["x-reve-credits-used"]&.to_i
+ end
+
+ # Returns the number of credits remaining after this request.
+ #
+ # @return [Integer, nil] Remaining credit balance
+ def credits_remaining
+ body_value(:credits_remaining) || headers["x-reve-credits-remaining"]&.to_i
end
end
end
diff --git a/lib/reve_ai/version.rb b/lib/reve_ai/version.rb
index 8b4f2d0..b248878 100644
--- a/lib/reve_ai/version.rb
+++ b/lib/reve_ai/version.rb
@@ -2,5 +2,5 @@
module ReveAI
# @return [String] Current gem version
- VERSION = "0.1.1"
+ VERSION = "0.2.0"
end
diff --git a/reve_ai.gemspec b/reve_ai.gemspec
index 026497b..10452dd 100644
--- a/reve_ai.gemspec
+++ b/reve_ai.gemspec
@@ -39,6 +39,7 @@ Gem::Specification.new do |spec|
spec.extra_rdoc_files = Dir["README.md", "CHANGELOG.md", "LICENSE.txt"]
# Runtime dependencies
+ spec.add_dependency "base64", "~> 0.3"
spec.add_dependency "faraday", "~> 2.0"
spec.add_dependency "faraday-retry", "~> 2.0"
end
diff --git a/test/fixtures/effects_response.json b/test/fixtures/effects_response.json
new file mode 100644
index 0000000..1602832
--- /dev/null
+++ b/test/fixtures/effects_response.json
@@ -0,0 +1,14 @@
+{
+ "effects": [
+ {
+ "name": "cmyk_halftone",
+ "description": "CMYK halftone print effect",
+ "source": "builtin",
+ "category": "textures"
+ },
+ {
+ "name": "my-saved-effect",
+ "source": "saved"
+ }
+ ]
+}
diff --git a/test/fixtures/v2_create_layout_response.json b/test/fixtures/v2_create_layout_response.json
new file mode 100644
index 0000000..81c2bd2
--- /dev/null
+++ b/test/fixtures/v2_create_layout_response.json
@@ -0,0 +1,23 @@
+{
+ "layout": {
+ "prompt": "a person at a cafe",
+ "regions": [
+ {
+ "label": "person",
+ "prompt": "a woman in a red coat",
+ "bbox": { "x0": 0.1, "y0": 0.1, "x1": 0.6, "y1": 0.9 },
+ "region_type": "coarse_detail"
+ },
+ {
+ "label": "table",
+ "prompt": "a small round cafe table",
+ "bbox": { "x0": 0.55, "y0": 0.6, "x1": 0.9, "y1": 0.85 },
+ "region_type": "medium_detail"
+ }
+ ]
+ },
+ "content_violation": false,
+ "request_id": "rsid-v2-create-layout-1",
+ "credits_used": 80,
+ "credits_remaining": 920
+}
diff --git a/test/fixtures/v2_create_response.json b/test/fixtures/v2_create_response.json
new file mode 100644
index 0000000..14ba18c
--- /dev/null
+++ b/test/fixtures/v2_create_response.json
@@ -0,0 +1,23 @@
+{
+ "image": "aGVsbG8gd29ybGQ=",
+ "layout": {
+ "prompt": "A serene mountain landscape at sunset",
+ "regions": [
+ {
+ "label": "mountain",
+ "prompt": "A snow-capped mountain peak",
+ "bbox": { "x0": 0.0, "y0": 0.0, "x1": 0.5, "y1": 1.0 }
+ },
+ {
+ "label": "lake",
+ "prompt": "A calm alpine lake reflecting the sunset",
+ "bbox": { "x0": 0.5, "y0": 0.6, "x1": 1.0, "y1": 1.0 }
+ }
+ ]
+ },
+ "version": "latest",
+ "content_violation": false,
+ "request_id": "rsid-v2-create-1",
+ "credits_used": 150,
+ "credits_remaining": 880
+}
diff --git a/test/fixtures/v2_extract_layout_response.json b/test/fixtures/v2_extract_layout_response.json
new file mode 100644
index 0000000..7dd61e0
--- /dev/null
+++ b/test/fixtures/v2_extract_layout_response.json
@@ -0,0 +1,26 @@
+{
+ "layout": {
+ "prompt": "A bottle of rosé wine on a terracotta surface",
+ "regions": [
+ {
+ "label": "bottle 1",
+ "prompt": "Tall, slender glass bottle filled with salmon-pink rosé wine",
+ "bbox": { "x0": 0.371, "y0": 0.07, "x1": 0.511, "y1": 0.869 },
+ "region_type": "coarse_detail"
+ },
+ {
+ "label": "label 1",
+ "prompt": "Off-white rectangular paper label with black serif text",
+ "bbox": { "x0": 0.379, "y0": 0.562, "x1": 0.507, "y1": 0.756 },
+ "parent": "bottle 1",
+ "region_type": "fine_detail"
+ }
+ ],
+ "width": 4672,
+ "height": 3520
+ },
+ "content_violation": false,
+ "request_id": "rsid-v2-extract-1",
+ "credits_used": 80,
+ "credits_remaining": 920
+}
diff --git a/test/fixtures/v2_render_layout_response.json b/test/fixtures/v2_render_layout_response.json
new file mode 100644
index 0000000..5a0a95f
--- /dev/null
+++ b/test/fixtures/v2_render_layout_response.json
@@ -0,0 +1,25 @@
+{
+ "image": "cmVuZGVyZWRfaW1hZ2U=",
+ "layout": {
+ "prompt": "a person at a cafe",
+ "regions": [
+ {
+ "label": "person",
+ "prompt": "a woman in a red coat",
+ "bbox": { "x0": 0.1, "y0": 0.1, "x1": 0.6, "y1": 0.9 },
+ "region_type": "coarse_detail"
+ },
+ {
+ "label": "table",
+ "prompt": "a small round cafe table",
+ "bbox": { "x0": 0.55, "y0": 0.6, "x1": 0.9, "y1": 0.85 },
+ "region_type": "medium_detail"
+ }
+ ]
+ },
+ "version": "latest",
+ "content_violation": false,
+ "request_id": "rsid-v2-render-1",
+ "credits_used": 150,
+ "credits_remaining": 770
+}
diff --git a/test/reve_ai/client_test.rb b/test/reve_ai/client_test.rb
index 2082ddc..54f51b0 100644
--- a/test/reve_ai/client_test.rb
+++ b/test/reve_ai/client_test.rb
@@ -61,4 +61,32 @@ def test_provides_http_client_accessor
client = ReveAI::Client.new(api_key: "key")
assert_instance_of ReveAI::HTTP::Client, client.http_client
end
+
+ def test_provides_effects_accessor
+ client = ReveAI::Client.new(api_key: "key")
+ assert_instance_of ReveAI::Resources::Effects, client.effects
+ end
+
+ def test_effects_returns_same_instance
+ client = ReveAI::Client.new(api_key: "key")
+ assert_same client.effects, client.effects
+ end
+
+ def test_provides_v2_accessor
+ client = ReveAI::Client.new(api_key: "key")
+ assert_instance_of ReveAI::Resources::V2, client.v2
+ end
+
+ def test_v2_provides_images_and_layouts_accessors
+ client = ReveAI::Client.new(api_key: "key")
+ assert_instance_of ReveAI::Resources::V2::Images, client.v2.images
+ assert_instance_of ReveAI::Resources::V2::Layouts, client.v2.layouts
+ end
+
+ def test_v2_returns_same_instances
+ client = ReveAI::Client.new(api_key: "key")
+ assert_same client.v2, client.v2
+ assert_same client.v2.images, client.v2.images
+ assert_same client.v2.layouts, client.v2.layouts
+ end
end
diff --git a/test/reve_ai/configuration_test.rb b/test/reve_ai/configuration_test.rb
index 7dd17b2..df1cfc7 100644
--- a/test/reve_ai/configuration_test.rb
+++ b/test/reve_ai/configuration_test.rb
@@ -67,4 +67,17 @@ def test_max_prompt_length_constant
def test_max_reference_images_constant
assert_equal 6, ReveAI::Configuration::MAX_REFERENCE_IMAGES
end
+
+ def test_aspect_ratios_constant
+ expected = %w[4:1 3:1 21:9 2:1 17:9 16:9 3:2 4:3 5:4 1:1 4:5 3:4 2:3 9:16 1:2 1:3 1:4 auto]
+ assert_equal expected, ReveAI::Configuration::ASPECT_RATIOS
+ end
+
+ def test_v2_max_prompt_length_constant
+ assert_equal 4000, ReveAI::Configuration::V2_MAX_PROMPT_LENGTH
+ end
+
+ def test_v2_max_references_constant
+ assert_equal 8, ReveAI::Configuration::V2_MAX_REFERENCES
+ end
end
diff --git a/test/reve_ai/errors_test.rb b/test/reve_ai/errors_test.rb
index 67f28d0..b900fc4 100644
--- a/test/reve_ai/errors_test.rb
+++ b/test/reve_ai/errors_test.rb
@@ -53,6 +53,88 @@ def test_api_error_extracts_error_code_from_body
assert_equal "PROMPT_TOO_LONG", error.error_code
end
+ def test_api_error_extracts_params_from_body
+ error = ReveAI::APIError.new(
+ "Error",
+ status: 400,
+ body: { error_code: "INVALID_PARAMS", params: { aspect_ratio: "invalid" } }
+ )
+
+ assert_equal({ aspect_ratio: "invalid" }, error.params)
+ end
+
+ def test_api_error_params_is_nil_when_body_has_no_params
+ error = ReveAI::APIError.new(
+ "Error",
+ status: 400,
+ body: { error_code: "BAD_REQUEST" }
+ )
+
+ assert_nil error.params
+ end
+
+ def test_api_error_params_is_nil_for_string_body
+ error = ReveAI::APIError.new(
+ "Error",
+ status: 400,
+ body: "raw bytes"
+ )
+
+ assert_nil error.params
+ end
+
+ def test_api_error_error_code_falls_back_to_header_for_string_body
+ error = ReveAI::APIError.new(
+ "Error",
+ status: 400,
+ body: "raw bytes",
+ headers: { "x-reve-error-code" => "CONTENT_POLICY_VIOLATION" }
+ )
+
+ assert_equal "CONTENT_POLICY_VIOLATION", error.error_code
+ end
+
+ def test_api_error_error_code_falls_back_to_header_when_body_lacks_code
+ error = ReveAI::APIError.new(
+ "Error",
+ status: 400,
+ body: { message: "Something failed" },
+ headers: { "x-reve-error-code" => "CONTENT_POLICY_VIOLATION" }
+ )
+
+ assert_equal "CONTENT_POLICY_VIOLATION", error.error_code
+ end
+
+ def test_api_error_error_code_prefers_body_over_header
+ error = ReveAI::APIError.new(
+ "Error",
+ status: 400,
+ body: { error_code: "PROMPT_TOO_LONG" },
+ headers: { "x-reve-error-code" => "HEADER_CODE" }
+ )
+
+ assert_equal "PROMPT_TOO_LONG", error.error_code
+ end
+
+ def test_api_error_error_code_is_nil_when_absent_everywhere
+ error = ReveAI::APIError.new("Error", status: 400)
+
+ assert_nil error.error_code
+ end
+
+ def test_api_error_string_body_never_raises
+ error = ReveAI::APIError.new(
+ "Error",
+ status: 400,
+ body: "\x89PNG".b
+ )
+
+ assert_equal "\x89PNG".b, error.body
+ assert_nil error.error_code
+ assert_nil error.params
+ assert_nil error.request_id
+ end
+
def test_unauthorized_error_inherits_from_api_error
assert ReveAI::UnauthorizedError < ReveAI::APIError
end
diff --git a/test/reve_ai/gemspec_test.rb b/test/reve_ai/gemspec_test.rb
new file mode 100644
index 0000000..0cc264e
--- /dev/null
+++ b/test/reve_ai/gemspec_test.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require_relative "../test_helper"
+
+class GemspecTest < Minitest::Test
+ def test_base64_is_a_runtime_dependency
+ dependency = gemspec.runtime_dependencies.find { |candidate| candidate.name == "base64" }
+
+ refute_nil dependency
+ assert_equal Gem::Requirement.new("~> 0.3"), dependency.requirement
+ end
+
+ private
+
+ def gemspec
+ @gemspec ||= Gem::Specification.load(File.expand_path("../../reve_ai.gemspec", __dir__))
+ end
+end
diff --git a/test/reve_ai/http/client_test.rb b/test/reve_ai/http/client_test.rb
index ac73a7c..a79d12c 100644
--- a/test/reve_ai/http/client_test.rb
+++ b/test/reve_ai/http/client_test.rb
@@ -110,6 +110,8 @@ def test_raises_unprocessable_entity_error_on_four_hundred_twenty_two
end
def test_raises_rate_limit_error_on_four_hundred_twenty_nine
+ # Disable retries: the retry middleware honors Retry-After (60s) per attempt.
+ @config.max_retries = 0
stub_request(:post, "https://api.reve.com/v1/image/create")
.to_return(
status: 429,
@@ -165,4 +167,178 @@ def test_includes_user_agent_header
@http_client.post("/v1/image/create", {})
end
+
+ def test_get_sends_request_with_correct_headers
+ stub_request(:get, "https://api.reve.com/v1/image/effect")
+ .with(
+ headers: {
+ "Authorization" => "Bearer test_api_key",
+ "Accept" => "application/json"
+ }
+ )
+ .to_return(status: 200, body: '{"effects":[]}', headers: { "Content-Type" => "application/json" })
+
+ response = @http_client.get("/v1/image/effect")
+
+ assert_instance_of ReveAI::Response, response
+ assert_equal [], response.body[:effects]
+ end
+
+ def test_get_merges_query_params_into_url
+ stub_request(:get, "https://api.reve.com/v1/image/effect")
+ .with(query: { source: "project" })
+ .to_return(status: 200, body: '{"effects":[]}', headers: {})
+
+ response = @http_client.get("/v1/image/effect", params: { source: "project" })
+
+ assert response.success?
+ end
+
+ def test_get_raises_bad_request_error_on_four_hundred
+ stub_request(:get, "https://api.reve.com/v1/image/effect")
+ .to_return(
+ status: 400,
+ body: '{"error_code":"INVALID_SOURCE","message":"Invalid source filter"}',
+ headers: {}
+ )
+
+ error = assert_raises(ReveAI::BadRequestError) do
+ @http_client.get("/v1/image/effect")
+ end
+
+ assert_equal 400, error.status
+ assert_equal "Invalid source filter", error.message
+ end
+
+ def test_get_raises_timeout_error_on_timeout
+ stub_request(:get, "https://api.reve.com/v1/image/effect").to_timeout
+
+ assert_raises(ReveAI::TimeoutError) do
+ @http_client.get("/v1/image/effect")
+ end
+ end
+
+ def test_get_raises_connection_error_on_connection_failed
+ stub_request(:get, "https://api.reve.com/v1/image/effect")
+ .to_raise(Faraday::ConnectionFailed.new("Connection refused"))
+
+ assert_raises(ReveAI::ConnectionError) do
+ @http_client.get("/v1/image/effect")
+ end
+ end
+
+ def test_post_merges_query_params_into_url
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .with(query: { breadcrumb: "checkout-step-2" })
+ .to_return(status: 200, body: '{"image":"data"}', headers: {})
+
+ response = @http_client.post("/v1/image/create", {}, params: { breadcrumb: "checkout-step-2" })
+
+ assert response.success?
+ end
+
+ def test_post_with_per_request_accept_header
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .with(headers: { "Accept" => "image/webp" })
+ .to_return(status: 200, body: '{"image":"data"}', headers: {})
+
+ @http_client.post("/v1/image/create", {}, accept: "image/webp")
+ end
+
+ def test_post_accept_override_does_not_leak_into_connection_default
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .with(headers: { "Accept" => "image/webp" })
+ .to_return(status: 200, body: "bytes", headers: { "Content-Type" => "image/webp" })
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .with(headers: { "Accept" => "application/json" })
+ .to_return(status: 200, body: '{"image":"data"}', headers: { "Content-Type" => "application/json" })
+
+ @http_client.post("/v1/image/create", {}, accept: "image/webp")
+ response = @http_client.post("/v1/image/create", {})
+
+ refute response.binary?
+ assert_equal "data", response.body[:image]
+ end
+
+ def test_post_returns_raw_bytes_for_binary_image_response
+ image_bytes = "\x89PNG\r\n\x1a\nfake".b
+ stub_binary_image_success(image_bytes)
+
+ response = @http_client.post("/v1/image/create", {}, accept: "image/webp")
+
+ assert response.binary?
+ assert_equal image_bytes, response.body.b
+ assert_equal "rsid-binary-1", response.request_id
+ end
+
+ def test_binary_image_response_exposes_metadata_via_headers
+ image_response = fetch_binary_image_response("\x89PNG\r\n\x1a\nfake".b)
+
+ assert_equal "\x89PNG\r\n\x1a\nfake".b, image_response.image
+ assert_equal "latest", image_response.version
+ assert_equal 18, image_response.credits_used
+ assert_equal 982, image_response.credits_remaining
+ refute image_response.content_violation?
+ end
+
+ def test_raises_error_with_header_error_code_for_grey_image_error_response
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .to_return(
+ status: 400,
+ body: "\x89PNG\r\n\x1a\ngrey".b,
+ headers: {
+ "Content-Type" => "image/png",
+ "X-Reve-Error-Code" => "CONTENT_POLICY_VIOLATION",
+ "X-Reve-Request-Id" => "rsid-grey-1"
+ }
+ )
+
+ error = assert_raises(ReveAI::BadRequestError) do
+ @http_client.post("/v1/image/create", {}, accept: "image/png")
+ end
+
+ assert_kind_of String, error.body
+ assert_equal "CONTENT_POLICY_VIOLATION", error.error_code
+ assert_equal "CONTENT_POLICY_VIOLATION", error.message
+ assert_equal "rsid-grey-1", error.request_id
+ end
+
+ def test_error_exposes_params_from_json_error_body
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .to_return(
+ status: 400,
+ body: '{"error_code":"INVALID_PARAMS","message":"Invalid parameters","params":{"aspect_ratio":"invalid"}}',
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ error = assert_raises(ReveAI::BadRequestError) do
+ @http_client.post("/v1/image/create", {})
+ end
+
+ assert_equal "INVALID_PARAMS", error.error_code
+ assert_equal({ aspect_ratio: "invalid" }, error.params)
+ end
+
+ private
+
+ def stub_binary_image_success(image_bytes)
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .to_return(
+ status: 200,
+ body: image_bytes,
+ headers: {
+ "Content-Type" => "image/webp",
+ "X-Reve-Version" => "latest",
+ "X-Reve-Credits-Used" => "18",
+ "X-Reve-Credits-Remaining" => "982",
+ "X-Reve-Request-Id" => "rsid-binary-1"
+ }
+ )
+ end
+
+ def fetch_binary_image_response(image_bytes)
+ stub_binary_image_success(image_bytes)
+ response = @http_client.post("/v1/image/create", {}, accept: "image/webp")
+ ReveAI::ImageResponse.new(status: response.status, headers: response.headers, body: response.body)
+ end
end
diff --git a/test/reve_ai/resources/base_test.rb b/test/reve_ai/resources/base_test.rb
index d41fa42..dde71a8 100644
--- a/test/reve_ai/resources/base_test.rb
+++ b/test/reve_ai/resources/base_test.rb
@@ -115,4 +115,144 @@ def test_validate_reference_images_accepts_valid_images
images = %w[base64data1 base64data2]
@resource.send(:validate_reference_images!, images)
end
+
+ def test_get_delegates_to_http_client
+ stub_request(:get, "https://api.reve.com/v1/image/effect")
+ .with(query: { source: "preset" })
+ .to_return(status: 200, body: '{"effects":[]}', headers: { "Content-Type" => "application/json" })
+
+ response = @resource.send(:get, "/v1/image/effect", params: { source: "preset" })
+
+ assert_instance_of ReveAI::Response, response
+ assert_equal [], response.body[:effects]
+ end
+
+ def test_get_without_params_delegates_to_http_client
+ stub_request(:get, "https://api.reve.com/v1/image/effect")
+ .to_return(status: 200, body: '{"effects":[]}', headers: {})
+
+ response = @resource.send(:get, "/v1/image/effect")
+
+ assert response.success?
+ end
+
+ def test_post_passes_params_and_accept_to_http_client
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .with(query: { breadcrumb: "b-1" }, headers: { "Accept" => "image/webp" })
+ .to_return(status: 200, body: "\x89PNG".b, headers: { "Content-Type" => "image/webp" })
+
+ response = @resource.send(:post, "/v1/image/create", { prompt: "x" },
+ params: { breadcrumb: "b-1" }, accept: "image/webp")
+
+ assert response.binary?
+ end
+
+ def test_post_without_options_still_works
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .with(headers: { "Accept" => "application/json" })
+ .to_return(status: 200, body: '{"image":"data"}', headers: { "Content-Type" => "application/json" })
+
+ response = @resource.send(:post, "/v1/image/create", { prompt: "x" })
+
+ assert_equal "data", response.body[:image]
+ end
+
+ def test_validate_postprocessing_accepts_nil
+ @resource.send(:validate_postprocessing!, nil)
+ end
+
+ def test_validate_postprocessing_accepts_valid_steps
+ @resource.send(:validate_postprocessing!, [{ process: "upscale", upscale_factor: 2 }])
+ @resource.send(:validate_postprocessing!, [{ "process" => "remove_background" }])
+ @resource.send(:validate_postprocessing!, [])
+ end
+
+ def test_validate_postprocessing_raises_on_non_array
+ error = assert_raises(ReveAI::ValidationError) do
+ @resource.send(:validate_postprocessing!, "upscale")
+ end
+ assert_match(/must be an Array of Hashes/, error.message)
+ end
+
+ def test_validate_postprocessing_raises_on_non_hash_step
+ error = assert_raises(ReveAI::ValidationError) do
+ @resource.send(:validate_postprocessing!, ["upscale"])
+ end
+ assert_match(/must be an Array of Hashes/, error.message)
+ end
+
+ def test_validate_postprocessing_raises_when_process_key_missing
+ error = assert_raises(ReveAI::ValidationError) do
+ @resource.send(:validate_postprocessing!, [{ upscale_factor: 2 }])
+ end
+ assert_match(/step at index 0 must include a 'process' key/, error.message)
+ end
+
+ def test_validate_test_time_scaling_accepts_nil
+ @resource.send(:validate_test_time_scaling!, nil)
+ end
+
+ def test_validate_test_time_scaling_accepts_valid_values
+ @resource.send(:validate_test_time_scaling!, 1)
+ @resource.send(:validate_test_time_scaling!, 15)
+ @resource.send(:validate_test_time_scaling!, 7.5)
+ end
+
+ def test_validate_test_time_scaling_raises_on_non_numeric
+ error = assert_raises(ReveAI::ValidationError) do
+ @resource.send(:validate_test_time_scaling!, "high")
+ end
+ assert_match(/between 1 and 15/, error.message)
+ end
+
+ def test_validate_test_time_scaling_raises_out_of_range
+ error = assert_raises(ReveAI::ValidationError) do
+ @resource.send(:validate_test_time_scaling!, 16)
+ end
+ assert_match(/between 1 and 15/, error.message)
+
+ error = assert_raises(ReveAI::ValidationError) do
+ @resource.send(:validate_test_time_scaling!, 0)
+ end
+ assert_match(/between 1 and 15/, error.message)
+ end
+
+ def test_validate_aspect_ratio_with_custom_list_accepts_extended_ratios
+ @resource.send(:validate_aspect_ratio!, "21:9", ReveAI::Configuration::ASPECT_RATIOS)
+ @resource.send(:validate_aspect_ratio!, "auto", ReveAI::Configuration::ASPECT_RATIOS)
+ end
+
+ def test_validate_aspect_ratio_legacy_default_list_rejects_extended_ratio
+ error = assert_raises(ReveAI::ValidationError) do
+ @resource.send(:validate_aspect_ratio!, "21:9")
+ end
+ assert_match(/Invalid aspect_ratio/, error.message)
+ end
+
+ def test_validate_aspect_ratio_with_custom_list_rejects_unknown_ratio
+ error = assert_raises(ReveAI::ValidationError) do
+ @resource.send(:validate_aspect_ratio!, "7:3", ReveAI::Configuration::ASPECT_RATIOS)
+ end
+ assert_match(/Invalid aspect_ratio/, error.message)
+ end
+
+ def test_validate_prompt_with_custom_max_length
+ prompt = "a" * 3000
+
+ error = assert_raises(ReveAI::ValidationError) do
+ @resource.send(:validate_prompt!, prompt)
+ end
+ assert_match(/exceeds maximum length of 2560/, error.message)
+
+ @resource.send(:validate_prompt!, prompt, max_length: ReveAI::Configuration::V2_MAX_PROMPT_LENGTH)
+ end
+
+ def test_validate_prompt_with_custom_max_length_rejects_too_long
+ prompt = "a" * 4001
+
+ error = assert_raises(ReveAI::ValidationError) do
+ @resource.send(:validate_prompt!, prompt, max_length: ReveAI::Configuration::V2_MAX_PROMPT_LENGTH)
+ end
+ assert_match(/exceeds maximum length of 4000/, error.message)
+ end
end
diff --git a/test/reve_ai/resources/effects_test.rb b/test/reve_ai/resources/effects_test.rb
new file mode 100644
index 0000000..8ff1af9
--- /dev/null
+++ b/test/reve_ai/resources/effects_test.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ReveAI::Resources::EffectsTest < Minitest::Test
+ def setup
+ super
+ @client = ReveAI::Client.new(api_key: "test_key")
+ @effects = ReveAI::Resources::Effects.new(@client)
+ end
+
+ def test_inherits_from_base
+ assert_kind_of ReveAI::Resources::Base, @effects
+ end
+
+ def test_list_returns_all_effects_by_default
+ stub_request(:get, "https://api.reve.com/v1/image/effect")
+ .to_return(
+ status: 200,
+ body: fixture("effects_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ response = @effects.list
+
+ assert_instance_of ReveAI::Response, response
+ effects = response.body[:effects]
+ assert_equal 2, effects.length
+ assert_equal "cmyk_halftone", effects[0][:name]
+ assert_equal "builtin", effects[0][:source]
+ assert_equal "CMYK halftone print effect", effects[0][:description]
+ assert_equal "textures", effects[0][:category]
+ assert_equal({ name: "my-saved-effect", source: "saved" }, effects[1])
+ end
+
+ def test_list_with_source_filter
+ stub_request(:get, "https://api.reve.com/v1/image/effect")
+ .with(query: { source: "project" })
+ .to_return(
+ status: 200,
+ body: fixture("effects_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ response = @effects.list(source: "project")
+
+ assert response.success?
+ end
+
+ def test_list_with_breadcrumb_sends_query_param
+ stub_request(:get, "https://api.reve.com/v1/image/effect")
+ .with(query: { breadcrumb: "effects-audit" })
+ .to_return(
+ status: 200,
+ body: fixture("effects_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ response = @effects.list(breadcrumb: "effects-audit")
+
+ assert response.success?
+ end
+
+ def test_list_validates_source
+ error = assert_raises(ReveAI::ValidationError) do
+ @effects.list(source: "invalid")
+ end
+ assert_match(/Invalid source 'invalid'/, error.message)
+ end
+end
diff --git a/test/reve_ai/resources/images_test.rb b/test/reve_ai/resources/images_test.rb
index d73eff4..7fd2191 100644
--- a/test/reve_ai/resources/images_test.rb
+++ b/test/reve_ai/resources/images_test.rb
@@ -241,6 +241,258 @@ def test_remix_validates_aspect_ratio
assert_match(/Invalid aspect_ratio/, error.message)
end
+ # Create tests: postprocessing, test_time_scaling, breadcrumb, accept
+
+ def test_create_with_postprocessing
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .with(body: hash_including(
+ prompt: "A sunset",
+ postprocessing: [{ process: "upscale", upscale_factor: 2 }]
+ ))
+ .to_return(
+ status: 200,
+ body: fixture("create_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ response = @images.create(
+ prompt: "A sunset",
+ postprocessing: [{ process: "upscale", upscale_factor: 2 }]
+ )
+
+ assert response.success?
+ end
+
+ def test_create_with_test_time_scaling
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .with(body: hash_including(prompt: "A sunset", test_time_scaling: 3))
+ .to_return(
+ status: 200,
+ body: fixture("create_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ response = @images.create(prompt: "A sunset", test_time_scaling: 3)
+
+ assert response.success?
+ end
+
+ def test_create_with_breadcrumb_sends_query_param
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .with(
+ body: hash_including(prompt: "A sunset"),
+ query: { breadcrumb: "my-workflow-step-1" }
+ )
+ .to_return(
+ status: 200,
+ body: fixture("create_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ response = @images.create(prompt: "A sunset", breadcrumb: "my-workflow-step-1")
+
+ assert response.success?
+ end
+
+ def test_create_with_accept_image_webp_returns_binary
+ stub_request(:post, "https://api.reve.com/v1/image/create")
+ .with(
+ body: hash_including(prompt: "A sunset"),
+ headers: { "Accept" => "image/webp" }
+ )
+ .to_return(
+ status: 200,
+ body: "fake-webp-binary-data",
+ headers: {
+ "Content-Type" => "image/webp",
+ "X-Reve-Version" => "reve-create@20250915",
+ "X-Reve-Content-Violation" => "false",
+ "X-Reve-Request-Id" => "rsid-binary-123",
+ "X-Reve-Credits-Used" => "18",
+ "X-Reve-Credits-Remaining" => "982"
+ }
+ )
+
+ response = @images.create(prompt: "A sunset", accept: "image/webp")
+
+ assert response.binary?
+ assert_equal "fake-webp-binary-data", response.image
+ assert_equal "reve-create@20250915", response.version
+ assert_equal 18, response.credits_used
+ assert_equal 982, response.credits_remaining
+ refute response.content_violation?
+ end
+
+ def test_create_validates_postprocessing_must_be_array_of_hashes
+ error = assert_raises(ReveAI::ValidationError) do
+ @images.create(prompt: "A sunset", postprocessing: "upscale")
+ end
+ assert_match(/Postprocessing must be an Array of Hashes/, error.message)
+ end
+
+ def test_create_validates_postprocessing_step_requires_process_key
+ error = assert_raises(ReveAI::ValidationError) do
+ @images.create(prompt: "A sunset", postprocessing: [{ upscale_factor: 2 }])
+ end
+ assert_match(/Postprocessing step at index 0 must include a 'process' key/, error.message)
+ end
+
+ def test_create_validates_test_time_scaling_range
+ error = assert_raises(ReveAI::ValidationError) do
+ @images.create(prompt: "A sunset", test_time_scaling: 16)
+ end
+ assert_match(/test_time_scaling must be a number between 1 and 15/, error.message)
+ end
+
+ # Edit tests: postprocessing, test_time_scaling, breadcrumb
+
+ def test_edit_with_postprocessing
+ stub_request(:post, "https://api.reve.com/v1/image/edit")
+ .with(body: hash_including(
+ edit_instruction: "Add clouds",
+ reference_image: "base64data",
+ postprocessing: [{ process: "remove_background" }]
+ ))
+ .to_return(
+ status: 200,
+ body: fixture("edit_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ response = @images.edit(
+ edit_instruction: "Add clouds",
+ reference_image: "base64data",
+ postprocessing: [{ process: "remove_background" }]
+ )
+
+ assert response.success?
+ end
+
+ def test_edit_with_test_time_scaling
+ stub_request(:post, "https://api.reve.com/v1/image/edit")
+ .with(body: hash_including(
+ edit_instruction: "Add clouds",
+ reference_image: "base64data",
+ test_time_scaling: 2
+ ))
+ .to_return(
+ status: 200,
+ body: fixture("edit_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ response = @images.edit(
+ edit_instruction: "Add clouds",
+ reference_image: "base64data",
+ test_time_scaling: 2
+ )
+
+ assert response.success?
+ end
+
+ def test_edit_with_breadcrumb_sends_query_param
+ stub_request(:post, "https://api.reve.com/v1/image/edit")
+ .with(
+ body: hash_including(edit_instruction: "Add clouds"),
+ query: { breadcrumb: "edit-step" }
+ )
+ .to_return(
+ status: 200,
+ body: fixture("edit_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ response = @images.edit(
+ edit_instruction: "Add clouds",
+ reference_image: "base64data",
+ breadcrumb: "edit-step"
+ )
+
+ assert response.success?
+ end
+
+ def test_edit_validates_postprocessing
+ error = assert_raises(ReveAI::ValidationError) do
+ @images.edit(edit_instruction: "Add clouds", reference_image: "base64data", postprocessing: "upscale")
+ end
+ assert_match(/Postprocessing must be an Array of Hashes/, error.message)
+ end
+
+ def test_edit_validates_test_time_scaling
+ error = assert_raises(ReveAI::ValidationError) do
+ @images.edit(edit_instruction: "Add clouds", reference_image: "base64data", test_time_scaling: 0)
+ end
+ assert_match(/test_time_scaling must be a number between 1 and 15/, error.message)
+ end
+
+ # Remix tests: postprocessing, test_time_scaling, breadcrumb
+
+ def test_remix_with_postprocessing
+ stub_request(:post, "https://api.reve.com/v1/image/remix")
+ .with(body: hash_including(
+ prompt: "Combine",
+ postprocessing: [{ process: "effect", effect_name: "cmyk_halftone" }]
+ ))
+ .to_return(
+ status: 200,
+ body: fixture("remix_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ response = @images.remix(
+ prompt: "Combine",
+ reference_images: ["base64data"],
+ postprocessing: [{ process: "effect", effect_name: "cmyk_halftone" }]
+ )
+
+ assert response.success?
+ end
+
+ def test_remix_with_test_time_scaling
+ stub_request(:post, "https://api.reve.com/v1/image/remix")
+ .with(body: hash_including(prompt: "Combine", test_time_scaling: 5))
+ .to_return(
+ status: 200,
+ body: fixture("remix_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ response = @images.remix(prompt: "Combine", reference_images: ["base64data"], test_time_scaling: 5)
+
+ assert response.success?
+ end
+
+ def test_remix_with_breadcrumb_sends_query_param
+ stub_request(:post, "https://api.reve.com/v1/image/remix")
+ .with(
+ body: hash_including(prompt: "Combine"),
+ query: { breadcrumb: "remix-step" }
+ )
+ .to_return(
+ status: 200,
+ body: fixture("remix_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ response = @images.remix(prompt: "Combine", reference_images: ["base64data"], breadcrumb: "remix-step")
+
+ assert response.success?
+ end
+
+ def test_remix_validates_postprocessing
+ error = assert_raises(ReveAI::ValidationError) do
+ @images.remix(prompt: "Combine", reference_images: ["base64data"], postprocessing: [{ factor: 2 }])
+ end
+ assert_match(/Postprocessing step at index 0 must include a 'process' key/, error.message)
+ end
+
+ def test_remix_validates_test_time_scaling
+ error = assert_raises(ReveAI::ValidationError) do
+ @images.remix(prompt: "Combine", reference_images: ["base64data"], test_time_scaling: 20)
+ end
+ assert_match(/test_time_scaling must be a number between 1 and 15/, error.message)
+ end
+
# Error handling tests
def test_handles_api_errors
@@ -258,6 +510,8 @@ def test_handles_api_errors
end
def test_handles_rate_limit_errors
+ # Disable retries: the retry middleware honors Retry-After (60s) per attempt.
+ @client.configuration.max_retries = 0
stub_request(:post, "https://api.reve.com/v1/image/create")
.to_return(
status: 429,
diff --git a/test/reve_ai/resources/v2/images_test.rb b/test/reve_ai/resources/v2/images_test.rb
new file mode 100644
index 0000000..b79a491
--- /dev/null
+++ b/test/reve_ai/resources/v2/images_test.rb
@@ -0,0 +1,210 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ReveAI::Resources::V2::ImagesTest < Minitest::Test
+ def setup
+ super
+ @client = ReveAI::Client.new(api_key: "test_key")
+ @images = ReveAI::Resources::V2::Images.new(@client)
+ end
+
+ def test_inherits_from_base
+ assert_kind_of ReveAI::Resources::Base, @images
+ end
+
+ def test_create_with_prompt_only
+ stub_create.with(body: { prompt: "A sunset" }).to_return(success_response)
+
+ response = @images.create(prompt: "A sunset")
+
+ assert_instance_of ReveAI::ImageResponse, response
+ assert_equal "aGVsbG8gd29ybGQ=", response.image
+ assert_equal "latest", response.version
+ assert_equal 150, response.credits_used
+ assert_equal 880, response.credits_remaining
+ refute response.content_violation?
+ end
+
+ def test_create_returns_layout_from_json_response
+ stub_create.to_return(success_response)
+
+ response = @images.create(prompt: "A sunset")
+
+ layout = response.layout
+ assert_equal "A serene mountain landscape at sunset", layout[:prompt]
+ assert_equal 2, layout[:regions].length
+ assert_equal "mountain", layout[:regions].first[:label]
+ assert_equal({ x0: 0.0, y0: 0.0, x1: 0.5, y1: 1.0 }, layout[:regions].first[:bbox])
+ end
+
+ def test_create_with_all_options_passed_through
+ stub_create
+ .with(body: hash_including(
+ prompt: "Edit this",
+ references: [{ data: "base64data" }],
+ aspect_ratio: "4:1",
+ postprocessing: [{ process: "fit_image", max_dim: 2048 }],
+ test_time_scaling: 2,
+ version: "reve-v2-create@260601"
+ ))
+ .to_return(success_response)
+
+ response = @images.create(
+ prompt: "Edit this",
+ references: [{ data: "base64data" }],
+ aspect_ratio: "4:1",
+ postprocessing: [{ process: "fit_image", max_dim: 2048 }],
+ test_time_scaling: 2,
+ version: "reve-v2-create@260601"
+ )
+
+ assert response.success?
+ end
+
+ def test_create_with_ref_reference
+ stub_create
+ .with(body: hash_including(references: [{ ref: "id:3fa85f64-5717-4562-b3fc-2c963f66afa6" }]))
+ .to_return(success_response)
+
+ response = @images.create(
+ prompt: "Extend 0",
+ references: [{ ref: "id:3fa85f64-5717-4562-b3fc-2c963f66afa6" }]
+ )
+
+ assert response.success?
+ end
+
+ def test_create_with_breadcrumb_sends_query_param
+ stub_create.with(query: { breadcrumb: "v2-step-1" }).to_return(success_response)
+
+ response = @images.create(prompt: "A sunset", breadcrumb: "v2-step-1")
+
+ assert response.success?
+ end
+
+ def test_create_with_accept_image_webp_returns_binary
+ image_bytes = "\x89PNG\r\n\x1a\nfake".b
+ stub_create.to_return(
+ status: 200,
+ body: image_bytes,
+ headers: {
+ "Content-Type" => "image/webp",
+ "X-Reve-Version" => "latest",
+ "X-Reve-Credits-Used" => "150",
+ "X-Reve-Request-Id" => "rsid-v2-binary-1"
+ }
+ )
+
+ response = @images.create(prompt: "A sunset", accept: "image/webp")
+
+ assert response.binary?
+ assert_equal image_bytes, response.image
+ assert_equal "latest", response.version
+ assert_equal 150, response.credits_used
+ assert_equal "rsid-v2-binary-1", response.request_id
+ assert_nil response.layout
+ end
+
+ def test_create_accepts_extended_aspect_ratios
+ %w[4:1 21:9 5:4 1:2 auto].each do |ratio|
+ stub_create.with(body: hash_including(aspect_ratio: ratio)).to_return(success_response)
+
+ assert @images.create(prompt: "A sunset", aspect_ratio: ratio).success?, "expected #{ratio} to be accepted"
+ end
+ end
+
+ def test_create_validates_prompt_required
+ assert_raises(ReveAI::ValidationError) { @images.create(prompt: "") }
+ assert_raises(ReveAI::ValidationError) { @images.create(prompt: nil) }
+ end
+
+ def test_create_validates_prompt_max_length
+ stub_create.to_return(success_response)
+ assert @images.create(prompt: "x" * 4000).success?
+
+ error = assert_raises(ReveAI::ValidationError) { @images.create(prompt: "x" * 4001) }
+ assert_match(/4000/, error.message)
+ end
+
+ def test_create_validates_aspect_ratio
+ error = assert_raises(ReveAI::ValidationError) { @images.create(prompt: "A sunset", aspect_ratio: "5:3") }
+ assert_match(/Invalid aspect_ratio/, error.message)
+ end
+
+ def test_create_validates_references_type
+ error = assert_raises(ReveAI::ValidationError) { @images.create(prompt: "A sunset", references: "base64data") }
+ assert_match(/must be an Array/, error.message)
+ end
+
+ def test_create_validates_references_max
+ references = Array.new(9) { { data: "base64data" } }
+
+ error = assert_raises(ReveAI::ValidationError) { @images.create(prompt: "A sunset", references: references) }
+ assert_match(/Maximum 8 references/, error.message)
+ end
+
+ def test_create_accepts_eight_references
+ stub_create.to_return(success_response)
+ references = Array.new(8) { { data: "base64data" } }
+
+ assert @images.create(prompt: "A sunset", references: references).success?
+ end
+
+ def test_create_validates_reference_entry_shape
+ error = assert_raises(ReveAI::ValidationError) { @images.create(prompt: "A sunset", references: ["base64data"]) }
+ assert_match(/index 0 must be a Hash/, error.message)
+ end
+
+ def test_create_validates_reference_with_both_data_and_ref
+ reference = { data: "base64data", ref: "id:123" }
+
+ error = assert_raises(ReveAI::ValidationError) { @images.create(prompt: "A sunset", references: [reference]) }
+ assert_match(/exactly one of 'data' or 'ref'/, error.message)
+ end
+
+ def test_create_validates_reference_with_neither_data_nor_ref
+ error = assert_raises(ReveAI::ValidationError) { @images.create(prompt: "A sunset", references: [{ image: "x" }]) }
+ assert_match(/exactly one of 'data' or 'ref'/, error.message)
+ end
+
+ def test_create_validates_reference_data_not_empty
+ error = assert_raises(ReveAI::ValidationError) { @images.create(prompt: "A sunset", references: [{ data: "" }]) }
+ assert_match(/'data' must be a non-empty String/, error.message)
+ end
+
+ def test_create_validates_postprocessing
+ error = assert_raises(ReveAI::ValidationError) { @images.create(prompt: "A sunset", postprocessing: "upscale") }
+ assert_match(/Postprocessing must be an Array of Hashes/, error.message)
+ end
+
+ def test_create_validates_test_time_scaling
+ error = assert_raises(ReveAI::ValidationError) { @images.create(prompt: "A sunset", test_time_scaling: 16) }
+ assert_match(/test_time_scaling must be a number between 1 and 15/, error.message)
+ end
+
+ def test_create_raises_api_error_on_bad_request
+ stub_create.to_return(
+ status: 400,
+ body: { error_code: "MISSING_REQUIRED_PARAMETER", message: "prompt is required" }.to_json,
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ error = assert_raises(ReveAI::BadRequestError) { @images.create(prompt: "A sunset") }
+ assert_equal "MISSING_REQUIRED_PARAMETER", error.error_code
+ end
+
+ private
+
+ def stub_create
+ stub_request(:post, "https://api.reve.com/v2/image/create")
+ end
+
+ def success_response
+ {
+ status: 200,
+ body: fixture("v2_create_response.json"),
+ headers: { "Content-Type" => "application/json" }
+ }
+ end
+end
diff --git a/test/reve_ai/resources/v2/layouts_test.rb b/test/reve_ai/resources/v2/layouts_test.rb
new file mode 100644
index 0000000..9b2d2c4
--- /dev/null
+++ b/test/reve_ai/resources/v2/layouts_test.rb
@@ -0,0 +1,262 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ReveAI::Resources::V2::LayoutsTest < Minitest::Test
+ SAMPLE_LAYOUT = {
+ prompt: "a person at a cafe",
+ regions: [
+ { label: "person", prompt: "a woman in a red coat",
+ bbox: { x0: 0.1, y0: 0.1, x1: 0.6, y1: 0.9 } }
+ ]
+ }.freeze
+
+ def setup
+ super
+ @client = ReveAI::Client.new(api_key: "test_key")
+ @layouts = ReveAI::Resources::V2::Layouts.new(@client)
+ end
+
+ def test_inherits_from_base
+ assert_kind_of ReveAI::Resources::Base, @layouts
+ end
+
+ # extract
+
+ def test_extract_with_data_image
+ stub_extract.with(body: { image: { data: "base64data" } }).to_return(extract_response)
+
+ response = @layouts.extract(image: { data: "base64data" })
+
+ assert_instance_of ReveAI::LayoutResponse, response
+ assert_equal "A bottle of rosé wine on a terracotta surface", response.layout[:prompt]
+ assert_equal 2, response.layout[:regions].length
+ assert_equal "label 1", response.layout[:regions].last[:label]
+ assert_equal "bottle 1", response.layout[:regions].last[:parent]
+ assert_equal 80, response.credits_used
+ assert_equal 920, response.credits_remaining
+ refute response.content_violation?
+ end
+
+ def test_extract_with_ref_image_prompt_and_version
+ stub_extract
+ .with(body: hash_including(image: { ref: "id:123" }, prompt: "simplify", version: "latest"))
+ .to_return(extract_response)
+
+ response = @layouts.extract(image: { ref: "id:123" }, prompt: "simplify", version: "latest")
+
+ assert response.success?
+ end
+
+ def test_extract_with_breadcrumb_sends_query_param
+ stub_extract.with(query: { breadcrumb: "extract-step" }).to_return(extract_response)
+
+ assert @layouts.extract(image: { data: "base64data" }, breadcrumb: "extract-step").success?
+ end
+
+ def test_extract_validates_image_shape
+ assert_raises(ReveAI::ValidationError) { @layouts.extract(image: "base64data") }
+ assert_raises(ReveAI::ValidationError) { @layouts.extract(image: { data: "x", ref: "y" }) }
+ assert_raises(ReveAI::ValidationError) { @layouts.extract(image: { other: "x" }) }
+ assert_raises(ReveAI::ValidationError) { @layouts.extract(image: { data: "" }) }
+ end
+
+ def test_extract_validates_prompt_length
+ error = assert_raises(ReveAI::ValidationError) do
+ @layouts.extract(image: { data: "base64data" }, prompt: "x" * 4001)
+ end
+ assert_match(/4000/, error.message)
+ end
+
+ # create
+
+ def test_create_with_prompt_only
+ stub_create_layout.with(body: { prompt: "a person at a cafe" }).to_return(create_layout_response)
+
+ response = @layouts.create(prompt: "a person at a cafe")
+
+ assert_instance_of ReveAI::LayoutResponse, response
+ assert_equal "a person at a cafe", response.layout[:prompt]
+ assert_equal "person", response.layout[:regions].first[:label]
+ end
+
+ def test_create_with_references_commands_and_aspect_ratio
+ stub_create_layout
+ .with(body: hash_including(
+ references: [{ image: { data: "base64data" }, prompt: "the cafe scene" }],
+ commands: [{ op: "add", label: "dog", at: { x: 0.5, y: 0.5 } }],
+ aspect_ratio: "21:9"
+ ))
+ .to_return(create_layout_response)
+
+ response = @layouts.create(
+ references: [{ image: { data: "base64data" }, prompt: "the cafe scene" }],
+ commands: [{ op: "add", label: "dog", at: { x: 0.5, y: 0.5 } }],
+ aspect_ratio: "21:9"
+ )
+
+ assert response.success?
+ end
+
+ def test_create_requires_prompt_or_references
+ error = assert_raises(ReveAI::ValidationError) { @layouts.create }
+ assert_match(/at least one of prompt or references/i, error.message)
+ end
+
+ def test_create_validates_compound_references
+ assert_raises(ReveAI::ValidationError) { @layouts.create(references: "base64data") }
+ assert_raises(ReveAI::ValidationError) { @layouts.create(references: [{}]) }
+ assert_raises(ReveAI::ValidationError) { @layouts.create(references: [{ image: "base64data" }]) }
+ assert_raises(ReveAI::ValidationError) { @layouts.create(references: [{ layout: "not-a-hash" }]) }
+
+ references = Array.new(9) { { prompt: "x" } }
+ error = assert_raises(ReveAI::ValidationError) { @layouts.create(references: references) }
+ assert_match(/Maximum 8 references/, error.message)
+ end
+
+ def test_create_accepts_layout_only_reference
+ stub_create_layout.to_return(create_layout_response)
+
+ response = @layouts.create(references: [{ layout: SAMPLE_LAYOUT }])
+
+ assert response.success?
+ end
+
+ def test_create_rejects_empty_compound_reference_prompt
+ error = assert_raises(ReveAI::ValidationError) do
+ @layouts.create(references: [{ prompt: "" }])
+ end
+
+ assert_match(/prompt.*non-empty String/, error.message)
+ end
+
+ def test_create_rejects_non_string_compound_reference_prompt
+ error = assert_raises(ReveAI::ValidationError) do
+ @layouts.create(references: [{ prompt: 123 }])
+ end
+
+ assert_match(/prompt.*non-empty String/, error.message)
+ end
+
+ def test_create_validates_commands
+ assert_raises(ReveAI::ValidationError) { @layouts.create(prompt: "x", commands: "add") }
+ assert_raises(ReveAI::ValidationError) { @layouts.create(prompt: "x", commands: [{ label: "dog" }]) }
+ end
+
+ def test_create_validates_aspect_ratio
+ assert_raises(ReveAI::ValidationError) { @layouts.create(prompt: "x", aspect_ratio: "5:3") }
+ end
+
+ # render
+
+ def test_render_with_layout_only
+ stub_render.with(body: hash_including(layout: hash_including(:regions))).to_return(render_response)
+
+ response = @layouts.render(layout: SAMPLE_LAYOUT)
+
+ assert_instance_of ReveAI::ImageResponse, response
+ assert_equal "cmVuZGVyZWRfaW1hZ2U=", response.image
+ assert_equal "a person at a cafe", response.layout[:prompt]
+ assert_equal "latest", response.version
+ assert_equal 150, response.credits_used
+ assert_equal 770, response.credits_remaining
+ end
+
+ def test_render_with_all_options
+ stub_render
+ .with(body: hash_including(
+ layout: hash_including(:regions),
+ references: [{ image: { data: "base64data" } }],
+ postprocessing: [{ process: "fit_image", max_dim: 2048 }],
+ version: "latest"
+ ))
+ .to_return(render_response)
+
+ response = @layouts.render(
+ layout: SAMPLE_LAYOUT,
+ references: [{ image: { data: "base64data" } }],
+ postprocessing: [{ process: "fit_image", max_dim: 2048 }],
+ version: "latest"
+ )
+
+ assert response.success?
+ end
+
+ def test_render_with_accept_image_webp_returns_binary
+ image_bytes = "\x89PNG\r\n\x1a\nfake".b
+ stub_render.to_return(
+ status: 200,
+ body: image_bytes,
+ headers: { "Content-Type" => "image/webp", "X-Reve-Credits-Used" => "150" }
+ )
+
+ response = @layouts.render(layout: SAMPLE_LAYOUT, accept: "image/webp")
+
+ assert response.binary?
+ assert_equal image_bytes, response.image
+ assert_equal 150, response.credits_used
+ assert_nil response.layout
+ end
+
+ def test_render_validates_layout
+ assert_raises(ReveAI::ValidationError) { @layouts.render(layout: nil) }
+ assert_raises(ReveAI::ValidationError) { @layouts.render(layout: "not-a-hash") }
+ assert_raises(ReveAI::ValidationError) { @layouts.render(layout: { prompt: "x" }) }
+ assert_raises(ReveAI::ValidationError) { @layouts.render(layout: { regions: [] }) }
+ end
+
+ def test_render_accepts_string_keyed_layout
+ stub_render.to_return(render_response)
+ layout = { "regions" => [{ "label" => "person", "prompt" => "a woman", "bbox" => {} }] }
+
+ assert @layouts.render(layout: layout).success?
+ end
+
+ def test_render_validates_postprocessing
+ error = assert_raises(ReveAI::ValidationError) do
+ @layouts.render(layout: SAMPLE_LAYOUT, postprocessing: [{ factor: 2 }])
+ end
+ assert_match(/'process' key/, error.message)
+ end
+
+ def test_render_raises_api_error_on_bad_request
+ stub_render.to_return(
+ status: 400,
+ body: { error_code: "INVALID_LAYOUT", message: "regions are overlapping" }.to_json,
+ headers: { "Content-Type" => "application/json" }
+ )
+
+ error = assert_raises(ReveAI::BadRequestError) { @layouts.render(layout: SAMPLE_LAYOUT) }
+ assert_equal "INVALID_LAYOUT", error.error_code
+ end
+
+ private
+
+ def stub_extract
+ stub_request(:post, "https://api.reve.com/v2/image/extract_layout")
+ end
+
+ def stub_create_layout
+ stub_request(:post, "https://api.reve.com/v2/image/create_layout")
+ end
+
+ def stub_render
+ stub_request(:post, "https://api.reve.com/v2/image/render_layout")
+ end
+
+ def extract_response
+ json_response(fixture("v2_extract_layout_response.json"))
+ end
+
+ def create_layout_response
+ json_response(fixture("v2_create_layout_response.json"))
+ end
+
+ def render_response
+ json_response(fixture("v2_render_layout_response.json"))
+ end
+
+ def json_response(body)
+ { status: 200, body: body, headers: { "Content-Type" => "application/json" } }
+ end
+end
diff --git a/test/reve_ai/response_test.rb b/test/reve_ai/response_test.rb
index 4dd4142..01d1b99 100644
--- a/test/reve_ai/response_test.rb
+++ b/test/reve_ai/response_test.rb
@@ -50,6 +50,28 @@ def test_request_id_from_headers
assert_equal "rsid-456", response.request_id
end
+
+ def test_binary_returns_false_for_hash_body
+ response = ReveAI::Response.new(status: 200, headers: {}, body: {})
+
+ refute response.binary?
+ end
+
+ def test_binary_returns_true_for_string_body
+ response = ReveAI::Response.new(status: 200, headers: {}, body: "\x89PNG".b)
+
+ assert response.binary?
+ end
+
+ def test_request_id_from_headers_with_binary_body
+ response = ReveAI::Response.new(
+ status: 200,
+ headers: { "x-reve-request-id" => "rsid-binary" },
+ body: "\x89PNG".b
+ )
+
+ assert_equal "rsid-binary", response.request_id
+ end
end
class ReveAI::ImageResponseTest < Minitest::Test
@@ -162,4 +184,176 @@ def test_credits_remaining_from_headers
assert_equal 970, response.credits_remaining
end
+
+ def test_layout_returns_layout_hash
+ layout = { prompt: "A cat", regions: [], width: 4096, height: 2560 }
+ response = ReveAI::ImageResponse.new(
+ status: 200,
+ headers: {},
+ body: { layout: layout }
+ )
+
+ assert_equal layout, response.layout
+ end
+
+ def test_layout_returns_nil_when_absent
+ response = ReveAI::ImageResponse.new(
+ status: 200,
+ headers: {},
+ body: {}
+ )
+
+ assert_nil response.layout
+ end
+
+ def test_image_returns_raw_bytes_for_binary_body
+ bytes = "\x89PNG\r\n\x1a\nfake".b
+ response = ReveAI::ImageResponse.new(
+ status: 200,
+ headers: {},
+ body: bytes
+ )
+
+ assert_equal bytes, response.image
+ assert_equal bytes, response.base64
+ end
+
+ def test_binary_body_accessors_fall_back_to_headers
+ response = ReveAI::ImageResponse.new(
+ status: 200,
+ headers: {
+ "x-reve-version" => "latest",
+ "x-reve-credits-used" => "18",
+ "x-reve-credits-remaining" => "982",
+ "x-reve-content-violation" => "false",
+ "x-reve-request-id" => "rsid-binary-1"
+ },
+ body: "\x89PNG\r\n\x1a\nfake".b
+ )
+
+ assert_equal "latest", response.version
+ assert_equal 18, response.credits_used
+ assert_equal 982, response.credits_remaining
+ refute response.content_violation?
+ assert_equal "rsid-binary-1", response.request_id
+ end
+
+ def test_binary_body_accessors_do_not_raise_without_metadata
+ response = ReveAI::ImageResponse.new(status: 200, headers: {}, body: "\x89PNG".b)
+
+ assert response.binary?
+ assert_nil response.version
+ assert_nil response.credits_used
+ assert_nil response.credits_remaining
+ refute response.content_violation?
+ assert_nil response.request_id
+ assert_nil response.layout
+ end
+
+ def test_content_violation_from_headers_for_binary_body
+ response = ReveAI::ImageResponse.new(
+ status: 200,
+ headers: { "x-reve-content-violation" => "true" },
+ body: "\x89PNG".b
+ )
+
+ assert response.content_violation?
+ end
+end
+
+class ReveAI::LayoutResponseTest < Minitest::Test
+ def test_layout_returns_layout_hash
+ layout = { prompt: "A cat", regions: [{ label: "cat" }], width: 4096, height: 2560 }
+ response = ReveAI::LayoutResponse.new(
+ status: 200,
+ headers: {},
+ body: { layout: layout }
+ )
+
+ assert_equal layout, response.layout
+ end
+
+ def test_layout_returns_nil_when_absent
+ response = ReveAI::LayoutResponse.new(status: 200, headers: {}, body: {})
+
+ assert_nil response.layout
+ end
+
+ def test_content_violation_from_body
+ response = ReveAI::LayoutResponse.new(
+ status: 200,
+ headers: {},
+ body: { content_violation: true }
+ )
+
+ assert response.content_violation?
+ end
+
+ def test_content_violation_from_headers
+ response = ReveAI::LayoutResponse.new(
+ status: 200,
+ headers: { "x-reve-content-violation" => "true" },
+ body: {}
+ )
+
+ assert response.content_violation?
+ end
+
+ def test_content_violation_false_by_default
+ response = ReveAI::LayoutResponse.new(status: 200, headers: {}, body: {})
+
+ refute response.content_violation?
+ end
+
+ def test_credits_used_from_body
+ response = ReveAI::LayoutResponse.new(
+ status: 200,
+ headers: {},
+ body: { credits_used: 4 }
+ )
+
+ assert_equal 4, response.credits_used
+ end
+
+ def test_credits_used_from_headers
+ response = ReveAI::LayoutResponse.new(
+ status: 200,
+ headers: { "x-reve-credits-used" => "4" },
+ body: {}
+ )
+
+ assert_equal 4, response.credits_used
+ end
+
+ def test_credits_remaining_from_body
+ response = ReveAI::LayoutResponse.new(
+ status: 200,
+ headers: {},
+ body: { credits_remaining: 996 }
+ )
+
+ assert_equal 996, response.credits_remaining
+ end
+
+ def test_credits_remaining_from_headers
+ response = ReveAI::LayoutResponse.new(
+ status: 200,
+ headers: { "x-reve-credits-remaining" => "996" },
+ body: {}
+ )
+
+ assert_equal 996, response.credits_remaining
+ end
+
+ def test_accessors_never_raise_for_string_body
+ response = ReveAI::LayoutResponse.new(
+ status: 200,
+ headers: { "x-reve-credits-used" => "4" },
+ body: "unexpected string"
+ )
+
+ assert_nil response.layout
+ assert_equal 4, response.credits_used
+ refute response.content_violation?
+ end
end