payloop 0.0.3 → 0.1.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: 79647d261089034fcabaf8bc8f02468c22a4dafac253f0e3b387bf169f0fe410
4
- data.tar.gz: 5d7796139816c6f880d0acfa0e364065480835def4e1f98fa9fd30004a1cab6b
3
+ metadata.gz: 046f4c8d567e9eb4bf8f6797ef0a693f90a279f04de29d9d875030b264323788
4
+ data.tar.gz: 1fa3b863d95bf6568bb78b0df6819bde803449786ecad95c97c8dde57394689e
5
5
  SHA512:
6
- metadata.gz: e87d2509afeee593b39cd9ab7a519f2fde382aaa939fecaa370cf4817aa38009a5c68a3550444052dbf0867a6162c6a3cd5771cfb039a4f812b280a9e3bb29f6
7
- data.tar.gz: d7ac74462b724bce7178e6588d41bb6e91e4bf5ec98ff067bd41164cf17b4fd570892eacdb633b797fc3aab1857503cd4cf88998ad95d675be647980fd0f85d0
6
+ metadata.gz: 97cf236a7d99c0cef892e2f8c3d615d47bbba504073d5f64a67b11023859c5ed1ff6ddc67a43b3287ac85ffe37d31e633f1531078ac0ac6ff69f795c30921566
7
+ data.tar.gz: 205bb28b079d0459021cfb8cd6f1032df7ff7d1c351d9280c4bad530d1fdc73a8e07943d2fc6b36b60bfbcc23df66c1c1c607577e03f6f9fe2af7a7442225291
data/CHANGELOG.md CHANGED
@@ -29,4 +29,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
29
29
  ## [0.0.3] - 2025-10-27
30
30
 
31
31
  ### Fixed
32
- - Corrected Payloop request payload structure for Google GenAI
32
+ - Correct Payloop request payload structure for Google GenAI
33
+
34
+ ## [0.0.4] - 2025-10-28
35
+
36
+ ### Fixed
37
+ - Correct Payloop request payload structure for OpenAI streaming
38
+
39
+ ## [0.1.0] - 2026-02-03
40
+
41
+ ### Added
42
+ - Support for Sentinel intercept.
43
+ - Support for Gemini AI client tracking.
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payloop
4
+ module API
5
+ class Sentinel < Base
6
+ def relevance_intercept(payload)
7
+ post("/sentinel/relevance/intercept", payload)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -5,7 +5,7 @@ require "securerandom"
5
5
  module Payloop
6
6
  # Main Payloop client for tracking AI costs
7
7
  class Client
8
- attr_reader :config, :collector
8
+ attr_reader :config, :collector, :sentinel
9
9
 
10
10
  def initialize(api_key: nil, collector_url: nil, api_url: nil, timeout: nil)
11
11
  api_key ||= ENV.fetch("PAYLOOP_API_KEY", nil)
@@ -18,21 +18,32 @@ module Payloop
18
18
  timeout: timeout
19
19
  )
20
20
  @collector = Collector.new(@config)
21
+ @sentinel = Sentinel.new(@config)
21
22
  end
22
23
 
23
24
  # OpenAI provider wrapper
24
25
  def openai
25
- @openai ||= Wrappers::OpenAI.new(@config, @collector)
26
+ @openai ||= Wrappers::OpenAI.new(@config, @collector, @sentinel)
26
27
  end
27
28
 
28
29
  # Anthropic provider wrapper
29
30
  def anthropic
30
- @anthropic ||= Wrappers::Anthropic.new(@config, @collector)
31
+ @anthropic ||= Wrappers::Anthropic.new(@config, @collector, @sentinel)
31
32
  end
32
33
 
33
- # Google GenAI provider wrapper
34
+ # Google GenAI provider wrapper (Tied to google-genai Gem)
35
+ # This is a port of the official Python library, but it does not
36
+ # support system prompts which is used for sentinel. This still
37
+ # works for non-sentinel use cases.
34
38
  def google
35
- @google ||= Wrappers::Google.new(@config, @collector)
39
+ @google ||= Wrappers::Google.new(@config, @collector, @sentinel)
40
+ end
41
+
42
+ # Google GenAI provider wrapper (Tied to gemini-ai Gem version 4.3.0)
43
+ # Google doesn't have an official Ruby library, so this is seems to be
44
+ # the most popular unofficial one.
45
+ def geminiai
46
+ @geminiai ||= Wrappers::GeminiAI.new(@config, @collector, @sentinel)
36
47
  end
37
48
 
38
49
  # Set attribution for cost tracking
@@ -15,6 +15,8 @@ module Payloop
15
15
  @version = Payloop::VERSION
16
16
  @attribution = Concurrent::AtomicReference.new(nil)
17
17
  @tx_uuid = Concurrent::AtomicReference.new(SecureRandom.uuid)
18
+ @raise_if_irrelevant = Concurrent::AtomicReference.new(false)
19
+ @secs_irrelevant_request_timeout = Concurrent::AtomicReference.new(5)
18
20
  end
19
21
 
20
22
  def attribution
@@ -33,6 +35,22 @@ module Payloop
33
35
  @tx_uuid.set(value)
34
36
  end
35
37
 
38
+ def raise_if_irrelevant
39
+ @raise_if_irrelevant.get
40
+ end
41
+
42
+ def raise_if_irrelevant=(value)
43
+ @raise_if_irrelevant.set(value)
44
+ end
45
+
46
+ def secs_irrelevant_request_timeout
47
+ @secs_irrelevant_request_timeout.get
48
+ end
49
+
50
+ def secs_irrelevant_request_timeout=(value)
51
+ @secs_irrelevant_request_timeout.set(value)
52
+ end
53
+
36
54
  def new_transaction
37
55
  @tx_uuid.set(SecureRandom.uuid)
38
56
  end
@@ -27,4 +27,6 @@ module Payloop
27
27
  super(message)
28
28
  end
29
29
  end
30
+
31
+ class PayloopRequestInterceptedError < Error; end
30
32
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Payloop
4
+ class Sentinel
5
+ def initialize(config)
6
+ @config = config
7
+ end
8
+
9
+ def raise_if_irrelevant(enabled: true)
10
+ raise TypeError, "enabled must be a bool" unless [true, false].include?(enabled)
11
+
12
+ @config.raise_if_irrelevant = enabled
13
+ self
14
+ end
15
+
16
+ def set_secs_irrelevant_request_timeout(timeout)
17
+ raise TypeError, "timeout must be a Numeric" unless timeout.is_a?(Numeric)
18
+ raise ArgumentError, "timeout must be greater than 0" unless timeout.positive?
19
+
20
+ @config.secs_irrelevant_request_timeout = timeout
21
+ self
22
+ end
23
+
24
+ def raise_if_irrelevant!(title:, request:, provider: nil, version: nil)
25
+ return unless @config.raise_if_irrelevant
26
+
27
+ begin
28
+ relevant, reason = make_relevance_intercept_request(
29
+ title: title,
30
+ request: request,
31
+ provider: provider,
32
+ version: version
33
+ )
34
+ rescue StandardError
35
+ return nil
36
+ end
37
+
38
+ raise PayloopRequestInterceptedError, (reason || "Irrelevant request blocked.") unless relevant
39
+ end
40
+
41
+ private
42
+
43
+ def make_relevance_intercept_request(title:, request:, provider: nil, version: nil)
44
+ payload = {
45
+ attribution: @config.attribution&.to_h,
46
+ conversation: {
47
+ client: {
48
+ provider: provider,
49
+ title: title,
50
+ version: version
51
+ },
52
+ request: normalize_request(request)
53
+ }
54
+ }
55
+
56
+ api = API::Sentinel.new(@config.api_url, @config.api_key, @config.secs_irrelevant_request_timeout)
57
+ response = api.relevance_intercept(payload) || {}
58
+
59
+ relevant = response.key?("relevant") ? response["relevant"] : true
60
+ reason = relevant ? nil : (response["reason"] || "Irrelevant request blocked.")
61
+
62
+ [relevant, reason]
63
+ end
64
+
65
+ def normalize_request(request)
66
+ # Remove Procs before JSON creation since the
67
+ # procs or methods will fail for normalization.
68
+ # We know that OpenAI sends a proc on their
69
+ # request, so this is mostly for that.
70
+ cleaned = remove_procs(request)
71
+ json_obj = JSON.generate(cleaned)
72
+ JSON.parse(json_obj)
73
+ rescue StandardError
74
+ request
75
+ end
76
+
77
+ def remove_procs(obj)
78
+ case obj
79
+ when Hash
80
+ obj.transform_values { |v| remove_procs(v) }
81
+ when Array
82
+ obj.map { |v| remove_procs(v) }
83
+ when Proc, Method
84
+ true
85
+ else
86
+ # Return true object for any callable object; otherwise return obj.
87
+ obj.respond_to?(:call) || obj
88
+ end
89
+ end
90
+ end
91
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Payloop
4
- VERSION = "0.0.3"
4
+ VERSION = "0.1.0"
5
5
  end
@@ -6,9 +6,10 @@ module Payloop
6
6
  module Wrappers
7
7
  # Wrapper for Anthropic Ruby client
8
8
  class Anthropic
9
- def initialize(config, collector)
9
+ def initialize(config, collector, sentinel = nil)
10
10
  @config = config
11
11
  @collector = collector
12
+ @sentinel = sentinel
12
13
  end
13
14
 
14
15
  def register(client)
@@ -20,6 +21,7 @@ module Payloop
20
21
  # Store references in client instance
21
22
  client.instance_variable_set(:@payloop_config, @config)
22
23
  client.instance_variable_set(:@payloop_collector, @collector)
24
+ client.instance_variable_set(:@payloop_sentinel, @sentinel)
23
25
  client.instance_variable_set(:@payloop_registered, true)
24
26
 
25
27
  # Wrap the messages method
@@ -43,6 +45,7 @@ module Payloop
43
45
  # Store references on the messages resource (needed for Base module)
44
46
  messages_resource.instance_variable_set(:@payloop_config, client.instance_variable_get(:@payloop_config))
45
47
  messages_resource.instance_variable_set(:@payloop_collector, client.instance_variable_get(:@payloop_collector))
48
+ messages_resource.instance_variable_set(:@payloop_sentinel, client.instance_variable_get(:@payloop_sentinel))
46
49
 
47
50
  # Store the original create method
48
51
  original_create = messages_resource.method(:create)
@@ -54,6 +57,11 @@ module Payloop
54
57
 
55
58
  start_time = Time.now
56
59
 
60
+ # Extract parameters for sentinel and analytics
61
+ params = kwargs.any? ? kwargs : (args.first || {})
62
+ sentinel = instance_variable_get(:@payloop_sentinel)
63
+ sentinel&.raise_if_irrelevant!(title: ANTHROPIC_CLIENT_TITLE, request: params)
64
+
57
65
  # Call original method
58
66
  response = if kwargs.any?
59
67
  original_create.call(**kwargs, &block)
@@ -61,9 +69,6 @@ module Payloop
61
69
  original_create.call(*args, &block)
62
70
  end
63
71
 
64
- # Extract parameters for analytics
65
- params = kwargs.any? ? kwargs : (args.first || {})
66
-
67
72
  # Submit analytics
68
73
  payloop_submit_analytics(
69
74
  method: :create,
@@ -6,7 +6,7 @@ module Payloop
6
6
  module Wrappers
7
7
  # Base functionality for all provider wrappers
8
8
  module Base
9
- def payloop_wrap_method(method_name, provider_name)
9
+ def payloop_wrap_method(method_name, _provider_name)
10
10
  return if method(method_name).source_location&.first&.include?("payloop")
11
11
 
12
12
  original_method = instance_method(method_name)
@@ -43,7 +43,8 @@ module Payloop
43
43
  end
44
44
  end
45
45
 
46
- def payloop_submit_analytics(method:, args:, kwargs:, response:, start_time:, end_time:, provider: nil, title: nil)
46
+ def payloop_submit_analytics(method:, args:, kwargs:, response:, start_time:,
47
+ end_time:, provider: nil, title: nil)
47
48
  collector = instance_variable_get(:@payloop_collector)
48
49
  config = instance_variable_get(:@payloop_config)
49
50
 
@@ -63,7 +64,8 @@ module Payloop
63
64
  collector.submit_async(payload)
64
65
  end
65
66
 
66
- def payloop_submit_error_analytics(method:, args:, kwargs:, error:, start_time:, end_time:, provider: nil, title: nil)
67
+ def payloop_submit_error_analytics(method:, args:, kwargs:, error:, start_time:,
68
+ end_time:, provider: nil, title: nil)
67
69
  collector = instance_variable_get(:@payloop_collector)
68
70
  config = instance_variable_get(:@payloop_config)
69
71
 
@@ -84,18 +86,67 @@ module Payloop
84
86
  collector.submit_async(payload)
85
87
  end
86
88
 
89
+ def payloop_merge_streaming_chunk(accumulated, chunk)
90
+ chunk.each do |key, value|
91
+ if accumulated.key?(key)
92
+ case accumulated[key]
93
+ when Hash
94
+ payloop_merge_streaming_chunk(accumulated[key], value) if value.is_a?(Hash)
95
+ when Array
96
+ # Concatenate arrays (matches Python SDK behavior: data[key].extend(chunk_value))
97
+ accumulated[key].concat(value) if value.is_a?(Array)
98
+ else
99
+ accumulated[key] = value
100
+ end
101
+ else
102
+ accumulated[key] = value
103
+ end
104
+ end
105
+ accumulated
106
+ end
107
+
108
+ def payloop_normalize_openai_chunk(chunk)
109
+ # Normalize OpenAI streaming chunks to match Python SDK format
110
+ # Ensure delta objects have all keys present with null values
111
+ return chunk unless chunk.is_a?(Hash)
112
+
113
+ if chunk["choices"].is_a?(Array)
114
+ chunk["choices"].each do |choice|
115
+ next unless choice.is_a?(Hash) && choice.key?("delta")
116
+
117
+ delta = choice["delta"]
118
+ if delta.is_a?(Hash)
119
+ # Add missing keys with nil values to match Python SDK
120
+ delta["role"] ||= nil unless delta.key?("role")
121
+ delta["content"] ||= nil unless delta.key?("content")
122
+ delta["refusal"] ||= nil unless delta.key?("refusal")
123
+ delta["tool_calls"] ||= nil unless delta.key?("tool_calls")
124
+ delta["function_call"] ||= nil unless delta.key?("function_call")
125
+ end
126
+ end
127
+ end
128
+
129
+ chunk
130
+ end
131
+
87
132
  private
88
133
 
89
134
  def extract_query(_method, _args, kwargs)
90
135
  # Deep copy to avoid mutation issues
91
- deep_copy(kwargs)
136
+ query = deep_copy(kwargs)
137
+
138
+ # Normalize stream parameter: convert Proc to boolean true
139
+ # This ensures streaming requests are properly serialized to JSON
140
+ query[:stream] = true if query.is_a?(Hash) && query[:stream].is_a?(Proc)
141
+
142
+ query
92
143
  rescue StandardError
93
144
  {}
94
145
  end
95
146
 
96
147
  def extract_response(response)
97
148
  case response
98
- when Hash
149
+ when Hash, Array
99
150
  deep_copy(response)
100
151
  when String
101
152
  { text: response }
@@ -155,7 +206,16 @@ module Payloop
155
206
  end
156
207
  end
157
208
 
158
- def build_payload(query:, response:, start_time:, end_time:, config:, status:, provider: nil, title: nil, exception: nil)
209
+ # Build the payload that is sent to the collector API which then sends the payload
210
+ # to the backend.
211
+ # - conversion.client - Routes the payload to the correct service
212
+ # in the backend. So, the title and provider values are known values. (e.g google,
213
+ # openai, and anthropic) are common title values.
214
+ # - conversion.query - Holds the request information.
215
+ # - conversion.response - Holds the response from the LLM. This is different for each LLM,
216
+ # and the different services handle them on the backend.
217
+ def build_payload(query:, response:, start_time:, end_time:, config:, status:,
218
+ provider: nil, title: nil, exception: nil)
159
219
  {
160
220
  attribution: config.attribution&.to_h,
161
221
  conversation: {
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+
5
+ module Payloop
6
+ module Wrappers
7
+ # Wrapper for Google GenerativeAI Ruby client (gemini-ai gem version 4.3.0)
8
+ class GeminiAI
9
+ def initialize(config, collector, sentinel = nil)
10
+ @config = config
11
+ @collector = collector
12
+ @sentinel = sentinel
13
+ end
14
+
15
+ def register(client)
16
+ validate_client!(client)
17
+
18
+ # Prevent double registration
19
+ return client if client.instance_variable_defined?(:@payloop_registered)
20
+
21
+ # Store references in client instance
22
+ client.instance_variable_set(:@payloop_config, @config)
23
+ client.instance_variable_set(:@payloop_collector, @collector)
24
+ client.instance_variable_set(:@payloop_sentinel, @sentinel)
25
+ client.instance_variable_set(:@payloop_registered, true)
26
+
27
+ # Wrap the generate_content and stream_generate_content methods
28
+ wrap_generate_content_method(client)
29
+ wrap_stream_generate_content_method(client)
30
+
31
+ client
32
+ end
33
+
34
+ private
35
+
36
+ def validate_client!(client)
37
+ return if client.respond_to?(:generate_content) && client.respond_to?(:stream_generate_content)
38
+
39
+ raise RegistrationError,
40
+ "Client does not appear to be a valid Gemini AI client (missing generate_content or stream_generate_content method)" # rubocop:disable Layout/LineLength
41
+ end
42
+
43
+ def wrap_generate_content_method(client)
44
+ client.singleton_class.class_eval do
45
+ include Base
46
+
47
+ alias_method :original_generate_content, :generate_content
48
+
49
+ define_method(:generate_content) do |*args, **kwargs, &block|
50
+ # Handle both positional hash and keyword arguments
51
+ parameters = if args.first.is_a?(Hash)
52
+ args.first
53
+ elsif kwargs.any?
54
+ kwargs
55
+ else
56
+ {}
57
+ end
58
+
59
+ start_time = Time.now
60
+
61
+ sentinel = instance_variable_get(:@payloop_sentinel)
62
+ sentinel&.raise_if_irrelevant!(title: GOOGLE_CLIENT_TITLE, request: parameters)
63
+
64
+ # Call original method
65
+ response = original_generate_content(*args, **kwargs, &block)
66
+
67
+ # Submit analytics
68
+ payloop_submit_analytics(
69
+ method: :generate_content,
70
+ args: [],
71
+ kwargs: parameters,
72
+ response: response,
73
+ start_time: start_time,
74
+ end_time: Time.now,
75
+ title: GOOGLE_CLIENT_TITLE
76
+ )
77
+
78
+ response
79
+ rescue StandardError => e
80
+ payloop_submit_error_analytics(
81
+ method: :generate_content,
82
+ args: [],
83
+ kwargs: parameters,
84
+ error: e,
85
+ start_time: start_time,
86
+ end_time: Time.now,
87
+ title: GOOGLE_CLIENT_TITLE
88
+ )
89
+
90
+ raise e
91
+ end
92
+ end
93
+ end
94
+
95
+ # Handles the stream_generate_content from the client. Note, that
96
+ # the Payloop backend does not expect the payload to be merged like
97
+ # the OpenAI payload is. So, all the results are just sent as an
98
+ # array.
99
+ def wrap_stream_generate_content_method(client)
100
+ client.singleton_class.class_eval do
101
+ include Base
102
+
103
+ alias_method :original_stream_generate_content, :stream_generate_content
104
+
105
+ define_method(:stream_generate_content) do |*args, **kwargs, &block|
106
+ # Handle both positional hash and keyword arguments
107
+ parameters = if args.first.is_a?(Hash)
108
+ args.first
109
+ elsif kwargs.any?
110
+ kwargs
111
+ else
112
+ {}
113
+ end
114
+
115
+ start_time = Time.now
116
+
117
+ sentinel = instance_variable_get(:@payloop_sentinel)
118
+ sentinel&.raise_if_irrelevant!(title: GOOGLE_CLIENT_TITLE, request: parameters)
119
+
120
+ # Call original method with wrapped block
121
+ response = original_stream_generate_content(*args, **kwargs, &block)
122
+
123
+ # Submit analytics with accumulated response
124
+ payloop_submit_analytics(
125
+ method: :stream_generate_content,
126
+ args: [],
127
+ kwargs: parameters,
128
+ response: response,
129
+ start_time: start_time,
130
+ end_time: Time.now,
131
+ title: GOOGLE_CLIENT_TITLE
132
+ )
133
+
134
+ response
135
+ rescue StandardError => e
136
+ payloop_submit_error_analytics(
137
+ method: :stream_generate_content,
138
+ args: [],
139
+ kwargs: parameters,
140
+ error: e,
141
+ start_time: start_time,
142
+ end_time: Time.now,
143
+ title: GOOGLE_CLIENT_TITLE
144
+ )
145
+
146
+ raise e
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -4,11 +4,12 @@ require_relative "constants"
4
4
 
5
5
  module Payloop
6
6
  module Wrappers
7
- # Wrapper for Google GenerativeAI Ruby client (google-genai gem)
7
+ # Wrapper for Google GenerativeAI Ruby client (google-genai library version 0.1)
8
8
  class Google
9
- def initialize(config, collector)
9
+ def initialize(config, collector, sentinel = nil)
10
10
  @config = config
11
11
  @collector = collector
12
+ @sentinel = sentinel
12
13
  end
13
14
 
14
15
  def register(client)
@@ -23,6 +24,7 @@ module Payloop
23
24
  # Store references in client instance
24
25
  client.instance_variable_set(:@payloop_config, @config)
25
26
  client.instance_variable_set(:@payloop_collector, @collector)
27
+ client.instance_variable_set(:@payloop_sentinel, @sentinel)
26
28
  client.instance_variable_set(:@payloop_registered, true)
27
29
 
28
30
  # Wrap the generate_content method
@@ -61,6 +63,7 @@ module Payloop
61
63
  # Store references on the models resource (needed for Base module)
62
64
  models_resource.instance_variable_set(:@payloop_config, client.instance_variable_get(:@payloop_config))
63
65
  models_resource.instance_variable_set(:@payloop_collector, client.instance_variable_get(:@payloop_collector))
66
+ models_resource.instance_variable_set(:@payloop_sentinel, client.instance_variable_get(:@payloop_sentinel))
64
67
 
65
68
  # Store the original generate_content method
66
69
  original_generate_content = models_resource.method(:generate_content)
@@ -72,6 +75,12 @@ module Payloop
72
75
 
73
76
  start_time = Time.now
74
77
 
78
+ # Extract parameters for analytics
79
+ params = kwargs.any? ? kwargs : (args.first || {})
80
+
81
+ sentinel = instance_variable_get(:@payloop_sentinel)
82
+ sentinel&.raise_if_irrelevant!(title: GOOGLE_CLIENT_TITLE, request: params)
83
+
75
84
  # Call original method
76
85
  response = if kwargs.any?
77
86
  original_generate_content.call(**kwargs, &block)
@@ -79,9 +88,6 @@ module Payloop
79
88
  original_generate_content.call(*args, &block)
80
89
  end
81
90
 
82
- # Extract parameters for analytics
83
- params = kwargs.any? ? kwargs : (args.first || {})
84
-
85
91
  # Submit analytics
86
92
  payloop_submit_analytics(
87
93
  method: :generate_content,
@@ -6,9 +6,10 @@ module Payloop
6
6
  module Wrappers
7
7
  # Wrapper for OpenAI Ruby client (ruby-openai gem)
8
8
  class OpenAI
9
- def initialize(config, collector)
9
+ def initialize(config, collector, sentinel = nil)
10
10
  @config = config
11
11
  @collector = collector
12
+ @sentinel = sentinel
12
13
  end
13
14
 
14
15
  def register(client)
@@ -20,6 +21,7 @@ module Payloop
20
21
  # Store references in client instance
21
22
  client.instance_variable_set(:@payloop_config, @config)
22
23
  client.instance_variable_set(:@payloop_collector, @collector)
24
+ client.instance_variable_set(:@payloop_sentinel, @sentinel)
23
25
  client.instance_variable_set(:@payloop_registered, true)
24
26
 
25
27
  # Wrap the chat method
@@ -45,15 +47,41 @@ module Payloop
45
47
  define_method(:chat) do |parameters: {}|
46
48
  start_time = Time.now
47
49
 
50
+ sentinel = instance_variable_get(:@payloop_sentinel)
51
+ sentinel&.raise_if_irrelevant!(title: OPENAI_CLIENT_TITLE, request: parameters)
52
+
53
+ # Detect streaming and wrap callback to accumulate chunks
54
+ streaming = parameters[:stream].is_a?(Proc)
55
+ accumulated_response = {} if streaming
56
+
57
+ if streaming
58
+ # Configure streaming to include usage (matches Python SDK behavior)
59
+ parameters[:stream_options] ||= {}
60
+ parameters[:stream_options][:include_usage] = true
61
+
62
+ user_callback = parameters[:stream]
63
+ parameters[:stream] = proc do |chunk, bytesize|
64
+ # Normalize chunk to match Python SDK format (add missing delta keys with nil)
65
+ normalized_chunk = payloop_normalize_openai_chunk(chunk)
66
+ # Accumulate chunk (merge into accumulated_response)
67
+ payloop_merge_streaming_chunk(accumulated_response, normalized_chunk)
68
+ # Call user's original callback with original chunk (don't modify user's data)
69
+ user_callback.call(chunk, bytesize)
70
+ end
71
+ end
72
+
48
73
  # Call original method
49
74
  response = original_chat(parameters: parameters)
50
75
 
76
+ # Use accumulated response for streaming, otherwise use returned response
77
+ final_response = streaming ? accumulated_response : response
78
+
51
79
  # Submit analytics
52
80
  payloop_submit_analytics(
53
81
  method: :chat,
54
82
  args: [],
55
83
  kwargs: parameters,
56
- response: response,
84
+ response: final_response,
57
85
  start_time: start_time,
58
86
  end_time: Time.now,
59
87
  title: OPENAI_CLIENT_TITLE
data/lib/payloop.rb CHANGED
@@ -3,13 +3,16 @@
3
3
  require_relative "payloop/version"
4
4
  require_relative "payloop/errors"
5
5
  require_relative "payloop/config"
6
+ require_relative "payloop/sentinel"
6
7
  require_relative "payloop/attribution"
7
8
  require_relative "payloop/collector"
8
9
  require_relative "payloop/wrappers/base"
9
10
  require_relative "payloop/wrappers/openai"
10
11
  require_relative "payloop/wrappers/anthropic"
11
12
  require_relative "payloop/wrappers/google"
13
+ require_relative "payloop/wrappers/geminiai"
12
14
  require_relative "payloop/api/base"
15
+ require_relative "payloop/api/sentinel"
13
16
  require_relative "payloop/api/workflows"
14
17
  require_relative "payloop/api/invocation"
15
18
  require_relative "payloop/api/workflow"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: payloop
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Payloop
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-10-27 00:00:00.000000000 Z
11
+ date: 2026-02-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -37,6 +37,7 @@ files:
37
37
  - lib/payloop.rb
38
38
  - lib/payloop/api/base.rb
39
39
  - lib/payloop/api/invocation.rb
40
+ - lib/payloop/api/sentinel.rb
40
41
  - lib/payloop/api/workflow.rb
41
42
  - lib/payloop/api/workflows.rb
42
43
  - lib/payloop/attribution.rb
@@ -44,10 +45,12 @@ files:
44
45
  - lib/payloop/collector.rb
45
46
  - lib/payloop/config.rb
46
47
  - lib/payloop/errors.rb
48
+ - lib/payloop/sentinel.rb
47
49
  - lib/payloop/version.rb
48
50
  - lib/payloop/wrappers/anthropic.rb
49
51
  - lib/payloop/wrappers/base.rb
50
52
  - lib/payloop/wrappers/constants.rb
53
+ - lib/payloop/wrappers/geminiai.rb
51
54
  - lib/payloop/wrappers/google.rb
52
55
  - lib/payloop/wrappers/openai.rb
53
56
  homepage: https://trypayloop.com