ruby-gemini-api 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +455 -0
- data/lib/gemini/client.rb +68 -3
- data/lib/gemini/embeddings.rb +108 -17
- data/lib/gemini/live/configuration.rb +65 -0
- data/lib/gemini/live/connection.rb +83 -0
- data/lib/gemini/live/message_builder.rb +217 -0
- data/lib/gemini/live/session.rb +223 -0
- data/lib/gemini/live.rb +102 -0
- data/lib/gemini/response.rb +141 -4
- data/lib/gemini/tokens.rb +77 -0
- data/lib/gemini/tts.rb +83 -0
- data/lib/gemini/version.rb +1 -1
- data/lib/gemini.rb +3 -0
- metadata +23 -2
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module Gemini
|
|
7
|
+
class Live
|
|
8
|
+
# Live API session manager
|
|
9
|
+
class Session
|
|
10
|
+
attr_reader :configuration, :last_resumption_token, :usage_metadata
|
|
11
|
+
|
|
12
|
+
def initialize(api_key:, configuration:)
|
|
13
|
+
@api_key = api_key
|
|
14
|
+
@configuration = configuration
|
|
15
|
+
@event_handlers = Hash.new { |h, k| h[k] = [] }
|
|
16
|
+
@connected = false
|
|
17
|
+
@setup_complete = false
|
|
18
|
+
@last_resumption_token = nil
|
|
19
|
+
@usage_metadata = nil
|
|
20
|
+
@connection = nil
|
|
21
|
+
|
|
22
|
+
setup_connection
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Register event handler
|
|
26
|
+
# Supported events:
|
|
27
|
+
# :setup_complete - Session setup completed
|
|
28
|
+
# :text - Text response received (text)
|
|
29
|
+
# :audio - Audio data received (base64_data, mime_type)
|
|
30
|
+
# :data - Other inline data received (base64_data, mime_type)
|
|
31
|
+
# :tool_call - Tool call requested (function_calls)
|
|
32
|
+
# :interrupted - User interrupted the model
|
|
33
|
+
# :turn_complete - Model turn completed
|
|
34
|
+
# :generation_complete - Generation completed
|
|
35
|
+
# :usage_metadata - Token usage info received (metadata)
|
|
36
|
+
# :session_resumption - Session resumption token updated (update)
|
|
37
|
+
# :go_away - Connection will close soon (info)
|
|
38
|
+
# :error - Error occurred (error)
|
|
39
|
+
# :close - Connection closed (code, reason)
|
|
40
|
+
def on(event, &block)
|
|
41
|
+
@event_handlers[event.to_sym] << block
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Send text message via clientContent.turns. This is the legacy form
|
|
46
|
+
# used by native-audio Live models. Newer models such as
|
|
47
|
+
# gemini-3.1-flash-live-preview reject this payload — use
|
|
48
|
+
# #send_realtime_text instead, which works on every Live model.
|
|
49
|
+
def send_text(text, turn_complete: true)
|
|
50
|
+
ensure_setup_complete!
|
|
51
|
+
message = MessageBuilder.client_content(
|
|
52
|
+
text: text,
|
|
53
|
+
turn_complete: turn_complete
|
|
54
|
+
)
|
|
55
|
+
@connection.send(message)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Send text input via realtimeInput.text (universal form).
|
|
59
|
+
# Works with every currently-deployed Live model, including
|
|
60
|
+
# gemini-3.1-flash-live-preview and native-audio variants.
|
|
61
|
+
def send_realtime_text(text)
|
|
62
|
+
ensure_setup_complete!
|
|
63
|
+
@connection.send(MessageBuilder.realtime_text(text))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Send audio data (Base64 encoded PCM)
|
|
67
|
+
def send_audio(audio_data, mime_type: "audio/pcm;rate=16000")
|
|
68
|
+
ensure_setup_complete!
|
|
69
|
+
encoded_data = audio_data.is_a?(String) && audio_data.encoding == Encoding::BINARY ?
|
|
70
|
+
Base64.strict_encode64(audio_data) : audio_data
|
|
71
|
+
message = MessageBuilder.realtime_input(
|
|
72
|
+
audio_data: encoded_data,
|
|
73
|
+
mime_type: mime_type
|
|
74
|
+
)
|
|
75
|
+
@connection.send(message)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Send video/image data (Base64 encoded)
|
|
79
|
+
def send_video(image_data, mime_type: "image/jpeg")
|
|
80
|
+
ensure_setup_complete!
|
|
81
|
+
encoded_data = image_data.is_a?(String) && image_data.encoding == Encoding::BINARY ?
|
|
82
|
+
Base64.strict_encode64(image_data) : image_data
|
|
83
|
+
message = MessageBuilder.realtime_input(
|
|
84
|
+
video_data: encoded_data,
|
|
85
|
+
mime_type: mime_type
|
|
86
|
+
)
|
|
87
|
+
@connection.send(message)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Send tool response
|
|
91
|
+
def send_tool_response(function_responses)
|
|
92
|
+
ensure_setup_complete!
|
|
93
|
+
message = MessageBuilder.tool_response(function_responses)
|
|
94
|
+
@connection.send(message)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Manual VAD control - signal activity start
|
|
98
|
+
def activity_start
|
|
99
|
+
ensure_setup_complete!
|
|
100
|
+
@connection.send(MessageBuilder.activity_start)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Manual VAD control - signal activity end
|
|
104
|
+
def activity_end
|
|
105
|
+
ensure_setup_complete!
|
|
106
|
+
@connection.send(MessageBuilder.activity_end)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Close the session
|
|
110
|
+
def close
|
|
111
|
+
@connection&.close
|
|
112
|
+
@connected = false
|
|
113
|
+
@setup_complete = false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def connected?
|
|
117
|
+
@connected && @connection&.connected?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def setup_complete?
|
|
121
|
+
@setup_complete
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def setup_connection
|
|
127
|
+
@connection = Connection.new(
|
|
128
|
+
api_key: @api_key,
|
|
129
|
+
on_message: method(:handle_message),
|
|
130
|
+
on_open: method(:handle_open),
|
|
131
|
+
on_error: method(:handle_error),
|
|
132
|
+
on_close: method(:handle_close)
|
|
133
|
+
)
|
|
134
|
+
@connection.connect
|
|
135
|
+
@connected = true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def handle_open
|
|
139
|
+
# Send setup message immediately after connection opens
|
|
140
|
+
setup_message = MessageBuilder.setup(@configuration)
|
|
141
|
+
@connection.send(setup_message)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def handle_message(data)
|
|
145
|
+
parsed = JSON.parse(data, symbolize_names: true)
|
|
146
|
+
|
|
147
|
+
if parsed[:setupComplete]
|
|
148
|
+
@setup_complete = true
|
|
149
|
+
emit(:setup_complete)
|
|
150
|
+
elsif parsed[:serverContent]
|
|
151
|
+
handle_server_content(parsed[:serverContent])
|
|
152
|
+
elsif parsed[:toolCall]
|
|
153
|
+
emit(:tool_call, parsed[:toolCall][:functionCalls])
|
|
154
|
+
elsif parsed[:usageMetadata]
|
|
155
|
+
@usage_metadata = parsed[:usageMetadata]
|
|
156
|
+
emit(:usage_metadata, parsed[:usageMetadata])
|
|
157
|
+
elsif parsed[:sessionResumptionUpdate]
|
|
158
|
+
handle_session_resumption(parsed[:sessionResumptionUpdate])
|
|
159
|
+
elsif parsed[:goAway]
|
|
160
|
+
emit(:go_away, parsed[:goAway])
|
|
161
|
+
end
|
|
162
|
+
rescue JSON::ParserError => e
|
|
163
|
+
emit(:error, e)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def handle_server_content(content)
|
|
167
|
+
# Check for interruption
|
|
168
|
+
if content[:interrupted]
|
|
169
|
+
emit(:interrupted)
|
|
170
|
+
return
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Check for generation complete
|
|
174
|
+
if content[:generationComplete]
|
|
175
|
+
emit(:generation_complete)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Process model turn
|
|
179
|
+
model_turn = content[:modelTurn]
|
|
180
|
+
if model_turn
|
|
181
|
+
model_turn[:parts]&.each do |part|
|
|
182
|
+
if part[:text]
|
|
183
|
+
emit(:text, part[:text])
|
|
184
|
+
elsif part[:inlineData]
|
|
185
|
+
inline = part[:inlineData]
|
|
186
|
+
if inline[:mimeType]&.start_with?("audio/")
|
|
187
|
+
emit(:audio, inline[:data], inline[:mimeType])
|
|
188
|
+
else
|
|
189
|
+
emit(:data, inline[:data], inline[:mimeType])
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Check for turn complete
|
|
196
|
+
emit(:turn_complete) if content[:turnComplete]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def handle_session_resumption(update)
|
|
200
|
+
@last_resumption_token = update[:newHandle]
|
|
201
|
+
emit(:session_resumption, update)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def handle_error(error)
|
|
205
|
+
emit(:error, error)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def handle_close(code, reason)
|
|
209
|
+
@connected = false
|
|
210
|
+
@setup_complete = false
|
|
211
|
+
emit(:close, code, reason)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def emit(event, *args)
|
|
215
|
+
@event_handlers[event].each { |handler| handler.call(*args) }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def ensure_setup_complete!
|
|
219
|
+
raise Gemini::Error, "Session setup not complete. Wait for :setup_complete event." unless @setup_complete
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
data/lib/gemini/live.rb
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "live/configuration"
|
|
4
|
+
require_relative "live/message_builder"
|
|
5
|
+
require_relative "live/connection"
|
|
6
|
+
require_relative "live/session"
|
|
7
|
+
|
|
8
|
+
module Gemini
|
|
9
|
+
# Live API client for real-time audio/video/text interactions
|
|
10
|
+
#
|
|
11
|
+
# @example Basic text conversation
|
|
12
|
+
# client = Gemini::Client.new(api_key)
|
|
13
|
+
# session = client.live.connect(model: "gemini-2.5-flash-live-preview")
|
|
14
|
+
#
|
|
15
|
+
# session.on(:setup_complete) { puts "Connected!" }
|
|
16
|
+
# session.on(:text) { |text| puts "AI: #{text}" }
|
|
17
|
+
# session.on(:error) { |e| puts "Error: #{e}" }
|
|
18
|
+
#
|
|
19
|
+
# session.send_text("Hello!")
|
|
20
|
+
# sleep 5
|
|
21
|
+
# session.close
|
|
22
|
+
#
|
|
23
|
+
# @example Audio conversation
|
|
24
|
+
# session = client.live.connect(
|
|
25
|
+
# model: "gemini-2.5-flash-live-preview",
|
|
26
|
+
# response_modality: "AUDIO",
|
|
27
|
+
# voice_name: "Puck"
|
|
28
|
+
# )
|
|
29
|
+
#
|
|
30
|
+
# session.on(:audio) { |data, mime| play_audio(data) }
|
|
31
|
+
# session.send_audio(pcm_data) # 16-bit PCM, 16kHz, mono
|
|
32
|
+
#
|
|
33
|
+
# @example With block (auto-close)
|
|
34
|
+
# client.live.connect(model: "gemini-2.5-flash-live-preview") do |session|
|
|
35
|
+
# session.on(:text) { |text| puts text }
|
|
36
|
+
# session.send_text("Hello!")
|
|
37
|
+
# sleep 5
|
|
38
|
+
# end # session.close called automatically
|
|
39
|
+
#
|
|
40
|
+
class Live
|
|
41
|
+
def initialize(client:)
|
|
42
|
+
@client = client
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Establish a WebSocket connection and return a session
|
|
46
|
+
#
|
|
47
|
+
# @param model [String] Model to use (default: "gemini-2.5-flash-live-preview")
|
|
48
|
+
# @param response_modality [String] "TEXT" or "AUDIO" (default: "TEXT")
|
|
49
|
+
# @param voice_name [String] Voice for audio responses (Puck, Charon, Kore, etc.)
|
|
50
|
+
# @param system_instruction [String] System prompt
|
|
51
|
+
# @param tools [Array] Tool definitions for function calling
|
|
52
|
+
# @param context_window_compression [Hash] Compression settings for long sessions
|
|
53
|
+
# @param session_resumption [Hash] Session resumption settings
|
|
54
|
+
# @param automatic_activity_detection [Boolean] Enable/disable automatic VAD (default: true)
|
|
55
|
+
# @param media_resolution [String] Media resolution setting
|
|
56
|
+
# @param output_audio_transcription [Boolean] Enable audio transcription (default: false)
|
|
57
|
+
# @yield [session] If block given, yields the session and closes it when block returns
|
|
58
|
+
# @return [Gemini::Live::Session] The live session
|
|
59
|
+
#
|
|
60
|
+
def connect(
|
|
61
|
+
model: Configuration::DEFAULT_MODEL,
|
|
62
|
+
response_modality: "TEXT",
|
|
63
|
+
voice_name: nil,
|
|
64
|
+
system_instruction: nil,
|
|
65
|
+
tools: nil,
|
|
66
|
+
context_window_compression: nil,
|
|
67
|
+
session_resumption: nil,
|
|
68
|
+
automatic_activity_detection: true,
|
|
69
|
+
media_resolution: nil,
|
|
70
|
+
output_audio_transcription: false,
|
|
71
|
+
&block
|
|
72
|
+
)
|
|
73
|
+
config = Configuration.new(
|
|
74
|
+
model: model,
|
|
75
|
+
response_modality: response_modality,
|
|
76
|
+
voice_name: voice_name,
|
|
77
|
+
system_instruction: system_instruction,
|
|
78
|
+
tools: tools,
|
|
79
|
+
context_window_compression: context_window_compression,
|
|
80
|
+
session_resumption: session_resumption,
|
|
81
|
+
automatic_activity_detection: automatic_activity_detection,
|
|
82
|
+
media_resolution: media_resolution,
|
|
83
|
+
output_audio_transcription: output_audio_transcription
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
session = Session.new(
|
|
87
|
+
api_key: @client.api_key,
|
|
88
|
+
configuration: config
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if block_given?
|
|
92
|
+
begin
|
|
93
|
+
yield session
|
|
94
|
+
ensure
|
|
95
|
+
session.close
|
|
96
|
+
end
|
|
97
|
+
else
|
|
98
|
+
session
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/gemini/response.rb
CHANGED
|
@@ -41,9 +41,83 @@ module Gemini
|
|
|
41
41
|
# Get image parts (if any)
|
|
42
42
|
def image_parts
|
|
43
43
|
return [] unless valid?
|
|
44
|
-
|
|
44
|
+
|
|
45
45
|
parts.select { |part| part.key?("inline_data") && part["inline_data"]["mime_type"].start_with?("image/") }
|
|
46
46
|
end
|
|
47
|
+
|
|
48
|
+
# Get the first audio inlineData part (TTS responses use camelCase "inlineData")
|
|
49
|
+
def audio_part
|
|
50
|
+
return nil unless valid?
|
|
51
|
+
|
|
52
|
+
parts.find do |part|
|
|
53
|
+
data_key = part["inlineData"] || part["inline_data"]
|
|
54
|
+
next false unless data_key
|
|
55
|
+
mt = data_key["mimeType"] || data_key["mime_type"]
|
|
56
|
+
mt.is_a?(String) && mt.start_with?("audio/")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Base64-encoded audio data from a TTS response
|
|
61
|
+
def audio_data
|
|
62
|
+
part = audio_part
|
|
63
|
+
return nil unless part
|
|
64
|
+
data_key = part["inlineData"] || part["inline_data"]
|
|
65
|
+
data_key["data"]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# MIME type of the audio payload (e.g. "audio/L16;codec=pcm;rate=24000")
|
|
69
|
+
def audio_mime_type
|
|
70
|
+
part = audio_part
|
|
71
|
+
return nil unless part
|
|
72
|
+
data_key = part["inlineData"] || part["inline_data"]
|
|
73
|
+
data_key["mimeType"] || data_key["mime_type"]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# True if the response contains audio inlineData
|
|
77
|
+
def audio_response?
|
|
78
|
+
!audio_part.nil?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Save audio to a file. PCM (L16) payloads are wrapped in a WAV header so
|
|
82
|
+
# the result is directly playable; other audio MIME types are written as-is.
|
|
83
|
+
# Returns the written file path or nil if no audio is present.
|
|
84
|
+
def save_audio(filepath)
|
|
85
|
+
data_b64 = audio_data
|
|
86
|
+
return nil unless data_b64
|
|
87
|
+
|
|
88
|
+
require 'base64'
|
|
89
|
+
raw = Base64.strict_decode64(data_b64)
|
|
90
|
+
mime = audio_mime_type.to_s
|
|
91
|
+
|
|
92
|
+
if mime.include?("L16") || mime.include?("pcm")
|
|
93
|
+
rate = mime[/rate=(\d+)/, 1]&.to_i || 24000
|
|
94
|
+
channels = 1
|
|
95
|
+
bits_per_sample = 16
|
|
96
|
+
byte_rate = rate * channels * bits_per_sample / 8
|
|
97
|
+
block_align = channels * bits_per_sample / 8
|
|
98
|
+
data_size = raw.bytesize
|
|
99
|
+
|
|
100
|
+
header = +""
|
|
101
|
+
header << "RIFF"
|
|
102
|
+
header << [36 + data_size].pack("V")
|
|
103
|
+
header << "WAVE"
|
|
104
|
+
header << "fmt "
|
|
105
|
+
header << [16].pack("V")
|
|
106
|
+
header << [1].pack("v")
|
|
107
|
+
header << [channels].pack("v")
|
|
108
|
+
header << [rate].pack("V")
|
|
109
|
+
header << [byte_rate].pack("V")
|
|
110
|
+
header << [block_align].pack("v")
|
|
111
|
+
header << [bits_per_sample].pack("v")
|
|
112
|
+
header << "data"
|
|
113
|
+
header << [data_size].pack("V")
|
|
114
|
+
|
|
115
|
+
File.binwrite(filepath, header + raw)
|
|
116
|
+
else
|
|
117
|
+
File.binwrite(filepath, raw)
|
|
118
|
+
end
|
|
119
|
+
filepath
|
|
120
|
+
end
|
|
47
121
|
|
|
48
122
|
# Get all content with string representation
|
|
49
123
|
def full_content
|
|
@@ -70,9 +144,50 @@ module Gemini
|
|
|
70
144
|
|
|
71
145
|
# Check if response is valid
|
|
72
146
|
def valid?
|
|
73
|
-
!@raw_data.nil? &&
|
|
74
|
-
((@raw_data.key?("candidates") && !@raw_data["candidates"].empty?) ||
|
|
75
|
-
(@raw_data.key?("predictions") && !@raw_data["predictions"].empty?)
|
|
147
|
+
!@raw_data.nil? &&
|
|
148
|
+
((@raw_data.key?("candidates") && !@raw_data["candidates"].empty?) ||
|
|
149
|
+
(@raw_data.key?("predictions") && !@raw_data["predictions"].empty?) ||
|
|
150
|
+
embedding_response? ||
|
|
151
|
+
count_tokens_response?)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Check if the raw response contains embedding data
|
|
155
|
+
def embedding_response?
|
|
156
|
+
return false if @raw_data.nil?
|
|
157
|
+
(@raw_data.key?("embedding") && !@raw_data["embedding"].nil?) ||
|
|
158
|
+
(@raw_data.key?("embeddings") && @raw_data["embeddings"].is_a?(Array) && !@raw_data["embeddings"].empty?)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Get the embedding values as an Array of Floats.
|
|
162
|
+
# For single embedContent responses returns the values array.
|
|
163
|
+
# For batchEmbedContents responses returns the first embedding's values.
|
|
164
|
+
def embedding
|
|
165
|
+
return nil unless @raw_data
|
|
166
|
+
if @raw_data["embedding"].is_a?(Hash)
|
|
167
|
+
@raw_data["embedding"]["values"]
|
|
168
|
+
elsif @raw_data["embeddings"].is_a?(Array) && @raw_data["embeddings"].first.is_a?(Hash)
|
|
169
|
+
@raw_data["embeddings"].first["values"]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Get all embedding value arrays for batch responses.
|
|
174
|
+
# Returns an Array of Arrays of Floats.
|
|
175
|
+
# For single embedContent responses, returns a single-element array.
|
|
176
|
+
def embeddings
|
|
177
|
+
return [] unless @raw_data
|
|
178
|
+
if @raw_data["embeddings"].is_a?(Array)
|
|
179
|
+
@raw_data["embeddings"].map { |e| e["values"] }.compact
|
|
180
|
+
elsif @raw_data["embedding"].is_a?(Hash) && @raw_data["embedding"]["values"]
|
|
181
|
+
[@raw_data["embedding"]["values"]]
|
|
182
|
+
else
|
|
183
|
+
[]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Get the dimensionality (length) of the first embedding vector
|
|
188
|
+
def embedding_dimension
|
|
189
|
+
values = embedding
|
|
190
|
+
values.is_a?(Array) ? values.length : 0
|
|
76
191
|
end
|
|
77
192
|
|
|
78
193
|
# Get error message if any
|
|
@@ -191,6 +306,28 @@ module Gemini
|
|
|
191
306
|
def total_tokens
|
|
192
307
|
usage&.dig("totalTokens") || 0
|
|
193
308
|
end
|
|
309
|
+
|
|
310
|
+
# Check whether this response is a countTokens API result
|
|
311
|
+
def count_tokens_response?
|
|
312
|
+
!@raw_data.nil? && @raw_data.key?("totalTokens") &&
|
|
313
|
+
!@raw_data.key?("candidates") && !@raw_data.key?("predictions") &&
|
|
314
|
+
!embedding_response?
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Total tokens reported by the countTokens API (top-level totalTokens)
|
|
318
|
+
def count_tokens
|
|
319
|
+
@raw_data&.dig("totalTokens")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Cached content token count reported by countTokens
|
|
323
|
+
def cached_content_token_count
|
|
324
|
+
@raw_data&.dig("cachedContentTokenCount") || 0
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Per-modality token breakdown reported by countTokens
|
|
328
|
+
def prompt_tokens_details
|
|
329
|
+
@raw_data&.dig("promptTokensDetails") || []
|
|
330
|
+
end
|
|
194
331
|
|
|
195
332
|
# Process chunks for streaming responses
|
|
196
333
|
def stream_chunks
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Gemini
|
|
2
|
+
class Tokens
|
|
3
|
+
DEFAULT_MODEL = "gemini-2.5-flash".freeze
|
|
4
|
+
|
|
5
|
+
def initialize(client:)
|
|
6
|
+
@client = client
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Count tokens for the given input.
|
|
10
|
+
#
|
|
11
|
+
# input: String, Array of parts/contents, or Hash. Optional when `contents:` is given.
|
|
12
|
+
# contents: full Array of Content objects (overrides input).
|
|
13
|
+
# system_instruction: String or Content hash.
|
|
14
|
+
# tools: Array of tool definitions (passed via generateContentRequest form).
|
|
15
|
+
# generation_config: Hash forwarded as generationConfig.
|
|
16
|
+
# cached_content: cachedContents/* resource name.
|
|
17
|
+
def count(input = nil, model: DEFAULT_MODEL, contents: nil, system_instruction: nil,
|
|
18
|
+
tools: nil, generation_config: nil, cached_content: nil, **parameters)
|
|
19
|
+
normalized_model = normalize_model(model)
|
|
20
|
+
|
|
21
|
+
payload = build_payload(
|
|
22
|
+
model: normalized_model,
|
|
23
|
+
input: input,
|
|
24
|
+
contents: contents,
|
|
25
|
+
system_instruction: system_instruction,
|
|
26
|
+
tools: tools,
|
|
27
|
+
generation_config: generation_config,
|
|
28
|
+
cached_content: cached_content
|
|
29
|
+
).merge(parameters)
|
|
30
|
+
|
|
31
|
+
response = @client.json_post(
|
|
32
|
+
path: "models/#{normalized_model}:countTokens",
|
|
33
|
+
parameters: payload
|
|
34
|
+
)
|
|
35
|
+
Gemini::Response.new(response)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_payload(model:, input:, contents:, system_instruction:, tools:, generation_config:, cached_content:)
|
|
41
|
+
resolved_contents = contents || [format_content(input)]
|
|
42
|
+
|
|
43
|
+
# Use generateContentRequest form when extra request fields are present
|
|
44
|
+
if system_instruction || tools || generation_config || cached_content
|
|
45
|
+
# model is required inside the nested GenerateContentRequest
|
|
46
|
+
gc_request = { model: "models/#{model}", contents: resolved_contents }
|
|
47
|
+
gc_request[:systemInstruction] = format_content(system_instruction) if system_instruction
|
|
48
|
+
gc_request[:tools] = tools if tools
|
|
49
|
+
gc_request[:generationConfig] = generation_config if generation_config
|
|
50
|
+
gc_request[:cachedContent] = cached_content if cached_content
|
|
51
|
+
{ generateContentRequest: gc_request }
|
|
52
|
+
else
|
|
53
|
+
{ contents: resolved_contents }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def format_content(input)
|
|
58
|
+
case input
|
|
59
|
+
when nil
|
|
60
|
+
raise ArgumentError, "input or contents parameter is required"
|
|
61
|
+
when String
|
|
62
|
+
{ parts: [{ text: input }] }
|
|
63
|
+
when Array
|
|
64
|
+
{ parts: input.map { |part| part.is_a?(String) ? { text: part } : part } }
|
|
65
|
+
when Hash
|
|
66
|
+
input.key?(:parts) || input.key?("parts") ? input : { parts: [input] }
|
|
67
|
+
else
|
|
68
|
+
{ parts: [{ text: input.to_s }] }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def normalize_model(model)
|
|
73
|
+
model_str = model.to_s
|
|
74
|
+
model_str.start_with?("models/") ? model_str.delete_prefix("models/") : model_str
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/gemini/tts.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Gemini
|
|
2
|
+
class TTS
|
|
3
|
+
DEFAULT_MODEL = "gemini-2.5-flash-preview-tts".freeze
|
|
4
|
+
|
|
5
|
+
# 30 prebuilt voice names available for the prebuiltVoiceConfig
|
|
6
|
+
VOICES = %w[
|
|
7
|
+
Zephyr Puck Charon Kore Fenrir Leda Orus Aoede Callirrhoe Autonoe
|
|
8
|
+
Enceladus Iapetus Umbriel Algieba Despina Erinome Algenib Rasalgethi
|
|
9
|
+
Laomedeia Achernar Alnilam Schedar Gacrux Pulcherrima Achird
|
|
10
|
+
Zubenelgenubi Vindemiatrix Sadachbia Sadaltager Sulafat
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(client:)
|
|
14
|
+
@client = client
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Generate speech audio from text.
|
|
18
|
+
#
|
|
19
|
+
# text: prompt String (use style cues / bracket tags like [excited] for control,
|
|
20
|
+
# or "Speaker 1: ... Speaker 2: ..." for multi-speaker).
|
|
21
|
+
# voice: a single voice name (prebuiltVoiceConfig). Mutually exclusive with multi_speaker.
|
|
22
|
+
# multi_speaker: Array of { speaker:, voice: } Hashes for multi-speaker output.
|
|
23
|
+
# model: TTS preview model name. Defaults to gemini-2.5-flash-preview-tts.
|
|
24
|
+
# speech_config: raw speechConfig Hash override (skips voice/multi_speaker handling).
|
|
25
|
+
def generate(text, voice: nil, multi_speaker: nil, model: DEFAULT_MODEL,
|
|
26
|
+
speech_config: nil, **parameters)
|
|
27
|
+
raise ArgumentError, "text is required" if text.nil? || text.to_s.empty?
|
|
28
|
+
if voice && multi_speaker
|
|
29
|
+
raise ArgumentError, "voice and multi_speaker are mutually exclusive"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
resolved_speech_config = speech_config || build_speech_config(voice: voice, multi_speaker: multi_speaker)
|
|
33
|
+
raise ArgumentError, "voice, multi_speaker, or speech_config is required" unless resolved_speech_config
|
|
34
|
+
|
|
35
|
+
payload = {
|
|
36
|
+
contents: [{ parts: [{ text: text }] }],
|
|
37
|
+
generationConfig: {
|
|
38
|
+
responseModalities: ["AUDIO"],
|
|
39
|
+
speechConfig: resolved_speech_config
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
payload.merge!(parameters) if parameters && !parameters.empty?
|
|
44
|
+
|
|
45
|
+
response = @client.json_post(
|
|
46
|
+
path: "models/#{normalize_model(model)}:generateContent",
|
|
47
|
+
parameters: payload
|
|
48
|
+
)
|
|
49
|
+
Gemini::Response.new(response)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def build_speech_config(voice:, multi_speaker:)
|
|
55
|
+
if multi_speaker
|
|
56
|
+
speaker_voice_configs = multi_speaker.map do |entry|
|
|
57
|
+
speaker = entry[:speaker] || entry["speaker"]
|
|
58
|
+
v = entry[:voice] || entry["voice"]
|
|
59
|
+
raise ArgumentError, "multi_speaker entries require :speaker and :voice" unless speaker && v
|
|
60
|
+
validate_voice!(v)
|
|
61
|
+
{
|
|
62
|
+
speaker: speaker,
|
|
63
|
+
voiceConfig: { prebuiltVoiceConfig: { voiceName: v } }
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
{ multiSpeakerVoiceConfig: { speakerVoiceConfigs: speaker_voice_configs } }
|
|
67
|
+
elsif voice
|
|
68
|
+
validate_voice!(voice)
|
|
69
|
+
{ voiceConfig: { prebuiltVoiceConfig: { voiceName: voice } } }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_voice!(voice)
|
|
74
|
+
return if VOICES.include?(voice.to_s)
|
|
75
|
+
raise ArgumentError, "Unknown voice '#{voice}'. Available voices: #{VOICES.join(', ')}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def normalize_model(model)
|
|
79
|
+
model_str = model.to_s
|
|
80
|
+
model_str.start_with?("models/") ? model_str.delete_prefix("models/") : model_str
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/gemini/version.rb
CHANGED
data/lib/gemini.rb
CHANGED
|
@@ -12,6 +12,8 @@ require_relative "gemini/threads"
|
|
|
12
12
|
require_relative "gemini/messages"
|
|
13
13
|
require_relative "gemini/runs"
|
|
14
14
|
require_relative "gemini/embeddings"
|
|
15
|
+
require_relative "gemini/tokens"
|
|
16
|
+
require_relative "gemini/tts"
|
|
15
17
|
require_relative "gemini/audio"
|
|
16
18
|
require_relative "gemini/files"
|
|
17
19
|
require_relative "gemini/images"
|
|
@@ -20,6 +22,7 @@ require_relative "gemini/function_calling_helper"
|
|
|
20
22
|
require_relative "gemini/documents"
|
|
21
23
|
require_relative "gemini/cached_content"
|
|
22
24
|
require_relative "gemini/video"
|
|
25
|
+
require_relative "gemini/live"
|
|
23
26
|
|
|
24
27
|
module Gemini
|
|
25
28
|
class Error < StandardError; end
|