imago 0.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d295fddaf05348d473874c6f08e10b93937f27f3e4b228b68ad33e96073fde17
4
- data.tar.gz: 88d463fefbb56a175ffce52504374615ddbdb61b15877a05a01ee0e7f75432c8
3
+ metadata.gz: 417e5a3f6b7a3d41cee0ce17ec1cc225862fcc72c2448fc1c9b1da989509afd3
4
+ data.tar.gz: feca98a16c48e79f386b88626d7fa5934be4637910ae250a91bf036f79a2865b
5
5
  SHA512:
6
- metadata.gz: 77e736f2487e31b1fc933fb4a174b8b67a0406116be1335a270890560a09a983418f2c7c368aa10d7489f51e745e9c5c27a9b9c238e82fe6314e0bd1b3d5713b
7
- data.tar.gz: bb9c1ecf14c3f911f0869c8247f80297b575a57febd299cfd3b01063dd790cae866fcc8ec18d3dbe37c4e35320ea72138a210191e7057f240416d49e8748ae81
6
+ metadata.gz: bb62ccf9dcb695a401f08674d68ba37e7b3f2e8cdb7ae5820735d987aa41839f95a2fd5152533016fd8a6b704fd43df26ebf9e18d8b4f64181ed4bfbea3a4a1e
7
+ data.tar.gz: da94d8fbd02b78cce3ab04c567fa90881752670973925254c77ed724a645c9974dbb635b810e70ebe7182e5f5b658ae83f29590adaa867844760c5501cb70027
data/.rubocop.yml CHANGED
@@ -45,8 +45,21 @@ Layout/LineLength:
45
45
  Max: 120
46
46
 
47
47
  Metrics/ClassLength:
48
+ Max: 100
49
+
50
+ Metrics/MethodLength:
51
+ Max: 25
52
+
53
+ Metrics/AbcSize:
54
+ Max: 10
48
55
  Exclude:
49
- - 'lib/imago/providers/openai.rb'
56
+ - 'spec/**/*'
57
+
58
+ Metrics/CyclomaticComplexity:
59
+ Max: 10
60
+
61
+ Metrics/PerceivedComplexity:
62
+ Max: 10
50
63
 
51
64
  RSpec/MessageSpies:
52
65
  EnforcedStyle: receive
@@ -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
@@ -31,16 +31,21 @@ module Imago
31
31
  def self.from_hash(hash)
32
32
  hash = hash.transform_keys(&:to_sym)
33
33
 
34
- if hash[:base64]
35
- raise ArgumentError, 'mime_type is required for base64 images' unless hash[:mime_type]
34
+ return from_base64_hash(hash) if hash[:base64]
35
+ return from_url_hash(hash) if hash[:url]
36
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
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)
44
49
  end
45
50
 
46
51
  def self.detect_mime_type(url)
@@ -50,7 +55,7 @@ module Imago
50
55
  nil
51
56
  end
52
57
 
53
- private_class_method :from_url_string, :from_hash, :detect_mime_type
58
+ private_class_method :from_url_string, :from_hash, :from_base64_hash, :from_url_hash, :detect_mime_type
54
59
 
55
60
  def initialize(url: nil, base64: nil, mime_type: nil)
56
61
  @url = url
@@ -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
@@ -16,15 +16,8 @@ module Imago
16
16
 
17
17
  def generate(prompt, opts = {})
18
18
  validate_image_count!(opts[:images], max: MAX_IMAGES)
19
- conn = connection(BASE_URL)
20
- endpoint = "models/#{model}:generateContent"
21
-
22
- response = conn.post(endpoint) do |req|
23
- req.params['key'] = api_key
24
- req.body = build_request_body(prompt, opts)
25
- end
26
-
27
- parse_generate_response(handle_response(response))
19
+ response = execute_generate_request(prompt, opts)
20
+ response_parser.parse(handle_response(response))
28
21
  end
29
22
 
30
23
  def models
@@ -43,6 +36,25 @@ module Imago
43
36
 
44
37
  private
45
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
+
46
58
  def build_request_body(prompt, opts)
47
59
  body = { contents: [{ parts: build_parts(prompt, opts) }] }
48
60
  body[:generationConfig] = build_generation_config(opts) if generation_config_present?(opts)
@@ -51,71 +63,57 @@ module Imago
51
63
 
52
64
  def build_parts(prompt, opts)
53
65
  parts = [{ text: build_prompt(prompt, opts) }]
54
- images = normalize_images(opts[:images])
55
- images.each { |img| parts << build_image_part(img) }
66
+ normalize_images(opts[:images]).each { |img| parts << build_image_part(img) }
56
67
  parts
57
68
  end
58
69
 
59
70
  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
71
+ image.url? ? build_file_data(image) : build_inline_data(image)
65
72
  end
66
73
 
67
- def build_prompt(prompt, opts)
68
- return prompt unless opts[:negative_prompt]
69
-
70
- "#{prompt}. Avoid: #{opts[:negative_prompt]}"
74
+ def build_file_data(image)
75
+ { fileData: { fileUri: image.url, mimeType: image.mime_type }.compact }
71
76
  end
72
77
 
73
- def generation_config_present?(opts)
74
- opts[:n] || opts[:sample_count] || opts[:aspect_ratio] || opts[:seed]
78
+ def build_inline_data(image)
79
+ { inlineData: { data: image.base64, mimeType: image.mime_type } }
75
80
  end
76
81
 
77
- def build_generation_config(opts)
78
- config = {}
79
- config[:candidateCount] = opts[:sample_count] || opts[:n] if opts[:n] || opts[:sample_count]
80
- config[:seed] = opts[:seed] if opts[:seed]
81
- config[:aspectRatio] = opts[:aspect_ratio] if opts[:aspect_ratio]
82
- config
82
+ def build_prompt(prompt, opts)
83
+ opts[:negative_prompt] ? "#{prompt}. Avoid: #{opts[:negative_prompt]}" : prompt
83
84
  end
84
85
 
85
- def parse_generate_response(body)
86
- candidates = body['candidates'] || []
87
- images = candidates.flat_map { |candidate| extract_images_from_candidate(candidate) }
88
- { images: images }
86
+ def generation_config_present?(opts)
87
+ opts[:n] || opts[:sample_count] || opts[:aspect_ratio] || opts[:seed]
89
88
  end
90
89
 
91
- def extract_images_from_candidate(candidate)
92
- parts = candidate.dig('content', 'parts') || []
93
- parts.filter_map do |part|
94
- next unless part['inlineData']
95
-
96
- { base64: part['inlineData']['data'], mime_type: part['inlineData']['mimeType'] }.compact
97
- end
90
+ def build_generation_config(opts)
91
+ { candidateCount: opts[:sample_count] || opts[:n], seed: opts[:seed], aspectRatio: opts[:aspect_ratio] }.compact
98
92
  end
99
93
 
100
94
  def fetch_models
101
- conn = connection(BASE_URL)
102
- response = conn.get('models') do |req|
103
- req.params['key'] = api_key
104
- end
105
-
106
- body = handle_response(response)
107
- 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'] || [])
108
97
  rescue ApiError
109
98
  KNOWN_IMAGE_MODELS
110
99
  end
111
100
 
112
101
  def filter_image_models(models)
113
- image_model_names = models
114
- .select { |m| m['supportedGenerationMethods']&.include?('generateContent') }
115
- .map { |m| m['name'].sub('models/', '') }
116
- .select { |name| name.include?('imagen') || name.include?('image') }
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
117
114
 
118
- image_model_names.empty? ? KNOWN_IMAGE_MODELS : image_model_names
115
+ def image_model?(name)
116
+ name.include?('imagen') || name.include?('image')
119
117
  end
120
118
  end
121
119
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'faraday/multipart'
4
- require 'base64'
5
4
 
6
5
  module Imago
7
6
  module Providers
@@ -9,28 +8,12 @@ module Imago
9
8
  BASE_URL = 'https://api.openai.com/v1'
10
9
  MAX_IMAGES = 16
11
10
 
12
- KNOWN_IMAGE_MODELS = %w[
13
- dall-e-3
14
- dall-e-2
15
- gpt-image-1
16
- gpt-image-1.5
17
- gpt-image-1-mini
18
- ].freeze
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
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
26
13
 
27
14
  def generate(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)
33
- end
15
+ has_images = opts[:images] && !opts[:images].empty?
16
+ has_images ? generate_with_images(prompt, opts) : generate_text_only(prompt, opts)
34
17
  end
35
18
 
36
19
  def models
@@ -50,82 +33,72 @@ module Imago
50
33
  private
51
34
 
52
35
  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))
36
+ response = post_with_auth('images/generations', build_request_body(prompt, opts))
37
+ parse_response(response)
60
38
  end
61
39
 
62
40
  def generate_with_images(prompt, opts)
63
41
  validate_model_supports_images!
64
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
52
+ end
65
53
 
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)
66
60
  images = normalize_images(opts[:images])
67
- conn = multipart_connection(BASE_URL)
61
+ multipart_builder.build_body(prompt, images, opts)
62
+ end
68
63
 
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
64
+ def configure_auth_request(req, body)
65
+ req.headers['Authorization'] = auth_header
66
+ req.body = body
67
+ end
73
68
 
74
- parse_generate_response(handle_response(response))
69
+ def parse_response(response)
70
+ body = handle_response(response)
71
+ images = body['data']&.map { |img| parse_image(img) }
72
+ { images: images || [], created: body['created'] }
75
73
  end
76
74
 
77
- def validate_model_supports_images!
78
- return if MODELS_SUPPORTING_IMAGE_INPUT.include?(model)
75
+ def auth_header
76
+ "Bearer #{api_key}"
77
+ end
79
78
 
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
- )
79
+ def multipart_builder
80
+ @multipart_builder ||= Imago::MultipartBuilder.new(model)
85
81
  end
86
82
 
87
- def multipart_connection(base_url)
88
- Faraday.new(url: base_url) do |conn|
83
+ def multipart_connection
84
+ @multipart_connection ||= Faraday.new(url: BASE_URL) do |conn|
89
85
  conn.request :multipart
90
86
  conn.response :json
91
87
  conn.adapter Faraday.default_adapter
92
88
  end
93
89
  end
94
90
 
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
91
+ def validate_model_supports_images!
92
+ return if MODELS_SUPPORTING_IMAGE_INPUT.include?(model)
108
93
 
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
94
+ raise InvalidRequestError.new(
95
+ "Model '#{model}' does not support image inputs. Supported: #{MODELS_SUPPORTING_IMAGE_INPUT.join(', ')}",
96
+ status_code: 400
97
+ )
117
98
  end
118
99
 
119
100
  def build_request_body(prompt, opts)
120
- {
121
- model: model,
122
- prompt: prompt
123
- }.merge(opts)
124
- end
125
-
126
- def parse_generate_response(body)
127
- images = body['data']&.map { |img| parse_image(img) }
128
- { images: images || [], created: body['created'] }
101
+ { model: model, prompt: prompt }.merge(opts)
129
102
  end
130
103
 
131
104
  def parse_image(img)
@@ -133,23 +106,15 @@ module Imago
133
106
  end
134
107
 
135
108
  def fetch_models
136
- conn = connection(BASE_URL)
137
- response = conn.get('models') do |req|
138
- req.headers['Authorization'] = "Bearer #{api_key}"
139
- end
140
-
141
- body = handle_response(response)
142
- 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'] || [])
143
111
  rescue ApiError
144
112
  KNOWN_IMAGE_MODELS
145
113
  end
146
114
 
147
115
  def filter_image_models(models)
148
- image_model_ids = models
149
- .map { |m| m['id'] }
150
- .select { |id| id.include?('dall-e') || id.include?('image') }
151
-
152
- 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
153
118
  end
154
119
  end
155
120
  end
@@ -12,13 +12,7 @@ module Imago
12
12
 
13
13
  def generate(prompt, opts = {})
14
14
  raise_if_images_provided(opts)
15
-
16
- conn = connection(BASE_URL)
17
- response = conn.post('images/generations') do |req|
18
- req.headers['Authorization'] = "Bearer #{api_key}"
19
- req.body = build_request_body(prompt, opts)
20
- end
21
-
15
+ response = execute_generate_request(prompt, opts)
22
16
  parse_generate_response(handle_response(response))
23
17
  end
24
18
 
@@ -38,6 +32,21 @@ module Imago
38
32
 
39
33
  private
40
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
+
41
50
  def build_request_body(prompt, opts)
42
51
  {
43
52
  model: model,
@@ -48,17 +57,8 @@ module Imago
48
57
  end
49
58
 
50
59
  def parse_generate_response(body)
51
- images = body['data']&.map do |img|
52
- {
53
- url: img['url'],
54
- base64: img['b64_json']
55
- }.compact
56
- end
57
-
58
- {
59
- images: images || [],
60
- created: body['created']
61
- }
60
+ images = body['data']&.map { |img| { url: img['url'], base64: img['b64_json'] }.compact }
61
+ { images: images || [], created: body['created'] }
62
62
  end
63
63
 
64
64
  def raise_if_images_provided(opts)
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.2.0'
4
+ VERSION = '0.2.1'
5
5
  end
data/lib/imago.rb CHANGED
@@ -6,6 +6,8 @@ require 'json'
6
6
  require_relative 'imago/version'
7
7
  require_relative 'imago/errors'
8
8
  require_relative 'imago/image_input'
9
+ require_relative 'imago/multipart_builder'
10
+ require_relative 'imago/gemini_response_parser'
9
11
  require_relative 'imago/providers/base'
10
12
  require_relative 'imago/providers/openai'
11
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.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - NEETzsche
@@ -67,7 +67,9 @@ files:
67
67
  - lib/imago.rb
68
68
  - lib/imago/client.rb
69
69
  - lib/imago/errors.rb
70
+ - lib/imago/gemini_response_parser.rb
70
71
  - lib/imago/image_input.rb
72
+ - lib/imago/multipart_builder.rb
71
73
  - lib/imago/providers/base.rb
72
74
  - lib/imago/providers/gemini.rb
73
75
  - lib/imago/providers/openai.rb