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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5954a8c865af0699193eec624f9ddbb6e7bb52f0df650d16a61ad3237b15aedd
4
- data.tar.gz: 3e455a236f2c073735e44054c55489c93418252259fd556df734d38ea7895043
3
+ metadata.gz: 9fd43c08069868c8d20d91ac6e1028baf97643eda9018a0db146c73ce0828c15
4
+ data.tar.gz: 04ca6d4c4789b91c194abb44f8d2d178f1e65605644fe3c72a72824e8707a9e2
5
5
  SHA512:
6
- metadata.gz: c99cb0f8e5d8e802ff247874ab5c4e4530fd5b91cadc617d506187125ed9603cc5468c09586f36f2ca2ddcde0549c7aba48d1b50285450a7d830b5c2fad3112b
7
- data.tar.gz: d32ccfaf6210fade5a0a7dfaf4c868a9474880d440f4465b959966bc1085509df3d67e50795555402b7ad069da47dedf1c91b2f1c8666799f47a16eb6ac43b7b
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
  #
@@ -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}"
@@ -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.include?("aiplatform.googleapis.com")
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OmniAI
4
4
  module Google
5
- VERSION = "3.14.0"
5
+ VERSION = "3.17.0"
6
6
  end
7
7
  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.14.0
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