omniai-google 3.14.0 → 3.17.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 +24 -0
- data/lib/omniai/google/chat/stream.rb +58 -0
- data/lib/omniai/google/chat.rb +22 -1
- data/lib/omniai/google/client.rb +18 -1
- data/lib/omniai/google/incomplete_stream_error.rb +10 -0
- data/lib/omniai/google/stream_error.rb +28 -0
- data/lib/omniai/google/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9fd43c08069868c8d20d91ac6e1028baf97643eda9018a0db146c73ce0828c15
|
|
4
|
+
data.tar.gz: 04ca6d4c4789b91c194abb44f8d2d178f1e65605644fe3c72a72824e8707a9e2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3b2076e4c7f504e899015fbd0c8fb4f3290d347d2191051cdad1a6e079f45b6086bf8b6725693d073c45e02adf85bc43be7aace65a37fce282a163ed338596c6
|
|
7
|
+
data.tar.gz: ebf586d81abd71f7959f12f85159a27b3a5a53cd4966adcc2e05bec009341378fc306140ca0f94e129a3a4df1875ba2b5433e1b2b738e2c78c6a2eaf24ab0e87
|
data/README.md
CHANGED
|
@@ -118,6 +118,30 @@ end
|
|
|
118
118
|
client.chat('Be poetic.', stream:)
|
|
119
119
|
```
|
|
120
120
|
|
|
121
|
+
#### Max Output Tokens
|
|
122
|
+
|
|
123
|
+
`max_tokens:` caps the response, mapping to Gemini's `generationConfig.maxOutputTokens`:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
client.chat("Summarize this page.", model: "gemini-3.7-flash", max_tokens: 8_000)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
It can also be set globally, and a per-call value wins:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
OmniAI::Google.configure do |config|
|
|
133
|
+
config.chat_options[:max_tokens] = 8_000
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The value is passed through unchanged — no floor is imposed, so the number you ask for is the number that reaches the wire. When the cap is hit, `response.finish_reason.reason` is `:length`.
|
|
138
|
+
|
|
139
|
+
**Size it as thinking headroom plus expected answer.** Unlike Anthropic's answer-only ceiling, Gemini spends this budget on thinking *before* emitting an answer. A cap sized to the expected answer alone gets consumed by thinking on any request the model reasons about:
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
max_tokens: 200 -> finish_reason :length, 196 output tokens, 115 of them thinking, 81 characters of answer
|
|
143
|
+
```
|
|
144
|
+
|
|
121
145
|
#### Extended Thinking
|
|
122
146
|
|
|
123
147
|
Gemini models support extended thinking, which shows the model's reasoning process.
|
|
@@ -18,11 +18,69 @@ module OmniAI
|
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
validate!
|
|
21
22
|
@data
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
protected
|
|
25
26
|
|
|
27
|
+
# Google reports a post-200 failure as an `error` object in the stream, which
|
|
28
|
+
# `process_data!` otherwise copies into @data and returns as an empty success.
|
|
29
|
+
def validate!
|
|
30
|
+
error = @data["error"]
|
|
31
|
+
if error
|
|
32
|
+
raise StreamError.new("the stream carried an error: #{error_summary(error)}",
|
|
33
|
+
provider_message: error["message"])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# No candidates at all (a usageMetadata-only stream) is incomplete too.
|
|
37
|
+
candidates = @data["candidates"] || []
|
|
38
|
+
incomplete = candidates.select { |candidate| incomplete?(candidate) }
|
|
39
|
+
return unless candidates.empty? || incomplete.any?
|
|
40
|
+
|
|
41
|
+
raise IncompleteStreamError,
|
|
42
|
+
"the stream ended without a finish reason: #{candidates_summary(incomplete)}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Only thought-only-and-empty counts. A missing finishReason alone would also condemn
|
|
46
|
+
# an unterminated stream that did deliver an answer, and discarding a usable answer to
|
|
47
|
+
# retry is the more expensive mistake.
|
|
48
|
+
def incomplete?(candidate)
|
|
49
|
+
return false if candidate["finishReason"]
|
|
50
|
+
|
|
51
|
+
parts = candidate.dig("content", "parts") || []
|
|
52
|
+
parts.none? { |part| part["functionCall"] || answer_part?(part) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# "" is truthy in Ruby, so an empty text part must not count as an answer.
|
|
56
|
+
def answer_part?(part)
|
|
57
|
+
!thought_part?(part) && !part["text"].to_s.strip.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Code and status only. Google's `message` can echo request content back, and consumers
|
|
61
|
+
# log these, so it is omitted for the same reason candidates_summary omits part text.
|
|
62
|
+
#
|
|
63
|
+
# @param error [Hash]
|
|
64
|
+
# @return [String]
|
|
65
|
+
def error_summary(error)
|
|
66
|
+
"code=#{error['code'].inspect} status=#{error['status'].inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Structure only: consumers log these messages and part text may be sensitive.
|
|
70
|
+
#
|
|
71
|
+
# @param candidates [Array<Hash>]
|
|
72
|
+
# @return [String]
|
|
73
|
+
def candidates_summary(candidates)
|
|
74
|
+
return "candidates=0" if candidates.empty?
|
|
75
|
+
|
|
76
|
+
candidates.map.with_index do |candidate, index|
|
|
77
|
+
parts = candidate.dig("content", "parts") || []
|
|
78
|
+
"candidate=#{index} parts=#{parts.size} " \
|
|
79
|
+
"thought_parts=#{parts.count { |part| part['thought'] }} " \
|
|
80
|
+
"finish_reason=#{candidate['finishReason'].inspect}"
|
|
81
|
+
end.join("; ")
|
|
82
|
+
end
|
|
83
|
+
|
|
26
84
|
# @yield [delta]
|
|
27
85
|
# @yieldparam delta [OmniAI::Chat::Delta]
|
|
28
86
|
#
|
data/lib/omniai/google/chat.rb
CHANGED
|
@@ -93,9 +93,15 @@ module OmniAI
|
|
|
93
93
|
}.compact, json: payload)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
# `chat_options` is forwarded verbatim, so a key this class builds itself must be excluded or it is sent
|
|
97
|
+
# twice — and `max_tokens` is OmniAI's normalized name, not one Gemini knows. Left in, it reaches the
|
|
98
|
+
# wire as an unknown top-level field and the request fails with
|
|
99
|
+
# `Invalid JSON payload received. Unknown name "max_tokens"`. `#generation_config` consumes it and
|
|
100
|
+
# emits `maxOutputTokens` instead.
|
|
101
|
+
#
|
|
96
102
|
# @return [Hash]
|
|
97
103
|
def payload
|
|
98
|
-
OmniAI::Google.config.chat_options.merge({
|
|
104
|
+
OmniAI::Google.config.chat_options.except(:max_tokens).merge({
|
|
99
105
|
system_instruction: @prompt.messages.find(&:system?)&.serialize(context:),
|
|
100
106
|
contents: @prompt.messages.reject(&:system?).map { |message| message.serialize(context:) },
|
|
101
107
|
tools:,
|
|
@@ -138,11 +144,26 @@ module OmniAI
|
|
|
138
144
|
|
|
139
145
|
data[:temperature] = @temperature if @temperature
|
|
140
146
|
data[:thinkingConfig] = thinking_config if @options[:thinking]
|
|
147
|
+
data[:maxOutputTokens] = max_tokens if max_tokens
|
|
141
148
|
|
|
142
149
|
data = data.compact
|
|
143
150
|
data unless data.empty?
|
|
144
151
|
end
|
|
145
152
|
|
|
153
|
+
# A per-call `max_tokens:`, falling back to `config.chat_options[:max_tokens]` so a cap can also be set
|
|
154
|
+
# globally — the same precedence omniai-anthropic applies. The value is passed through unchanged: no
|
|
155
|
+
# floor is imposed, so the number a caller asks for is the number that reaches the wire.
|
|
156
|
+
#
|
|
157
|
+
# Note that Gemini spends this budget on thinking BEFORE emitting an answer, unlike Anthropic's
|
|
158
|
+
# answer-only ceiling. A cap sized to the expected answer alone will be consumed by thinking on any
|
|
159
|
+
# request the model reasons about, returning `finishReason: MAX_TOKENS` with little or no text. Size it
|
|
160
|
+
# as thinking headroom plus expected answer.
|
|
161
|
+
#
|
|
162
|
+
# @return [Integer, nil]
|
|
163
|
+
def max_tokens
|
|
164
|
+
@options[:max_tokens] || OmniAI::Google.config.chat_options[:max_tokens]
|
|
165
|
+
end
|
|
166
|
+
|
|
146
167
|
# @return [String]
|
|
147
168
|
def path
|
|
148
169
|
"#{@client.path}/models/#{@model}:#{operation}"
|
data/lib/omniai/google/client.rb
CHANGED
|
@@ -121,9 +121,26 @@ module OmniAI
|
|
|
121
121
|
!@credentials.nil?
|
|
122
122
|
end
|
|
123
123
|
|
|
124
|
+
# Vertex AI is served from three host shapes under `googleapis.com`: the global `aiplatform`, a
|
|
125
|
+
# region-prefixed `<region>-aiplatform`, and the multi-region `aiplatform.<geo>.rep`. Only the first two
|
|
126
|
+
# contain the literal "aiplatform.googleapis.com", so a substring test misses the multi-region endpoint.
|
|
127
|
+
#
|
|
128
|
+
# Deliberately wider than those three shapes: any labels are accepted between `aiplatform` and
|
|
129
|
+
# `googleapis.com`, so a future multi-region shape needs no change here. Everything it accepts is still
|
|
130
|
+
# under `googleapis.com`.
|
|
131
|
+
#
|
|
132
|
+
# Matched against the parsed hostname rather than the raw host, so a proxy whose path or query merely
|
|
133
|
+
# mentions a Vertex host is not treated as Vertex. A host given with no scheme has no hostname to parse, so
|
|
134
|
+
# the raw value is matched instead; the pattern is anchored, which keeps that safe — note that a schemeless
|
|
135
|
+
# host carrying a path therefore does not match.
|
|
136
|
+
VERTEX_HOSTNAME = /\A(?:[a-z0-9-]+-)?aiplatform(?:\.[a-z0-9-]+)*\.googleapis\.com\z/
|
|
137
|
+
|
|
124
138
|
# @return [Boolean]
|
|
125
139
|
def vertex?
|
|
126
|
-
@host.
|
|
140
|
+
hostname = URI.parse(@host).hostname || @host
|
|
141
|
+
VERTEX_HOSTNAME.match?(hostname.downcase)
|
|
142
|
+
rescue URI::InvalidURIError
|
|
143
|
+
false
|
|
127
144
|
end
|
|
128
145
|
|
|
129
146
|
private
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Google
|
|
5
|
+
# Raised when a stream ends with no `finishReason` and produced nothing but thinking.
|
|
6
|
+
# Rescue StreamError to catch this and stream errors together. `#provider_message` is
|
|
7
|
+
# always nil here -- there is no provider error to carry.
|
|
8
|
+
class IncompleteStreamError < StreamError; end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmniAI
|
|
4
|
+
module Google
|
|
5
|
+
# Raised when a streamed generation does not complete.
|
|
6
|
+
#
|
|
7
|
+
# Deliberately has no `#response`: the request returned 200 and the failure arrived inside
|
|
8
|
+
# the stream. Consumers branch on that to decide whether to retry.
|
|
9
|
+
class StreamError < OmniAI::Error
|
|
10
|
+
# Google's own explanation of the failure, verbatim.
|
|
11
|
+
#
|
|
12
|
+
# Named for what it holds rather than borrowing `value` from FinishReason, which is a
|
|
13
|
+
# provider enum rather than prose. Kept off `#message` because consumers log that and
|
|
14
|
+
# this text can echo request content -- a deliberate departure from HTTPError, which puts
|
|
15
|
+
# the whole response body into its message.
|
|
16
|
+
#
|
|
17
|
+
# @return [String, nil]
|
|
18
|
+
attr_reader :provider_message
|
|
19
|
+
|
|
20
|
+
# @param message [String, nil]
|
|
21
|
+
# @param provider_message [String, nil]
|
|
22
|
+
def initialize(message = nil, provider_message: nil)
|
|
23
|
+
@provider_message = provider_message
|
|
24
|
+
super(message)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: omniai-google
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.17.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kevin Sylvestre
|
|
@@ -122,6 +122,8 @@ files:
|
|
|
122
122
|
- lib/omniai/google/config.rb
|
|
123
123
|
- lib/omniai/google/credentials.rb
|
|
124
124
|
- lib/omniai/google/embed.rb
|
|
125
|
+
- lib/omniai/google/incomplete_stream_error.rb
|
|
126
|
+
- lib/omniai/google/stream_error.rb
|
|
125
127
|
- lib/omniai/google/transcribe.rb
|
|
126
128
|
- lib/omniai/google/transcribe_helpers.rb
|
|
127
129
|
- lib/omniai/google/upload.rb
|