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.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.standard.yml +3 -0
  4. data/.yardopts +14 -0
  5. data/CHANGELOG.md +24 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/CONTRIBUTING.md +109 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +423 -0
  10. data/Rakefile +10 -0
  11. data/examples/README.md +75 -0
  12. data/examples/configuration.rb +58 -0
  13. data/examples/embeddings.rb +195 -0
  14. data/examples/multimodal.rb +126 -0
  15. data/examples/rails_chat/README.md +69 -0
  16. data/examples/rails_chat/app/controllers/chat_controller.rb +26 -0
  17. data/examples/rails_chat/app/views/chat/index.html.erb +112 -0
  18. data/examples/rails_chat/config/routes.rb +8 -0
  19. data/examples/rails_initializer.rb +46 -0
  20. data/examples/system_instructions.rb +101 -0
  21. data/lib/geminize/chat.rb +98 -0
  22. data/lib/geminize/client.rb +318 -0
  23. data/lib/geminize/configuration.rb +98 -0
  24. data/lib/geminize/conversation_repository.rb +161 -0
  25. data/lib/geminize/conversation_service.rb +126 -0
  26. data/lib/geminize/embeddings.rb +145 -0
  27. data/lib/geminize/error_mapper.rb +96 -0
  28. data/lib/geminize/error_parser.rb +120 -0
  29. data/lib/geminize/errors.rb +185 -0
  30. data/lib/geminize/middleware/error_handler.rb +72 -0
  31. data/lib/geminize/model_info.rb +91 -0
  32. data/lib/geminize/models/chat_request.rb +186 -0
  33. data/lib/geminize/models/chat_response.rb +118 -0
  34. data/lib/geminize/models/content_request.rb +530 -0
  35. data/lib/geminize/models/content_response.rb +99 -0
  36. data/lib/geminize/models/conversation.rb +156 -0
  37. data/lib/geminize/models/embedding_request.rb +222 -0
  38. data/lib/geminize/models/embedding_response.rb +1064 -0
  39. data/lib/geminize/models/memory.rb +88 -0
  40. data/lib/geminize/models/message.rb +140 -0
  41. data/lib/geminize/models/model.rb +171 -0
  42. data/lib/geminize/models/model_list.rb +124 -0
  43. data/lib/geminize/models/stream_response.rb +99 -0
  44. data/lib/geminize/rails/app/controllers/concerns/geminize/controller.rb +105 -0
  45. data/lib/geminize/rails/app/helpers/geminize_helper.rb +125 -0
  46. data/lib/geminize/rails/controller_additions.rb +41 -0
  47. data/lib/geminize/rails/engine.rb +29 -0
  48. data/lib/geminize/rails/helper_additions.rb +37 -0
  49. data/lib/geminize/rails.rb +50 -0
  50. data/lib/geminize/railtie.rb +33 -0
  51. data/lib/geminize/request_builder.rb +57 -0
  52. data/lib/geminize/text_generation.rb +285 -0
  53. data/lib/geminize/validators.rb +150 -0
  54. data/lib/geminize/vector_utils.rb +164 -0
  55. data/lib/geminize/version.rb +5 -0
  56. data/lib/geminize.rb +527 -0
  57. data/lib/generators/geminize/install_generator.rb +22 -0
  58. data/lib/generators/geminize/templates/README +31 -0
  59. data/lib/generators/geminize/templates/initializer.rb +38 -0
  60. data/sig/geminize.rbs +4 -0
  61. 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