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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/lib/payloop/api/invocation.rb +2 -4
- data/lib/payloop/attribution.rb +4 -2
- data/lib/payloop/client.rb +7 -0
- data/lib/payloop/config.rb +23 -2
- data/lib/payloop/errors.rb +3 -0
- data/lib/payloop/sentinel.rb +10 -1
- data/lib/payloop/version.rb +1 -1
- data/lib/payloop/wrappers/anthropic.rb +10 -3
- data/lib/payloop/wrappers/base.rb +31 -26
- data/lib/payloop/wrappers/constants.rb +1 -0
- data/lib/payloop/wrappers/geminiai.rb +22 -7
- data/lib/payloop/wrappers/google.rb +14 -5
- data/lib/payloop/wrappers/groq.rb +295 -0
- data/lib/payloop/wrappers/openai.rb +144 -6
- data/lib/payloop/wrappers/ruby_llm.rb +18 -4
- data/lib/payloop.rb +1 -0
- data/sig/payloop/api/base.rbs +24 -0
- data/sig/payloop/api/invocation.rbs +16 -0
- data/sig/payloop/api/sentinel.rbs +9 -0
- data/sig/payloop/api/workflow.rbs +15 -0
- data/sig/payloop/api/workflows.rbs +16 -0
- data/sig/payloop/attribution.rbs +17 -0
- data/sig/payloop/client.rbs +29 -0
- data/sig/payloop/collector.rbs +20 -0
- data/sig/payloop/config.rbs +33 -0
- data/sig/payloop/errors.rbs +28 -0
- data/sig/payloop/sentinel.rbs +17 -0
- data/sig/payloop/version.rbs +5 -0
- data/sig/payloop/wrappers/anthropic.rbs +18 -0
- data/sig/payloop/wrappers/base.rbs +21 -0
- data/sig/payloop/wrappers/constants.rbs +10 -0
- data/sig/payloop/wrappers/geminiai.rbs +19 -0
- data/sig/payloop/wrappers/google.rbs +19 -0
- data/sig/payloop/wrappers/groq.rbs +22 -0
- data/sig/payloop/wrappers/openai.rbs +19 -0
- data/sig/payloop/wrappers/ruby_llm.rbs +21 -0
- 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!(
|
|
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
|
-
|
|
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
|