omniai 3.7.1 → 3.9.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: e22534811cc346b74927ec47382420e86faee449ff730cbb0665d30a714cd872
4
- data.tar.gz: 1c3e58ad36506a2564563c46a700e8be04731b7266d8008bbd63bcc22d9cfa64
3
+ metadata.gz: 79d29d3011d03634e81f70e37b83af802c8881afb6920769a2b49e165f244735
4
+ data.tar.gz: 15b2e1cc6af34b9692f2d0628c8a94754f7d2dafc6b545bd739c2d4d88fe8141
5
5
  SHA512:
6
- metadata.gz: ea3efdf811897d5fff8f55b0e4051b71f151d077230518b139fde0ef38656c6b784f9aafcfccb43c692fe85a102b47d0ca41c8696af767294b1a2270dfeee5fa
7
- data.tar.gz: f3e87e1dd51ca72ec1d8706d3833a06c10b65947fdaf1e9a375518ca2b3d78f03a43af1c2869de1e84fb1c5cd77226ecbd5aa45fa7092f84691f0ea5f5afd183
6
+ metadata.gz: d3d3636fc343966d21f40c7b09b566bcc3a3375e5d52e3b700f9d9db6e69194cb6d657cc25f721216ece7f8fa3d775216ca1baaaeb527b865d16be9398aaee47
7
+ data.tar.gz: bb45ac481dd2b27edf8e62ba66667ed1d17e77b4c44ab7505b17af0b78db4e8c7550d25fd67b8e5556b97e8455a8c65db443fcfb52abc3a2c3c4130904fafb65
data/README.md CHANGED
@@ -545,6 +545,28 @@ end
545
545
  client.chat('What is the weather in "London" in Celsius and "Paris" in Fahrenheit?', tools: [WeatherTool.new])
546
546
  ```
547
547
 
548
+ #### Per-Round Usage
549
+
550
+ A tool-call chain makes one request per round, but only the final `Response` is returned. Pass `on_response:` to receive each round's `Response` as it completes, before that round's tool calls run. It fires for single-round chats too and is independent of `stream:`.
551
+
552
+ Usage then survives an abort. When a stream block or a tool raises mid-chain, `process!` unwinds without returning a `Response`, but the rounds already handed over are still accounted for:
553
+
554
+ ```ruby
555
+ rounds = []
556
+
557
+ stream = proc do |chunk|
558
+ raise TooManyRounds if rounds.length >= 20
559
+
560
+ print(chunk.text)
561
+ end
562
+
563
+ begin
564
+ client.chat(prompt, tools:, stream:, on_response: proc { |round| rounds << round })
565
+ rescue TooManyRounds
566
+ rounds.filter_map(&:usage).sum { |usage| usage.input_tokens.to_i + usage.output_tokens.to_i }
567
+ end
568
+ ```
569
+
548
570
  #### Extended Thinking / Reasoning
549
571
 
550
572
  Some models support extended thinking or reasoning capabilities. OmniAI provides a unified `thinking:` option that works across all supported providers:
@@ -578,6 +600,30 @@ client.chat("Solve this step by step: What is 123 * 456?", thinking: true, strea
578
600
  | Google | `thinking: true` | Requires Gemini 2.0+ with thinking enabled |
579
601
  | OpenAI | `thinking: true` or `thinking: { effort: "high" }` | Requires o1/o3 models |
580
602
 
603
+ #### Thinking Token Accounting
604
+
605
+ Reasoning tokens are billable. `OmniAI::Chat::Usage#thinking_tokens` reports how many of a response's output tokens were internal reasoning:
606
+
607
+ ```ruby
608
+ response = client.chat("What is 25 * 25?", thinking: true)
609
+
610
+ response.usage.output_tokens # => 1424 — billable output, reasoning included
611
+ response.usage.thinking_tokens # => 840 — the reasoning subset of the above
612
+ ```
613
+
614
+ `thinking_tokens` is always a **subset** of `output_tokens`, never an addition to it. Adding the two together double counts.
615
+
616
+ It is `nil` when the provider reported no breakdown, which is deliberately distinct from `0` (the provider reported that no reasoning occurred). Each provider gem reads its own vocabulary:
617
+
618
+ | Provider | Populated | Read from |
619
+ |----------|-----------|-----------|
620
+ | omniai-google >= 3.12 | yes | `thoughtsTokenCount` |
621
+ | omniai-anthropic >= 3.6 | yes | `usage.output_tokens_details.thinking_tokens` |
622
+ | omniai-openai >= 3.2 | yes | `usage.output_tokens_details.reasoning_tokens` (Responses API) |
623
+ | omniai-mistral | no | no breakdown reported |
624
+
625
+ Note also that `total_tokens` may exceed `input_tokens + output_tokens` — providers count buckets OmniAI does not model, such as cached input and tool-use prompts — and may be `nil` where a provider reports no total. The reported total is authoritative and is never recomputed from the parts.
626
+
581
627
  ### 🎤 Speech to Text
582
628
 
583
629
  Clients that support transcribe (e.g. OpenAI w/ "Whisper") convert recordings to text via the following calls:
@@ -153,20 +153,30 @@ module OmniAI
153
153
  # Returns aggregated usage across all responses in the chain.
154
154
  # Walks the parent chain and sums all token counts.
155
155
  #
156
+ # `total_tokens` prefers each response's provider-reported total and only falls back to `input + output` for
157
+ # responses where the provider reported none. Summing the reported totals matters wherever a provider counts
158
+ # tokens that are neither input nor output — Google's `totalTokenCount` includes thinking tokens, so
159
+ # recomputing unconditionally would discard them.
160
+ #
161
+ # Known limitation: Anthropic reports no total at all, so its contribution is always the derived
162
+ # `input + output`, which excludes `cache_creation_input_tokens` and `cache_read_input_tokens`. An aggregate
163
+ # spanning Anthropic responses therefore understates cache-heavy conversations.
164
+ #
156
165
  # @return [Usage, nil]
157
166
  def total_usage
158
- chain = response_chain
159
- usages = chain.map(&:usage).compact
167
+ usages = response_chain.map(&:usage).compact
160
168
  return nil if usages.empty?
161
169
 
162
- input_tokens = usages.sum { |u| u.input_tokens || 0 }
163
- output_tokens = usages.sum { |u| u.output_tokens || 0 }
170
+ input_tokens = usages.sum { |usage| usage.input_tokens || 0 }
171
+ output_tokens = usages.sum { |usage| usage.output_tokens || 0 }
172
+ total_tokens = usages.sum do |usage|
173
+ usage.total_tokens || ((usage.input_tokens || 0) + (usage.output_tokens || 0))
174
+ end
175
+
176
+ thinking = usages.filter_map(&:thinking_tokens)
177
+ thinking_tokens = thinking.sum unless thinking.empty?
164
178
 
165
- Usage.new(
166
- input_tokens:,
167
- output_tokens:,
168
- total_tokens: input_tokens + output_tokens
169
- )
179
+ Usage.new(input_tokens:, output_tokens:, total_tokens:, thinking_tokens:)
170
180
  end
171
181
  end
172
182
  end
@@ -3,28 +3,52 @@
3
3
  module OmniAI
4
4
  class Chat
5
5
  # The usage of a chat in terms of tokens (input / output / total).
6
+ #
7
+ # Two invariants hold across every provider:
8
+ #
9
+ # - `thinking_tokens` is a *subset* of `output_tokens`, never an addition to it. Providers either fold reasoning
10
+ # into their output count already (reporting the breakdown separately) or report it separately and have it
11
+ # added in by their own serializer. Adding `thinking_tokens` to `output_tokens` double counts.
12
+ # - `total_tokens` may exceed `input_tokens + output_tokens`. Providers count buckets this class does not model
13
+ # — cached input, tool-use prompts — so the reported total is authoritative and is never recomputed from the
14
+ # parts. It may also be `nil`: some providers report no total at all.
15
+ #
16
+ # Provider-specific vocabulary is read by that provider's own `:usage` deserializer, not here. This class reads
17
+ # only its own keys and the flat OpenAI-compatible aliases the base client speaks.
6
18
  class Usage
7
- # @return [Integer]
19
+ # @return [Integer, nil]
8
20
  attr_accessor :input_tokens
9
21
 
10
- # @return [Integer]
22
+ # @return [Integer, nil]
11
23
  attr_accessor :output_tokens
12
24
 
13
- # @return [Integer]
25
+ # @return [Integer, nil]
14
26
  attr_accessor :total_tokens
15
27
 
16
- # @param input_tokens [Integer]
17
- # @param output_tokens [Integer]
18
- # @param total_tokens [Integer]
19
- def initialize(input_tokens:, output_tokens:, total_tokens:)
28
+ # The subset of `output_tokens` a provider attributes to internal reasoning ("thinking"). `nil` when the
29
+ # provider does not report a breakdown — which is distinct from `0`, meaning the provider reported that no
30
+ # reasoning occurred.
31
+ #
32
+ # @return [Integer, nil]
33
+ attr_accessor :thinking_tokens
34
+
35
+ # @param input_tokens [Integer, nil]
36
+ # @param output_tokens [Integer, nil]
37
+ # @param total_tokens [Integer, nil]
38
+ # @param thinking_tokens [Integer, nil] optional
39
+ def initialize(input_tokens:, output_tokens:, total_tokens:, thinking_tokens: nil)
20
40
  @input_tokens = input_tokens
21
41
  @output_tokens = output_tokens
22
42
  @total_tokens = total_tokens
43
+ @thinking_tokens = thinking_tokens
23
44
  end
24
45
 
25
46
  # @return [String]
26
47
  def inspect
27
- "#<#{self.class.name} input_tokens=#{input_tokens} output_tokens=#{output_tokens} total_tokens=#{total_tokens}>"
48
+ text = "#<#{self.class.name} input_tokens=#{input_tokens} output_tokens=#{output_tokens} " \
49
+ "total_tokens=#{total_tokens}"
50
+ text += " thinking_tokens=#{thinking_tokens}" unless thinking_tokens.nil?
51
+ "#{text}>"
28
52
  end
29
53
 
30
54
  # @param data [Hash]
@@ -38,8 +62,9 @@ module OmniAI
38
62
  input_tokens = data["input_tokens"] || data["prompt_tokens"]
39
63
  output_tokens = data["output_tokens"] || data["completion_tokens"]
40
64
  total_tokens = data["total_tokens"]
65
+ thinking_tokens = data["thinking_tokens"]
41
66
 
42
- new(input_tokens:, output_tokens:, total_tokens:)
67
+ new(input_tokens:, output_tokens:, total_tokens:, thinking_tokens:)
43
68
  end
44
69
 
45
70
  # @param context [OmniAI::Context] optional
@@ -53,7 +78,7 @@ module OmniAI
53
78
  input_tokens:,
54
79
  output_tokens:,
55
80
  total_tokens:,
56
- }
81
+ }.tap { |data| data[:thinking_tokens] = thinking_tokens unless thinking_tokens.nil? }
57
82
  end
58
83
  end
59
84
  end
data/lib/omniai/chat.rb CHANGED
@@ -66,6 +66,8 @@ module OmniAI
66
66
  # @param stream [Proc, IO, nil] optional
67
67
  # @param tools [Array<OmniAI::Tool>, nil] optional
68
68
  # @param format [:json, :text, OmniAI::Schema::Object, nil] optional
69
+ # @param on_response [Proc, nil] optional - called with each completed round's `Response` before its tool
70
+ # calls run
69
71
  # @param options [Hash] optional (used for vendor specific options)
70
72
  #
71
73
  # @yield [prompt] optional
@@ -80,6 +82,7 @@ module OmniAI
80
82
  stream: nil,
81
83
  tools: nil,
82
84
  format: nil,
85
+ on_response: nil,
83
86
  **options,
84
87
  &block
85
88
  )
@@ -94,6 +97,7 @@ module OmniAI
94
97
  @stream = stream
95
98
  @tools = tools
96
99
  @format = format
100
+ @on_response = on_response
97
101
  @options = options || {}
98
102
  end
99
103
 
@@ -112,6 +116,8 @@ module OmniAI
112
116
  raise SSLError, e.message, cause: e
113
117
  end
114
118
 
119
+ @on_response&.call(completion)
120
+
115
121
  if tools? && completion.tool_call_list?
116
122
  next_completion = spawn!(
117
123
  @prompt.dup.tap do |prompt|
@@ -165,6 +171,7 @@ module OmniAI
165
171
  stream: @stream,
166
172
  tools: @tools,
167
173
  format: @format,
174
+ on_response: @on_response,
168
175
  **@options
169
176
  )
170
177
  end
data/lib/omniai/client.rb CHANGED
@@ -191,13 +191,16 @@ module OmniAI
191
191
  # @param temperature [Float, nil] optional
192
192
  # @param stream [Proc, nil] optional
193
193
  # @param tools [Array<OmniAI::Tool>] optional
194
+ # @param on_response [Proc, nil] optional - called with each completed round's `Response` before its tool
195
+ # calls run
194
196
  # @param options [Hash, nil] optional
195
197
  #
196
198
  # @yield [prompt] optional
197
199
  # @yieldparam prompt [OmniAI::Chat::Prompt]
198
200
  #
199
201
  # @return [OmniAI::Chat::Response]
200
- def chat(prompt = nil, model:, temperature: nil, format: nil, stream: nil, tools: nil, options: {}, &)
202
+ def chat(prompt = nil, model:, temperature: nil, format: nil, stream: nil, tools: nil, on_response: nil,
203
+ options: {}, &)
201
204
  raise NotImplementedError, "#{self.class.name}#chat undefined"
202
205
  end
203
206
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OmniAI
4
- VERSION = "3.7.1"
4
+ VERSION = "3.9.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omniai
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.7.1
4
+ version: 3.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin Sylvestre