omniai 3.7.0 → 3.8.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: 7464f0580c8f8392155996aa7b4675491bec1fe62b48ff9b12e53264952a50e5
4
- data.tar.gz: 3ef5570bd7eac6ed72a9826c90313565dc0fd00b6ec52745fe02a04ce2855264
3
+ metadata.gz: 444922e4ffb4b54da232b01faab2b171f39ec2547830a7c02a4dc607173acae9
4
+ data.tar.gz: 221d5e646b968f9cb422c64547966855518351038c9f1d7248961778785dd8fc
5
5
  SHA512:
6
- metadata.gz: e5ab80ba78ab316ca74e46dffb0bdea26ed13b83f4ec4ff7c5e985580c5460d926366d71bf547ac905273968db44cf1949eb57c56b0560077f1b805f52a4c93d
7
- data.tar.gz: bd453f4fd6355066833db225b771b1078fd8d916138213a1f0d4d18f37316cae7b60f47a215c46d7a76fb096eeece2e6d887a8dc9a4819c8c54dd65f3ece731f
6
+ metadata.gz: 30da07946fab93796f28c4d831ef00f0b609051e664a1cdc01373af8e6079d857239bf26be88d5cbeb6e9d25c7c2bcc08588979e3393da661bc73057e4c34bcb
7
+ data.tar.gz: '09907c95ccd31c1f672f2fcc324a11a23b63799916db570a4a4fcaee4677249f430e15071a3f5e5ca34e72d867dedfc039a52527aa2dc9d6ffe7734b4f7d7dc7'
data/README.md CHANGED
@@ -578,6 +578,30 @@ client.chat("Solve this step by step: What is 123 * 456?", thinking: true, strea
578
578
  | Google | `thinking: true` | Requires Gemini 2.0+ with thinking enabled |
579
579
  | OpenAI | `thinking: true` or `thinking: { effort: "high" }` | Requires o1/o3 models |
580
580
 
581
+ #### Thinking Token Accounting
582
+
583
+ Reasoning tokens are billable. `OmniAI::Chat::Usage#thinking_tokens` reports how many of a response's output tokens were internal reasoning:
584
+
585
+ ```ruby
586
+ response = client.chat("What is 25 * 25?", thinking: true)
587
+
588
+ response.usage.output_tokens # => 1424 — billable output, reasoning included
589
+ response.usage.thinking_tokens # => 840 — the reasoning subset of the above
590
+ ```
591
+
592
+ `thinking_tokens` is always a **subset** of `output_tokens`, never an addition to it. Adding the two together double counts.
593
+
594
+ 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:
595
+
596
+ | Provider | Populated | Read from |
597
+ |----------|-----------|-----------|
598
+ | omniai-google >= 3.12 | yes | `thoughtsTokenCount` |
599
+ | omniai-anthropic >= 3.6 | yes | `usage.output_tokens_details.thinking_tokens` |
600
+ | omniai-openai >= 3.2 | yes | `usage.output_tokens_details.reasoning_tokens` (Responses API) |
601
+ | omniai-mistral | no | no breakdown reported |
602
+
603
+ 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.
604
+
581
605
  ### 🎤 Speech to Text
582
606
 
583
607
  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
@@ -8,27 +8,78 @@ module OmniAI
8
8
  @logger = logger
9
9
  end
10
10
 
11
+ # ActiveSupport::Notifications-compatible instrument.
12
+ #
13
+ # On http 6 the instrumentation feature drives every request through
14
+ # `around_request`, which (per http's own feature docs) "emits two events on
15
+ # every request: `start_request.http` before the request is made [and]
16
+ # `request.http` after the response is received". Both are delivered here as
17
+ # `instrument(name) { ... }` calls: the start event carries an empty block,
18
+ # and the request event's block wraps the exchange and returns the response.
19
+ # The block of the request event MUST be yielded and its value returned,
20
+ # otherwise the response is lost as `nil` (http uses the return value as the
21
+ # response). We log the request on the start event and the response on the
22
+ # request event. The event namespace is caller-configurable (the names are
23
+ # `start_request.#{namespace}` / `request.#{namespace}`), so #start_event?
24
+ # prefix-matches rather than comparing the full name.
25
+ #
26
+ # On http 5 this is only ever called without a block (for the start and
27
+ # error events); request/response logging there happens via #start / #finish.
28
+ #
11
29
  # @param name [String]
12
30
  # @param payload [Hash]
31
+ # @option payload [HTTP::Request] :request
32
+ # @option payload [HTTP::Response] :response
13
33
  # @option payload [Exception] :error
14
34
  def instrument(name, payload = {})
15
35
  error = payload[:error]
16
- return unless error
36
+ @logger.error("#{name}: #{error.message}") if error
17
37
 
18
- @logger.error("#{name}: #{error.message}")
38
+ return unless block_given?
39
+
40
+ if start_event?(name)
41
+ log_request(payload[:request])
42
+ yield payload
43
+ else
44
+ response = yield payload
45
+ log_response(payload[:response] || response)
46
+ response
47
+ end
19
48
  end
20
49
 
21
50
  # @param payload [Hash]
22
51
  # @option payload [HTTP::Request] :request
23
52
  def start(_, payload)
24
- request = payload[:request]
25
- @logger.info("#{request.verb.upcase} #{request.uri}")
53
+ log_request(payload[:request])
26
54
  end
27
55
 
28
56
  # @param payload [Hash]
29
57
  # @option payload [HTTP::Response] :response
30
58
  def finish(_, payload)
31
- response = payload[:response]
59
+ log_response(payload[:response])
60
+ end
61
+
62
+ private
63
+
64
+ # @param name [String] the http instrumentation event name, e.g.
65
+ # "start_request.http" (pre-flight) or "request.http" (the exchange).
66
+ #
67
+ # @return [Boolean] true for the pre-flight "start_..." event
68
+ def start_event?(name)
69
+ name.to_s.start_with?("start_")
70
+ end
71
+
72
+ # @param request [HTTP::Request, nil]
73
+ def log_request(request)
74
+ return unless request
75
+
76
+ @logger.info("#{request.verb.upcase} #{request.uri}")
77
+ end
78
+
79
+ # @param response [HTTP::Response, nil]
80
+ def log_response(response)
81
+ return unless response
82
+
32
83
  @logger.info("#{response.status.code} #{response.status.reason}")
33
84
  end
34
85
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OmniAI
4
- VERSION = "3.7.0"
4
+ VERSION = "3.8.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.0
4
+ version: 3.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin Sylvestre