omniai 3.6.0 → 3.7.1

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: ae7a0b04197a95f107c66d4d7108435f44f82d0e3e4b3a956cb755d3ac352c6a
4
- data.tar.gz: 9d8a85ede589c08431d49ada35d2a13ed57338f7eafbc7231134e1866e083bc3
3
+ metadata.gz: e22534811cc346b74927ec47382420e86faee449ff730cbb0665d30a714cd872
4
+ data.tar.gz: 1c3e58ad36506a2564563c46a700e8be04731b7266d8008bbd63bcc22d9cfa64
5
5
  SHA512:
6
- metadata.gz: 05372f0de7a16a3da31702eebfa49b2cd1bbc890ee1777820b575e81c72d59d532941ed96d52ac572b58fd34ab9f897fcda2d85bd91d21be00b115de39a1dac2
7
- data.tar.gz: f2b518b58405e2a4995b282280693f4d985cd1b9ce129abc04a55e679e62b5b9a495aa9ed980449874af43f7d8cd5108c717bed5b5f4337a3d65f3ef1ecd9c31
6
+ metadata.gz: ea3efdf811897d5fff8f55b0e4051b71f151d077230518b139fde0ef38656c6b784f9aafcfccb43c692fe85a102b47d0ca41c8696af767294b1a2270dfeee5fa
7
+ data.tar.gz: f3e87e1dd51ca72ec1d8706d3833a06c10b65947fdaf1e9a375518ca2b3d78f03a43af1c2869de1e84fb1c5cd77226ecbd5aa45fa7092f84691f0ea5f5afd183
data/README.md CHANGED
@@ -398,6 +398,19 @@ require 'omniai/openai'
398
398
  client = OmniAI::OpenAI::Client.new
399
399
  ```
400
400
 
401
+ OpenAI-compatible gateways can be configured by passing a custom host. This keeps
402
+ normal OpenAI usage unchanged while allowing private gateways, local model
403
+ servers, or governed endpoints such as Tuning Engines:
404
+
405
+ ```ruby
406
+ require 'omniai/openai'
407
+
408
+ client = OmniAI::OpenAI::Client.new(
409
+ api_key: ENV.fetch('OPENAI_API_KEY'),
410
+ host: ENV.fetch('OPENAI_HOST', 'https://api.openai.com')
411
+ )
412
+ ```
413
+
401
414
  #### Usage with LocalAI
402
415
 
403
416
  LocalAI support is offered through [OmniAI::OpenAI](https://github.com/ksylvest/omniai-openai):
@@ -16,11 +16,18 @@ module OmniAI
16
16
  # @return [Message]
17
17
  attr_accessor :message
18
18
 
19
+ # @!attribute [rw] finish_reason
20
+ # @return [FinishReason, nil] the normalized reason generation stopped (carrying both `#reason` and the
21
+ # verbatim provider `#value`), or `nil` when absent.
22
+ attr_accessor :finish_reason
23
+
19
24
  # @param message [Message]
20
25
  # @param index [Integer]
21
- def initialize(message:, index: DEFAULT_INDEX)
26
+ # @param finish_reason [FinishReason, nil]
27
+ def initialize(message:, index: DEFAULT_INDEX, finish_reason: nil)
22
28
  @message = message
23
29
  @index = index
30
+ @finish_reason = finish_reason
24
31
  end
25
32
 
26
33
  # @return [String]
@@ -39,7 +46,7 @@ module OmniAI
39
46
  index = data["index"] || DEFAULT_INDEX
40
47
  message = Message.deserialize(data["message"] || data["delta"], context:)
41
48
 
42
- new(message:, index:)
49
+ new(message:, index:, finish_reason: FinishReason.deserialize(data["finish_reason"]))
43
50
  end
44
51
 
45
52
  # @param context [OmniAI::Context] optional
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAI
4
+ class Chat
5
+ # A normalized, provider-agnostic reason a generation stopped, paired with the verbatim provider value.
6
+ #
7
+ # `#reason` is one of the canonical symbols ({REASONS}) for branching/alerting; `#value` is the raw provider
8
+ # token (e.g. "RECITATION", "end_turn", "stop"), preserved verbatim — including when the reason normalizes to
9
+ # `:other`. Each provider maps its own vocabulary onto a reason in its deserializer; the verbatim value is never
10
+ # discarded, so consumers keep provider granularity without digging the provider-specific response `data`.
11
+ class FinishReason
12
+ # A natural stopping point was reached.
13
+ STOP = :stop
14
+
15
+ # The token budget (requested max tokens or the model's context window) was reached.
16
+ LENGTH = :length
17
+
18
+ # The model is requesting a tool / function call.
19
+ TOOL_CALL = :tool_call
20
+
21
+ # The provider deliberately suppressed or blocked output (safety, policy, recitation, unsupported language).
22
+ FILTER = :filter
23
+
24
+ # A value was present but does not map to a known category (forward-compatible fallback).
25
+ OTHER = :other
26
+
27
+ # The canonical normalized reasons.
28
+ REASONS = %i[stop length tool_call filter other].freeze
29
+
30
+ # The Chat Completions `finish_reason` vocabulary (originated by OpenAI; also emitted by Mistral and
31
+ # OpenAI-compatible gateways). Applied by the base `Choice.deserialize` path, which models that schema.
32
+ CHAT_COMPLETIONS = {
33
+ "stop" => STOP,
34
+ "length" => LENGTH,
35
+ "tool_calls" => TOOL_CALL,
36
+ "function_call" => TOOL_CALL,
37
+ "content_filter" => FILTER,
38
+ }.freeze
39
+
40
+ # @!attribute [r] reason
41
+ # @return [Symbol] one of {REASONS}
42
+ attr_reader :reason
43
+
44
+ # @!attribute [r] value
45
+ # @return [String] the verbatim provider token
46
+ attr_reader :value
47
+
48
+ # Normalizes a raw provider value through a mapping table into a FinishReason.
49
+ #
50
+ # - `nil` in → `nil` out (absence is not the same as unrecognized).
51
+ # - otherwise → a FinishReason whose `reason` is the table's mapping (or `:other` when unmapped) and whose
52
+ # `value` is the raw token, preserved verbatim — always, including on `:other`.
53
+ #
54
+ # @param value [String, nil] the raw provider value
55
+ # @param table [Hash{String => Symbol}] the provider's mapping table (defaults to the Chat Completions vocabulary)
56
+ #
57
+ # @return [FinishReason, nil]
58
+ def self.deserialize(value, table: CHAT_COMPLETIONS)
59
+ return if value.nil?
60
+
61
+ new(reason: table.fetch(value, OTHER), value:)
62
+ end
63
+
64
+ # @param reason [Symbol] one of {REASONS}
65
+ # @param value [String] the verbatim provider token
66
+ def initialize(reason:, value:)
67
+ @reason = reason
68
+ @value = value
69
+ end
70
+
71
+ # @return [String]
72
+ def inspect
73
+ "#<#{self.class.name} reason=#{reason.inspect} value=#{value.inspect}>"
74
+ end
75
+
76
+ # @return [Boolean]
77
+ def stop?
78
+ reason == STOP
79
+ end
80
+
81
+ # @return [Boolean]
82
+ def length?
83
+ reason == LENGTH
84
+ end
85
+
86
+ # @return [Boolean]
87
+ def tool_call?
88
+ reason == TOOL_CALL
89
+ end
90
+
91
+ # @return [Boolean]
92
+ def filter?
93
+ reason == FILTER
94
+ end
95
+
96
+ # @return [Boolean]
97
+ def other?
98
+ reason == OTHER
99
+ end
100
+ end
101
+ end
102
+ end
@@ -145,7 +145,7 @@ module OmniAI
145
145
  return if @content.nil?
146
146
  return @content if @content.is_a?(String)
147
147
 
148
- parts = arrayify(@content).filter { |content| content.is_a?(Text) }
148
+ parts = arrayify(@content).grep(Text)
149
149
  parts.map(&:text).join("\n") unless parts.empty?
150
150
  end
151
151
 
@@ -158,7 +158,7 @@ module OmniAI
158
158
  def thinking
159
159
  return if @content.nil?
160
160
 
161
- parts = arrayify(@content).filter { |content| content.is_a?(Thinking) }
161
+ parts = arrayify(@content).grep(Thinking)
162
162
  parts.map(&:thinking).join("\n") unless parts.empty?
163
163
  end
164
164
 
@@ -23,12 +23,29 @@ module OmniAI
23
23
  # @param data [Hash]
24
24
  # @param choices [Array<Choice>]
25
25
  # @param usage [Usage, nil]
26
- def initialize(data:, choices: [], usage: nil)
26
+ # @param finish_reason [FinishReason, nil] an optional response-level finish reason (used by providers that
27
+ # expose it at the response level, e.g. OpenAI's Responses API); when omitted, `#finish_reason` falls back to
28
+ # the first choice's finish reason.
29
+ def initialize(data:, choices: [], usage: nil, finish_reason: nil)
27
30
  @data = data
28
31
  @choices = choices
29
32
  @usage = usage
33
+ @finish_reason = finish_reason
30
34
  end
31
35
 
36
+ # The normalized {FinishReason} for the final turn (carrying both `#reason` and the verbatim provider `#value`),
37
+ # or `nil` when absent. Some providers (e.g. OpenAI's Responses API) expose this at the response level; most
38
+ # expose it per-choice. Prefers an explicit response-level value, then falls back to the first choice. Reflects
39
+ # this response only (the final turn) — it is not aggregated across the parent chain, unlike {#total_usage}.
40
+ #
41
+ # @return [FinishReason, nil]
42
+ def finish_reason
43
+ @finish_reason || @choices.first&.finish_reason
44
+ end
45
+
46
+ # @!attribute [w] finish_reason
47
+ attr_writer :finish_reason
48
+
32
49
  # @return [String]
33
50
  def inspect
34
51
  "#<#{self.class.name} choices=#{@choices.inspect} usage=#{@usage.inspect}>"
data/lib/omniai/client.rb CHANGED
@@ -10,7 +10,7 @@ module OmniAI
10
10
  # super
11
11
  # end
12
12
  #
13
- # # @return [HTTP::Client]
13
+ # # @return [HTTP::Client, HTTP::Session]
14
14
  # def connection
15
15
  # @connection ||= super.auth("Bearer: #{@api_key}")
16
16
  # end
@@ -175,7 +175,7 @@ module OmniAI
175
175
  "#{api_key[..2]}***" if api_key
176
176
  end
177
177
 
178
- # @return [HTTP::Client]
178
+ # @return [HTTP::Client, HTTP::Session] an `HTTP::Client` on http 5, an `HTTP::Session` on http 6
179
179
  def connection
180
180
  http = HTTP.persistent(@host)
181
181
  http = http.use(instrumentation: { instrumenter: Instrumentation.new(logger: @logger) }) if @logger
@@ -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.6.0"
4
+ VERSION = "3.7.1"
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.6.0
4
+ version: 3.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin Sylvestre
@@ -41,16 +41,22 @@ dependencies:
41
41
  name: http
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
- - - "~>"
44
+ - - ">="
45
45
  - !ruby/object:Gem::Version
46
46
  version: '5'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '7'
47
50
  type: :runtime
48
51
  prerelease: false
49
52
  version_requirements: !ruby/object:Gem::Requirement
50
53
  requirements:
51
- - - "~>"
54
+ - - ">="
52
55
  - !ruby/object:Gem::Version
53
56
  version: '5'
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '7'
54
60
  - !ruby/object:Gem::Dependency
55
61
  name: logger
56
62
  requirement: !ruby/object:Gem::Requirement
@@ -99,6 +105,7 @@ files:
99
105
  - lib/omniai/chat/content.rb
100
106
  - lib/omniai/chat/delta.rb
101
107
  - lib/omniai/chat/file.rb
108
+ - lib/omniai/chat/finish_reason.rb
102
109
  - lib/omniai/chat/function.rb
103
110
  - lib/omniai/chat/media.rb
104
111
  - lib/omniai/chat/message.rb