crystil 0.5.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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +77 -0
  3. data/LICENSE +21 -0
  4. data/README.md +112 -0
  5. data/lib/crystil/api/base.rb +104 -0
  6. data/lib/crystil/api/invocation.rb +59 -0
  7. data/lib/crystil/api/sentinel.rb +11 -0
  8. data/lib/crystil/api/workflow.rb +34 -0
  9. data/lib/crystil/api/workflows.rb +39 -0
  10. data/lib/crystil/attribution.rb +66 -0
  11. data/lib/crystil/client.rb +95 -0
  12. data/lib/crystil/collector.rb +88 -0
  13. data/lib/crystil/config.rb +79 -0
  14. data/lib/crystil/errors.rb +35 -0
  15. data/lib/crystil/sentinel.rb +100 -0
  16. data/lib/crystil/version.rb +5 -0
  17. data/lib/crystil/wrappers/anthropic.rb +112 -0
  18. data/lib/crystil/wrappers/base.rb +259 -0
  19. data/lib/crystil/wrappers/constants.rb +12 -0
  20. data/lib/crystil/wrappers/geminiai.rb +173 -0
  21. data/lib/crystil/wrappers/google.rb +133 -0
  22. data/lib/crystil/wrappers/groq.rb +295 -0
  23. data/lib/crystil/wrappers/openai.rb +249 -0
  24. data/lib/crystil/wrappers/ruby_llm.rb +170 -0
  25. data/lib/crystil.rb +52 -0
  26. data/sig/crystil/api/base.rbs +24 -0
  27. data/sig/crystil/api/invocation.rbs +16 -0
  28. data/sig/crystil/api/sentinel.rbs +9 -0
  29. data/sig/crystil/api/workflow.rbs +15 -0
  30. data/sig/crystil/api/workflows.rbs +16 -0
  31. data/sig/crystil/attribution.rbs +17 -0
  32. data/sig/crystil/client.rbs +29 -0
  33. data/sig/crystil/collector.rbs +20 -0
  34. data/sig/crystil/config.rbs +33 -0
  35. data/sig/crystil/errors.rbs +28 -0
  36. data/sig/crystil/sentinel.rbs +17 -0
  37. data/sig/crystil/version.rbs +5 -0
  38. data/sig/crystil/wrappers/anthropic.rbs +18 -0
  39. data/sig/crystil/wrappers/base.rbs +21 -0
  40. data/sig/crystil/wrappers/constants.rbs +10 -0
  41. data/sig/crystil/wrappers/geminiai.rbs +19 -0
  42. data/sig/crystil/wrappers/google.rbs +19 -0
  43. data/sig/crystil/wrappers/groq.rbs +22 -0
  44. data/sig/crystil/wrappers/openai.rbs +19 -0
  45. data/sig/crystil/wrappers/ruby_llm.rbs +21 -0
  46. metadata +100 -0
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+
5
+ module Crystil
6
+ module Wrappers
7
+ # Wrapper for Google GenerativeAI Ruby client (gemini-ai gem version 4.3.0)
8
+ class GeminiAI
9
+ def initialize(config, collector, sentinel = nil)
10
+ @config = config
11
+ @collector = collector
12
+ @sentinel = sentinel
13
+ end
14
+
15
+ def register(client)
16
+ validate_client!(client)
17
+
18
+ # Prevent double registration
19
+ return client if client.instance_variable_defined?(:@crystil_registered)
20
+
21
+ # Store references in client instance
22
+ client.instance_variable_set(:@crystil_config, @config)
23
+ client.instance_variable_set(:@crystil_collector, @collector)
24
+ client.instance_variable_set(:@crystil_sentinel, @sentinel)
25
+ client.instance_variable_set(:@crystil_registered, true)
26
+
27
+ # Wrap the generate_content and stream_generate_content methods
28
+ wrap_generate_content_method(client)
29
+ wrap_stream_generate_content_method(client)
30
+
31
+ client
32
+ end
33
+
34
+ private
35
+
36
+ def validate_client!(client)
37
+ return if client.respond_to?(:generate_content) && client.respond_to?(:stream_generate_content)
38
+
39
+ raise RegistrationError,
40
+ "Client does not appear to be a valid Gemini AI client " \
41
+ "(missing generate_content or stream_generate_content method)"
42
+ end
43
+
44
+ def wrap_generate_content_method(client)
45
+ client.singleton_class.class_eval do
46
+ include Base
47
+
48
+ alias_method :original_generate_content, :generate_content
49
+
50
+ define_method(:generate_content) do |*args, **kwargs, &block|
51
+ # Handle both positional hash and keyword arguments
52
+ parameters = if args.first.is_a?(Hash)
53
+ args.first
54
+ elsif kwargs.any?
55
+ kwargs
56
+ else
57
+ {}
58
+ end
59
+
60
+ start_time = Time.now
61
+ version = defined?(::Gemini::GEM) && ::Gemini::GEM.is_a?(Hash) ? ::Gemini::GEM[:version] : nil
62
+
63
+ sentinel = instance_variable_get(:@crystil_sentinel)
64
+ sentinel&.raise_if_irrelevant!(
65
+ title: GOOGLE_CLIENT_TITLE,
66
+ request: parameters,
67
+ version: version
68
+ )
69
+
70
+ # Call original method
71
+ response = original_generate_content(*args, **kwargs, &block)
72
+
73
+ # Submit analytics
74
+ crystil_submit_analytics(
75
+ method: :generate_content,
76
+ args: [],
77
+ kwargs: parameters,
78
+ response: response,
79
+ start_time: start_time,
80
+ end_time: Time.now,
81
+ title: GOOGLE_CLIENT_TITLE,
82
+ version: version
83
+ )
84
+
85
+ response
86
+ rescue CrystilRequestInterceptedError => e
87
+ # We don't want to send intercepts to collector
88
+ raise e
89
+ rescue StandardError => e
90
+ crystil_submit_error_analytics(
91
+ method: :generate_content,
92
+ args: [],
93
+ kwargs: parameters,
94
+ error: e,
95
+ start_time: start_time,
96
+ end_time: Time.now,
97
+ title: GOOGLE_CLIENT_TITLE,
98
+ version: version
99
+ )
100
+
101
+ raise e
102
+ end
103
+ end
104
+ end
105
+
106
+ # Handles the stream_generate_content from the client. Note, that
107
+ # the Crystil backend does not expect the payload to be merged like
108
+ # the OpenAI payload is. So, all the results are just sent as an
109
+ # array.
110
+ def wrap_stream_generate_content_method(client)
111
+ client.singleton_class.class_eval do
112
+ include Base
113
+
114
+ alias_method :original_stream_generate_content, :stream_generate_content
115
+
116
+ define_method(:stream_generate_content) do |*args, **kwargs, &block|
117
+ # Handle both positional hash and keyword arguments
118
+ parameters = if args.first.is_a?(Hash)
119
+ args.first
120
+ elsif kwargs.any?
121
+ kwargs
122
+ else
123
+ {}
124
+ end
125
+
126
+ start_time = Time.now
127
+ version = defined?(::Gemini::GEM) && ::Gemini::GEM.is_a?(Hash) ? ::Gemini::GEM[:version] : nil
128
+
129
+ sentinel = instance_variable_get(:@crystil_sentinel)
130
+ sentinel&.raise_if_irrelevant!(
131
+ title: GOOGLE_CLIENT_TITLE,
132
+ request: parameters,
133
+ version: version
134
+ )
135
+
136
+ # Call original method with wrapped block
137
+ response = original_stream_generate_content(*args, **kwargs, &block)
138
+
139
+ # Submit analytics with accumulated response
140
+ crystil_submit_analytics(
141
+ method: :stream_generate_content,
142
+ args: [],
143
+ kwargs: parameters,
144
+ response: response,
145
+ start_time: start_time,
146
+ end_time: Time.now,
147
+ title: GOOGLE_CLIENT_TITLE,
148
+ version: version
149
+ )
150
+
151
+ response
152
+ rescue CrystilRequestInterceptedError => e
153
+ # We don't want to send intercepts to collector
154
+ raise e
155
+ rescue StandardError => e
156
+ crystil_submit_error_analytics(
157
+ method: :stream_generate_content,
158
+ args: [],
159
+ kwargs: parameters,
160
+ error: e,
161
+ start_time: start_time,
162
+ end_time: Time.now,
163
+ title: GOOGLE_CLIENT_TITLE,
164
+ version: version
165
+ )
166
+
167
+ raise e
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+
5
+ module Crystil
6
+ module Wrappers
7
+ # Wrapper for Google GenerativeAI Ruby client (google-genai library version 0.1)
8
+ class Google
9
+ def initialize(config, collector, sentinel = nil)
10
+ @config = config
11
+ @collector = collector
12
+ @sentinel = sentinel
13
+ end
14
+
15
+ def register(client)
16
+ validate_client!(client)
17
+
18
+ # Prevent double registration
19
+ return client if client.instance_variable_defined?(:@crystil_registered)
20
+
21
+ # Patch response class after client is validated (ensures gem is loaded)
22
+ patch_response_class!
23
+
24
+ # Store references in client instance
25
+ client.instance_variable_set(:@crystil_config, @config)
26
+ client.instance_variable_set(:@crystil_collector, @collector)
27
+ client.instance_variable_set(:@crystil_sentinel, @sentinel)
28
+ client.instance_variable_set(:@crystil_registered, true)
29
+
30
+ # Wrap the generate_content method
31
+ wrap_generate_content_method(client)
32
+
33
+ client
34
+ end
35
+
36
+ private
37
+
38
+ def validate_client!(client)
39
+ return if client.respond_to?(:models)
40
+
41
+ raise RegistrationError,
42
+ "Client does not appear to be a valid Google GenAI client (missing models method)"
43
+ end
44
+
45
+ # Monkey-patch the GenerateContentResponse class to include missing fields
46
+ def patch_response_class!
47
+ # Check if the constant exists and hasn't been patched yet
48
+ return unless defined?(::Google::Genai::Types::GenerateContentResponse)
49
+ return if ::Google::Genai::Types::GenerateContentResponse.instance_variable_defined?(:@_crystil_patched)
50
+
51
+ ::Google::Genai::Types::GenerateContentResponse.class_eval do
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
56
+ end
57
+
58
+ ::Google::Genai::Types::GenerateContentResponse.instance_variable_set(:@_crystil_patched, true)
59
+ end
60
+
61
+ def wrap_generate_content_method(client)
62
+ # Get the models resource
63
+ models_resource = client.models
64
+
65
+ # Store references on the models resource (needed for Base module)
66
+ models_resource.instance_variable_set(:@crystil_config, client.instance_variable_get(:@crystil_config))
67
+ models_resource.instance_variable_set(:@crystil_collector, client.instance_variable_get(:@crystil_collector))
68
+ models_resource.instance_variable_set(:@crystil_sentinel, client.instance_variable_get(:@crystil_sentinel))
69
+
70
+ # Store the original generate_content method
71
+ original_generate_content = models_resource.method(:generate_content)
72
+
73
+ # Wrap the generate_content method
74
+ models_resource.define_singleton_method(:generate_content) do |*args, **kwargs, &block|
75
+ # Include Base module methods
76
+ extend Base unless singleton_class.include?(Base)
77
+
78
+ start_time = Time.now
79
+ version = defined?(::Google::Genai::VERSION) ? ::Google::Genai::VERSION : nil
80
+
81
+ # Extract parameters for analytics
82
+ params = kwargs.any? ? kwargs : (args.first || {})
83
+
84
+ sentinel = instance_variable_get(:@crystil_sentinel)
85
+ sentinel&.raise_if_irrelevant!(
86
+ title: GOOGLE_CLIENT_TITLE,
87
+ request: params,
88
+ version: version
89
+ )
90
+
91
+ # Call original method
92
+ response = if kwargs.any?
93
+ original_generate_content.call(**kwargs, &block)
94
+ else
95
+ original_generate_content.call(*args, &block)
96
+ end
97
+
98
+ # Submit analytics
99
+ crystil_submit_analytics(
100
+ method: :generate_content,
101
+ args: args,
102
+ kwargs: params,
103
+ response: response,
104
+ start_time: start_time,
105
+ end_time: Time.now,
106
+ title: GOOGLE_CLIENT_TITLE,
107
+ version: version
108
+ )
109
+
110
+ response
111
+ rescue CrystilRequestInterceptedError => e
112
+ # We don't want to send intercepts to collector
113
+ raise e
114
+ rescue StandardError => e
115
+ params = kwargs.any? ? kwargs : (args.first || {})
116
+
117
+ crystil_submit_error_analytics(
118
+ method: :generate_content,
119
+ args: args,
120
+ kwargs: params,
121
+ error: e,
122
+ start_time: start_time,
123
+ end_time: Time.now,
124
+ title: GOOGLE_CLIENT_TITLE,
125
+ version: version
126
+ )
127
+
128
+ raise e
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+
5
+ module Crystil
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?(:@crystil_registered)
44
+
45
+ # Patch the gem's streaming JSON parser. Class-level patch on
46
+ # ::Groq::Client, idempotent via @_crystil_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(:@crystil_config, @config)
54
+ client.instance_variable_set(:@crystil_collector, @collector)
55
+ client.instance_variable_set(:@crystil_sentinel, @sentinel)
56
+ client.instance_variable_set(:@crystil_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 `@_crystil_stream_patched`.
131
+ def patch_stream_handler!
132
+ return unless defined?(::Groq::Client)
133
+ return if ::Groq::Client.instance_variable_defined?(:@_crystil_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(:@_crystil_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 = Crystil::Wrappers::Groq.extract_model_title(body[:model], GROQ_PROVIDER)
190
+
191
+ sentinel = instance_variable_get(:@crystil_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 = crystil_normalize_openai_chunk(chunk)
215
+ crystil_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
+ crystil_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 CrystilRequestInterceptedError => 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 = Crystil::Wrappers::Groq.build_error_response(
273
+ streaming: streaming, accumulated: accumulated_response, error: e
274
+ )
275
+
276
+ crystil_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