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 +4 -4
- data/README.md +107 -0
- data/lib/omniai/google/chat.rb +5 -0
- data/lib/omniai/google/client.rb +59 -5
- data/lib/omniai/google/config.rb +6 -0
- data/lib/omniai/google/embed.rb +6 -1
- data/lib/omniai/google/live/connection.rb +253 -0
- data/lib/omniai/google/live/event.rb +49 -0
- data/lib/omniai/google/live.rb +411 -0
- data/lib/omniai/google/transcribe/gemini.rb +218 -0
- data/lib/omniai/google/transcribe.rb +22 -0
- data/lib/omniai/google/transcribe_helpers.rb +4 -0
- data/lib/omniai/google/unknown_location_error.rb +17 -0
- data/lib/omniai/google/version.rb +1 -1
- data/lib/omniai/google/vertex.rb +25 -0
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bdc1ade66e25bb33aca449a360b70e9e0df885996022ce6ed6bc8350137cf69c
|
|
4
|
+
data.tar.gz: f54782298b7bd730164e437564ac3184b23eeebb5df7779c429d9cc978d68983
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
data/lib/omniai/google/chat.rb
CHANGED
|
@@ -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]
|
data/lib/omniai/google/client.rb
CHANGED
|
@@ -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:
|
|
44
|
+
location_id: UNSET,
|
|
39
45
|
credentials: OmniAI::Google.config.credentials,
|
|
40
46
|
logger: OmniAI::Google.config.logger,
|
|
41
|
-
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
|
-
|
|
100
|
-
|
|
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
|
data/lib/omniai/google/config.rb
CHANGED
|
@@ -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']`
|
data/lib/omniai/google/embed.rb
CHANGED
|
@@ -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
|
-
"
|
|
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
|