generate_image 1.1.2.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/generate_image/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "generate_image"
7
+ spec.version = GenerateImage::VERSION
8
+ spec.authors = ["AMQOR Merouane"]
9
+ spec.email = ["marouaneamqor@gmail.com"]
10
+
11
+ spec.summary = "OpenAI Images API client (GPT Image, DALL·E) for Ruby"
12
+ spec.description = <<~DESC
13
+ Lightweight Ruby client for OpenAI image generation and edits (/v1/images/generations, /v1/images/edits).
14
+ Defaults to GPT Image models; stdlib-only (Net::HTTP + JSON). Ruby 3.1+.
15
+ DESC
16
+ spec.homepage = "https://github.com/merouaneamqor/generate_image"
17
+ spec.license = "MIT"
18
+ spec.required_ruby_version = ">= 3.1.0"
19
+
20
+ spec.metadata["homepage_uri"] = spec.homepage
21
+ spec.metadata["source_code_uri"] = spec.homepage
22
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
23
+ spec.metadata["rubygems_mfa_required"] = "true"
24
+
25
+ spec.files = Dir.chdir(__dir__) do
26
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
27
+ rescue StandardError
28
+ Dir["lib/**/*", "LICENSE.txt", "README.md", "CHANGELOG.md"]
29
+ end
30
+ spec.bindir = "exe"
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = ["lib"]
33
+
34
+ spec.add_development_dependency "rake", "~> 13.0"
35
+ spec.add_development_dependency "rspec", "~> 3.12"
36
+ spec.add_development_dependency "webmock", "~> 3.19"
37
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module GenerateImage
6
+ class Client
7
+ DEPRECATE_GENERATE_IMAGE_MSG =
8
+ "[DEPRECATION] GenerateImage::Client#generate_image is deprecated; use #generate instead."
9
+
10
+ def initialize(api_key = nil)
11
+ @api_key_override = api_key
12
+ end
13
+
14
+ def generate(prompt, **opts)
15
+ raise ValidationError, "prompt must be a non-empty String" unless prompt.is_a?(String) && !prompt.strip.empty?
16
+
17
+ model = (opts[:model] || configuration.default_model).to_s
18
+ validate_generation_model!(model)
19
+
20
+ body = build_generation_body(prompt, model, opts)
21
+ parsed = http.post_json("/v1/images/generations", body)
22
+ Response.new(parsed, model: model)
23
+ end
24
+
25
+ def edit(image:, prompt:, **opts)
26
+ raise ValidationError, "prompt must be a non-empty String" unless prompt.is_a?(String) && !prompt.strip.empty?
27
+ raise ValidationError, "image is required" if image.nil?
28
+
29
+ model = (opts[:model] || configuration.default_model).to_s
30
+ validate_edit_model!(model)
31
+
32
+ fields = build_edit_fields(model, prompt, opts)
33
+ files = { "image" => image }
34
+ files["mask"] = opts[:mask] if opts[:mask]
35
+
36
+ parsed = http.post_multipart("/v1/images/edits", fields, files)
37
+ Response.new(parsed, model: model)
38
+ end
39
+
40
+ def generate_image(text, options = {})
41
+ warn "#{DEPRECATE_GENERATE_IMAGE_MSG}\n"
42
+ generate(text, **legacy_options_to_new(options)).to_h
43
+ end
44
+
45
+ private
46
+
47
+ def configuration
48
+ base = GenerateImage.configuration
49
+ if @api_key_override && !@api_key_override.to_s.strip.empty?
50
+ KeyOverride.new(base, @api_key_override)
51
+ else
52
+ base
53
+ end
54
+ end
55
+
56
+ def http
57
+ @http ||= HTTP.new(configuration: configuration)
58
+ end
59
+
60
+ def build_generation_body(prompt, model, opts)
61
+ cfg = configuration
62
+ n = (opts.key?(:n) ? opts[:n] : 1).to_i
63
+ size = (opts[:size] || cfg.default_size).to_s
64
+
65
+ validate_generation_n!(model, n)
66
+ validate_generation_size!(model, size)
67
+
68
+ body = {
69
+ prompt: prompt,
70
+ model: model,
71
+ n: n,
72
+ size: size
73
+ }
74
+
75
+ if Models.gpt_image?(model)
76
+ body[:quality] = (opts[:quality] || cfg.default_quality).to_s
77
+ of = opts[:output_format] || cfg.default_output_format
78
+ body[:output_format] = of.to_s if of && !of.to_s.empty?
79
+ body[:background] = opts[:background].to_s if opts[:background]
80
+ body[:response_format] = opts[:response_format].to_s if opts[:response_format]
81
+ body[:style] = opts[:style].to_s if opts[:style]
82
+ elsif Models.dall_e_3?(model)
83
+ body[:quality] = (opts[:quality] || "standard").to_s
84
+ body[:response_format] = (opts[:response_format] || "url").to_s
85
+ body[:style] = opts[:style].to_s if opts[:style]
86
+ else
87
+ body[:response_format] = opts[:response_format].to_s if opts[:response_format]
88
+ end
89
+
90
+ body[:user] = opts[:user].to_s if opts[:user]
91
+ body.compact
92
+ end
93
+
94
+ def build_edit_fields(model, prompt, opts)
95
+ cfg = configuration
96
+ n = (opts.key?(:n) ? opts[:n] : 1).to_i
97
+ size = (opts[:size] || cfg.default_size).to_s
98
+
99
+ validate_generation_n!(model, n)
100
+ validate_generation_size!(model, size)
101
+
102
+ fields = {
103
+ "model" => model,
104
+ "prompt" => prompt,
105
+ "n" => n.to_s,
106
+ "size" => size
107
+ }
108
+
109
+ if Models.gpt_image?(model)
110
+ fields["quality"] = (opts[:quality] || cfg.default_quality).to_s
111
+ of = opts[:output_format] || cfg.default_output_format
112
+ fields["output_format"] = of.to_s if of && !of.to_s.empty?
113
+ fields["background"] = opts[:background].to_s if opts[:background]
114
+ fields["input_fidelity"] = opts[:input_fidelity].to_s if opts[:input_fidelity]
115
+ fields["response_format"] = opts[:response_format].to_s if opts[:response_format]
116
+ elsif Models.dall_e_3?(model)
117
+ fields["quality"] = (opts[:quality] || "standard").to_s
118
+ fields["response_format"] = (opts[:response_format] || "url").to_s
119
+ elsif opts[:response_format]
120
+ fields["response_format"] = opts[:response_format].to_s
121
+ end
122
+
123
+ fields["user"] = opts[:user].to_s if opts[:user]
124
+ fields.compact
125
+ end
126
+
127
+ def validate_generation_model!(model)
128
+ return if Models::ALL.include?(model)
129
+
130
+ raise ValidationError, "Unknown model #{model.inspect}. Expected one of: #{Models::ALL.join(", ")}"
131
+ end
132
+
133
+ def validate_edit_model!(model)
134
+ # Edits are supported for gpt-image and dall-e-2 historically; dall-e-3 may differ — allow gpt + dall-e-2 + dall-e-3 per API
135
+ return if Models.gpt_image?(model) || model == Models::DALLE2 || model == Models::DALLE3
136
+
137
+ raise ValidationError,
138
+ "Unsupported edit model #{model.inspect}. Use a GPT Image model, #{Models::DALLE2}, or #{Models::DALLE3}."
139
+ end
140
+
141
+ def validate_generation_n!(model, n)
142
+ raise ValidationError, "n must be between 1 and 10" unless n >= 1 && n <= 10
143
+
144
+ return unless Models.dall_e_3?(model)
145
+
146
+ raise ValidationError, "n must be 1 for #{model}" if n != 1
147
+ end
148
+
149
+ def validate_generation_size!(model, size)
150
+ valid =
151
+ if Models.gpt_image?(model)
152
+ Models::GPT_IMAGE_SIZES.include?(size)
153
+ elsif Models.dall_e_3?(model)
154
+ Models::DALLE3_SIZES.include?(size)
155
+ else
156
+ Models::DALLE2_SIZES.include?(size)
157
+ end
158
+
159
+ return if valid
160
+
161
+ allowed =
162
+ if Models.gpt_image?(model)
163
+ Models::GPT_IMAGE_SIZES
164
+ elsif Models.dall_e_3?(model)
165
+ Models::DALLE3_SIZES
166
+ else
167
+ Models::DALLE2_SIZES
168
+ end
169
+
170
+ raise ValidationError, "Invalid size #{size.inspect} for #{model}. Allowed: #{allowed.join(", ")}"
171
+ end
172
+
173
+ def legacy_options_to_new(options)
174
+ h = options.transform_keys { |k| k.to_sym }
175
+ if h.key?(:num_images)
176
+ h[:n] = h.delete(:num_images)
177
+ end
178
+ if h.key?(:response_format)
179
+ case h[:response_format].to_s
180
+ when "base64", "b64"
181
+ h[:response_format] = "b64_json"
182
+ end
183
+ end
184
+ h
185
+ end
186
+
187
+ class KeyOverride < SimpleDelegator
188
+ def initialize(base, api_key)
189
+ super(base)
190
+ @override_key = api_key
191
+ end
192
+
193
+ def resolved_api_key
194
+ s = @override_key.to_s
195
+ return s unless s.strip.empty?
196
+
197
+ __getobj__.resolved_api_key
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerateImage
4
+ class Configuration
5
+ attr_accessor :api_key, :default_model, :base_url, :default_size, :default_quality,
6
+ :default_output_format, :open_timeout, :read_timeout, :max_retries
7
+
8
+ def initialize
9
+ @api_key = nil
10
+ @default_model = "gpt-image-1"
11
+ @base_url = "https://api.openai.com"
12
+ @default_size = "1024x1024"
13
+ @default_quality = "auto"
14
+ @default_output_format = "png"
15
+ @open_timeout = 30
16
+ @read_timeout = 120
17
+ @max_retries = 1
18
+ end
19
+
20
+ def resolved_api_key
21
+ [api_key, ENV["OPENAI_API_KEY"], ENV["DALL_E_API_KEY"]]
22
+ .compact
23
+ .map(&:to_s)
24
+ .find { |s| !s.strip.empty? }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerateImage
4
+ class Error < StandardError; end
5
+
6
+ class AuthenticationError < Error; end
7
+
8
+ class RateLimitError < Error
9
+ attr_reader :retry_after
10
+
11
+ def initialize(message = nil, retry_after: 1)
12
+ super(message)
13
+ @retry_after = retry_after
14
+ end
15
+ end
16
+
17
+ class ApiError < Error
18
+ attr_reader :status_code, :body
19
+
20
+ def initialize(message = nil, status_code: nil, body: nil)
21
+ super(message)
22
+ @status_code = status_code
23
+ @body = body
24
+ end
25
+ end
26
+
27
+ class ValidationError < Error; end
28
+
29
+ # @deprecated Use {ApiError} instead. Kept for v1.x rescue compatibility.
30
+ RequestFailed = ApiError
31
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "stringio"
7
+ require "uri"
8
+
9
+ module GenerateImage
10
+ class HTTP
11
+ def initialize(configuration:)
12
+ @configuration = configuration
13
+ end
14
+
15
+ def post_json(path, body_hash)
16
+ uri = build_uri(path)
17
+ body = JSON.generate(body_hash)
18
+
19
+ with_retries do
20
+ request = Net::HTTP::Post.new(uri)
21
+ request["Authorization"] = "Bearer #{api_key}"
22
+ request["Content-Type"] = "application/json"
23
+ request.body = body
24
+ perform(uri, request)
25
+ end
26
+ end
27
+
28
+ def post_multipart(path, fields, files)
29
+ uri = build_uri(path)
30
+ boundary = "----RubyGenerateImage#{SecureRandom.hex(16)}"
31
+ body = build_multipart_body(boundary, fields, files)
32
+
33
+ with_retries do
34
+ request = Net::HTTP::Post.new(uri)
35
+ request["Authorization"] = "Bearer #{api_key}"
36
+ request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
37
+ request.body = body
38
+ perform(uri, request)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :configuration
45
+
46
+ def api_key
47
+ key = configuration.resolved_api_key
48
+ raise AuthenticationError, "Missing API key. Set OPENAI_API_KEY or configure api_key." if key.nil? || key.strip.empty?
49
+
50
+ key
51
+ end
52
+
53
+ def build_uri(path)
54
+ base = configuration.base_url.to_s.chomp("/")
55
+ URI.join("#{base}/", path.delete_prefix("/"))
56
+ end
57
+
58
+ def perform(uri, request)
59
+ http = Net::HTTP.new(uri.host, uri.port)
60
+ http.use_ssl = uri.scheme == "https"
61
+ http.open_timeout = configuration.open_timeout
62
+ http.read_timeout = configuration.read_timeout
63
+
64
+ response = http.request(request)
65
+ handle_response(response)
66
+ end
67
+
68
+ def handle_response(response)
69
+ case response.code.to_i
70
+ when 200, 201
71
+ parse_json_body(response.body)
72
+ when 401
73
+ raise AuthenticationError, error_message_from(response)
74
+ when 429
75
+ raise RateLimitError.new(
76
+ error_message_from(response),
77
+ retry_after: parse_retry_after(response["Retry-After"])
78
+ )
79
+ else
80
+ raise ApiError.new(error_message_from(response), status_code: response.code.to_i, body: response.body)
81
+ end
82
+ end
83
+
84
+ def parse_json_body(body)
85
+ return {} if body.nil? || body.empty?
86
+
87
+ JSON.parse(body)
88
+ rescue JSON::ParserError
89
+ raise ApiError.new("Invalid JSON response", body: body)
90
+ end
91
+
92
+ def error_message_from(response)
93
+ parsed = safe_parse_json(response.body)
94
+ err = parsed["error"]
95
+ if err.is_a?(Hash)
96
+ err["message"] || err.inspect
97
+ elsif err.is_a?(String)
98
+ err
99
+ else
100
+ "HTTP #{response.code}: #{response.message}"
101
+ end
102
+ rescue StandardError
103
+ "HTTP #{response.code}: #{response.message}"
104
+ end
105
+
106
+ def safe_parse_json(body)
107
+ return {} if body.nil? || body.empty?
108
+
109
+ JSON.parse(body)
110
+ rescue JSON::ParserError, TypeError
111
+ {}
112
+ end
113
+
114
+ def parse_retry_after(value)
115
+ s = value.to_s.strip
116
+ return 1 if s.empty?
117
+
118
+ if s.match?(/^\d+$/)
119
+ s.to_i.clamp(1, 120)
120
+ else
121
+ 1
122
+ end
123
+ end
124
+
125
+ def with_retries
126
+ attempts = 0
127
+ max = [configuration.max_retries.to_i, 0].max
128
+
129
+ begin
130
+ yield
131
+ rescue RateLimitError => e
132
+ attempts += 1
133
+ raise e if attempts > max
134
+
135
+ sleep(e.retry_after.to_f.positive? ? e.retry_after : 1)
136
+ retry
137
+ end
138
+ end
139
+
140
+ def build_multipart_body(boundary, fields, files)
141
+ io = StringIO.new
142
+ crlf = "\r\n"
143
+
144
+ fields.each do |name, value|
145
+ next if value.nil?
146
+
147
+ io << "--#{boundary}#{crlf}"
148
+ io << "Content-Disposition: form-data; name=\"#{escape_name(name)}\"#{crlf}#{crlf}"
149
+ io << value.to_s
150
+ io << crlf
151
+ end
152
+
153
+ files.each do |name, file_spec|
154
+ filename, content, content_type = normalize_file_spec(name, file_spec)
155
+ io << "--#{boundary}#{crlf}"
156
+ io << "Content-Disposition: form-data; name=\"#{escape_name(name)}\"; filename=\"#{escape_filename(filename)}\"#{crlf}"
157
+ io << "Content-Type: #{content_type}#{crlf}#{crlf}"
158
+ io << content
159
+ io << crlf
160
+ end
161
+
162
+ io << "--#{boundary}--#{crlf}"
163
+ io.string
164
+ end
165
+
166
+ def escape_name(name)
167
+ name.to_s.gsub(/["\r\n]/, "")
168
+ end
169
+
170
+ def escape_filename(name)
171
+ name.to_s.gsub(/["\r\n]/, "")
172
+ end
173
+
174
+ def normalize_file_spec(name, spec)
175
+ case spec
176
+ when String
177
+ path = spec
178
+ [File.basename(path), File.binread(path), mime_for_path(path)]
179
+ when Pathname
180
+ path = spec.to_s
181
+ [File.basename(path), File.binread(path), mime_for_path(path)]
182
+ when Hash
183
+ filename = spec[:filename] || spec["filename"] || "image.png"
184
+ io = spec[:io] || spec["io"]
185
+ content_type = spec[:content_type] || spec["content_type"] || "application/octet-stream"
186
+ content =
187
+ if io.respond_to?(:read)
188
+ io.rewind if io.respond_to?(:rewind)
189
+ io.read
190
+ else
191
+ spec[:data] || spec["data"] || ""
192
+ end
193
+ [filename, content, content_type]
194
+ else
195
+ if spec.respond_to?(:read)
196
+ filename = spec.respond_to?(:path) && spec.path ? File.basename(spec.path) : "#{name}.png"
197
+ spec.rewind if spec.respond_to?(:rewind)
198
+ [filename, spec.read, "application/octet-stream"]
199
+ else
200
+ raise ValidationError, "Unsupported file payload for #{name}"
201
+ end
202
+ end
203
+ end
204
+
205
+ def mime_for_path(path)
206
+ case File.extname(path).downcase
207
+ when ".png" then "image/png"
208
+ when ".jpg", ".jpeg" then "image/jpeg"
209
+ when ".webp" then "image/webp"
210
+ when ".gif" then "image/gif"
211
+ else "application/octet-stream"
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerateImage
4
+ module Models
5
+ GPT_IMAGE = %w[gpt-image-1 gpt-image-1.5 gpt-image-1-mini].freeze
6
+ DALLE3 = "dall-e-3"
7
+ DALLE2 = "dall-e-2"
8
+
9
+ ALL = (GPT_IMAGE + [DALLE3, DALLE2]).freeze
10
+
11
+ GPT_IMAGE_SIZES = %w[auto 1024x1024 1536x1024 1024x1536].freeze
12
+ DALLE3_SIZES = %w[1024x1024 1792x1024 1024x1792].freeze
13
+ DALLE2_SIZES = %w[256x256 512x512 1024x1024].freeze
14
+
15
+ GPT_IMAGE_QUALITIES = %w[auto high medium low].freeze
16
+ DALLE3_QUALITIES = %w[standard hd].freeze
17
+
18
+ OUTPUT_FORMATS = %w[png jpeg webp].freeze
19
+
20
+ module_function
21
+
22
+ def gpt_image?(model)
23
+ GPT_IMAGE.include?(model.to_s)
24
+ end
25
+
26
+ def dall_e_3?(model)
27
+ model.to_s == DALLE3
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GenerateImage
4
+ class Response
5
+ attr_reader :raw, :model
6
+
7
+ def initialize(parsed_json, model: nil)
8
+ @raw = parsed_json
9
+ @model = model
10
+ end
11
+
12
+ def data_items
13
+ Array(raw["data"])
14
+ end
15
+
16
+ def images
17
+ data_items.map do |item|
18
+ if item["url"]
19
+ { url: item["url"] }
20
+ elsif item["b64_json"]
21
+ { b64_json: item["b64_json"] }
22
+ else
23
+ {}
24
+ end
25
+ end
26
+ end
27
+
28
+ def url
29
+ data_items.find { |d| d["url"] }&.fetch("url", nil)
30
+ end
31
+
32
+ def b64
33
+ data_items.find { |d| d["b64_json"] }&.fetch("b64_json", nil)
34
+ end
35
+
36
+ def usage
37
+ raw["usage"]
38
+ end
39
+
40
+ def created
41
+ raw["created"]
42
+ end
43
+
44
+ def to_h
45
+ if url
46
+ { image_url: url }
47
+ elsif b64
48
+ { image_base64: b64 }
49
+ else
50
+ {}
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,3 +1,5 @@
1
- module GenerateImage
2
- VERSION = "1.1.2.2"
3
- end
1
+ # frozen_string_literal: true
2
+
3
+ module GenerateImage
4
+ VERSION = "2.0.0"
5
+ end
@@ -1,46 +1,21 @@
1
- require 'net/http'
2
- require 'json'
3
-
4
- module GenerateImage
5
- class RequestFailed < StandardError; end
6
-
7
- class Client
8
- API_ENDPOINT = 'https://api.openai.com/v1/images/generations'
9
-
10
- def initialize(api_key = nil)
11
- @api_key = api_key || ENV['DALL_E_API_KEY']
12
- end
13
-
14
- def generate_image(text, options = {})
15
- unless @api_key
16
- raise StandardError, "API Key not set"
17
- end
18
- uri = URI(API_ENDPOINT)
19
- request = Net::HTTP::Post.new(uri)
20
- request['Content-Type'] = 'application/json'
21
- request['Authorization'] = "Bearer #{API_KEY}"
22
- request.body = {
23
- prompt: text,
24
- n: options[:num_images] || 1,
25
- size: options[:size] || '512x512',
26
- response_format: options[:response_format] || 'url',
27
- }.to_json
28
-
29
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
30
- http.request(request)
31
- end
32
-
33
- if response.code == '200'
34
- if options[:response_format] == 'base64'
35
- image_base64 = JSON.parse(response.body)['data'][0]['base64']
36
- return { image_base64: image_base64 }
37
- else
38
- image_url = JSON.parse(response.body)['data'][0]['url']
39
- return { image_url: image_url }
40
- end
41
- else
42
- raise RequestFailed, "Failed to generate image. Response code: #{response.code}. Response body: #{response.body}"
43
- end
44
- end
45
- end
46
- end
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "generate_image/version"
4
+ require_relative "generate_image/errors"
5
+ require_relative "generate_image/configuration"
6
+ require_relative "generate_image/models"
7
+ require_relative "generate_image/response"
8
+ require_relative "generate_image/http"
9
+ require_relative "generate_image/client"
10
+
11
+ module GenerateImage
12
+ class << self
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield configuration
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ @echo off
2
+ setlocal
3
+ REM Run from repo root: scripts\gem-docker.cmd build | push
4
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0gem-docker.ps1" %*
5
+ exit /b %ERRORLEVEL%