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
@@ -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
@@ -55,10 +55,22 @@ module Payloop
55
55
  define_method(:ask) do |message, with: nil, &block|
56
56
  start_time = Time.now
57
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
58
61
 
59
62
  sentinel = instance_variable_get(:@payloop_sentinel)
60
63
  provider = model.provider
61
- title = provider == "gemini" ? GOOGLE_CLIENT_TITLE : 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
62
74
 
63
75
  # Capture existing messages (e.g. system instruction) before the call
64
76
  # adds the new user message to history.
@@ -70,7 +82,7 @@ module Payloop
70
82
 
71
83
  # Pass the actual provider title (e.g. "anthropic", "openai", "google") so the
72
84
  # sentinel backend can route to the correct classifier for this request format.
73
- sentinel&.raise_if_irrelevant!(title: title, request: query)
85
+ sentinel&.raise_if_irrelevant!(title: title, request: query, version: version)
74
86
 
75
87
  response = original_ask(message, with: with, &block)
76
88
 
@@ -82,7 +94,8 @@ module Payloop
82
94
  start_time: start_time,
83
95
  end_time: Time.now,
84
96
  title: title,
85
- provider: RUBY_LLM_PROVIDER
97
+ provider: RUBY_LLM_PROVIDER,
98
+ version: version
86
99
  )
87
100
 
88
101
  response
@@ -97,7 +110,8 @@ module Payloop
97
110
  start_time: start_time,
98
111
  end_time: Time.now,
99
112
  title: title,
100
- provider: RUBY_LLM_PROVIDER
113
+ provider: RUBY_LLM_PROVIDER,
114
+ version: version
101
115
  )
102
116
  raise e
103
117
  end
data/lib/payloop.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "payloop/wrappers/anthropic"
12
12
  require_relative "payloop/wrappers/google"
13
13
  require_relative "payloop/wrappers/geminiai"
14
14
  require_relative "payloop/wrappers/ruby_llm"
15
+ require_relative "payloop/wrappers/groq"
15
16
  require_relative "payloop/api/base"
16
17
  require_relative "payloop/api/sentinel"
17
18
  require_relative "payloop/api/workflows"
@@ -0,0 +1,24 @@
1
+ # Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
2
+ # "Code style: type signatures (RBS)" for the refinement convention.
3
+ module Payloop
4
+ module API
5
+ class Base
6
+ @original_api_url: String
7
+ @api_url: String
8
+ @api_key: String
9
+ @timeout: Numeric
10
+
11
+ def initialize: (String api_url, String api_key, Numeric timeout) -> void
12
+
13
+ private
14
+ def get: (String path) -> untyped
15
+ def post: (String path, ?Hash[Symbol, untyped]? body) -> untyped
16
+ def put: (String path, ?Hash[Symbol, untyped]? body) -> untyped
17
+ def delete: (String path) -> untyped
18
+ def request: (Symbol method, String path, ?Hash[Symbol, untyped]? body) -> untyped
19
+ def build_request: (Symbol method, URI::Generic uri, Hash[Symbol, untyped]? body) -> (Net::HTTP::Delete | Net::HTTP::Get | Net::HTTP::Post | Net::HTTP::Put)
20
+ def handle_response: (Net::HTTPResponse response) -> untyped
21
+ def parse_json_response: (String? body) -> untyped
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ # Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
2
+ # "Code style: type signatures (RBS)" for the refinement convention.
3
+ module Payloop
4
+ module API
5
+ class Invocation < Base
6
+ @attribution: Hash[Symbol, untyped]?
7
+
8
+ def initialize: (String api_url, String api_key, Numeric timeout) -> void
9
+ def attribution: (parent_id: String, ?parent_name: String?, ?subsidiary_id: String?, ?subsidiary_name: String?) -> self
10
+ def summary: (String workflow_uuid, date_start: (Date | Time | String), ?date_end: (Date | Time | String)?) -> untyped
11
+
12
+ private
13
+ def format_date: ((Date | Time | String) date) -> String
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,9 @@
1
+ # Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
2
+ # "Code style: type signatures (RBS)" for the refinement convention.
3
+ module Payloop
4
+ module API
5
+ class Sentinel < Base
6
+ def relevance_intercept: (Hash[Symbol, untyped] payload) -> untyped
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
2
+ # "Code style: type signatures (RBS)" for the refinement convention.
3
+ module Payloop
4
+ module API
5
+ class Workflow < Base
6
+ @uuid: String
7
+
8
+ def initialize: (String uuid, String api_url, String api_key, Numeric timeout) -> void
9
+ def details: -> untyped
10
+ def update: (label: String) -> untyped
11
+ def destroy: -> untyped
12
+ def invocation: -> Payloop::API::Invocation
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
2
+ # "Code style: type signatures (RBS)" for the refinement convention.
3
+ module Payloop
4
+ module API
5
+ class Workflows < Base
6
+ @invocation: Payloop::API::Invocation
7
+
8
+ def list: -> untyped
9
+ def create: (name: String, ?description: String?) -> untyped
10
+ def details: (String uuid) -> untyped
11
+ def update: (String uuid, label: String) -> untyped
12
+ def destroy: (String uuid) -> untyped
13
+ def invocation: -> Payloop::API::Invocation
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # Starter signatures generated by typeprof + hand-refined; see CLAUDE.md
2
+ # "Code style: type signatures (RBS)" for the refinement convention.
3
+ module Payloop
4
+ class Attribution
5
+ attr_reader parent_id: String
6
+ attr_reader parent_name: String?
7
+ attr_reader subsidiary_id: String?
8
+ attr_reader subsidiary_name: String?
9
+ def initialize: (parent_id: String, ?parent_name: String?, ?subsidiary_id: String?, ?subsidiary_name: String?) -> void
10
+ def to_h: -> Hash[Symbol, untyped]
11
+
12
+ private
13
+ def validate_parent_id!: (untyped value) -> String
14
+ def validate_string_length!: (untyped value, String field_name) -> String?
15
+ def validate_subsidiary_requirements!: -> void
16
+ end
17
+ end