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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +77 -0
- data/LICENSE +21 -0
- data/README.md +112 -0
- data/lib/crystil/api/base.rb +104 -0
- data/lib/crystil/api/invocation.rb +59 -0
- data/lib/crystil/api/sentinel.rb +11 -0
- data/lib/crystil/api/workflow.rb +34 -0
- data/lib/crystil/api/workflows.rb +39 -0
- data/lib/crystil/attribution.rb +66 -0
- data/lib/crystil/client.rb +95 -0
- data/lib/crystil/collector.rb +88 -0
- data/lib/crystil/config.rb +79 -0
- data/lib/crystil/errors.rb +35 -0
- data/lib/crystil/sentinel.rb +100 -0
- data/lib/crystil/version.rb +5 -0
- data/lib/crystil/wrappers/anthropic.rb +112 -0
- data/lib/crystil/wrappers/base.rb +259 -0
- data/lib/crystil/wrappers/constants.rb +12 -0
- data/lib/crystil/wrappers/geminiai.rb +173 -0
- data/lib/crystil/wrappers/google.rb +133 -0
- data/lib/crystil/wrappers/groq.rb +295 -0
- data/lib/crystil/wrappers/openai.rb +249 -0
- data/lib/crystil/wrappers/ruby_llm.rb +170 -0
- data/lib/crystil.rb +52 -0
- data/sig/crystil/api/base.rbs +24 -0
- data/sig/crystil/api/invocation.rbs +16 -0
- data/sig/crystil/api/sentinel.rbs +9 -0
- data/sig/crystil/api/workflow.rbs +15 -0
- data/sig/crystil/api/workflows.rbs +16 -0
- data/sig/crystil/attribution.rbs +17 -0
- data/sig/crystil/client.rbs +29 -0
- data/sig/crystil/collector.rbs +20 -0
- data/sig/crystil/config.rbs +33 -0
- data/sig/crystil/errors.rbs +28 -0
- data/sig/crystil/sentinel.rbs +17 -0
- data/sig/crystil/version.rbs +5 -0
- data/sig/crystil/wrappers/anthropic.rbs +18 -0
- data/sig/crystil/wrappers/base.rbs +21 -0
- data/sig/crystil/wrappers/constants.rbs +10 -0
- data/sig/crystil/wrappers/geminiai.rbs +19 -0
- data/sig/crystil/wrappers/google.rbs +19 -0
- data/sig/crystil/wrappers/groq.rbs +22 -0
- data/sig/crystil/wrappers/openai.rbs +19 -0
- data/sig/crystil/wrappers/ruby_llm.rbs +21 -0
- 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
|