imago 0.1.2 → 0.2.1
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 +17 -0
- data/README.md +42 -0
- data/imago.gemspec +1 -0
- data/lib/imago/errors.rb +10 -0
- data/lib/imago/gemini_response_parser.rb +24 -0
- data/lib/imago/image_input.rb +74 -0
- data/lib/imago/multipart_builder.rb +43 -0
- data/lib/imago/providers/base.rb +15 -0
- data/lib/imago/providers/gemini.rb +58 -43
- data/lib/imago/providers/openai.rb +74 -32
- data/lib/imago/providers/xai.rb +29 -16
- data/lib/imago/version.rb +1 -1
- data/lib/imago.rb +3 -0
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 417e5a3f6b7a3d41cee0ce17ec1cc225862fcc72c2448fc1c9b1da989509afd3
|
|
4
|
+
data.tar.gz: feca98a16c48e79f386b88626d7fa5934be4637910ae250a91bf036f79a2865b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb62ccf9dcb695a401f08674d68ba37e7b3f2e8cdb7ae5820735d987aa41839f95a2fd5152533016fd8a6b704fd43df26ebf9e18d8b4f64181ed4bfbea3a4a1e
|
|
7
|
+
data.tar.gz: da94d8fbd02b78cce3ab04c567fa90881752670973925254c77ed724a645c9974dbb635b810e70ebe7182e5f5b658ae83f29590adaa867844760c5501cb70027
|
data/.rubocop.yml
CHANGED
|
@@ -44,5 +44,22 @@ RSpec/NestedGroups:
|
|
|
44
44
|
Layout/LineLength:
|
|
45
45
|
Max: 120
|
|
46
46
|
|
|
47
|
+
Metrics/ClassLength:
|
|
48
|
+
Max: 100
|
|
49
|
+
|
|
50
|
+
Metrics/MethodLength:
|
|
51
|
+
Max: 25
|
|
52
|
+
|
|
53
|
+
Metrics/AbcSize:
|
|
54
|
+
Max: 10
|
|
55
|
+
Exclude:
|
|
56
|
+
- 'spec/**/*'
|
|
57
|
+
|
|
58
|
+
Metrics/CyclomaticComplexity:
|
|
59
|
+
Max: 10
|
|
60
|
+
|
|
61
|
+
Metrics/PerceivedComplexity:
|
|
62
|
+
Max: 10
|
|
63
|
+
|
|
47
64
|
RSpec/MessageSpies:
|
|
48
65
|
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,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Imago
|
|
4
|
+
class GeminiResponseParser
|
|
5
|
+
def parse(body)
|
|
6
|
+
candidates = body['candidates'] || []
|
|
7
|
+
images = candidates.flat_map { |candidate| extract_images(candidate) }
|
|
8
|
+
{ images: images }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def extract_images(candidate)
|
|
14
|
+
parts = candidate.dig('content', 'parts') || []
|
|
15
|
+
parts.filter_map { |part| parse_image_part(part) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse_image_part(part)
|
|
19
|
+
return unless part['inlineData']
|
|
20
|
+
|
|
21
|
+
{ base64: part['inlineData']['data'], mime_type: part['inlineData']['mimeType'] }.compact
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
return from_base64_hash(hash) if hash[:base64]
|
|
35
|
+
return from_url_hash(hash) if hash[:url]
|
|
36
|
+
|
|
37
|
+
raise ArgumentError, 'Image hash must contain either :url or :base64 key'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.from_base64_hash(hash)
|
|
41
|
+
raise ArgumentError, 'mime_type is required for base64 images' unless hash[:mime_type]
|
|
42
|
+
|
|
43
|
+
new(base64: hash[:base64], mime_type: hash[:mime_type])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.from_url_hash(hash)
|
|
47
|
+
mime_type = hash[:mime_type] || detect_mime_type(hash[:url])
|
|
48
|
+
new(url: hash[:url], mime_type: mime_type)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.detect_mime_type(url)
|
|
52
|
+
extension = File.extname(URI.parse(url).path).delete('.').downcase
|
|
53
|
+
MIME_TYPES[extension]
|
|
54
|
+
rescue URI::InvalidURIError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private_class_method :from_url_string, :from_hash, :from_base64_hash, :from_url_hash, :detect_mime_type
|
|
59
|
+
|
|
60
|
+
def initialize(url: nil, base64: nil, mime_type: nil)
|
|
61
|
+
@url = url
|
|
62
|
+
@base64 = base64
|
|
63
|
+
@mime_type = mime_type
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def url?
|
|
67
|
+
!@url.nil?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def base64?
|
|
71
|
+
!@base64.nil?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
|
|
5
|
+
module Imago
|
|
6
|
+
class MultipartBuilder
|
|
7
|
+
def initialize(model)
|
|
8
|
+
@model = model
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def build_body(prompt, images, opts)
|
|
12
|
+
body = base_body(prompt)
|
|
13
|
+
add_images(body, images)
|
|
14
|
+
body.merge(opts.except(:images))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def build_image_part(image)
|
|
18
|
+
image.url? ? image.url : build_file_part(image)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def base_body(prompt)
|
|
24
|
+
{ model: @model, prompt: prompt }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_images(body, images)
|
|
28
|
+
images.each_with_index do |image, index|
|
|
29
|
+
body["image[#{index}]"] = build_image_part(image)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def build_file_part(image)
|
|
34
|
+
io = StringIO.new(Base64.decode64(image.base64))
|
|
35
|
+
extension = extract_extension(image.mime_type)
|
|
36
|
+
Faraday::Multipart::FilePart.new(io, image.mime_type, "image.#{extension}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def extract_extension(mime_type)
|
|
40
|
+
mime_type&.split('/')&.last || 'png'
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
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,15 +15,9 @@ module Imago
|
|
|
14
15
|
].freeze
|
|
15
16
|
|
|
16
17
|
def generate(prompt, opts = {})
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
response = conn.post(endpoint) do |req|
|
|
21
|
-
req.params['key'] = api_key
|
|
22
|
-
req.body = build_request_body(prompt, opts)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
parse_generate_response(handle_response(response))
|
|
18
|
+
validate_image_count!(opts[:images], max: MAX_IMAGES)
|
|
19
|
+
response = execute_generate_request(prompt, opts)
|
|
20
|
+
response_parser.parse(handle_response(response))
|
|
26
21
|
end
|
|
27
22
|
|
|
28
23
|
def models
|
|
@@ -41,64 +36,84 @@ module Imago
|
|
|
41
36
|
|
|
42
37
|
private
|
|
43
38
|
|
|
39
|
+
def response_parser
|
|
40
|
+
@response_parser ||= Imago::GeminiResponseParser.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def execute_generate_request(prompt, opts)
|
|
44
|
+
conn = connection(BASE_URL)
|
|
45
|
+
body = build_request_body(prompt, opts)
|
|
46
|
+
conn.post(generate_endpoint) { |req| configure_request(req, body) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def generate_endpoint
|
|
50
|
+
"models/#{model}:generateContent"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def configure_request(req, body)
|
|
54
|
+
req.params['key'] = api_key
|
|
55
|
+
req.body = body
|
|
56
|
+
end
|
|
57
|
+
|
|
44
58
|
def build_request_body(prompt, opts)
|
|
45
|
-
body = { contents: [{ parts:
|
|
59
|
+
body = { contents: [{ parts: build_parts(prompt, opts) }] }
|
|
46
60
|
body[:generationConfig] = build_generation_config(opts) if generation_config_present?(opts)
|
|
47
61
|
body
|
|
48
62
|
end
|
|
49
63
|
|
|
50
|
-
def
|
|
51
|
-
|
|
64
|
+
def build_parts(prompt, opts)
|
|
65
|
+
parts = [{ text: build_prompt(prompt, opts) }]
|
|
66
|
+
normalize_images(opts[:images]).each { |img| parts << build_image_part(img) }
|
|
67
|
+
parts
|
|
68
|
+
end
|
|
52
69
|
|
|
53
|
-
|
|
70
|
+
def build_image_part(image)
|
|
71
|
+
image.url? ? build_file_data(image) : build_inline_data(image)
|
|
54
72
|
end
|
|
55
73
|
|
|
56
|
-
def
|
|
57
|
-
|
|
74
|
+
def build_file_data(image)
|
|
75
|
+
{ fileData: { fileUri: image.url, mimeType: image.mime_type }.compact }
|
|
58
76
|
end
|
|
59
77
|
|
|
60
|
-
def
|
|
61
|
-
|
|
62
|
-
config[:candidateCount] = opts[:sample_count] || opts[:n] if opts[:n] || opts[:sample_count]
|
|
63
|
-
config[:seed] = opts[:seed] if opts[:seed]
|
|
64
|
-
config[:aspectRatio] = opts[:aspect_ratio] if opts[:aspect_ratio]
|
|
65
|
-
config
|
|
78
|
+
def build_inline_data(image)
|
|
79
|
+
{ inlineData: { data: image.base64, mimeType: image.mime_type } }
|
|
66
80
|
end
|
|
67
81
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
images = candidates.flat_map { |candidate| extract_images_from_candidate(candidate) }
|
|
71
|
-
{ images: images }
|
|
82
|
+
def build_prompt(prompt, opts)
|
|
83
|
+
opts[:negative_prompt] ? "#{prompt}. Avoid: #{opts[:negative_prompt]}" : prompt
|
|
72
84
|
end
|
|
73
85
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
next unless part['inlineData']
|
|
86
|
+
def generation_config_present?(opts)
|
|
87
|
+
opts[:n] || opts[:sample_count] || opts[:aspect_ratio] || opts[:seed]
|
|
88
|
+
end
|
|
78
89
|
|
|
79
|
-
|
|
80
|
-
|
|
90
|
+
def build_generation_config(opts)
|
|
91
|
+
{ candidateCount: opts[:sample_count] || opts[:n], seed: opts[:seed], aspectRatio: opts[:aspect_ratio] }.compact
|
|
81
92
|
end
|
|
82
93
|
|
|
83
94
|
def fetch_models
|
|
84
|
-
|
|
85
|
-
response
|
|
86
|
-
req.params['key'] = api_key
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
body = handle_response(response)
|
|
90
|
-
filter_image_models(body['models'] || [])
|
|
95
|
+
response = connection(BASE_URL).get('models') { |req| req.params['key'] = api_key }
|
|
96
|
+
filter_image_models(handle_response(response)['models'] || [])
|
|
91
97
|
rescue ApiError
|
|
92
98
|
KNOWN_IMAGE_MODELS
|
|
93
99
|
end
|
|
94
100
|
|
|
95
101
|
def filter_image_models(models)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
names = extract_image_model_names(models)
|
|
103
|
+
names.empty? ? KNOWN_IMAGE_MODELS : names
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def extract_image_model_names(models)
|
|
107
|
+
content_models = models.select { |m| supports_generate_content?(m) }
|
|
108
|
+
content_models.map { |m| m['name'].sub('models/', '') }.select { |n| image_model?(n) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def supports_generate_content?(model)
|
|
112
|
+
model['supportedGenerationMethods']&.include?('generateContent')
|
|
113
|
+
end
|
|
100
114
|
|
|
101
|
-
|
|
115
|
+
def image_model?(name)
|
|
116
|
+
name.include?('imagen') || name.include?('image')
|
|
102
117
|
end
|
|
103
118
|
end
|
|
104
119
|
end
|
|
@@ -1,26 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'faraday/multipart'
|
|
4
|
+
|
|
3
5
|
module Imago
|
|
4
6
|
module Providers
|
|
5
7
|
class OpenAI < Base
|
|
6
8
|
BASE_URL = 'https://api.openai.com/v1'
|
|
9
|
+
MAX_IMAGES = 16
|
|
7
10
|
|
|
8
|
-
KNOWN_IMAGE_MODELS = %w[
|
|
9
|
-
|
|
10
|
-
dall-e-2
|
|
11
|
-
gpt-image-1
|
|
12
|
-
gpt-image-1.5
|
|
13
|
-
gpt-image-1-mini
|
|
14
|
-
].freeze
|
|
11
|
+
KNOWN_IMAGE_MODELS = %w[dall-e-3 dall-e-2 gpt-image-1 gpt-image-1.5 gpt-image-1-mini].freeze
|
|
12
|
+
MODELS_SUPPORTING_IMAGE_INPUT = %w[dall-e-2 gpt-image-1 gpt-image-1.5 gpt-image-1-mini].freeze
|
|
15
13
|
|
|
16
14
|
def generate(prompt, opts = {})
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
req.headers['Authorization'] = "Bearer #{api_key}"
|
|
20
|
-
req.body = build_request_body(prompt, opts)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
parse_generate_response(handle_response(response))
|
|
15
|
+
has_images = opts[:images] && !opts[:images].empty?
|
|
16
|
+
has_images ? generate_with_images(prompt, opts) : generate_text_only(prompt, opts)
|
|
24
17
|
end
|
|
25
18
|
|
|
26
19
|
def models
|
|
@@ -39,40 +32,89 @@ module Imago
|
|
|
39
32
|
|
|
40
33
|
private
|
|
41
34
|
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
35
|
+
def generate_text_only(prompt, opts)
|
|
36
|
+
response = post_with_auth('images/generations', build_request_body(prompt, opts))
|
|
37
|
+
parse_response(response)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def generate_with_images(prompt, opts)
|
|
41
|
+
validate_model_supports_images!
|
|
42
|
+
validate_image_count!(opts[:images], max: MAX_IMAGES)
|
|
43
|
+
response = post_multipart_edit(prompt, opts)
|
|
44
|
+
parse_response(response)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def post_with_auth(endpoint, body)
|
|
48
|
+
connection(BASE_URL).post(endpoint) do |req|
|
|
49
|
+
req.headers['Authorization'] = auth_header
|
|
50
|
+
req.body = body
|
|
51
|
+
end
|
|
47
52
|
end
|
|
48
53
|
|
|
49
|
-
def
|
|
54
|
+
def post_multipart_edit(prompt, opts)
|
|
55
|
+
body = build_multipart_body(prompt, opts)
|
|
56
|
+
multipart_connection.post('images/edits') { |req| configure_auth_request(req, body) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_multipart_body(prompt, opts)
|
|
60
|
+
images = normalize_images(opts[:images])
|
|
61
|
+
multipart_builder.build_body(prompt, images, opts)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def configure_auth_request(req, body)
|
|
65
|
+
req.headers['Authorization'] = auth_header
|
|
66
|
+
req.body = body
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_response(response)
|
|
70
|
+
body = handle_response(response)
|
|
50
71
|
images = body['data']&.map { |img| parse_image(img) }
|
|
51
72
|
{ images: images || [], created: body['created'] }
|
|
52
73
|
end
|
|
53
74
|
|
|
75
|
+
def auth_header
|
|
76
|
+
"Bearer #{api_key}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def multipart_builder
|
|
80
|
+
@multipart_builder ||= Imago::MultipartBuilder.new(model)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def multipart_connection
|
|
84
|
+
@multipart_connection ||= Faraday.new(url: BASE_URL) do |conn|
|
|
85
|
+
conn.request :multipart
|
|
86
|
+
conn.response :json
|
|
87
|
+
conn.adapter Faraday.default_adapter
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_model_supports_images!
|
|
92
|
+
return if MODELS_SUPPORTING_IMAGE_INPUT.include?(model)
|
|
93
|
+
|
|
94
|
+
raise InvalidRequestError.new(
|
|
95
|
+
"Model '#{model}' does not support image inputs. Supported: #{MODELS_SUPPORTING_IMAGE_INPUT.join(', ')}",
|
|
96
|
+
status_code: 400
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_request_body(prompt, opts)
|
|
101
|
+
{ model: model, prompt: prompt }.merge(opts)
|
|
102
|
+
end
|
|
103
|
+
|
|
54
104
|
def parse_image(img)
|
|
55
105
|
{ url: img['url'], base64: img['b64_json'], revised_prompt: img['revised_prompt'] }.compact
|
|
56
106
|
end
|
|
57
107
|
|
|
58
108
|
def fetch_models
|
|
59
|
-
|
|
60
|
-
response
|
|
61
|
-
req.headers['Authorization'] = "Bearer #{api_key}"
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
body = handle_response(response)
|
|
65
|
-
filter_image_models(body['data'] || [])
|
|
109
|
+
response = connection(BASE_URL).get('models') { |req| req.headers['Authorization'] = auth_header }
|
|
110
|
+
filter_image_models(handle_response(response)['data'] || [])
|
|
66
111
|
rescue ApiError
|
|
67
112
|
KNOWN_IMAGE_MODELS
|
|
68
113
|
end
|
|
69
114
|
|
|
70
115
|
def filter_image_models(models)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.select { |id| id.include?('dall-e') || id.include?('image') }
|
|
74
|
-
|
|
75
|
-
image_model_ids.empty? ? KNOWN_IMAGE_MODELS : image_model_ids
|
|
116
|
+
ids = models.map { |m| m['id'] }.select { |id| id.include?('dall-e') || id.include?('image') }
|
|
117
|
+
ids.empty? ? KNOWN_IMAGE_MODELS : ids
|
|
76
118
|
end
|
|
77
119
|
end
|
|
78
120
|
end
|
data/lib/imago/providers/xai.rb
CHANGED
|
@@ -11,12 +11,8 @@ module Imago
|
|
|
11
11
|
].freeze
|
|
12
12
|
|
|
13
13
|
def generate(prompt, opts = {})
|
|
14
|
-
|
|
15
|
-
response =
|
|
16
|
-
req.headers['Authorization'] = "Bearer #{api_key}"
|
|
17
|
-
req.body = build_request_body(prompt, opts)
|
|
18
|
-
end
|
|
19
|
-
|
|
14
|
+
raise_if_images_provided(opts)
|
|
15
|
+
response = execute_generate_request(prompt, opts)
|
|
20
16
|
parse_generate_response(handle_response(response))
|
|
21
17
|
end
|
|
22
18
|
|
|
@@ -36,6 +32,21 @@ module Imago
|
|
|
36
32
|
|
|
37
33
|
private
|
|
38
34
|
|
|
35
|
+
def execute_generate_request(prompt, opts)
|
|
36
|
+
conn = connection(BASE_URL)
|
|
37
|
+
body = build_request_body(prompt, opts)
|
|
38
|
+
conn.post('images/generations') { |req| configure_request(req, body) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def configure_request(req, body)
|
|
42
|
+
req.headers['Authorization'] = auth_header
|
|
43
|
+
req.body = body
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def auth_header
|
|
47
|
+
"Bearer #{api_key}"
|
|
48
|
+
end
|
|
49
|
+
|
|
39
50
|
def build_request_body(prompt, opts)
|
|
40
51
|
{
|
|
41
52
|
model: model,
|
|
@@ -46,17 +57,19 @@ module Imago
|
|
|
46
57
|
end
|
|
47
58
|
|
|
48
59
|
def parse_generate_response(body)
|
|
49
|
-
images = body['data']&.map
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
base64: img['b64_json']
|
|
53
|
-
}.compact
|
|
54
|
-
end
|
|
60
|
+
images = body['data']&.map { |img| { url: img['url'], base64: img['b64_json'] }.compact }
|
|
61
|
+
{ images: images || [], created: body['created'] }
|
|
62
|
+
end
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
)
|
|
60
73
|
end
|
|
61
74
|
end
|
|
62
75
|
end
|
data/lib/imago/version.rb
CHANGED
data/lib/imago.rb
CHANGED
|
@@ -5,6 +5,9 @@ require 'json'
|
|
|
5
5
|
|
|
6
6
|
require_relative 'imago/version'
|
|
7
7
|
require_relative 'imago/errors'
|
|
8
|
+
require_relative 'imago/image_input'
|
|
9
|
+
require_relative 'imago/multipart_builder'
|
|
10
|
+
require_relative 'imago/gemini_response_parser'
|
|
8
11
|
require_relative 'imago/providers/base'
|
|
9
12
|
require_relative 'imago/providers/openai'
|
|
10
13
|
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
|
|
4
|
+
version: 0.2.1
|
|
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,9 @@ files:
|
|
|
53
67
|
- lib/imago.rb
|
|
54
68
|
- lib/imago/client.rb
|
|
55
69
|
- lib/imago/errors.rb
|
|
70
|
+
- lib/imago/gemini_response_parser.rb
|
|
71
|
+
- lib/imago/image_input.rb
|
|
72
|
+
- lib/imago/multipart_builder.rb
|
|
56
73
|
- lib/imago/providers/base.rb
|
|
57
74
|
- lib/imago/providers/gemini.rb
|
|
58
75
|
- lib/imago/providers/openai.rb
|
|
@@ -80,7 +97,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
80
97
|
- !ruby/object:Gem::Version
|
|
81
98
|
version: '0'
|
|
82
99
|
requirements: []
|
|
83
|
-
rubygems_version: 4.0.
|
|
100
|
+
rubygems_version: 4.0.4
|
|
84
101
|
specification_version: 4
|
|
85
102
|
summary: A unified Ruby interface for multiple image generation AI providers
|
|
86
103
|
test_files: []
|