geminize 0.1.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 +7 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/.yardopts +14 -0
- data/CHANGELOG.md +24 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CONTRIBUTING.md +109 -0
- data/LICENSE.txt +21 -0
- data/README.md +423 -0
- data/Rakefile +10 -0
- data/examples/README.md +75 -0
- data/examples/configuration.rb +58 -0
- data/examples/embeddings.rb +195 -0
- data/examples/multimodal.rb +126 -0
- data/examples/rails_chat/README.md +69 -0
- data/examples/rails_chat/app/controllers/chat_controller.rb +26 -0
- data/examples/rails_chat/app/views/chat/index.html.erb +112 -0
- data/examples/rails_chat/config/routes.rb +8 -0
- data/examples/rails_initializer.rb +46 -0
- data/examples/system_instructions.rb +101 -0
- data/lib/geminize/chat.rb +98 -0
- data/lib/geminize/client.rb +318 -0
- data/lib/geminize/configuration.rb +98 -0
- data/lib/geminize/conversation_repository.rb +161 -0
- data/lib/geminize/conversation_service.rb +126 -0
- data/lib/geminize/embeddings.rb +145 -0
- data/lib/geminize/error_mapper.rb +96 -0
- data/lib/geminize/error_parser.rb +120 -0
- data/lib/geminize/errors.rb +185 -0
- data/lib/geminize/middleware/error_handler.rb +72 -0
- data/lib/geminize/model_info.rb +91 -0
- data/lib/geminize/models/chat_request.rb +186 -0
- data/lib/geminize/models/chat_response.rb +118 -0
- data/lib/geminize/models/content_request.rb +530 -0
- data/lib/geminize/models/content_response.rb +99 -0
- data/lib/geminize/models/conversation.rb +156 -0
- data/lib/geminize/models/embedding_request.rb +222 -0
- data/lib/geminize/models/embedding_response.rb +1064 -0
- data/lib/geminize/models/memory.rb +88 -0
- data/lib/geminize/models/message.rb +140 -0
- data/lib/geminize/models/model.rb +171 -0
- data/lib/geminize/models/model_list.rb +124 -0
- data/lib/geminize/models/stream_response.rb +99 -0
- data/lib/geminize/rails/app/controllers/concerns/geminize/controller.rb +105 -0
- data/lib/geminize/rails/app/helpers/geminize_helper.rb +125 -0
- data/lib/geminize/rails/controller_additions.rb +41 -0
- data/lib/geminize/rails/engine.rb +29 -0
- data/lib/geminize/rails/helper_additions.rb +37 -0
- data/lib/geminize/rails.rb +50 -0
- data/lib/geminize/railtie.rb +33 -0
- data/lib/geminize/request_builder.rb +57 -0
- data/lib/geminize/text_generation.rb +285 -0
- data/lib/geminize/validators.rb +150 -0
- data/lib/geminize/vector_utils.rb +164 -0
- data/lib/geminize/version.rb +5 -0
- data/lib/geminize.rb +527 -0
- data/lib/generators/geminize/install_generator.rb +22 -0
- data/lib/generators/geminize/templates/README +31 -0
- data/lib/generators/geminize/templates/initializer.rb +38 -0
- data/sig/geminize.rbs +4 -0
- metadata +218 -0
@@ -0,0 +1,530 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "base64"
|
4
|
+
require "mime/types"
|
5
|
+
require "open-uri"
|
6
|
+
require "net/http"
|
7
|
+
|
8
|
+
module Geminize
|
9
|
+
module Models
|
10
|
+
# Represents a request for text generation from the Gemini API
|
11
|
+
class ContentRequest
|
12
|
+
# Gemini API generation parameters
|
13
|
+
# @return [String] The input prompt text
|
14
|
+
attr_reader :prompt
|
15
|
+
|
16
|
+
# @return [String] The model name to use
|
17
|
+
attr_reader :model_name
|
18
|
+
|
19
|
+
# @return [Float] Temperature (controls randomness)
|
20
|
+
attr_accessor :temperature
|
21
|
+
|
22
|
+
# @return [Integer] Maximum tokens to generate
|
23
|
+
attr_accessor :max_tokens
|
24
|
+
|
25
|
+
# @return [Float] Top-p value for nucleus sampling
|
26
|
+
attr_accessor :top_p
|
27
|
+
|
28
|
+
# @return [Integer] Top-k value for sampling
|
29
|
+
attr_accessor :top_k
|
30
|
+
|
31
|
+
# @return [Array<String>] Stop sequences to end generation
|
32
|
+
attr_accessor :stop_sequences
|
33
|
+
|
34
|
+
# @return [String, nil] System instruction to guide model behavior
|
35
|
+
attr_accessor :system_instruction
|
36
|
+
|
37
|
+
# @return [Array<Hash>] Content parts for multimodal input
|
38
|
+
attr_reader :content_parts
|
39
|
+
|
40
|
+
# Supported image MIME types
|
41
|
+
SUPPORTED_IMAGE_MIME_TYPES = [
|
42
|
+
"image/jpeg",
|
43
|
+
"image/png",
|
44
|
+
"image/gif",
|
45
|
+
"image/webp"
|
46
|
+
].freeze
|
47
|
+
|
48
|
+
# Maximum image size in bytes (10MB)
|
49
|
+
# Gemini API has limits on image sizes to prevent abuse and ensure performance
|
50
|
+
MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024
|
51
|
+
|
52
|
+
# Initialize a new content generation request
|
53
|
+
# @param prompt [String] The input prompt text
|
54
|
+
# @param model_name [String] The model name to use
|
55
|
+
# @param params [Hash] Additional parameters
|
56
|
+
# @option params [Float] :temperature Controls randomness (0.0-1.0)
|
57
|
+
# @option params [Integer] :max_tokens Maximum tokens to generate
|
58
|
+
# @option params [Float] :top_p Top-p value for nucleus sampling (0.0-1.0)
|
59
|
+
# @option params [Integer] :top_k Top-k value for sampling
|
60
|
+
# @option params [Array<String>] :stop_sequences Stop sequences to end generation
|
61
|
+
# @option params [String] :system_instruction System instruction to guide model behavior
|
62
|
+
def initialize(prompt, model_name = nil, params = {})
|
63
|
+
# Validate prompt first, before even trying to use it
|
64
|
+
validate_prompt!(prompt)
|
65
|
+
|
66
|
+
@prompt = prompt
|
67
|
+
@model_name = model_name || Geminize.configuration.default_model
|
68
|
+
@temperature = params[:temperature]
|
69
|
+
@max_tokens = params[:max_tokens]
|
70
|
+
@top_p = params[:top_p]
|
71
|
+
@top_k = params[:top_k]
|
72
|
+
@stop_sequences = params[:stop_sequences]
|
73
|
+
@system_instruction = params[:system_instruction]
|
74
|
+
|
75
|
+
# Initialize content parts with the prompt as the first text part
|
76
|
+
@content_parts = []
|
77
|
+
add_text(prompt)
|
78
|
+
|
79
|
+
validate!
|
80
|
+
end
|
81
|
+
|
82
|
+
# Add text content to the request
|
83
|
+
# @param text [String] The text to add
|
84
|
+
# @return [self] The request object for chaining
|
85
|
+
def add_text(text)
|
86
|
+
Validators.validate_not_empty!(text, "Text content")
|
87
|
+
@content_parts << {type: "text", text: text}
|
88
|
+
self
|
89
|
+
end
|
90
|
+
|
91
|
+
# Add an image to the request from a file path
|
92
|
+
# @param file_path [String] Path to the image file
|
93
|
+
# @return [self] The request object for chaining
|
94
|
+
# @raise [Geminize::ValidationError] If the file is invalid or not found
|
95
|
+
def add_image_from_file(file_path)
|
96
|
+
unless File.exist?(file_path)
|
97
|
+
raise Geminize::ValidationError.new(
|
98
|
+
"Image file not found: #{file_path}",
|
99
|
+
"INVALID_ARGUMENT"
|
100
|
+
)
|
101
|
+
end
|
102
|
+
|
103
|
+
unless File.file?(file_path)
|
104
|
+
raise Geminize::ValidationError.new(
|
105
|
+
"Path is not a file: #{file_path}",
|
106
|
+
"INVALID_ARGUMENT"
|
107
|
+
)
|
108
|
+
end
|
109
|
+
|
110
|
+
begin
|
111
|
+
image_data = File.binread(file_path)
|
112
|
+
mime_type = detect_mime_type(file_path)
|
113
|
+
add_image_from_bytes(image_data, mime_type)
|
114
|
+
rescue => e
|
115
|
+
raise Geminize::ValidationError.new(
|
116
|
+
"Error reading image file: #{e.message}",
|
117
|
+
"INVALID_ARGUMENT"
|
118
|
+
)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Add an image to the request from raw bytes
|
123
|
+
# @param image_bytes [String] Raw binary image data
|
124
|
+
# @param mime_type [String] The MIME type of the image
|
125
|
+
# @return [self] The request object for chaining
|
126
|
+
# @raise [Geminize::ValidationError] If the image data is invalid
|
127
|
+
def add_image_from_bytes(image_bytes, mime_type)
|
128
|
+
validate_image_bytes!(image_bytes)
|
129
|
+
validate_mime_type!(mime_type)
|
130
|
+
|
131
|
+
# Encode the image as base64
|
132
|
+
base64_data = Base64.strict_encode64(image_bytes)
|
133
|
+
|
134
|
+
@content_parts << {
|
135
|
+
type: "image",
|
136
|
+
mime_type: mime_type,
|
137
|
+
data: base64_data
|
138
|
+
}
|
139
|
+
|
140
|
+
self
|
141
|
+
end
|
142
|
+
|
143
|
+
# Add an image to the request from a URL
|
144
|
+
# @param url [String] URL of the image
|
145
|
+
# @return [self] The request object for chaining
|
146
|
+
# @raise [Geminize::ValidationError] If the URL is invalid or the image cannot be fetched
|
147
|
+
def add_image_from_url(url)
|
148
|
+
validate_url!(url)
|
149
|
+
|
150
|
+
begin
|
151
|
+
# Use Net::HTTP to fetch the image instead of URI.open
|
152
|
+
uri = URI.parse(url)
|
153
|
+
response = Net::HTTP.get_response(uri)
|
154
|
+
|
155
|
+
# Check for HTTP errors
|
156
|
+
unless response.is_a?(Net::HTTPSuccess)
|
157
|
+
raise OpenURI::HTTPError.new(response.message, response)
|
158
|
+
end
|
159
|
+
|
160
|
+
# Read the response body
|
161
|
+
image_data = response.body
|
162
|
+
|
163
|
+
# Try to detect MIME type from URL extension or Content-Type header
|
164
|
+
content_type = response["Content-Type"]
|
165
|
+
|
166
|
+
# If Content-Type header exists and is a supported image type, use it
|
167
|
+
mime_type = if content_type && SUPPORTED_IMAGE_MIME_TYPES.include?(content_type)
|
168
|
+
content_type
|
169
|
+
else
|
170
|
+
# Otherwise try to detect from URL
|
171
|
+
detect_mime_type_from_url(url) || "image/jpeg"
|
172
|
+
end
|
173
|
+
|
174
|
+
add_image_from_bytes(image_data, mime_type)
|
175
|
+
rescue OpenURI::HTTPError => e
|
176
|
+
raise Geminize::ValidationError.new(
|
177
|
+
"Error fetching image from URL: HTTP error #{e.message}",
|
178
|
+
"INVALID_ARGUMENT"
|
179
|
+
)
|
180
|
+
rescue => e
|
181
|
+
raise Geminize::ValidationError.new(
|
182
|
+
"Error fetching image from URL: #{e.message}",
|
183
|
+
"INVALID_ARGUMENT"
|
184
|
+
)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# Check if this request has multimodal content
|
189
|
+
# @return [Boolean] True if the request contains multiple content types
|
190
|
+
def multimodal?
|
191
|
+
return false if @content_parts.empty?
|
192
|
+
|
193
|
+
# Check if we have any non-text parts or multiple text parts
|
194
|
+
@content_parts.any? { |part| part[:type] != "text" } || @content_parts.count > 1
|
195
|
+
end
|
196
|
+
|
197
|
+
# Validate the request parameters
|
198
|
+
# @raise [Geminize::ValidationError] If any parameter is invalid
|
199
|
+
# @return [Boolean] true if all parameters are valid
|
200
|
+
def validate!
|
201
|
+
validate_temperature!
|
202
|
+
validate_max_tokens!
|
203
|
+
validate_top_p!
|
204
|
+
validate_top_k!
|
205
|
+
validate_stop_sequences!
|
206
|
+
validate_content_parts!
|
207
|
+
validate_system_instruction!
|
208
|
+
true
|
209
|
+
end
|
210
|
+
|
211
|
+
# Convert the request to a hash suitable for the API
|
212
|
+
# @return [Hash] The request as a hash
|
213
|
+
def to_hash
|
214
|
+
request = if multimodal?
|
215
|
+
{
|
216
|
+
contents: [
|
217
|
+
{
|
218
|
+
parts: @content_parts
|
219
|
+
}
|
220
|
+
],
|
221
|
+
generationConfig: generation_config
|
222
|
+
}.compact
|
223
|
+
else
|
224
|
+
# Keep backward compatibility for text-only requests
|
225
|
+
{
|
226
|
+
contents: [
|
227
|
+
{
|
228
|
+
parts: [
|
229
|
+
{
|
230
|
+
text: @prompt
|
231
|
+
}
|
232
|
+
]
|
233
|
+
}
|
234
|
+
],
|
235
|
+
generationConfig: generation_config
|
236
|
+
}.compact
|
237
|
+
end
|
238
|
+
|
239
|
+
# Add system_instruction if provided
|
240
|
+
if @system_instruction
|
241
|
+
request[:systemInstruction] = {
|
242
|
+
parts: [
|
243
|
+
{
|
244
|
+
text: @system_instruction
|
245
|
+
}
|
246
|
+
]
|
247
|
+
}
|
248
|
+
end
|
249
|
+
|
250
|
+
request
|
251
|
+
end
|
252
|
+
|
253
|
+
# Alias for to_hash for consistency with Ruby conventions
|
254
|
+
# @return [Hash] The request as a hash
|
255
|
+
def to_h
|
256
|
+
to_hash
|
257
|
+
end
|
258
|
+
|
259
|
+
private
|
260
|
+
|
261
|
+
# Build the generation configuration hash
|
262
|
+
# @return [Hash] The generation configuration
|
263
|
+
def generation_config
|
264
|
+
config = {}
|
265
|
+
config[:temperature] = @temperature if @temperature
|
266
|
+
config[:maxOutputTokens] = @max_tokens if @max_tokens
|
267
|
+
config[:topP] = @top_p if @top_p
|
268
|
+
config[:topK] = @top_k if @top_k
|
269
|
+
config[:stopSequences] = @stop_sequences if @stop_sequences && !@stop_sequences.empty?
|
270
|
+
|
271
|
+
config.empty? ? nil : config
|
272
|
+
end
|
273
|
+
|
274
|
+
# Validate the prompt parameter
|
275
|
+
# @param prompt_text [String] The prompt text to validate
|
276
|
+
# @raise [Geminize::ValidationError] If the prompt is invalid
|
277
|
+
def validate_prompt!(prompt_text = @prompt)
|
278
|
+
if prompt_text.nil?
|
279
|
+
raise Geminize::ValidationError.new("Prompt cannot be nil", "INVALID_ARGUMENT")
|
280
|
+
end
|
281
|
+
|
282
|
+
unless prompt_text.is_a?(String)
|
283
|
+
raise Geminize::ValidationError.new("Prompt must be a string", "INVALID_ARGUMENT")
|
284
|
+
end
|
285
|
+
|
286
|
+
if prompt_text.empty?
|
287
|
+
raise Geminize::ValidationError.new("Prompt cannot be empty", "INVALID_ARGUMENT")
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
# Validate the system_instruction parameter
|
292
|
+
# @raise [Geminize::ValidationError] If the system_instruction is invalid
|
293
|
+
def validate_system_instruction!
|
294
|
+
return if @system_instruction.nil?
|
295
|
+
|
296
|
+
unless @system_instruction.is_a?(String)
|
297
|
+
raise Geminize::ValidationError.new("System instruction must be a string", "INVALID_ARGUMENT")
|
298
|
+
end
|
299
|
+
|
300
|
+
if @system_instruction.empty?
|
301
|
+
raise Geminize::ValidationError.new("System instruction cannot be empty", "INVALID_ARGUMENT")
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
# Validate the temperature parameter
|
306
|
+
# @raise [Geminize::ValidationError] If the temperature is invalid
|
307
|
+
def validate_temperature!
|
308
|
+
Validators.validate_probability!(@temperature, "Temperature")
|
309
|
+
end
|
310
|
+
|
311
|
+
# Validate the max_tokens parameter
|
312
|
+
# @raise [Geminize::ValidationError] If the max_tokens is invalid
|
313
|
+
def validate_max_tokens!
|
314
|
+
Validators.validate_positive_integer!(@max_tokens, "Max tokens")
|
315
|
+
end
|
316
|
+
|
317
|
+
# Validate the top_p parameter
|
318
|
+
# @raise [Geminize::ValidationError] If the top_p is invalid
|
319
|
+
def validate_top_p!
|
320
|
+
Validators.validate_probability!(@top_p, "Top-p")
|
321
|
+
end
|
322
|
+
|
323
|
+
# Validate the top_k parameter
|
324
|
+
# @raise [Geminize::ValidationError] If the top_k is invalid
|
325
|
+
def validate_top_k!
|
326
|
+
Validators.validate_positive_integer!(@top_k, "Top-k")
|
327
|
+
end
|
328
|
+
|
329
|
+
# Validate the stop_sequences parameter
|
330
|
+
# @raise [Geminize::ValidationError] If the stop_sequences is invalid
|
331
|
+
def validate_stop_sequences!
|
332
|
+
Validators.validate_string_array!(@stop_sequences, "Stop sequences")
|
333
|
+
end
|
334
|
+
|
335
|
+
# Validate the content_parts
|
336
|
+
# @raise [Geminize::ValidationError] If any content part is invalid
|
337
|
+
def validate_content_parts!
|
338
|
+
return if @content_parts.empty?
|
339
|
+
|
340
|
+
@content_parts.each_with_index do |part, index|
|
341
|
+
unless part.is_a?(Hash) && part[:type]
|
342
|
+
raise Geminize::ValidationError.new(
|
343
|
+
"Content part #{index} must be a hash with a :type key",
|
344
|
+
"INVALID_ARGUMENT"
|
345
|
+
)
|
346
|
+
end
|
347
|
+
|
348
|
+
case part[:type]
|
349
|
+
when "text"
|
350
|
+
Validators.validate_not_empty!(part[:text], "Text content for part #{index}")
|
351
|
+
when "image"
|
352
|
+
validate_image_part!(part, index)
|
353
|
+
else
|
354
|
+
raise Geminize::ValidationError.new(
|
355
|
+
"Content part #{index} has an invalid type: #{part[:type]}",
|
356
|
+
"INVALID_ARGUMENT"
|
357
|
+
)
|
358
|
+
end
|
359
|
+
end
|
360
|
+
end
|
361
|
+
|
362
|
+
# Validate an image part
|
363
|
+
# @param part [Hash] The image part to validate
|
364
|
+
# @param index [Integer] The index of the part in the content_parts array
|
365
|
+
# @raise [Geminize::ValidationError] If the image part is invalid
|
366
|
+
def validate_image_part!(part, index)
|
367
|
+
unless part[:mime_type]
|
368
|
+
raise Geminize::ValidationError.new(
|
369
|
+
"Image part #{index} is missing mime_type",
|
370
|
+
"INVALID_ARGUMENT"
|
371
|
+
)
|
372
|
+
end
|
373
|
+
|
374
|
+
unless part[:data]
|
375
|
+
raise Geminize::ValidationError.new(
|
376
|
+
"Image part #{index} is missing data",
|
377
|
+
"INVALID_ARGUMENT"
|
378
|
+
)
|
379
|
+
end
|
380
|
+
|
381
|
+
validate_mime_type!(part[:mime_type], "Image part #{index} mime_type")
|
382
|
+
end
|
383
|
+
|
384
|
+
# Validate image bytes
|
385
|
+
# @param image_bytes [String] The image bytes to validate
|
386
|
+
# @raise [Geminize::ValidationError] If the image bytes are invalid
|
387
|
+
def validate_image_bytes!(image_bytes)
|
388
|
+
if image_bytes.nil?
|
389
|
+
raise Geminize::ValidationError.new(
|
390
|
+
"Image data cannot be nil",
|
391
|
+
"INVALID_ARGUMENT"
|
392
|
+
)
|
393
|
+
end
|
394
|
+
|
395
|
+
unless image_bytes.is_a?(String)
|
396
|
+
raise Geminize::ValidationError.new(
|
397
|
+
"Image data must be a binary string",
|
398
|
+
"INVALID_ARGUMENT"
|
399
|
+
)
|
400
|
+
end
|
401
|
+
|
402
|
+
if image_bytes.empty?
|
403
|
+
raise Geminize::ValidationError.new(
|
404
|
+
"Image data cannot be empty",
|
405
|
+
"INVALID_ARGUMENT"
|
406
|
+
)
|
407
|
+
end
|
408
|
+
|
409
|
+
if image_bytes.bytesize > MAX_IMAGE_SIZE_BYTES
|
410
|
+
raise Geminize::ValidationError.new(
|
411
|
+
"Image size exceeds maximum allowed (10MB)",
|
412
|
+
"INVALID_ARGUMENT"
|
413
|
+
)
|
414
|
+
end
|
415
|
+
end
|
416
|
+
|
417
|
+
# Validate MIME type
|
418
|
+
# @param mime_type [String] The MIME type to validate
|
419
|
+
# @param context [String] Additional context for error messages
|
420
|
+
# @raise [Geminize::ValidationError] If the MIME type is invalid
|
421
|
+
def validate_mime_type!(mime_type, context = "MIME type")
|
422
|
+
if mime_type.nil?
|
423
|
+
raise Geminize::ValidationError.new(
|
424
|
+
"#{context} cannot be nil",
|
425
|
+
"INVALID_ARGUMENT"
|
426
|
+
)
|
427
|
+
end
|
428
|
+
|
429
|
+
unless mime_type.is_a?(String)
|
430
|
+
raise Geminize::ValidationError.new(
|
431
|
+
"#{context} must be a string",
|
432
|
+
"INVALID_ARGUMENT"
|
433
|
+
)
|
434
|
+
end
|
435
|
+
|
436
|
+
if mime_type.empty?
|
437
|
+
raise Geminize::ValidationError.new(
|
438
|
+
"#{context} cannot be empty",
|
439
|
+
"INVALID_ARGUMENT"
|
440
|
+
)
|
441
|
+
end
|
442
|
+
|
443
|
+
unless SUPPORTED_IMAGE_MIME_TYPES.include?(mime_type)
|
444
|
+
raise Geminize::ValidationError.new(
|
445
|
+
"#{context} must be one of: #{SUPPORTED_IMAGE_MIME_TYPES.join(", ")}",
|
446
|
+
"INVALID_ARGUMENT"
|
447
|
+
)
|
448
|
+
end
|
449
|
+
end
|
450
|
+
|
451
|
+
# Validate URL
|
452
|
+
# @param url [String] The URL to validate
|
453
|
+
# @raise [Geminize::ValidationError] If the URL is invalid
|
454
|
+
def validate_url!(url)
|
455
|
+
Validators.validate_not_empty!(url, "URL")
|
456
|
+
|
457
|
+
# Simple URL validation
|
458
|
+
unless url.match?(%r{\A(http|https)://})
|
459
|
+
raise Geminize::ValidationError.new(
|
460
|
+
"URL must start with http:// or https://",
|
461
|
+
"INVALID_ARGUMENT"
|
462
|
+
)
|
463
|
+
end
|
464
|
+
end
|
465
|
+
|
466
|
+
# Detect MIME type from file path
|
467
|
+
# @param file_path [String] The file path
|
468
|
+
# @return [String] The detected MIME type
|
469
|
+
# @raise [Geminize::ValidationError] If the MIME type is not supported
|
470
|
+
def detect_mime_type(file_path)
|
471
|
+
# First try to detect MIME type from file extension
|
472
|
+
types = MIME::Types.type_for(file_path)
|
473
|
+
mime_type = types.first&.content_type if types.any?
|
474
|
+
|
475
|
+
# If we couldn't detect MIME type from extension, try to detect from file content
|
476
|
+
unless mime_type && SUPPORTED_IMAGE_MIME_TYPES.include?(mime_type)
|
477
|
+
mime_type = detect_mime_type_from_content(file_path)
|
478
|
+
end
|
479
|
+
|
480
|
+
# If we still couldn't detect a supported MIME type, raise an error
|
481
|
+
unless mime_type && SUPPORTED_IMAGE_MIME_TYPES.include?(mime_type)
|
482
|
+
raise Geminize::ValidationError.new(
|
483
|
+
"Unsupported image format. Supported formats: #{SUPPORTED_IMAGE_MIME_TYPES.join(", ")}",
|
484
|
+
"INVALID_ARGUMENT"
|
485
|
+
)
|
486
|
+
end
|
487
|
+
|
488
|
+
mime_type
|
489
|
+
end
|
490
|
+
|
491
|
+
# Detect MIME type from file content by checking the file signature/magic bytes
|
492
|
+
# @param file_path [String] The file path
|
493
|
+
# @return [String, nil] The detected MIME type or nil if not detected
|
494
|
+
def detect_mime_type_from_content(file_path)
|
495
|
+
# Read the first few bytes to check file signature
|
496
|
+
header = File.binread(file_path, 12)
|
497
|
+
|
498
|
+
# Check for common image signatures (using bytes comparison for binary safety)
|
499
|
+
return "image/jpeg" if header.bytes[0..2] == [0xFF, 0xD8, 0xFF]
|
500
|
+
return "image/png" if header.bytes[0..7] == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
|
501
|
+
|
502
|
+
# GIF check using pattern matching instead of start_with?
|
503
|
+
return "image/gif" if header[0..5] == "GIF87a" || header[0..5] == "GIF89a"
|
504
|
+
|
505
|
+
return "image/webp" if header[8..11] == "WEBP"
|
506
|
+
|
507
|
+
nil
|
508
|
+
end
|
509
|
+
|
510
|
+
# Detect MIME type from URL
|
511
|
+
# @param url [String] The URL to detect MIME type from
|
512
|
+
# @return [String, nil] The detected MIME type or nil if not detected
|
513
|
+
def detect_mime_type_from_url(url)
|
514
|
+
# Extract the file extension from the URL
|
515
|
+
extension = File.extname(url.split("?").first).downcase
|
516
|
+
|
517
|
+
case extension
|
518
|
+
when ".jpg", ".jpeg"
|
519
|
+
"image/jpeg"
|
520
|
+
when ".png"
|
521
|
+
"image/png"
|
522
|
+
when ".gif"
|
523
|
+
"image/gif"
|
524
|
+
when ".webp"
|
525
|
+
"image/webp"
|
526
|
+
end
|
527
|
+
end
|
528
|
+
end
|
529
|
+
end
|
530
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Geminize
|
4
|
+
module Models
|
5
|
+
# Represents a response from the Gemini API text generation
|
6
|
+
class ContentResponse
|
7
|
+
# @return [Hash] The raw API response data
|
8
|
+
attr_reader :raw_response
|
9
|
+
|
10
|
+
# @return [String, nil] The reason why generation stopped (if applicable)
|
11
|
+
attr_reader :finish_reason
|
12
|
+
|
13
|
+
# @return [Hash, nil] Token counts for the request and response
|
14
|
+
attr_reader :usage
|
15
|
+
|
16
|
+
# Initialize a new content generation response
|
17
|
+
# @param response_data [Hash] The raw API response
|
18
|
+
def initialize(response_data)
|
19
|
+
@raw_response = response_data
|
20
|
+
parse_response
|
21
|
+
end
|
22
|
+
|
23
|
+
# Get the generated text from the response
|
24
|
+
# @return [String, nil] The generated text or nil if no text was generated
|
25
|
+
def text
|
26
|
+
return @text if defined?(@text)
|
27
|
+
|
28
|
+
@text = nil
|
29
|
+
candidates = @raw_response["candidates"]
|
30
|
+
if candidates && !candidates.empty?
|
31
|
+
content = candidates.first["content"]
|
32
|
+
if content && content["parts"] && !content["parts"].empty?
|
33
|
+
parts_text = content["parts"].map { |part| part["text"] }.compact
|
34
|
+
@text = parts_text.join(" ") unless parts_text.empty?
|
35
|
+
end
|
36
|
+
end
|
37
|
+
@text
|
38
|
+
end
|
39
|
+
|
40
|
+
# Check if the response has generated text
|
41
|
+
# @return [Boolean] True if the response has generated text
|
42
|
+
def has_text?
|
43
|
+
!text.nil? && !text.empty?
|
44
|
+
end
|
45
|
+
|
46
|
+
# Get the total token count
|
47
|
+
# @return [Integer, nil] Total token count or nil if not available
|
48
|
+
def total_tokens
|
49
|
+
return nil unless @usage
|
50
|
+
|
51
|
+
(@usage["promptTokenCount"] || 0) + (@usage["candidatesTokenCount"] || 0)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Get the prompt token count
|
55
|
+
# @return [Integer, nil] Prompt token count or nil if not available
|
56
|
+
def prompt_tokens
|
57
|
+
return nil unless @usage
|
58
|
+
|
59
|
+
@usage["promptTokenCount"]
|
60
|
+
end
|
61
|
+
|
62
|
+
# Get the completion token count
|
63
|
+
# @return [Integer, nil] Completion token count or nil if not available
|
64
|
+
def completion_tokens
|
65
|
+
return nil unless @usage
|
66
|
+
|
67
|
+
@usage["candidatesTokenCount"]
|
68
|
+
end
|
69
|
+
|
70
|
+
# Create a ContentResponse object from a raw API response
|
71
|
+
# @param response_data [Hash] The raw API response
|
72
|
+
# @return [ContentResponse] A new ContentResponse object
|
73
|
+
def self.from_hash(response_data)
|
74
|
+
new(response_data)
|
75
|
+
end
|
76
|
+
|
77
|
+
private
|
78
|
+
|
79
|
+
# Parse the response data and extract relevant information
|
80
|
+
def parse_response
|
81
|
+
parse_finish_reason
|
82
|
+
parse_usage
|
83
|
+
end
|
84
|
+
|
85
|
+
# Parse the finish reason from the response
|
86
|
+
def parse_finish_reason
|
87
|
+
candidates = @raw_response["candidates"]
|
88
|
+
if candidates && !candidates.empty? && candidates.first["finishReason"]
|
89
|
+
@finish_reason = candidates.first["finishReason"]
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Parse usage information from the response
|
94
|
+
def parse_usage
|
95
|
+
@usage = @raw_response["usageMetadata"] if @raw_response["usageMetadata"]
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|