imago 0.1.1 → 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 +4 -4
- data/.rubocop.yml +4 -0
- data/README.md +42 -0
- data/imago.gemspec +3 -2
- data/lib/imago/errors.rb +10 -0
- data/lib/imago/image_input.rb +69 -0
- data/lib/imago/providers/base.rb +15 -0
- data/lib/imago/providers/gemini.rb +18 -1
- data/lib/imago/providers/openai.rb +83 -6
- data/lib/imago/providers/xai.rb +13 -0
- data/lib/imago/version.rb +1 -1
- data/lib/imago.rb +1 -0
- metadata +19 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d295fddaf05348d473874c6f08e10b93937f27f3e4b228b68ad33e96073fde17
|
|
4
|
+
data.tar.gz: 88d463fefbb56a175ffce52504374615ddbdb61b15877a05a01ee0e7f75432c8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 77e736f2487e31b1fc933fb4a174b8b67a0406116be1335a270890560a09a983418f2c7c368aa10d7489f51e745e9c5c27a9b9c238e82fe6314e0bd1b3d5713b
|
|
7
|
+
data.tar.gz: bb9c1ecf14c3f911f0869c8247f80297b575a57febd299cfd3b01063dd790cae866fcc8ec18d3dbe37c4e35320ea72138a210191e7057f240416d49e8748ae81
|
data/.rubocop.yml
CHANGED
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
|
@@ -5,8 +5,8 @@ require_relative 'lib/imago/version'
|
|
|
5
5
|
Gem::Specification.new do |spec|
|
|
6
6
|
spec.name = 'imago'
|
|
7
7
|
spec.version = Imago::VERSION
|
|
8
|
-
spec.authors = ['
|
|
9
|
-
spec.email = ['
|
|
8
|
+
spec.authors = ['NEETzsche']
|
|
9
|
+
spec.email = ['thestranjer@protonmail.com']
|
|
10
10
|
|
|
11
11
|
spec.summary = 'A unified Ruby interface for multiple image generation AI providers'
|
|
12
12
|
spec.description = 'Imago provides a simple, unified API to generate images using various AI providers ' \
|
|
@@ -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
|
data/lib/imago/providers/base.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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,
|
data/lib/imago/providers/xai.rb
CHANGED
|
@@ -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
data/lib/imago.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: imago
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- NEETzsche
|
|
8
8
|
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
|
|
@@ -40,7 +54,7 @@ dependencies:
|
|
|
40
54
|
description: Imago provides a simple, unified API to generate images using various
|
|
41
55
|
AI providers including OpenAI (DALL-E), Google Gemini, and xAI (Grok).
|
|
42
56
|
email:
|
|
43
|
-
-
|
|
57
|
+
- thestranjer@protonmail.com
|
|
44
58
|
executables: []
|
|
45
59
|
extensions: []
|
|
46
60
|
extra_rdoc_files: []
|
|
@@ -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.
|
|
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: []
|