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 +4 -4
- data/CHANGELOG.md +12 -1
- data/lib/payloop/api/sentinel.rb +11 -0
- data/lib/payloop/client.rb +16 -5
- data/lib/payloop/config.rb +18 -0
- data/lib/payloop/errors.rb +2 -0
- data/lib/payloop/sentinel.rb +91 -0
- data/lib/payloop/version.rb +1 -1
- data/lib/payloop/wrappers/anthropic.rb +9 -4
- data/lib/payloop/wrappers/base.rb +66 -6
- data/lib/payloop/wrappers/geminiai.rb +152 -0
- data/lib/payloop/wrappers/google.rb +11 -5
- data/lib/payloop/wrappers/openai.rb +30 -2
- data/lib/payloop.rb +3 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 046f4c8d567e9eb4bf8f6797ef0a693f90a279f04de29d9d875030b264323788
|
|
4
|
+
data.tar.gz: 1fa3b863d95bf6568bb78b0df6819bde803449786ecad95c97c8dde57394689e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
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.
|
data/lib/payloop/client.rb
CHANGED
|
@@ -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
|
data/lib/payloop/config.rb
CHANGED
|
@@ -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
|
data/lib/payloop/errors.rb
CHANGED
|
@@ -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
|
data/lib/payloop/version.rb
CHANGED
|
@@ -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,
|
|
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:,
|
|
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:,
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|