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