imago 0.1.2 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32b2597d5abd824bba56fe349f70df96231527c712c587666c07882cd046388d
4
- data.tar.gz: e7a2f4a5c7845b6bd548f03adc2586236a056192c80c707c49d6109509982a58
3
+ metadata.gz: d295fddaf05348d473874c6f08e10b93937f27f3e4b228b68ad33e96073fde17
4
+ data.tar.gz: 88d463fefbb56a175ffce52504374615ddbdb61b15877a05a01ee0e7f75432c8
5
5
  SHA512:
6
- metadata.gz: 165ae60b51d64db276bf3ec15979ba0e06fc352074635b8386468ccf72fc0dcca9de35102e592ae9fce9eddd6dab2f59ff1855e51f44feecf053ef9f93ae5e55
7
- data.tar.gz: 9af79343ed2ea4e4ade8079b95bd8fce0fe6cadd17796064c6677d7613b1311012c38ac18e9507def8bf9c19db05a2d4a8660f5c776ea26f26777482bb1fac01
6
+ metadata.gz: 77e736f2487e31b1fc933fb4a174b8b67a0406116be1335a270890560a09a983418f2c7c368aa10d7489f51e745e9c5c27a9b9c238e82fe6314e0bd1b3d5713b
7
+ data.tar.gz: bb9c1ecf14c3f911f0869c8247f80297b575a57febd299cfd3b01063dd790cae866fcc8ec18d3dbe37c4e35320ea72138a210191e7057f240416d49e8748ae81
data/.rubocop.yml CHANGED
@@ -44,5 +44,9 @@ RSpec/NestedGroups:
44
44
  Layout/LineLength:
45
45
  Max: 120
46
46
 
47
+ Metrics/ClassLength:
48
+ Exclude:
49
+ - 'lib/imago/providers/openai.rb'
50
+
47
51
  RSpec/MessageSpies:
48
52
  EnforcedStyle: receive
data/README.md CHANGED
@@ -124,6 +124,45 @@ result[:images].each do |image|
124
124
  end
125
125
  ```
126
126
 
127
+ ### Image Input (Image-to-Image)
128
+
129
+ Imago supports image inputs for image editing and image-to-image generation. You can provide images as URLs or base64-encoded data.
130
+
131
+ ```ruby
132
+ # URL string (auto-detect mime type from extension)
133
+ client = Imago.new(provider: :openai)
134
+ result = client.generate("Make this colorful", images: ["https://example.com/photo.jpg"])
135
+
136
+ # Base64 with explicit mime type
137
+ result = client.generate("Add a hat", images: [
138
+ { base64: "iVBORw0KGgo...", mime_type: "image/png" }
139
+ ])
140
+
141
+ # URL with explicit mime type (useful when URL has no extension)
142
+ result = client.generate("Edit this", images: [
143
+ { url: "https://example.com/photo", mime_type: "image/jpeg" }
144
+ ])
145
+
146
+ # Mixed inputs
147
+ result = client.generate("Combine these", images: [
148
+ "https://example.com/photo1.jpg",
149
+ { base64: "iVBORw0KGgo...", mime_type: "image/jpeg" }
150
+ ])
151
+ ```
152
+
153
+ #### Image Input Provider Support
154
+
155
+ | Provider | Support | Limits |
156
+ |----------|---------|--------|
157
+ | OpenAI | Yes (gpt-image-*, dall-e-2) | 16 images max |
158
+ | Gemini | Yes | 10 images max |
159
+ | xAI | No | N/A |
160
+
161
+ **Notes:**
162
+ - DALL-E 3 does not support image inputs
163
+ - Mime types are auto-detected from URL extensions (png, jpg, jpeg, webp, gif)
164
+ - Base64 images require an explicit `mime_type`
165
+
127
166
  ### Listing Available Models
128
167
 
129
168
  ```ruby
@@ -167,6 +206,9 @@ rescue Imago::ConfigurationError => e
167
206
  puts "Configuration error: #{e.message}"
168
207
  rescue Imago::ProviderNotFoundError => e
169
208
  puts "Unknown provider: #{e.message}"
209
+ rescue Imago::UnsupportedFeatureError => e
210
+ puts "Feature not supported: #{e.message}"
211
+ puts "Provider: #{e.provider}, Feature: #{e.feature}"
170
212
  end
171
213
  ```
172
214
 
data/imago.gemspec CHANGED
@@ -30,6 +30,7 @@ Gem::Specification.new do |spec|
30
30
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
31
  spec.require_paths = ['lib']
32
32
 
33
+ spec.add_dependency 'base64', '~> 0.2'
33
34
  spec.add_dependency 'faraday', '~> 2.0'
34
35
  spec.add_dependency 'faraday-multipart', '~> 1.0'
35
36
  end
data/lib/imago/errors.rb CHANGED
@@ -22,4 +22,14 @@ module Imago
22
22
  class InvalidRequestError < ApiError; end
23
23
 
24
24
  class ProviderNotFoundError < Error; end
25
+
26
+ class UnsupportedFeatureError < Error
27
+ attr_reader :provider, :feature
28
+
29
+ def initialize(message, provider: nil, feature: nil)
30
+ @provider = provider
31
+ @feature = feature
32
+ super(message)
33
+ end
34
+ end
25
35
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Imago
4
+ class ImageInput
5
+ MIME_TYPES = {
6
+ 'png' => 'image/png',
7
+ 'jpg' => 'image/jpeg',
8
+ 'jpeg' => 'image/jpeg',
9
+ 'webp' => 'image/webp',
10
+ 'gif' => 'image/gif'
11
+ }.freeze
12
+
13
+ attr_reader :url, :base64, :mime_type
14
+
15
+ def self.from(input)
16
+ case input
17
+ when String
18
+ from_url_string(input)
19
+ when Hash
20
+ from_hash(input)
21
+ else
22
+ raise ArgumentError, "Invalid image input: expected String or Hash, got #{input.class}"
23
+ end
24
+ end
25
+
26
+ def self.from_url_string(url)
27
+ mime_type = detect_mime_type(url)
28
+ new(url: url, mime_type: mime_type)
29
+ end
30
+
31
+ def self.from_hash(hash)
32
+ hash = hash.transform_keys(&:to_sym)
33
+
34
+ if hash[:base64]
35
+ raise ArgumentError, 'mime_type is required for base64 images' unless hash[:mime_type]
36
+
37
+ new(base64: hash[:base64], mime_type: hash[:mime_type])
38
+ elsif hash[:url]
39
+ mime_type = hash[:mime_type] || detect_mime_type(hash[:url])
40
+ new(url: hash[:url], mime_type: mime_type)
41
+ else
42
+ raise ArgumentError, 'Image hash must contain either :url or :base64 key'
43
+ end
44
+ end
45
+
46
+ def self.detect_mime_type(url)
47
+ extension = File.extname(URI.parse(url).path).delete('.').downcase
48
+ MIME_TYPES[extension]
49
+ rescue URI::InvalidURIError
50
+ nil
51
+ end
52
+
53
+ private_class_method :from_url_string, :from_hash, :detect_mime_type
54
+
55
+ def initialize(url: nil, base64: nil, mime_type: nil)
56
+ @url = url
57
+ @base64 = base64
58
+ @mime_type = mime_type
59
+ end
60
+
61
+ def url?
62
+ !@url.nil?
63
+ end
64
+
65
+ def base64?
66
+ !@base64.nil?
67
+ end
68
+ end
69
+ end
@@ -77,6 +77,21 @@ module Imago
77
77
  else "API error: #{response.body}"
78
78
  end
79
79
  end
80
+
81
+ def normalize_images(images)
82
+ return [] if images.nil? || images.empty?
83
+
84
+ images.map { |img| ImageInput.from(img) }
85
+ end
86
+
87
+ def validate_image_count!(images, max:)
88
+ return if images.nil? || images.length <= max
89
+
90
+ raise InvalidRequestError.new(
91
+ "Too many images: #{images.length} provided, maximum is #{max}",
92
+ status_code: 400
93
+ )
94
+ end
80
95
  end
81
96
  end
82
97
  end
@@ -4,6 +4,7 @@ module Imago
4
4
  module Providers
5
5
  class Gemini < Base
6
6
  BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'
7
+ MAX_IMAGES = 10
7
8
 
8
9
  KNOWN_IMAGE_MODELS = %w[
9
10
  imagen-3.0-generate-002
@@ -14,6 +15,7 @@ module Imago
14
15
  ].freeze
15
16
 
16
17
  def generate(prompt, opts = {})
18
+ validate_image_count!(opts[:images], max: MAX_IMAGES)
17
19
  conn = connection(BASE_URL)
18
20
  endpoint = "models/#{model}:generateContent"
19
21
 
@@ -42,11 +44,26 @@ module Imago
42
44
  private
43
45
 
44
46
  def build_request_body(prompt, opts)
45
- body = { contents: [{ parts: [{ text: build_prompt(prompt, opts) }] }] }
47
+ body = { contents: [{ parts: build_parts(prompt, opts) }] }
46
48
  body[:generationConfig] = build_generation_config(opts) if generation_config_present?(opts)
47
49
  body
48
50
  end
49
51
 
52
+ def build_parts(prompt, opts)
53
+ parts = [{ text: build_prompt(prompt, opts) }]
54
+ images = normalize_images(opts[:images])
55
+ images.each { |img| parts << build_image_part(img) }
56
+ parts
57
+ end
58
+
59
+ def build_image_part(image)
60
+ if image.url?
61
+ { fileData: { fileUri: image.url, mimeType: image.mime_type }.compact }
62
+ else
63
+ { inlineData: { data: image.base64, mimeType: image.mime_type } }
64
+ end
65
+ end
66
+
50
67
  def build_prompt(prompt, opts)
51
68
  return prompt unless opts[:negative_prompt]
52
69
 
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'faraday/multipart'
4
+ require 'base64'
5
+
3
6
  module Imago
4
7
  module Providers
5
8
  class OpenAI < Base
6
9
  BASE_URL = 'https://api.openai.com/v1'
10
+ MAX_IMAGES = 16
7
11
 
8
12
  KNOWN_IMAGE_MODELS = %w[
9
13
  dall-e-3
@@ -13,14 +17,20 @@ module Imago
13
17
  gpt-image-1-mini
14
18
  ].freeze
15
19
 
20
+ MODELS_SUPPORTING_IMAGE_INPUT = %w[
21
+ dall-e-2
22
+ gpt-image-1
23
+ gpt-image-1.5
24
+ gpt-image-1-mini
25
+ ].freeze
26
+
16
27
  def generate(prompt, opts = {})
17
- conn = connection(BASE_URL)
18
- response = conn.post('images/generations') do |req|
19
- req.headers['Authorization'] = "Bearer #{api_key}"
20
- req.body = build_request_body(prompt, opts)
28
+ images = opts[:images]
29
+ if images && !images.empty?
30
+ generate_with_images(prompt, opts)
31
+ else
32
+ generate_text_only(prompt, opts)
21
33
  end
22
-
23
- parse_generate_response(handle_response(response))
24
34
  end
25
35
 
26
36
  def models
@@ -39,6 +49,73 @@ module Imago
39
49
 
40
50
  private
41
51
 
52
+ def generate_text_only(prompt, opts)
53
+ conn = connection(BASE_URL)
54
+ response = conn.post('images/generations') do |req|
55
+ req.headers['Authorization'] = "Bearer #{api_key}"
56
+ req.body = build_request_body(prompt, opts)
57
+ end
58
+
59
+ parse_generate_response(handle_response(response))
60
+ end
61
+
62
+ def generate_with_images(prompt, opts)
63
+ validate_model_supports_images!
64
+ validate_image_count!(opts[:images], max: MAX_IMAGES)
65
+
66
+ images = normalize_images(opts[:images])
67
+ conn = multipart_connection(BASE_URL)
68
+
69
+ response = conn.post('images/edits') do |req|
70
+ req.headers['Authorization'] = "Bearer #{api_key}"
71
+ req.body = build_multipart_body(prompt, images, opts)
72
+ end
73
+
74
+ parse_generate_response(handle_response(response))
75
+ end
76
+
77
+ def validate_model_supports_images!
78
+ return if MODELS_SUPPORTING_IMAGE_INPUT.include?(model)
79
+
80
+ supported = MODELS_SUPPORTING_IMAGE_INPUT.join(', ')
81
+ raise InvalidRequestError.new(
82
+ "Model '#{model}' does not support image inputs. Supported models: #{supported}",
83
+ status_code: 400
84
+ )
85
+ end
86
+
87
+ def multipart_connection(base_url)
88
+ Faraday.new(url: base_url) do |conn|
89
+ conn.request :multipart
90
+ conn.response :json
91
+ conn.adapter Faraday.default_adapter
92
+ end
93
+ end
94
+
95
+ def build_multipart_body(prompt, images, opts)
96
+ body = {
97
+ model: model,
98
+ prompt: prompt
99
+ }
100
+
101
+ images.each_with_index do |image, index|
102
+ body["image[#{index}]"] = build_image_part(image)
103
+ end
104
+
105
+ clean_opts = opts.except(:images)
106
+ body.merge(clean_opts)
107
+ end
108
+
109
+ def build_image_part(image)
110
+ if image.url?
111
+ image.url
112
+ else
113
+ io = StringIO.new(Base64.decode64(image.base64))
114
+ extension = image.mime_type&.split('/')&.last || 'png'
115
+ Faraday::Multipart::FilePart.new(io, image.mime_type, "image.#{extension}")
116
+ end
117
+ end
118
+
42
119
  def build_request_body(prompt, opts)
43
120
  {
44
121
  model: model,
@@ -11,6 +11,8 @@ module Imago
11
11
  ].freeze
12
12
 
13
13
  def generate(prompt, opts = {})
14
+ raise_if_images_provided(opts)
15
+
14
16
  conn = connection(BASE_URL)
15
17
  response = conn.post('images/generations') do |req|
16
18
  req.headers['Authorization'] = "Bearer #{api_key}"
@@ -58,6 +60,17 @@ module Imago
58
60
  created: body['created']
59
61
  }
60
62
  end
63
+
64
+ def raise_if_images_provided(opts)
65
+ return unless opts[:images] && !opts[:images].empty?
66
+
67
+ raise UnsupportedFeatureError.new(
68
+ 'xAI does not currently support image inputs. ' \
69
+ 'Image-to-image generation may be available in future API versions.',
70
+ provider: :xai,
71
+ feature: :image_input
72
+ )
73
+ end
61
74
  end
62
75
  end
63
76
  end
data/lib/imago/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Imago
4
- VERSION = '0.1.2'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/imago.rb CHANGED
@@ -5,6 +5,7 @@ require 'json'
5
5
 
6
6
  require_relative 'imago/version'
7
7
  require_relative 'imago/errors'
8
+ require_relative 'imago/image_input'
8
9
  require_relative 'imago/providers/base'
9
10
  require_relative 'imago/providers/openai'
10
11
  require_relative 'imago/providers/gemini'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: imago
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - NEETzsche
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: faraday
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -53,6 +67,7 @@ files:
53
67
  - lib/imago.rb
54
68
  - lib/imago/client.rb
55
69
  - lib/imago/errors.rb
70
+ - lib/imago/image_input.rb
56
71
  - lib/imago/providers/base.rb
57
72
  - lib/imago/providers/gemini.rb
58
73
  - lib/imago/providers/openai.rb
@@ -80,7 +95,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
80
95
  - !ruby/object:Gem::Version
81
96
  version: '0'
82
97
  requirements: []
83
- rubygems_version: 4.0.3
98
+ rubygems_version: 4.0.4
84
99
  specification_version: 4
85
100
  summary: A unified Ruby interface for multiple image generation AI providers
86
101
  test_files: []