payloop 0.1.1 → 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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +1 -1
  4. data/lib/payloop/api/invocation.rb +2 -4
  5. data/lib/payloop/attribution.rb +4 -2
  6. data/lib/payloop/client.rb +13 -0
  7. data/lib/payloop/config.rb +23 -2
  8. data/lib/payloop/errors.rb +3 -0
  9. data/lib/payloop/sentinel.rb +10 -1
  10. data/lib/payloop/version.rb +1 -1
  11. data/lib/payloop/wrappers/anthropic.rb +10 -3
  12. data/lib/payloop/wrappers/base.rb +31 -26
  13. data/lib/payloop/wrappers/constants.rb +2 -0
  14. data/lib/payloop/wrappers/geminiai.rb +22 -7
  15. data/lib/payloop/wrappers/google.rb +14 -5
  16. data/lib/payloop/wrappers/groq.rb +295 -0
  17. data/lib/payloop/wrappers/openai.rb +144 -6
  18. data/lib/payloop/wrappers/ruby_llm.rb +170 -0
  19. data/lib/payloop.rb +2 -0
  20. data/sig/payloop/api/base.rbs +24 -0
  21. data/sig/payloop/api/invocation.rbs +16 -0
  22. data/sig/payloop/api/sentinel.rbs +9 -0
  23. data/sig/payloop/api/workflow.rbs +15 -0
  24. data/sig/payloop/api/workflows.rbs +16 -0
  25. data/sig/payloop/attribution.rbs +17 -0
  26. data/sig/payloop/client.rbs +29 -0
  27. data/sig/payloop/collector.rbs +20 -0
  28. data/sig/payloop/config.rbs +33 -0
  29. data/sig/payloop/errors.rbs +28 -0
  30. data/sig/payloop/sentinel.rbs +17 -0
  31. data/sig/payloop/version.rbs +5 -0
  32. data/sig/payloop/wrappers/anthropic.rbs +18 -0
  33. data/sig/payloop/wrappers/base.rbs +21 -0
  34. data/sig/payloop/wrappers/constants.rbs +10 -0
  35. data/sig/payloop/wrappers/geminiai.rbs +19 -0
  36. data/sig/payloop/wrappers/google.rbs +19 -0
  37. data/sig/payloop/wrappers/groq.rbs +22 -0
  38. data/sig/payloop/wrappers/openai.rbs +19 -0
  39. data/sig/payloop/wrappers/ruby_llm.rbs +21 -0
  40. metadata +24 -2
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+
5
+ module Payloop
6
+ module Wrappers
7
+ # Wrapper for the Groq Ruby client (`groq` gem, drnic/groq-ruby).
8
+ #
9
+ # Groq is an inference framework that hosts third-party models (Meta Llama,
10
+ # OpenAI gpt-oss, Qwen, compound systems), so it sits in
11
+ # `conversation.client.provider = "groq"` rather than `title`. `title` is
12
+ # derived per-call from the model-ID prefix — e.g.
13
+ # `meta-llama/llama-4-scout-17b-16e-instruct` → `"meta-llama"`,
14
+ # `openai/gpt-oss-20b` → `"openai"`. Legacy un-prefixed IDs
15
+ # (`llama-3.1-8b-instant`, `allam-2-7b`) fall back to the first
16
+ # alphanumeric run (`"llama"`, `"allam"`); anything unparseable falls
17
+ # back to `GROQ_PROVIDER` (`"groq"`). `title` is never nil.
18
+ #
19
+ # Unlike the JS / Python groq-sdk, the Ruby `groq` gem's `Client#chat`
20
+ # returns only the assistant message hash (`response.body.dig("choices", 0,
21
+ # "message")`), discarding `usage`, `model`, and the rest of the chat
22
+ # completion envelope. To preserve the wire shape the backend extractor
23
+ # expects, we patch the lower-level `Client#post(path:, body:)` and filter
24
+ # by path — every chat call goes through `/openai/v1/chat/completions`, and
25
+ # `body`/`response.body` at that layer carry the full chat-completion
26
+ # request and response.
27
+ class Groq
28
+ # HTTP path for Groq's chat-completion endpoint (Groq's API is OpenAI-compatible,
29
+ # namespaced under `/openai/v1/`). The `post` wrapper filters on this so non-chat
30
+ # requests pass through without analytics or sentinel.
31
+ CHAT_COMPLETIONS_PATH = "/openai/v1/chat/completions"
32
+
33
+ def initialize(config, collector, sentinel = nil)
34
+ @config = config
35
+ @collector = collector
36
+ @sentinel = sentinel
37
+ end
38
+
39
+ def register(client)
40
+ validate_client!(client)
41
+
42
+ # Prevent double registration
43
+ return client if client.instance_variable_defined?(:@payloop_registered)
44
+
45
+ # Patch the gem's streaming JSON parser. Class-level patch on
46
+ # ::Groq::Client, idempotent via @_payloop_stream_patched — runs once
47
+ # per process. Placed after the per-client guard so repeat register
48
+ # calls on the same client are a true no-op. See patch_stream_handler!
49
+ # for the bug being worked around.
50
+ patch_stream_handler!
51
+
52
+ # Store references in client instance
53
+ client.instance_variable_set(:@payloop_config, @config)
54
+ client.instance_variable_set(:@payloop_collector, @collector)
55
+ client.instance_variable_set(:@payloop_sentinel, @sentinel)
56
+ client.instance_variable_set(:@payloop_registered, true)
57
+
58
+ wrap_post_method(client)
59
+
60
+ client
61
+ end
62
+
63
+ # Derive a telemetry `title` from a Groq model identifier. Mirrors
64
+ # `extractModelTitle` in the JS SDK (`javascript-sdk/src/utils.ts`).
65
+ #
66
+ # Rules:
67
+ # - String with `/`: return everything before the first `/`
68
+ # (`meta-llama/llama-4-...` → `"meta-llama"`, `openai/gpt-oss-20b`
69
+ # → `"openai"`).
70
+ # - String without `/`: return the first run of alphanumeric characters
71
+ # (`llama-3.1-8b-instant` → `"llama"`, `allam-2-7b` → `"allam"`,
72
+ # `gpt-4o` → `"gpt"`).
73
+ # - Anything else (non-string, empty, unrecognized shape): return
74
+ # `fallback`.
75
+ #
76
+ # Telemetry invariant: `conversation.client.title` is never nil — the
77
+ # caller always supplies a sensible string fallback (`GROQ_PROVIDER` for
78
+ # Groq calls).
79
+ #
80
+ # Public because the wrapped-method closure (inside
81
+ # singleton_class.class_eval) needs to call it.
82
+ def self.extract_model_title(model, fallback)
83
+ return fallback unless model.is_a?(String)
84
+
85
+ slash = model.index("/")
86
+ return model[0...slash] || fallback if slash&.positive?
87
+
88
+ model[/\A[A-Za-z0-9]+/] || fallback
89
+ end
90
+
91
+ # Build the `response` hash sent on a failed call. For streaming requests
92
+ # that errored after one or more chunks were already merged, the partial
93
+ # `accumulated` response is preserved and the error info is folded in —
94
+ # so the backend can record what was generated before the failure.
95
+ # Non-streaming and pre-chunk failures get the original error-only shape.
96
+ #
97
+ # Public for the same reason as `extract_model_title` — invoked from the
98
+ # wrapped-method closure inside `singleton_class.class_eval`.
99
+ def self.build_error_response(streaming:, accumulated:, error:)
100
+ if streaming && accumulated.is_a?(Hash) && !accumulated.empty?
101
+ accumulated.merge("error" => error.message, "error_class" => error.class.name)
102
+ else
103
+ { error: error.message, class: error.class.name }
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def validate_client!(client)
110
+ return if defined?(::Groq::Client) && client.is_a?(::Groq::Client)
111
+ # Fallback for mock objects in tests — must have both methods.
112
+ return if client.respond_to?(:chat) && client.respond_to?(:post)
113
+
114
+ raise RegistrationError,
115
+ "Client does not appear to be a valid Groq client (missing chat method)"
116
+ end
117
+
118
+ # The `groq` gem (drnic/groq-ruby v0.3.2) parses each SSE chunk inside
119
+ # `Client#to_json_stream` with:
120
+ #
121
+ # delta = chunk.dig("choices", 0, "delta")
122
+ # content = delta.dig("content")
123
+ #
124
+ # That second line crashes with `NoMethodError: undefined method 'dig' for
125
+ # nil:NilClass` when `delta` is nil — which happens on the terminal usage
126
+ # chunk Groq sends when `stream_options.include_usage = true` (the chunk
127
+ # has `choices: []`, so the first `dig` returns nil). Since we inject
128
+ # `include_usage: true` for JS/Python parity (token counts), this fires
129
+ # on every real streaming call. Replace the method with a copy that uses
130
+ # `delta&.dig("content")`. Idempotent via `@_payloop_stream_patched`.
131
+ def patch_stream_handler!
132
+ return unless defined?(::Groq::Client)
133
+ return if ::Groq::Client.instance_variable_defined?(:@_payloop_stream_patched)
134
+
135
+ # The gem lazy-loads event_stream_parser inside Client#chat. Our patched
136
+ # to_json_stream needs the constant available at the patch's class_eval
137
+ # site too, so eagerly require it now.
138
+ require "event_stream_parser"
139
+
140
+ ::Groq::Client.class_eval do
141
+ private
142
+
143
+ def to_json_stream(user_proc:)
144
+ parser = ::EventStreamParser::Parser.new
145
+
146
+ proc do |chunk, _bytes, env|
147
+ if env && env.status != 200
148
+ raise_error = Faraday::Response::RaiseError.new
149
+ raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
150
+ end
151
+
152
+ parser.feed(chunk) do |_type, data|
153
+ next if data == "[DONE]"
154
+
155
+ chunk = JSON.parse(data)
156
+ delta = chunk.dig("choices", 0, "delta")
157
+ content = delta&.dig("content")
158
+
159
+ arity = user_proc.is_a?(Proc) ? user_proc.arity : user_proc.method(:call).arity
160
+ if arity == 1
161
+ user_proc.call(content)
162
+ else
163
+ user_proc.call(content, chunk)
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+
170
+ ::Groq::Client.instance_variable_set(:@_payloop_stream_patched, true)
171
+ end
172
+
173
+ def wrap_post_method(client)
174
+ chat_path = CHAT_COMPLETIONS_PATH
175
+
176
+ client.singleton_class.class_eval do
177
+ include Base
178
+
179
+ alias_method :original_post, :post
180
+
181
+ define_method(:post) do |path:, body:|
182
+ # Non-chat-completions paths pass through untouched — no analytics,
183
+ # no sentinel. (At time of writing the gem only ever uses post for
184
+ # chat completions, but be defensive about future endpoints.)
185
+ return original_post(path: path, body: body) unless path == chat_path
186
+
187
+ start_time = Time.now
188
+ version = defined?(::Groq::VERSION) ? ::Groq::VERSION : nil
189
+ title = Payloop::Wrappers::Groq.extract_model_title(body[:model], GROQ_PROVIDER)
190
+
191
+ sentinel = instance_variable_get(:@payloop_sentinel)
192
+ sentinel&.raise_if_irrelevant!(
193
+ title: title,
194
+ request: body,
195
+ provider: GROQ_PROVIDER,
196
+ version: version
197
+ )
198
+
199
+ # Dup before mutating so the caller's body hash is unchanged.
200
+ body = body.dup
201
+ streaming = body[:stream_chunk].respond_to?(:call)
202
+ accumulated_response = streaming ? {} : nil
203
+
204
+ if streaming
205
+ # Match the JS/Python behavior: ask Groq to include usage on the
206
+ # terminal chunk so the merged response carries token counts.
207
+ # A caller-set value (true or false) wins — we only force `true`
208
+ # when the key is absent.
209
+ body[:stream_options] = { include_usage: true }.merge(body[:stream_options] || {})
210
+
211
+ user_callback = body[:stream_chunk]
212
+ body[:stream_chunk] = proc do |content, chunk|
213
+ if chunk.is_a?(Hash)
214
+ normalized = payloop_normalize_openai_chunk(chunk)
215
+ payloop_merge_streaming_chunk(accumulated_response, normalized)
216
+ end
217
+
218
+ # The gem inspects user_proc.arity to decide between
219
+ # call(content) and call(content, chunk); forward with the
220
+ # caller's intended signature.
221
+ arity = user_callback.is_a?(Proc) ? user_callback.arity : user_callback.method(:call).arity
222
+ if arity == 1
223
+ user_callback.call(content)
224
+ else
225
+ user_callback.call(content, chunk)
226
+ end
227
+ end
228
+ end
229
+
230
+ response = original_post(path: path, body: body)
231
+
232
+ final_response = if streaming
233
+ accumulated_response
234
+ elsif response.respond_to?(:body)
235
+ response.body
236
+ else
237
+ response
238
+ end
239
+
240
+ # Sanitize body for analytics: stream_chunk is a Proc and the
241
+ # collector would fail to JSON-encode it. Mirror the OpenAI
242
+ # wrapper's `:stream` → `true` collapse.
243
+ analytics_body = body.dup
244
+ analytics_body[:stream_chunk] = true if analytics_body[:stream_chunk].respond_to?(:call)
245
+
246
+ payloop_submit_analytics(
247
+ method: :post,
248
+ args: [],
249
+ kwargs: analytics_body,
250
+ response: final_response,
251
+ start_time: start_time,
252
+ end_time: Time.now,
253
+ provider: GROQ_PROVIDER,
254
+ title: title,
255
+ version: version
256
+ )
257
+
258
+ response
259
+ rescue PayloopRequestInterceptedError => e
260
+ # We don't want to send intercepts to collector
261
+ raise e
262
+ rescue StandardError => e
263
+ analytics_body = body.dup
264
+ analytics_body[:stream_chunk] = true if analytics_body[:stream_chunk].respond_to?(:call)
265
+
266
+ # On streaming failure after one or more chunks merged, pass the
267
+ # accumulated response (plus error info) through so the backend
268
+ # extractor can pull partial assistant content / token usage from
269
+ # what was received before the error. Non-streaming and
270
+ # pre-chunk failures fall back to the helper's default
271
+ # `{error, class}` shape.
272
+ error_response = Payloop::Wrappers::Groq.build_error_response(
273
+ streaming: streaming, accumulated: accumulated_response, error: e
274
+ )
275
+
276
+ payloop_submit_error_analytics(
277
+ method: :post,
278
+ args: [],
279
+ kwargs: analytics_body,
280
+ error: e,
281
+ start_time: start_time,
282
+ end_time: Time.now,
283
+ provider: GROQ_PROVIDER,
284
+ title: title,
285
+ version: version,
286
+ response: error_response
287
+ )
288
+
289
+ raise e
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
295
+ end
@@ -27,12 +27,141 @@ module Payloop
27
27
  # Wrap the chat method
28
28
  wrap_chat_method(client)
29
29
 
30
+ # Wrap responses.create if the client supports the Responses API (ruby-openai >= 8.0)
31
+ wrap_responses_method(client) if client.respond_to?(:responses)
32
+
30
33
  client
31
34
  end
32
35
 
33
36
  private
34
37
 
38
+ def wrap_responses_method(client)
39
+ responses_obj = client.responses
40
+
41
+ # Copy Payloop references onto the responses sub-object so Base helpers can access them
42
+ responses_obj.instance_variable_set(:@payloop_config, client.instance_variable_get(:@payloop_config))
43
+ responses_obj.instance_variable_set(:@payloop_collector, client.instance_variable_get(:@payloop_collector))
44
+ responses_obj.instance_variable_set(:@payloop_sentinel, client.instance_variable_get(:@payloop_sentinel))
45
+
46
+ responses_obj.singleton_class.class_eval do
47
+ include Base
48
+
49
+ alias_method :original_create, :create
50
+
51
+ define_method(:create) do |parameters: {}|
52
+ start_time = Time.now
53
+ version = defined?(::OpenAI::VERSION) ? ::OpenAI::VERSION : nil
54
+
55
+ sentinel = instance_variable_get(:@payloop_sentinel)
56
+ sentinel&.raise_if_irrelevant!(
57
+ title: OPENAI_CLIENT_TITLE,
58
+ request: parameters,
59
+ version: version
60
+ )
61
+
62
+ # Dup before mutating so we don't replace the caller's :stream key with our
63
+ # internal wrapping proc — the caller's hash should be unchanged after the call.
64
+ parameters = parameters.dup
65
+
66
+ streaming = parameters[:stream].respond_to?(:call)
67
+
68
+ if streaming
69
+ accumulated_events = []
70
+ user_callback = parameters[:stream]
71
+ # Determine how many arguments to forward to the user's callback.
72
+ # .arity returns negative values for methods with optional/splat params
73
+ # (e.g. def call(*args) => -1, def call(a, b=nil) => -2), so .abs gives
74
+ # us the minimum required argument count. For fixed-arity procs/lambdas
75
+ # this is exact. Note: a variadic callable (def call(*args)) has arity -1,
76
+ # so .abs yields 1 — the event_type argument will be silently dropped for
77
+ # that signature. Use def call(event, event_type = nil) to receive both.
78
+ user_callback_arity =
79
+ case user_callback
80
+ when Proc
81
+ user_callback.arity.abs
82
+ else
83
+ user_callback.method(:call).arity.abs
84
+ end
85
+
86
+ parameters[:stream] = proc do |chunk, event_type|
87
+ accumulated_events << chunk if chunk.is_a?(Hash)
88
+ user_callback.call(*[chunk, event_type].first(user_callback_arity))
89
+ end
90
+ end
91
+
92
+ response = original_create(parameters: parameters)
93
+
94
+ # Default to the returned response; for streaming, replace it with the
95
+ # full response from the terminal response.completed event.
96
+ final_response = response
97
+
98
+ if streaming
99
+ error_event = accumulated_events.find { |e| e["type"] == "error" }
100
+ if error_event
101
+ final_response = {
102
+ "status" => "failed",
103
+ "error" => error_event["error"]
104
+ }
105
+ end
106
+
107
+ # Extract the full response from the terminal response.completed event.
108
+ # A missing terminal event means the stream was interrupted.
109
+ completed = accumulated_events.find { |e| e["type"] == "response.completed" }
110
+ unless completed || error_event
111
+ raise StreamError,
112
+ "Responses API stream ended without response.completed event"
113
+ end
114
+
115
+ final_response = completed["response"] if completed && !error_event
116
+ end
117
+
118
+ # Treat only an explicit failed response status as failed analytics.
119
+ # Other non-exceptional statuses are still recorded as succeeded.
120
+ response_status = final_response.is_a?(Hash) ? final_response["status"] : nil
121
+ analytics_status = response_status == "failed" ? "failed" : "succeeded"
122
+ analytics_exception =
123
+ if analytics_status == "failed"
124
+ final_response.dig("error", "message") ||
125
+ "OpenAI response status: #{response_status || "unknown"}"
126
+ end
127
+
128
+ payloop_submit_analytics(
129
+ method: :create,
130
+ args: [],
131
+ kwargs: parameters,
132
+ response: final_response,
133
+ start_time: start_time,
134
+ end_time: Time.now,
135
+ title: OPENAI_CLIENT_TITLE,
136
+ version: version,
137
+ status: analytics_status,
138
+ exception: analytics_exception
139
+ )
140
+
141
+ response
142
+ rescue PayloopRequestInterceptedError => e
143
+ raise e
144
+ rescue StandardError => e
145
+ payloop_submit_error_analytics(
146
+ method: :create,
147
+ args: [],
148
+ kwargs: parameters,
149
+ error: e,
150
+ start_time: start_time,
151
+ end_time: Time.now,
152
+ title: OPENAI_CLIENT_TITLE,
153
+ version: version
154
+ )
155
+ raise e
156
+ end
157
+ end
158
+ end
159
+
35
160
  def validate_client!(client)
161
+ # When register an OpenAI client, it currently only checks if there
162
+ # is the classic "chat" interface. OpenAI now supports the newer
163
+ # "responses" interface. So, if an OpenAI client later only has
164
+ # the newer "responses" interface, this validate_client will fail.
36
165
  return if client.respond_to?(:chat)
37
166
 
38
167
  raise RegistrationError, "Client does not appear to be a valid OpenAI client (missing chat method)"
@@ -46,18 +175,25 @@ module Payloop
46
175
 
47
176
  define_method(:chat) do |parameters: {}|
48
177
  start_time = Time.now
178
+ version = defined?(::OpenAI::VERSION) ? ::OpenAI::VERSION : nil
49
179
 
50
180
  sentinel = instance_variable_get(:@payloop_sentinel)
51
- sentinel&.raise_if_irrelevant!(title: OPENAI_CLIENT_TITLE, request: parameters)
181
+ sentinel&.raise_if_irrelevant!(
182
+ title: OPENAI_CLIENT_TITLE,
183
+ request: parameters,
184
+ version: version
185
+ )
52
186
 
53
187
  # Detect streaming and wrap callback to accumulate chunks
54
188
  streaming = parameters[:stream].is_a?(Proc)
55
189
  accumulated_response = {} if streaming
56
190
 
57
191
  if streaming
58
- # Configure streaming to include usage (matches Python SDK behavior)
192
+ # Configure streaming to include usage (matches Python SDK behavior).
193
+ # A caller-set value (true or false) wins — we only force `true`
194
+ # when the key is absent.
59
195
  parameters[:stream_options] ||= {}
60
- parameters[:stream_options][:include_usage] = true
196
+ parameters[:stream_options][:include_usage] = true unless parameters[:stream_options].key?(:include_usage)
61
197
 
62
198
  user_callback = parameters[:stream]
63
199
  parameters[:stream] = proc do |chunk, bytesize|
@@ -70,7 +206,7 @@ module Payloop
70
206
  end
71
207
  end
72
208
 
73
- # Call original method
209
+ # Call the original method
74
210
  response = original_chat(parameters: parameters)
75
211
 
76
212
  # Use accumulated response for streaming, otherwise use returned response
@@ -84,7 +220,8 @@ module Payloop
84
220
  response: final_response,
85
221
  start_time: start_time,
86
222
  end_time: Time.now,
87
- title: OPENAI_CLIENT_TITLE
223
+ title: OPENAI_CLIENT_TITLE,
224
+ version: version
88
225
  )
89
226
 
90
227
  response
@@ -99,7 +236,8 @@ module Payloop
99
236
  error: e,
100
237
  start_time: start_time,
101
238
  end_time: Time.now,
102
- title: OPENAI_CLIENT_TITLE
239
+ title: OPENAI_CLIENT_TITLE,
240
+ version: version
103
241
  )
104
242
 
105
243
  raise e
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+
5
+ module Payloop
6
+ module Wrappers
7
+ # Wrapper for RubyLLM (ruby_llm gem)
8
+ # Supports any provider available via RubyLLM (Anthropic, OpenAI, Google, etc.)
9
+ # Returns raw response and raw formatted query to backend so that the existing
10
+ # extractors can be used.
11
+ class RubyLLM
12
+ def initialize(config, collector, sentinel = nil)
13
+ @config = config
14
+ @collector = collector
15
+ @sentinel = sentinel
16
+ end
17
+
18
+ def register(client)
19
+ validate_client!(client)
20
+
21
+ # Prevent double registration
22
+ return client if client.instance_variable_defined?(:@payloop_registered)
23
+
24
+ # Store references in the client instance
25
+ client.instance_variable_set(:@payloop_config, @config)
26
+ client.instance_variable_set(:@payloop_collector, @collector)
27
+ client.instance_variable_set(:@payloop_sentinel, @sentinel)
28
+ client.instance_variable_set(:@payloop_registered, true)
29
+
30
+ # Ask method handles both streaming and non-streaming cases unlike some others.
31
+ wrap_ask_method(client)
32
+
33
+ client
34
+ end
35
+
36
+ private
37
+
38
+ def validate_client!(client)
39
+ return if client.respond_to?(:ask)
40
+
41
+ raise RegistrationError,
42
+ "Client does not appear to be a valid RubyLLM client (missing ask method)"
43
+ end
44
+
45
+ def wrap_ask_method(client)
46
+ # Capture the wrapper instance in a local variable so it's accessible as a closure
47
+ # inside the block, where self will no longer refer to it.
48
+ wrapper = self
49
+
50
+ client.singleton_class.class_eval do
51
+ include Base
52
+
53
+ alias_method :original_ask, :ask
54
+
55
+ define_method(:ask) do |message, with: nil, &block|
56
+ start_time = Time.now
57
+ query = {} # Fallback for error analytics if an exception occurs before build_query completes
58
+ # Version reported is RubyLLM's, not the underlying provider's — RubyLLM is the
59
+ # meta-wrapper Payloop sees; the native provider gem may not even be loaded.
60
+ version = defined?(::RubyLLM::VERSION) ? ::RubyLLM::VERSION : nil
61
+
62
+ sentinel = instance_variable_get(:@payloop_sentinel)
63
+ provider = model.provider
64
+ # Telemetry invariant: `conversation.client.title` is never nil.
65
+ # `model.provider` should always return a non-empty string for
66
+ # well-formed RubyLLM models, but fall back to the meta-wrapper
67
+ # name (matches JS's pattern of "fallback to the SDK family
68
+ # name") if it ever isn't.
69
+ title = case provider
70
+ when "gemini" then GOOGLE_CLIENT_TITLE
71
+ when nil, "" then RUBY_LLM_PROVIDER
72
+ else provider
73
+ end
74
+
75
+ # Capture existing messages (e.g. system instruction) before the call
76
+ # adds the new user message to history.
77
+ existing_msgs = messages.map { |m| { role: m.role.to_s, content: m.content.to_s } }
78
+ all_msgs = existing_msgs + [{ role: "user", content: message.to_s }]
79
+
80
+ # Build a query in the provider's native format so the backend extractor can parse it.
81
+ query = wrapper.send(:build_query, provider, model.id, all_msgs, tools)
82
+
83
+ # Pass the actual provider title (e.g. "anthropic", "openai", "google") so the
84
+ # sentinel backend can route to the correct classifier for this request format.
85
+ sentinel&.raise_if_irrelevant!(title: title, request: query, version: version)
86
+
87
+ response = original_ask(message, with: with, &block)
88
+
89
+ payloop_submit_analytics(
90
+ method: :ask,
91
+ args: [],
92
+ kwargs: query,
93
+ response: response.raw&.body,
94
+ start_time: start_time,
95
+ end_time: Time.now,
96
+ title: title,
97
+ provider: RUBY_LLM_PROVIDER,
98
+ version: version
99
+ )
100
+
101
+ response
102
+ rescue PayloopRequestInterceptedError => e
103
+ raise e
104
+ rescue StandardError => e
105
+ payloop_submit_error_analytics(
106
+ method: :ask,
107
+ args: [],
108
+ kwargs: query,
109
+ error: e,
110
+ start_time: start_time,
111
+ end_time: Time.now,
112
+ title: title,
113
+ provider: RUBY_LLM_PROVIDER,
114
+ version: version
115
+ )
116
+ raise e
117
+ end
118
+ end
119
+ end
120
+
121
+ # Dispatches to the appropriate provider-native query builder.
122
+ def build_query(provider, model_id, messages, tools)
123
+ if provider == "gemini"
124
+ build_google_query(model_id, messages, tools)
125
+ else
126
+ # Anthropic and OpenAI have the same query format
127
+ build_messages_query(model_id, messages, tools)
128
+ end
129
+ end
130
+
131
+ # Builds an OpenAI-compatible query (used for Anthropic and OpenAI providers).
132
+ def build_messages_query(model_id, messages, tools)
133
+ query = { model: model_id, messages: messages }
134
+ unless tools.empty?
135
+ query[:tools] = tools.values.map do |t|
136
+ { name: t.name, description: t.description, parameters: t.params_schema }
137
+ end
138
+ end
139
+ query
140
+ end
141
+
142
+ # Builds a Google-native query with contents/systemInstruction structure.
143
+ def build_google_query(model_id, messages, tools)
144
+ system_msgs = messages.select { |m| m[:role] == "system" }
145
+ content_msgs = messages.reject { |m| m[:role] == "system" }
146
+
147
+ query = {
148
+ model: model_id,
149
+ contents: content_msgs.map do |m|
150
+ google_role = m[:role] == "assistant" ? "model" : m[:role]
151
+ { role: google_role, parts: [{ text: m[:content] }] }
152
+ end
153
+ }
154
+
155
+ if system_msgs.any?
156
+ system_text = system_msgs.map { |m| m[:content] }.join("\n")
157
+ query[:systemInstruction] = { parts: [{ text: system_text }] }
158
+ end
159
+
160
+ unless tools.empty?
161
+ query[:tools] = tools.values.map do |t|
162
+ { name: t.name, description: t.description, parameters: t.params_schema }
163
+ end
164
+ end
165
+
166
+ query
167
+ end
168
+ end
169
+ end
170
+ end
data/lib/payloop.rb CHANGED
@@ -11,6 +11,8 @@ require_relative "payloop/wrappers/openai"
11
11
  require_relative "payloop/wrappers/anthropic"
12
12
  require_relative "payloop/wrappers/google"
13
13
  require_relative "payloop/wrappers/geminiai"
14
+ require_relative "payloop/wrappers/ruby_llm"
15
+ require_relative "payloop/wrappers/groq"
14
16
  require_relative "payloop/api/base"
15
17
  require_relative "payloop/api/sentinel"
16
18
  require_relative "payloop/api/workflows"