payloop 0.2.0 → 0.4.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/lib/payloop/api/invocation.rb +2 -4
  4. data/lib/payloop/attribution.rb +4 -2
  5. data/lib/payloop/client.rb +7 -0
  6. data/lib/payloop/config.rb +23 -2
  7. data/lib/payloop/errors.rb +3 -0
  8. data/lib/payloop/sentinel.rb +10 -1
  9. data/lib/payloop/version.rb +1 -1
  10. data/lib/payloop/wrappers/anthropic.rb +10 -3
  11. data/lib/payloop/wrappers/base.rb +31 -26
  12. data/lib/payloop/wrappers/constants.rb +1 -0
  13. data/lib/payloop/wrappers/geminiai.rb +22 -7
  14. data/lib/payloop/wrappers/google.rb +14 -5
  15. data/lib/payloop/wrappers/groq.rb +295 -0
  16. data/lib/payloop/wrappers/openai.rb +144 -6
  17. data/lib/payloop/wrappers/ruby_llm.rb +18 -4
  18. data/lib/payloop.rb +1 -0
  19. data/sig/payloop/api/base.rbs +24 -0
  20. data/sig/payloop/api/invocation.rbs +16 -0
  21. data/sig/payloop/api/sentinel.rbs +9 -0
  22. data/sig/payloop/api/workflow.rbs +15 -0
  23. data/sig/payloop/api/workflows.rbs +16 -0
  24. data/sig/payloop/attribution.rbs +17 -0
  25. data/sig/payloop/client.rbs +29 -0
  26. data/sig/payloop/collector.rbs +20 -0
  27. data/sig/payloop/config.rbs +33 -0
  28. data/sig/payloop/errors.rbs +28 -0
  29. data/sig/payloop/sentinel.rbs +17 -0
  30. data/sig/payloop/version.rbs +5 -0
  31. data/sig/payloop/wrappers/anthropic.rbs +18 -0
  32. data/sig/payloop/wrappers/base.rbs +21 -0
  33. data/sig/payloop/wrappers/constants.rbs +10 -0
  34. data/sig/payloop/wrappers/geminiai.rbs +19 -0
  35. data/sig/payloop/wrappers/google.rbs +19 -0
  36. data/sig/payloop/wrappers/groq.rbs +22 -0
  37. data/sig/payloop/wrappers/openai.rbs +19 -0
  38. data/sig/payloop/wrappers/ruby_llm.rbs +21 -0
  39. metadata +23 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7783fa796b37bc909b7902b2477e59eeadb7b28460f4867cece51629b50e114f
4
- data.tar.gz: 69871d85d4066f899cf697179ed3468b7adad4420319ee5c2c50f0a11314f8b3
3
+ metadata.gz: dc7fddc9f323048d0b9930cfa546f569407e82f6114b88c9700e0d635ac04df0
4
+ data.tar.gz: 306b03a60258ad5ea53ad8fc1cc97efd9c9959c4b517a17c04a1b37c7fde2084
5
5
  SHA512:
6
- metadata.gz: afc394253b0c42ea0e4bf41dc9c7cfee11dad20dccffbd6689e9c5e3f19ccde79d2a1b1b807b8b07dab83562f8242286047efe6716de476882134be384d1610d
7
- data.tar.gz: c98627c98fedec6e77e0b152614dcc1bce1adebe149344c344e10f99e8292607aa35e600da32cb29f9c74d77541de1474b79b5cfdfb519e129b0fdc6b8bd7aeb
6
+ metadata.gz: 96b3bcb520e6e0e062bad34f56f1a5487c466881b7e29eaf4e2b33d98ecda3a4188ec52db19242f6bd10e45cecff1b029c62e223a7ae7664d630f92454ceeed0
7
+ data.tar.gz: 576e9946a609e38d998979ce71480c08513e263ac2b6fa0421303ba9c26b228b0035c3acbc9898a375f923d1a80ab577b69d2f6edce8e3affad367a00c4562eb
data/CHANGELOG.md CHANGED
@@ -51,3 +51,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
51
51
 
52
52
  ### Added
53
53
  - Support for the RubyLLM library
54
+
55
+ ## [0.3.0] - 2026-04-15
56
+
57
+ ### Added
58
+ - Support OpenAI Responses API (responses.create)
59
+
60
+ ## [0.4.0] - 2026-05-19
61
+
62
+ ### Added
63
+ - Support for the Groq Ruby client (`groq` gem, drnic/groq-ruby), including streaming.
64
+ - RBS type signatures shipped with the gem.
@@ -7,7 +7,7 @@ module Payloop
7
7
  # API client for workflow invocation operations
8
8
  class Invocation < Base
9
9
  def initialize(api_url, api_key, timeout)
10
- super(api_url, api_key, timeout)
10
+ super
11
11
  @attribution = nil
12
12
  end
13
13
 
@@ -35,9 +35,7 @@ module Payloop
35
35
  }
36
36
  }
37
37
 
38
- if date_end
39
- body[:date][:end] = format_date(date_end)
40
- end
38
+ body[:date][:end] = format_date(date_end) if date_end
41
39
 
42
40
  body[:attribution] = @attribution if @attribution
43
41
 
@@ -38,7 +38,8 @@ module Payloop
38
38
 
39
39
  value_str = value.to_s
40
40
  if value_str.length > 100
41
- raise ValidationError, "parent_id cannot exceed 100 characters (got #{value_str.length})"
41
+ raise ValidationError,
42
+ "parent_id cannot exceed 100 characters (got #{value_str.length})"
42
43
  end
43
44
 
44
45
  value_str
@@ -49,7 +50,8 @@ module Payloop
49
50
 
50
51
  value_str = value.to_s
51
52
  if value_str.length > 100
52
- raise ValidationError, "#{field_name} cannot exceed 100 characters (got #{value_str.length})"
53
+ raise ValidationError,
54
+ "#{field_name} cannot exceed 100 characters (got #{value_str.length})"
53
55
  end
54
56
 
55
57
  value_str
@@ -52,6 +52,13 @@ module Payloop
52
52
  @ruby_llm ||= Wrappers::RubyLLM.new(@config, @collector, @sentinel)
53
53
  end
54
54
 
55
+ # Groq provider wrapper (groq gem, drnic/groq-ruby)
56
+ # Groq hosts third-party models (Meta Llama, OpenAI gpt-oss, Qwen, etc.).
57
+ # Telemetry sets provider="groq" and title from the model-ID prefix.
58
+ def groq
59
+ @groq ||= Wrappers::Groq.new(@config, @collector, @sentinel)
60
+ end
61
+
55
62
  # Set attribution for cost tracking
56
63
  def attribution(parent_id:, parent_name: nil, subsidiary_id: nil, subsidiary_name: nil)
57
64
  attr = Attribution.new(
@@ -9,8 +9,16 @@ module Payloop
9
9
 
10
10
  def initialize(api_key: nil, collector_url: nil, api_url: nil, timeout: nil)
11
11
  @api_key = api_key
12
- @collector_url = collector_url || "https://collector.trypayloop.com"
13
- @api_url = api_url || "https://api.trypayloop.com"
12
+ # URL precedence: constructor arg > PAYLOOP_*_URL_BASE env var > hardcoded
13
+ # prod default. Matches the JS SDK so an integration-test run pointed at
14
+ # staging or a local backend only requires setting the env vars in
15
+ # `.env` — no spec changes needed.
16
+ @collector_url = collector_url ||
17
+ nonempty_env("PAYLOOP_COLLECTOR_URL_BASE") ||
18
+ "https://collector.trypayloop.com"
19
+ @api_url = api_url ||
20
+ nonempty_env("PAYLOOP_API_URL_BASE") ||
21
+ "https://api.trypayloop.com"
14
22
  @timeout = timeout || 5
15
23
  @version = Payloop::VERSION
16
24
  @attribution = Concurrent::AtomicReference.new(nil)
@@ -54,5 +62,18 @@ module Payloop
54
62
  def new_transaction
55
63
  @tx_uuid.set(SecureRandom.uuid)
56
64
  end
65
+
66
+ private
67
+
68
+ # Returns ENV[name] unless it's missing or empty. We can't use the JS-style
69
+ # `ENV[name] || default` because Ruby treats empty strings as truthy — and
70
+ # the `.env.example` ships with blank values for the URL keys, so naïve
71
+ # truthiness would route every request to "".
72
+ def nonempty_env(name)
73
+ value = ENV.fetch(name, nil)
74
+ return nil if value.nil? || value.empty?
75
+
76
+ value
77
+ end
57
78
  end
58
79
  end
@@ -29,4 +29,7 @@ module Payloop
29
29
  end
30
30
 
31
31
  class PayloopRequestInterceptedError < Error; end
32
+
33
+ # Raised when a streaming response ends without the expected terminal event
34
+ class StreamError < Error; end
32
35
  end
@@ -13,7 +13,10 @@ module Payloop
13
13
  self
14
14
  end
15
15
 
16
- def set_secs_irrelevant_request_timeout(timeout)
16
+ # Intentional explicit `set_*` verb prefix — matches JS/Python SDK naming
17
+ # so callers porting code between SDKs see the same surface. See CLAUDE.md
18
+ # "Python SDK is the source of truth" / known divergences.
19
+ def set_secs_irrelevant_request_timeout(timeout) # rubocop:disable Naming/AccessorMethodName
17
20
  raise TypeError, "timeout must be a Numeric" unless timeout.is_a?(Numeric)
18
21
  raise ArgumentError, "timeout must be greater than 0" unless timeout.positive?
19
22
 
@@ -50,6 +53,12 @@ module Payloop
50
53
  version: version
51
54
  },
52
55
  request: normalize_request(request)
56
+ },
57
+ meta: {
58
+ sdk: {
59
+ client: "ruby",
60
+ version: @config.version
61
+ }
53
62
  }
54
63
  }
55
64
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Payloop
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -56,11 +56,16 @@ module Payloop
56
56
  extend Base unless singleton_class.include?(Base)
57
57
 
58
58
  start_time = Time.now
59
+ version = defined?(::Anthropic::VERSION) ? ::Anthropic::VERSION : nil
59
60
 
60
61
  # Extract parameters for sentinel and analytics
61
62
  params = kwargs.any? ? kwargs : (args.first || {})
62
63
  sentinel = instance_variable_get(:@payloop_sentinel)
63
- sentinel&.raise_if_irrelevant!(title: ANTHROPIC_CLIENT_TITLE, request: params)
64
+ sentinel&.raise_if_irrelevant!(
65
+ title: ANTHROPIC_CLIENT_TITLE,
66
+ request: params,
67
+ version: version
68
+ )
64
69
 
65
70
  # Call original method
66
71
  response = if kwargs.any?
@@ -77,7 +82,8 @@ module Payloop
77
82
  response: response,
78
83
  start_time: start_time,
79
84
  end_time: Time.now,
80
- title: ANTHROPIC_CLIENT_TITLE
85
+ title: ANTHROPIC_CLIENT_TITLE,
86
+ version: version
81
87
  )
82
88
 
83
89
  response
@@ -94,7 +100,8 @@ module Payloop
94
100
  error: e,
95
101
  start_time: start_time,
96
102
  end_time: Time.now,
97
- title: ANTHROPIC_CLIENT_TITLE
103
+ title: ANTHROPIC_CLIENT_TITLE,
104
+ version: version
98
105
  )
99
106
 
100
107
  raise e
@@ -44,7 +44,8 @@ module Payloop
44
44
  end
45
45
 
46
46
  def payloop_submit_analytics(method:, args:, kwargs:, response:, start_time:,
47
- end_time:, provider: nil, title: nil)
47
+ end_time:, provider: nil, title: nil, version: nil,
48
+ status: "succeeded", exception: nil)
48
49
  collector = instance_variable_get(:@payloop_collector)
49
50
  config = instance_variable_get(:@payloop_config)
50
51
 
@@ -56,34 +57,38 @@ module Payloop
56
57
  start_time: start_time,
57
58
  end_time: end_time,
58
59
  config: config,
59
- status: "succeeded",
60
+ status: status,
60
61
  provider: provider,
61
- title: title
62
+ title: title,
63
+ version: version,
64
+ exception: exception
62
65
  )
63
66
 
64
67
  collector.submit_async(payload)
65
68
  end
66
69
 
70
+ # Submit a failed-call payload to the collector. `response:` is optional —
71
+ # when omitted, the default `{ error:, class: }` shape is used. Wrappers
72
+ # that preserve richer data on error (e.g. Groq's streaming wrapper
73
+ # merging accumulated chunks with error info via build_error_response)
74
+ # pass it explicitly; the backend extractor reads whatever fields it can
75
+ # from the supplied hash and falls back gracefully on missing keys.
67
76
  def payloop_submit_error_analytics(method:, args:, kwargs:, error:, start_time:,
68
- end_time:, provider: nil, title: nil)
69
- collector = instance_variable_get(:@payloop_collector)
70
- config = instance_variable_get(:@payloop_config)
71
-
72
- return unless collector && config
73
-
74
- payload = build_payload(
75
- query: extract_query(method, args, kwargs),
76
- response: { error: error.message, class: error.class.name },
77
+ end_time:, provider: nil, title: nil, version: nil,
78
+ response: nil)
79
+ payloop_submit_analytics(
80
+ method: method,
81
+ args: args,
82
+ kwargs: kwargs,
83
+ response: response || { error: error.message, class: error.class.name },
77
84
  start_time: start_time,
78
85
  end_time: end_time,
79
- config: config,
80
- status: "failed",
81
86
  provider: provider,
82
87
  title: title,
88
+ version: version,
89
+ status: "failed",
83
90
  exception: error.message
84
91
  )
85
-
86
- collector.submit_async(payload)
87
92
  end
88
93
 
89
94
  def payloop_merge_streaming_chunk(accumulated, chunk)
@@ -115,14 +120,14 @@ module Payloop
115
120
  next unless choice.is_a?(Hash) && choice.key?("delta")
116
121
 
117
122
  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
123
+ next unless delta.is_a?(Hash)
124
+
125
+ # Add missing keys with nil values to match Python SDK
126
+ delta["role"] ||= nil unless delta.key?("role")
127
+ delta["content"] ||= nil unless delta.key?("content")
128
+ delta["refusal"] ||= nil unless delta.key?("refusal")
129
+ delta["tool_calls"] ||= nil unless delta.key?("tool_calls")
130
+ delta["function_call"] ||= nil unless delta.key?("function_call")
126
131
  end
127
132
  end
128
133
 
@@ -215,14 +220,14 @@ module Payloop
215
220
  # - conversion.response - Holds the response from the LLM. This is different for each LLM,
216
221
  # and the different services handle them on the backend.
217
222
  def build_payload(query:, response:, start_time:, end_time:, config:, status:,
218
- provider: nil, title: nil, exception: nil)
223
+ provider: nil, title: nil, version: nil, exception: nil)
219
224
  {
220
225
  attribution: config.attribution&.to_h,
221
226
  conversation: {
222
227
  client: {
223
228
  provider: provider,
224
229
  title: title,
225
- version: nil
230
+ version: version
226
231
  },
227
232
  query: query,
228
233
  response: response
@@ -7,5 +7,6 @@ module Payloop
7
7
  ANTHROPIC_CLIENT_TITLE = "anthropic"
8
8
  GOOGLE_CLIENT_TITLE = "google"
9
9
  RUBY_LLM_PROVIDER = "ruby_llm"
10
+ GROQ_PROVIDER = "groq"
10
11
  end
11
12
  end
@@ -37,7 +37,8 @@ module Payloop
37
37
  return if client.respond_to?(:generate_content) && client.respond_to?(:stream_generate_content)
38
38
 
39
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
40
+ "Client does not appear to be a valid Gemini AI client " \
41
+ "(missing generate_content or stream_generate_content method)"
41
42
  end
42
43
 
43
44
  def wrap_generate_content_method(client)
@@ -57,9 +58,14 @@ module Payloop
57
58
  end
58
59
 
59
60
  start_time = Time.now
61
+ version = defined?(::Gemini::GEM) && ::Gemini::GEM.is_a?(Hash) ? ::Gemini::GEM[:version] : nil
60
62
 
61
63
  sentinel = instance_variable_get(:@payloop_sentinel)
62
- sentinel&.raise_if_irrelevant!(title: GOOGLE_CLIENT_TITLE, request: parameters)
64
+ sentinel&.raise_if_irrelevant!(
65
+ title: GOOGLE_CLIENT_TITLE,
66
+ request: parameters,
67
+ version: version
68
+ )
63
69
 
64
70
  # Call original method
65
71
  response = original_generate_content(*args, **kwargs, &block)
@@ -72,7 +78,8 @@ module Payloop
72
78
  response: response,
73
79
  start_time: start_time,
74
80
  end_time: Time.now,
75
- title: GOOGLE_CLIENT_TITLE
81
+ title: GOOGLE_CLIENT_TITLE,
82
+ version: version
76
83
  )
77
84
 
78
85
  response
@@ -87,7 +94,8 @@ module Payloop
87
94
  error: e,
88
95
  start_time: start_time,
89
96
  end_time: Time.now,
90
- title: GOOGLE_CLIENT_TITLE
97
+ title: GOOGLE_CLIENT_TITLE,
98
+ version: version
91
99
  )
92
100
 
93
101
  raise e
@@ -116,9 +124,14 @@ module Payloop
116
124
  end
117
125
 
118
126
  start_time = Time.now
127
+ version = defined?(::Gemini::GEM) && ::Gemini::GEM.is_a?(Hash) ? ::Gemini::GEM[:version] : nil
119
128
 
120
129
  sentinel = instance_variable_get(:@payloop_sentinel)
121
- sentinel&.raise_if_irrelevant!(title: GOOGLE_CLIENT_TITLE, request: parameters)
130
+ sentinel&.raise_if_irrelevant!(
131
+ title: GOOGLE_CLIENT_TITLE,
132
+ request: parameters,
133
+ version: version
134
+ )
122
135
 
123
136
  # Call original method with wrapped block
124
137
  response = original_stream_generate_content(*args, **kwargs, &block)
@@ -131,7 +144,8 @@ module Payloop
131
144
  response: response,
132
145
  start_time: start_time,
133
146
  end_time: Time.now,
134
- title: GOOGLE_CLIENT_TITLE
147
+ title: GOOGLE_CLIENT_TITLE,
148
+ version: version
135
149
  )
136
150
 
137
151
  response
@@ -146,7 +160,8 @@ module Payloop
146
160
  error: e,
147
161
  start_time: start_time,
148
162
  end_time: Time.now,
149
- title: GOOGLE_CLIENT_TITLE
163
+ title: GOOGLE_CLIENT_TITLE,
164
+ version: version
150
165
  )
151
166
 
152
167
  raise e
@@ -49,8 +49,10 @@ module Payloop
49
49
  return if ::Google::Genai::Types::GenerateContentResponse.instance_variable_defined?(:@_payloop_patched)
50
50
 
51
51
  ::Google::Genai::Types::GenerateContentResponse.class_eval do
52
- # Use camelCase to match Google's API response keys
53
- attr_accessor :modelVersion, :usageMetadata, :responseId, :createTime
52
+ # camelCase intentional — these names must match Google's API response
53
+ # keys verbatim so the patched accessors survive serialization through
54
+ # `Base#extract_response`.
55
+ attr_accessor :modelVersion, :usageMetadata, :responseId, :createTime # rubocop:disable Naming/MethodName
54
56
  end
55
57
 
56
58
  ::Google::Genai::Types::GenerateContentResponse.instance_variable_set(:@_payloop_patched, true)
@@ -74,12 +76,17 @@ module Payloop
74
76
  extend Base unless singleton_class.include?(Base)
75
77
 
76
78
  start_time = Time.now
79
+ version = defined?(::Google::Genai::VERSION) ? ::Google::Genai::VERSION : nil
77
80
 
78
81
  # Extract parameters for analytics
79
82
  params = kwargs.any? ? kwargs : (args.first || {})
80
83
 
81
84
  sentinel = instance_variable_get(:@payloop_sentinel)
82
- sentinel&.raise_if_irrelevant!(title: GOOGLE_CLIENT_TITLE, request: params)
85
+ sentinel&.raise_if_irrelevant!(
86
+ title: GOOGLE_CLIENT_TITLE,
87
+ request: params,
88
+ version: version
89
+ )
83
90
 
84
91
  # Call original method
85
92
  response = if kwargs.any?
@@ -96,7 +103,8 @@ module Payloop
96
103
  response: response,
97
104
  start_time: start_time,
98
105
  end_time: Time.now,
99
- title: GOOGLE_CLIENT_TITLE
106
+ title: GOOGLE_CLIENT_TITLE,
107
+ version: version
100
108
  )
101
109
 
102
110
  response
@@ -113,7 +121,8 @@ module Payloop
113
121
  error: e,
114
122
  start_time: start_time,
115
123
  end_time: Time.now,
116
- title: GOOGLE_CLIENT_TITLE
124
+ title: GOOGLE_CLIENT_TITLE,
125
+ version: version
117
126
  )
118
127
 
119
128
  raise e