omniai-google 3.19.0 → 3.22.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4c5367190abf998fe07c957ea0bcb092660540d96e6a81bbe82b1f67e751895
4
- data.tar.gz: 1d37b3a65b76667d647f35091b432cfa4c94458d222a1d43120d509145045d94
3
+ metadata.gz: bdc1ade66e25bb33aca449a360b70e9e0df885996022ce6ed6bc8350137cf69c
4
+ data.tar.gz: f54782298b7bd730164e437564ac3184b23eeebb5df7779c429d9cc978d68983
5
5
  SHA512:
6
- metadata.gz: 4227aa6e92bc19605188ba27e0d5b3b33672f4d66b1cbfdc14a0d17c47ad205f86e893d131f9986c830df21135d778a97e962f1575c67adf5d72c5cac2d6b3ae
7
- data.tar.gz: eb585c514a31d9fa8577968ff5e558a9b9da3762d7c316e4528dfecd7e60890f408adf5172d94a2b7981e3382d00c75177b3a2c6f092a371f1685037ec6dea06
6
+ metadata.gz: 0d7b57da0258da82684ac694c35d056b77b60ac2edbf6b3146c674f84b2906d42457915672276a5c19a8ea24ec6aa7108359e224ce33660e2c5eb6bbb110b692
7
+ data.tar.gz: b577e9ce548f3cae58d0fbffea9a933c648855f48a5455c277f04d77fd842b839de095e767da1f235f648994fe265fc7814d9b7acfb1412d91d91c4b5667349d
data/README.md CHANGED
@@ -58,6 +58,30 @@ OmniAI::Google.configure do |config|
58
58
  end
59
59
  ```
60
60
 
61
+ #### Vertex Locations per Model
62
+
63
+ No single Vertex location serves every model. To route each model to its own location, set `locations`; the host is
64
+ derived from the location the way Google's SDKs do it (`global`, the `us` / `eu` multi-regions, or a single region):
65
+
66
+ ```ruby
67
+ OmniAI::Google.configure do |config|
68
+ config.credentials = File.open("./credentials.json")
69
+ config.project_id = '...'
70
+ config.locations = {
71
+ 'gemini-3.8-flash' => 'us', # https://aiplatform.us.rep.googleapis.com
72
+ 'gemini-2.5-flash' => 'us-central1', # https://us-central1-aiplatform.googleapis.com
73
+ 'gemini-embedding-001' => 'us-central1',
74
+ 'chirp_3' => 'us',
75
+ }
76
+ end
77
+ ```
78
+
79
+ - Applies to chat (including streaming), embed, transcribe and live. A mapped location is used as given.
80
+ - A model missing from the map raises `OmniAI::Google::UnknownLocationError` (with `#model`) before any request.
81
+ List every model you call, including any default you rely on (`Chat::DEFAULT_MODEL`, `Embed::DEFAULT_MODEL`,
82
+ `Transcribe::DEFAULT_MODEL`).
83
+ - A client built with an explicit `host:` or `location_id:` ignores the map.
84
+
61
85
  **Note for Transcription**: When using transcription features, ensure your service account has the necessary permissions for Google Cloud Speech-to-Text API and Google Cloud Storage (for automatic file uploads). See the [GCS Setup](#gcs-setup-for-transcription) section below for detailed configuration.
62
86
 
63
87
  Credentials may be configured using:
@@ -344,6 +368,36 @@ client.transcribe("audio.mp3", model: OmniAI::Google::Transcribe::Model::CHIRP)
344
368
 
345
369
  [Google Speech-to-Text API Reference](https://cloud.google.com/speech-to-text/docs)
346
370
 
371
+ #### Gemini Transcribe
372
+
373
+ `gemini-3.5-transcribe` is a Gemini model served by Vertex `generateContent`, not Speech-to-Text, so it needs Vertex
374
+ credentials. Pass it as the model; the Chirp models above are unaffected.
375
+
376
+ ```ruby
377
+ transcription = client.transcribe("interview.wav", model: OmniAI::Google::Transcribe::Model::GEMINI_3_5_TRANSCRIBE)
378
+ transcription.text
379
+
380
+ # Speaker labels per utterance (experimental, up to 8 speakers)
381
+ transcription = client.transcribe("interview.wav", model: "gemini-3.5-transcribe", diarization: true, format: "verbose_json")
382
+ transcription.segments # => [{ "id" => 0, "text" => "...", "speaker" => "spk:0" }, ...]
383
+
384
+ # Language hints and custom vocabulary (up to 1,000 terms); the language is auto-detected when omitted
385
+ client.transcribe("interview.wav", model: "gemini-3.5-transcribe", language: "es-US", vocabulary: %w[ibuprofeno])
386
+
387
+ # Word times (experimental, off by default; see the caveat below)
388
+ client.transcribe("interview.wav", model: "gemini-3.5-transcribe", word_timestamps: true, format: "verbose_json")
389
+ ```
390
+
391
+ - **Location:** served from `global` and the `us` / `eu` multi-regions, never a single region. A zonal `location_id`
392
+ maps to its multi-region (`us` by default, `eu` for `eu*` / `europe*`); `global` is kept.
393
+ - **Input:** wav, mp3, flac, ogg, webm, m4a or aiff (detected from the bytes), or a `gs://` URI. Files over 10 MB are
394
+ uploaded to the `<project_id>-speech-audio` bucket and deleted afterwards.
395
+ - **Length:** Google caps a file at 15 minutes when diarization or timestamps are on, so split longer recordings.
396
+ - **Word timestamps can drop speech.** In live tests on a two-speaker, 8-second clip (2026-10-15), `word_timestamps: true`
397
+ returned only the first word of the answer, and with `diarization: true` as well it dropped the whole first utterance.
398
+ Google labels both features experimental and warns that word timestamps degrade accuracy. `diarization:` alone
399
+ returned the complete transcript but no times.
400
+
347
401
  #### GCS Setup for Transcription
348
402
 
349
403
  For transcription to work properly with automatic file uploads, you need to set up Google Cloud Storage and configure the appropriate permissions.
@@ -426,6 +480,59 @@ client.transcribe("audio.mp3", bucket_name: "my-custom-audio-bucket")
426
480
  - Audio files are automatically deleted after successful transcription
427
481
  - If transcription fails, temporary files may remain and should be cleaned up manually
428
482
 
483
+ ### Live
484
+
485
+ A Gemini Live session (Vertex `BidiGenerateContent` over a WebSocket): stream microphone audio in, get speech,
486
+ transcripts and tool calls back. Vertex credentials only.
487
+
488
+ ```ruby
489
+ client = OmniAI::Google::Client.new(
490
+ project_id: "...", credentials: "...",
491
+ location_id: "us", host: "https://aiplatform.us.rep.googleapis.com" # gemini-3.8-live: us, eu or us-central1 (not global)
492
+ )
493
+
494
+ session = client.live(
495
+ model: OmniAI::Google::Live::Model::GEMINI_3_8_LIVE,
496
+ system: "You are an intake assistant...",
497
+ tools: [RecordAnswer.new], # OmniAI::Tool; run automatically, results sent back
498
+ tool_behavior: :blocking, # default; or :non_blocking, or { "flag" => :non_blocking }
499
+ voice: "Kore", # optional
500
+ vad: { silence_ms: 800 }, # optional
501
+ compression: { trigger_tokens: 10_000, target_tokens: 5_000 } # optional (or true): see Limits
502
+ ) do |event|
503
+ case event
504
+ when OmniAI::Google::Live::Event::Audio then speaker.write(event.data) # PCM16, 24 kHz, mono
505
+ when OmniAI::Google::Live::Event::Transcript then caption(event.role, event.text) # :user / :model
506
+ when OmniAI::Google::Live::Event::Interrupted then speaker.flush
507
+ when OmniAI::Google::Live::Event::GoAway then reconnect_within(event.time_left)
508
+ when OmniAI::Google::Live::Event::Usage then track(event.usage, event.data) # data has per-modality counts
509
+ when OmniAI::Google::Live::Event::Closed then reconnect_or_finish(event.code, event.error)
510
+ end
511
+ end
512
+
513
+ session.send_audio(pcm16_16khz) # from any thread
514
+ session.send_text("We are at question 12; answers so far: ...") # e.g. to re-seed after reconnecting
515
+ session.close
516
+ ```
517
+
518
+ - **Threads:** each session has one socket and one reader thread. Events are yielded, and tools run, on that thread;
519
+ `send_*` may be called from any thread. A send that blocks longer than `write_timeout:` (default 5 s) closes the
520
+ session and raises `Live::TimeoutError`, as does a connect (TCP + TLS) slower than 10 s. An idle connection is pinged
521
+ every 15 s; with no answer in 10 s it is dropped, so a silently dead path still ends the session. The session always
522
+ ends with exactly one `Event::Closed`, including when the socket drops or your handler raises (`event.error`).
523
+ - **Tools:** a raising tool answers the model with its error class only, and the session carries on.
524
+ - **Limits (Google):** a connection lasts about 10 minutes, and an audio-only session 15 minutes without
525
+ `compression:`. Measured live at `us` (2026-10-15): `GoAway` arrived at 9 min (`time_left` 30 s, not the 60 s
526
+ Google documents) and the server closed with 1000 at 9.5 min. Reconnect when `GoAway` arrives, not on a timer: the documented numbers are off.
527
+ - **Cost:** every turn re-bills the whole context. `compression:` bounds it: in a live test with a 10,000-token trigger
528
+ and 5,000-token target, `promptTokenCount` dropped from about 8,500 to 3,400 whenever the next turn would pass the
529
+ trigger (a sawtooth instead of linear growth; 51% fewer prompt tokens over 12 turns). The oldest turns are dropped,
530
+ so keep anything the model must remember in your own state and re-send it when needed. The gem does not reconnect: open a new session and re-seed it
531
+ (`resumption:` takes Google's resumption handles if you prefer that).
532
+ - **Privacy:** the gem never logs or raises audio, transcripts or tool arguments. `Live::Error#message` carries codes and
533
+ ids only; Google's close reason is on `#reason`, and `Event::Closed#inspect` leaves it out. A server frame that fails
534
+ to parse closes the session with a `Live::Error` naming only the exception class.
535
+
429
536
  ### Embed
430
537
 
431
538
  Text can be converted into a vector embedding for similarity comparison usage via:
@@ -76,6 +76,11 @@ module OmniAI
76
76
  context.deserializers[:thinking] = ThinkingSerializer.method(:deserialize)
77
77
  end
78
78
 
79
+ # `client` is swapped for `client.for_model(model)`, so `locations` routes the request.
80
+ def initialize(prompt = nil, client:, model:, **, &)
81
+ super(prompt, client: client.for_model(model), model:, **, &)
82
+ end
83
+
79
84
  protected
80
85
 
81
86
  # @return [Context]
@@ -20,6 +20,10 @@ module OmniAI
20
20
  #
21
21
  # client = OmniAI::Google::Client.new
22
22
  class Client < OmniAI::Client
23
+ # Marks `host` / `location_id` as not passed, so an explicit value can take precedence over `locations`.
24
+ UNSET = Object.new.freeze
25
+ private_constant :UNSET
26
+
23
27
  # @!attribute [rw] version
24
28
  # @return [String, nil]
25
29
  attr_accessor :version
@@ -32,28 +36,56 @@ module OmniAI
32
36
  # @param version [String] default is `OmniAI::Google.config.version`
33
37
  # @param logger [Logger] default is `OmniAI::Google.config.logger`
34
38
  # @param timeout [Integer] default is `OmniAI::Google.config.timeout`
39
+ # @param locations [Hash{String => String}, nil] default is `OmniAI::Google.config.locations`; ignored when
40
+ # `host` or `location_id` is passed
35
41
  def initialize(
36
42
  api_key: OmniAI::Google.config.api_key,
37
43
  project_id: OmniAI::Google.config.project_id,
38
- location_id: OmniAI::Google.config.location_id,
44
+ location_id: UNSET,
39
45
  credentials: OmniAI::Google.config.credentials,
40
46
  logger: OmniAI::Google.config.logger,
41
- host: OmniAI::Google.config.host,
47
+ host: UNSET,
42
48
  version: nil,
43
- timeout: OmniAI::Google.config.timeout
49
+ timeout: OmniAI::Google.config.timeout,
50
+ locations: OmniAI::Google.config.locations
44
51
  )
45
52
  if api_key.nil? && credentials.nil?
46
53
  raise(ArgumentError, "either an `api_key` or `credentials` must be provided")
47
54
  end
48
55
 
56
+ pinned = !(UNSET.equal?(host) && UNSET.equal?(location_id))
57
+ @locations = locations unless pinned
58
+ host = OmniAI::Google.config.host if UNSET.equal?(host)
59
+ location_id = OmniAI::Google.config.location_id if UNSET.equal?(location_id)
60
+
49
61
  super(api_key:, host:, logger:, timeout:)
50
62
 
51
63
  @project_id = project_id
52
64
  @location_id = location_id
53
65
  @credentials = Credentials.parse(credentials)
66
+ @version_override = version
54
67
  @version = version || default_version
55
68
  end
56
69
 
70
+ # A copy of this client pointed at the model's location in `locations`, or this client when no map applies.
71
+ #
72
+ # @param model [String]
73
+ #
74
+ # @raise [UnknownLocationError] when the map has no entry for the model
75
+ #
76
+ # @return [Client]
77
+ def for_model(model)
78
+ return self if @locations.nil?
79
+
80
+ location_id = @locations.fetch(model) { raise UnknownLocationError, model }
81
+ dup.tap { |client| client.locate!(location_id) }
82
+ end
83
+
84
+ # @return [Boolean] true for a client returned by `#for_model`
85
+ def located?
86
+ @located || false
87
+ end
88
+
57
89
  # @raise [OmniAI::Error]
58
90
  #
59
91
  # @param messages [String] optional
@@ -96,8 +128,20 @@ module OmniAI
96
128
  # @param model [String] optional
97
129
  # @param language [String, Array<String>] optional - language codes for transcription
98
130
  # @param format [Symbol] optional - :json or :verbose_json
99
- def transcribe(input, model: Transcribe::DEFAULT_MODEL, language: nil, format: nil)
100
- Transcribe.process!(input, model:, language:, format:, client: self)
131
+ # @param options [Hash] Gemini transcribe models only: `diarization:`, `word_timestamps:`, `vocabulary:`
132
+ def transcribe(input, model: Transcribe::DEFAULT_MODEL, language: nil, format: nil, **)
133
+ Transcribe.process!(input, model:, language:, format:, client: self, **)
134
+ end
135
+
136
+ # Opens a Gemini Live session (Vertex only). See `OmniAI::Google::Live#initialize` for the options.
137
+ #
138
+ # @raise [OmniAI::Google::Live::Error]
139
+ #
140
+ # @yieldparam event [OmniAI::Google::Live::Event::*]
141
+ #
142
+ # @return [OmniAI::Google::Live]
143
+ def live(model: Live::DEFAULT_MODEL, **, &)
144
+ Live.open(client: self, model:, **, &)
101
145
  end
102
146
 
103
147
  # @return [String]
@@ -143,6 +187,16 @@ module OmniAI
143
187
  false
144
188
  end
145
189
 
190
+ protected
191
+
192
+ # @param location_id [String]
193
+ def locate!(location_id)
194
+ @location_id = location_id
195
+ @host = Vertex.host(location_id)
196
+ @version = @version_override || Config::Version::STABLE
197
+ @located = true
198
+ end
199
+
146
200
  private
147
201
 
148
202
  # Vertex AI serves `v1`; the Gemini API serves `v1beta`. `OmniAI::Google.config.version` derives from the
@@ -19,6 +19,12 @@ module OmniAI
19
19
  # @return [String, nil]
20
20
  attr_accessor :location_id
21
21
 
22
+ # @!attribute [rw] locations
23
+ # Vertex location per model, e.g. `{ "gemini-3.8-flash" => "us" }`. When set, each request derives its host
24
+ # from the model's location, and a model missing from the map raises `UnknownLocationError`.
25
+ # @return [Hash{String => String}, nil]
26
+ attr_accessor :locations
27
+
22
28
  # @param api_key [String, nil] optional - defaults to `ENV['GOOGLE_API_KEY']`
23
29
  # @param project_id [String, nil] optional - defaults to `ENV['GOOGLE_PROJECT_ID']`
24
30
  # @param location_id [String, nil] optional - defaults to `ENV['GOOGLE_LOCATION_ID']`
@@ -72,6 +72,11 @@ module OmniAI
72
72
  context.deserializers[:usage] = USAGE_METADATA_DESERIALIZER
73
73
  end
74
74
 
75
+ # `client` is swapped for `client.for_model(model)`, so `locations` routes the request.
76
+ def initialize(input, client:, model:, **)
77
+ super(input, client: client.for_model(model), model:, **)
78
+ end
79
+
75
80
  protected
76
81
 
77
82
  # Determines which endpoint to use based on client and model configuration.
@@ -155,7 +160,7 @@ module OmniAI
155
160
 
156
161
  # @return [String]
157
162
  def path
158
- "/#{@client.path}/models/#{@model}:#{PROCEDURES[endpoint]}"
163
+ "#{@client.path}/models/#{@model}:#{PROCEDURES[endpoint]}"
159
164
  end
160
165
  end
161
166
  end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "websocket/driver"
5
+
6
+ module OmniAI
7
+ module Google
8
+ class Live
9
+ # A WebSocket client over TCP or TLS, driven by websocket-driver with one reader thread per connection.
10
+ #
11
+ # Callbacks run on the reader thread. Writes may come from any thread: they are serialized by a mutex and bounded
12
+ # by `write_timeout`, so a stalled peer can't block the caller indefinitely. An idle connection is pinged, so a
13
+ # silently dropped path is noticed. `on_close` is called exactly once, whether the peer closes, the socket hits
14
+ # EOF or errors, a write or pong times out, or a callback raises.
15
+ class Connection
16
+ CONNECT_TIMEOUT = 10 # seconds, TCP and TLS together
17
+ CLOSE_TIMEOUT = 2 # seconds
18
+ PING_INTERVAL = 15 # seconds without a frame before pinging
19
+ PONG_TIMEOUT = 10 # seconds
20
+ READ_CHUNK = 16_384 # bytes
21
+
22
+ class ConnectTimeout < StandardError; end
23
+ class WriteTimeout < StandardError; end
24
+ class PongTimeout < StandardError; end
25
+
26
+ # @return [String]
27
+ attr_reader :url
28
+
29
+ # @param url [String] a ws:// or wss:// URL
30
+ # @param headers [Hash] sent with the upgrade request
31
+ # @param write_timeout [Numeric] seconds
32
+ # @param on_open [Proc] called with no arguments
33
+ # @param on_message [Proc] called with the payload String
34
+ # @param on_close [Proc] called with (code, reason, error)
35
+ def initialize(url:, headers:, write_timeout:, on_open:, on_message:, on_close:)
36
+ @url = url
37
+ @headers = headers
38
+ @write_timeout = write_timeout
39
+ @on_open = on_open
40
+ @on_message = on_message
41
+ @on_close = on_close
42
+ @mutex = Mutex.new
43
+ @pending = []
44
+ @finished = false
45
+ end
46
+
47
+ # @raise [ConnectTimeout]
48
+ def connect
49
+ @io = open_socket
50
+ @wake_reader, @wake_writer = IO.pipe # for abort!: on Linux, closing a socket doesn't interrupt a select on it
51
+ @driver = WebSocket::Driver.client(self, binary_data_format: :string)
52
+ @headers.each { |name, value| @driver.set_header(name, value) }
53
+ @driver.on(:open) { @pending << [:open] }
54
+ @driver.on(:message) { |event| @pending << [:message, event.data] }
55
+ @driver.on(:close) { |event| @pending << [:close, event.code, event.reason] }
56
+ @mutex.synchronize { @driver.start }
57
+ @reader = Thread.new { read_loop }
58
+ @reader.name = "omniai-google-live"
59
+ @reader.report_on_exception = false
60
+ rescue StandardError
61
+ [@io, @wake_reader, @wake_writer].compact.each { |io| close_quietly(io) } unless @reader
62
+ raise
63
+ end
64
+
65
+ # @return [Boolean]
66
+ def open?
67
+ !@finished && !@aborted
68
+ end
69
+
70
+ # @param payload [String]
71
+ #
72
+ # @raise [WriteTimeout]
73
+ def send_text(payload)
74
+ @mutex.synchronize { @driver.text(payload) }
75
+ rescue WriteTimeout => e
76
+ abort!(e)
77
+ raise
78
+ end
79
+
80
+ # Sends a close frame and waits briefly for the peer's reply before dropping the socket. Called from a callback
81
+ # (the reader thread itself), it only sends the frame: the reader finishes when the reply arrives.
82
+ def close
83
+ return if @finished || @reader.nil?
84
+
85
+ begin
86
+ @mutex.synchronize { @driver.close }
87
+ rescue WriteTimeout, IOError, SystemCallError
88
+ nil
89
+ end
90
+ return if Thread.current == @reader
91
+
92
+ abort! unless @reader.join(CLOSE_TIMEOUT)
93
+ @reader.join(CLOSE_TIMEOUT)
94
+ end
95
+
96
+ # Called by websocket-driver (under the mutex) for every outgoing frame.
97
+ #
98
+ # @param data [String]
99
+ def write(data)
100
+ deadline = monotonic + @write_timeout
101
+ data = data.b
102
+ until data.empty?
103
+ result = @io.write_nonblock(data, exception: false)
104
+ case result
105
+ when :wait_writable then wait!(nil, [@io.to_io], deadline) { write_timeout }
106
+ when :wait_readable then wait!([@io.to_io], nil, deadline) { write_timeout }
107
+ else data = data.byteslice(result..)
108
+ end
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ # @return [WriteTimeout]
115
+ def write_timeout
116
+ WriteTimeout.new("write timed out after #{@write_timeout}s")
117
+ end
118
+
119
+ # @return [Socket, OpenSSL::SSL::SSLSocket]
120
+ def open_socket
121
+ uri = URI.parse(@url)
122
+ deadline = monotonic + CONNECT_TIMEOUT
123
+ tcp = begin
124
+ Socket.tcp(uri.host, uri.port, connect_timeout: CONNECT_TIMEOUT)
125
+ rescue Errno::ETIMEDOUT, IO::TimeoutError
126
+ raise ConnectTimeout, "connect timed out after #{CONNECT_TIMEOUT}s"
127
+ end
128
+ return tcp unless uri.scheme == "wss"
129
+
130
+ handshake(tcp, uri.host, deadline)
131
+ end
132
+
133
+ # @return [OpenSSL::SSL::SSLSocket]
134
+ def handshake(tcp, host, deadline)
135
+ context = OpenSSL::SSL::SSLContext.new
136
+ context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
137
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
138
+ ssl.hostname = host
139
+ ssl.sync_close = true
140
+ timeout = -> { ConnectTimeout.new("connect timed out after #{CONNECT_TIMEOUT}s") }
141
+ loop do
142
+ case ssl.connect_nonblock(exception: false)
143
+ when :wait_readable then wait!([tcp], nil, deadline, &timeout)
144
+ when :wait_writable then wait!(nil, [tcp], deadline, &timeout)
145
+ else return ssl
146
+ end
147
+ end
148
+ rescue StandardError
149
+ tcp.close
150
+ raise
151
+ end
152
+
153
+ def read_loop
154
+ loop do
155
+ data = read_chunk
156
+ @ping_at = nil
157
+ events = @mutex.synchronize do
158
+ @driver.parse(data)
159
+ @pending.slice!(0..)
160
+ end
161
+ events.each { |event| dispatch(*event) }
162
+ break if @finished
163
+ end
164
+ rescue EOFError
165
+ finish(nil, nil, @abort_error)
166
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError => e
167
+ finish(nil, nil, @abort_error || e)
168
+ rescue StandardError => e # a callback raised, or a ping went unanswered
169
+ finish(nil, nil, e)
170
+ ensure
171
+ finish(nil, nil, @abort_error)
172
+ end
173
+
174
+ # Waits for data, pinging when idle for PING_INTERVAL. Any frame (a pong included) counts as an answer.
175
+ #
176
+ # @raise [PongTimeout, EOFError]
177
+ #
178
+ # @return [String]
179
+ def read_chunk
180
+ loop do
181
+ result = @io.read_nonblock(READ_CHUNK, exception: false)
182
+ case result
183
+ when :wait_readable then idle([@io.to_io], nil)
184
+ when :wait_writable then idle(nil, [@io.to_io])
185
+ when nil then raise EOFError
186
+ else return result
187
+ end
188
+ end
189
+ end
190
+
191
+ def idle(readers, writers)
192
+ timeout = @ping_at ? @ping_at + PONG_TIMEOUT - monotonic : PING_INTERVAL
193
+ ready = IO.select([*readers, @wake_reader], writers, nil, timeout) if timeout.positive?
194
+ raise IOError, "aborted" if @aborted
195
+ return if ready
196
+
197
+ raise PongTimeout, "no pong within #{PONG_TIMEOUT}s" if @ping_at
198
+
199
+ @ping_at = monotonic
200
+ @mutex.synchronize { @driver.ping }
201
+ end
202
+
203
+ def dispatch(type, *args)
204
+ case type
205
+ when :open then @on_open.call
206
+ when :message then @on_message.call(args.first)
207
+ when :close then finish(args[0], args[1], nil)
208
+ end
209
+ end
210
+
211
+ # Drops the socket; the reader thread then finishes with `error`.
212
+ def abort!(error = nil)
213
+ @abort_error ||= error
214
+ @aborted = true
215
+ close_quietly(@io)
216
+ @wake_writer.write_nonblock(".", exception: false)
217
+ rescue IOError
218
+ nil # already finished
219
+ end
220
+
221
+ def finish(code, reason, error)
222
+ return if @finished
223
+
224
+ @finished = true
225
+ [@io, @wake_reader, @wake_writer].each { |io| close_quietly(io) }
226
+ begin
227
+ @on_close.call(code, reason, error)
228
+ rescue StandardError
229
+ nil
230
+ end
231
+ end
232
+
233
+ def close_quietly(io)
234
+ io.close
235
+ rescue IOError, SystemCallError
236
+ nil
237
+ end
238
+
239
+ # @yieldreturn [StandardError] raised once the deadline passes
240
+ def wait!(readers, writers, deadline)
241
+ remaining = deadline - monotonic
242
+ return if remaining.positive? && IO.select(readers, writers, nil, remaining)
243
+
244
+ raise yield
245
+ end
246
+
247
+ def monotonic
248
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAI
4
+ module Google
5
+ class Live
6
+ # Events yielded by a Live session, on its reader thread.
7
+ module Event
8
+ # Model speech: raw PCM16 (24 kHz, mono, little-endian) bytes.
9
+ Audio = Data.define(:data, :mime_type) do
10
+ def inspect = "#<#{self.class.name} bytes=#{data.bytesize} mime_type=#{mime_type}>"
11
+ end
12
+
13
+ # Model text (only when the session asks for text responses).
14
+ Text = Data.define(:text)
15
+
16
+ # A transcript fragment of the user's (`:user`) or the model's (`:model`) speech.
17
+ Transcript = Data.define(:role, :text, :finished)
18
+
19
+ # @!attribute calls [Array<FunctionCall>]
20
+ ToolCall = Data.define(:calls)
21
+ FunctionCall = Data.define(:id, :name, :arguments)
22
+ ToolCallCancellation = Data.define(:ids)
23
+
24
+ # The user spoke over the model; drop any audio still queued for playback.
25
+ Interrupted = Data.define
26
+ GenerationComplete = Data.define
27
+ TurnComplete = Data.define
28
+
29
+ # Speech start/end detected by the server; `offset` is seconds into the session's input audio.
30
+ VoiceActivity = Data.define(:type, :offset)
31
+
32
+ # The server will end the connection in `time_left` seconds.
33
+ GoAway = Data.define(:time_left)
34
+ ResumptionUpdate = Data.define(:handle, :resumable)
35
+
36
+ # @!attribute usage [OmniAI::Chat::Usage] (`output_tokens` includes thinking)
37
+ # @!attribute data [Hash] the raw usageMetadata, with per-modality token details
38
+ Usage = Data.define(:usage, :data)
39
+
40
+ # The session ended. `code` is nil when the connection dropped without a close frame; `error` is set when it
41
+ # ended because of an exception (socket error, write timeout, or a raising handler). Google's close `reason`
42
+ # can echo request content, so `inspect` leaves it out.
43
+ Closed = Data.define(:code, :reason, :error) do
44
+ def inspect = "#<#{self.class.name} code=#{code.inspect} error=#{error&.class.inspect}>"
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end