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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "concurrent"
7
+
8
+ module Crystil
9
+ # Handles asynchronous submission of analytics to Crystil collector
10
+ class Collector
11
+ DEFAULT_MAX_RETRIES = 3
12
+ RETRY_DELAY = 1 # seconds
13
+
14
+ def initialize(config)
15
+ @config = config
16
+ @executor = Concurrent::ThreadPoolExecutor.new(
17
+ min_threads: 1,
18
+ max_threads: 5,
19
+ max_queue: 100,
20
+ fallback_policy: :discard
21
+ )
22
+ end
23
+
24
+ # Submit analytics payload asynchronously
25
+ def submit_async(payload)
26
+ @executor.post do
27
+ submit_with_retry(payload)
28
+ rescue StandardError => e
29
+ warn "Crystil: Failed to submit analytics: #{e.message}"
30
+ end
31
+ end
32
+
33
+ # Submit analytics payload synchronously (for testing)
34
+ def submit(payload)
35
+ submit_with_retry(payload)
36
+ end
37
+
38
+ # Gracefully shutdown the collector
39
+ def shutdown
40
+ @executor.shutdown
41
+ @executor.wait_for_termination(5)
42
+ end
43
+
44
+ private
45
+
46
+ def submit_with_retry(payload, attempt = 1)
47
+ response = post_to_collector(payload)
48
+
49
+ unless response.is_a?(Net::HTTPSuccess)
50
+ raise APIError.new(
51
+ "Collector request failed: #{response.code} #{response.message}",
52
+ status_code: response.code.to_i,
53
+ response_body: response.body
54
+ )
55
+ end
56
+
57
+ response
58
+ rescue StandardError => e
59
+ raise e unless attempt < DEFAULT_MAX_RETRIES
60
+
61
+ sleep(RETRY_DELAY * attempt)
62
+ submit_with_retry(payload, attempt + 1)
63
+ end
64
+
65
+ def post_to_collector(payload)
66
+ uri = URI.parse(@config.collector_url)
67
+ http = Net::HTTP.new(uri.host, uri.port)
68
+ http.use_ssl = uri.scheme == "https"
69
+
70
+ # Configure SSL to use system certificates
71
+ if http.use_ssl?
72
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
73
+ http.cert_store = OpenSSL::X509::Store.new
74
+ http.cert_store.set_default_paths
75
+ end
76
+
77
+ http.open_timeout = @config.timeout
78
+ http.read_timeout = @config.timeout
79
+
80
+ request = Net::HTTP::Post.new("/rec")
81
+ request["Content-Type"] = "application/json"
82
+ request["User-Agent"] = "crystil-ruby/#{@config.version}"
83
+ request.body = JSON.generate(payload)
84
+
85
+ http.request(request)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Crystil
6
+ # Thread-safe configuration for Crystil client
7
+ class Config
8
+ attr_reader :api_key, :collector_url, :api_url, :timeout, :version
9
+
10
+ def initialize(api_key: nil, collector_url: nil, api_url: nil, timeout: nil)
11
+ @api_key = api_key
12
+ # URL precedence: constructor arg > CRYSTIL_*_URL_BASE env var > hardcoded
13
+ # prod default. Matches the JS SDK so an integration-test run pointed at
14
+ # staging or a local backend only requires setting the env vars in
15
+ # `.env` — no spec changes needed.
16
+ @collector_url = collector_url ||
17
+ nonempty_env("CRYSTIL_COLLECTOR_URL_BASE") ||
18
+ "https://collector.crystil.com"
19
+ @api_url = api_url ||
20
+ nonempty_env("CRYSTIL_API_URL_BASE") ||
21
+ "https://api.crystil.com"
22
+ @timeout = timeout || 5
23
+ @version = Crystil::VERSION
24
+ @attribution = Concurrent::AtomicReference.new(nil)
25
+ @tx_uuid = Concurrent::AtomicReference.new(SecureRandom.uuid)
26
+ @raise_if_irrelevant = Concurrent::AtomicReference.new(false)
27
+ @secs_irrelevant_request_timeout = Concurrent::AtomicReference.new(5)
28
+ end
29
+
30
+ def attribution
31
+ @attribution.get
32
+ end
33
+
34
+ def attribution=(value)
35
+ @attribution.set(value)
36
+ end
37
+
38
+ def tx_uuid
39
+ @tx_uuid.get
40
+ end
41
+
42
+ def tx_uuid=(value)
43
+ @tx_uuid.set(value)
44
+ end
45
+
46
+ def raise_if_irrelevant
47
+ @raise_if_irrelevant.get
48
+ end
49
+
50
+ def raise_if_irrelevant=(value)
51
+ @raise_if_irrelevant.set(value)
52
+ end
53
+
54
+ def secs_irrelevant_request_timeout
55
+ @secs_irrelevant_request_timeout.get
56
+ end
57
+
58
+ def secs_irrelevant_request_timeout=(value)
59
+ @secs_irrelevant_request_timeout.set(value)
60
+ end
61
+
62
+ def new_transaction
63
+ @tx_uuid.set(SecureRandom.uuid)
64
+ end
65
+
66
+ private
67
+
68
+ # Returns ENV[name] unless it's missing or empty. We can't use the JS-style
69
+ # `ENV[name] || default` because Ruby treats empty strings as truthy — and
70
+ # the `.env.example` ships with blank values for the URL keys, so naïve
71
+ # truthiness would route every request to "".
72
+ def nonempty_env(name)
73
+ value = ENV.fetch(name, nil)
74
+ return nil if value.nil? || value.empty?
75
+
76
+ value
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystil
4
+ # Base error class for all Crystil errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when API key is missing
8
+ class MissingAPIKeyError < Error
9
+ def initialize(msg = "API key is missing. Set CRYSTIL_API_KEY environment variable or pass api_key parameter.")
10
+ super
11
+ end
12
+ end
13
+
14
+ # Raised when validation fails
15
+ class ValidationError < Error; end
16
+
17
+ # Raised when client registration fails
18
+ class RegistrationError < Error; end
19
+
20
+ # Raised when API request fails
21
+ class APIError < Error
22
+ attr_reader :status_code, :response_body
23
+
24
+ def initialize(message, status_code: nil, response_body: nil)
25
+ @status_code = status_code
26
+ @response_body = response_body
27
+ super(message)
28
+ end
29
+ end
30
+
31
+ class CrystilRequestInterceptedError < Error; end
32
+
33
+ # Raised when a streaming response ends without the expected terminal event
34
+ class StreamError < Error; end
35
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystil
4
+ class Sentinel
5
+ def initialize(config)
6
+ @config = config
7
+ end
8
+
9
+ def raise_if_irrelevant(enabled: true)
10
+ raise TypeError, "enabled must be a bool" unless [true, false].include?(enabled)
11
+
12
+ @config.raise_if_irrelevant = enabled
13
+ self
14
+ end
15
+
16
+ # Intentional explicit `set_*` verb prefix — matches JS/Python SDK naming
17
+ # so callers porting code between SDKs see the same surface. See CLAUDE.md
18
+ # "Python SDK is the source of truth" / known divergences.
19
+ def set_secs_irrelevant_request_timeout(timeout) # rubocop:disable Naming/AccessorMethodName
20
+ raise TypeError, "timeout must be a Numeric" unless timeout.is_a?(Numeric)
21
+ raise ArgumentError, "timeout must be greater than 0" unless timeout.positive?
22
+
23
+ @config.secs_irrelevant_request_timeout = timeout
24
+ self
25
+ end
26
+
27
+ def raise_if_irrelevant!(title:, request:, provider: nil, version: nil)
28
+ return unless @config.raise_if_irrelevant
29
+
30
+ begin
31
+ relevant, reason = make_relevance_intercept_request(
32
+ title: title,
33
+ request: request,
34
+ provider: provider,
35
+ version: version
36
+ )
37
+ rescue StandardError
38
+ return nil
39
+ end
40
+
41
+ raise CrystilRequestInterceptedError, (reason || "Irrelevant request blocked.") unless relevant
42
+ end
43
+
44
+ private
45
+
46
+ def make_relevance_intercept_request(title:, request:, provider: nil, version: nil)
47
+ payload = {
48
+ attribution: @config.attribution&.to_h,
49
+ conversation: {
50
+ client: {
51
+ provider: provider,
52
+ title: title,
53
+ version: version
54
+ },
55
+ request: normalize_request(request)
56
+ },
57
+ meta: {
58
+ sdk: {
59
+ client: "ruby",
60
+ version: @config.version
61
+ }
62
+ }
63
+ }
64
+
65
+ api = API::Sentinel.new(@config.api_url, @config.api_key, @config.secs_irrelevant_request_timeout)
66
+ response = api.relevance_intercept(payload) || {}
67
+
68
+ relevant = response.key?("relevant") ? response["relevant"] : true
69
+ reason = relevant ? nil : (response["reason"] || "Irrelevant request blocked.")
70
+
71
+ [relevant, reason]
72
+ end
73
+
74
+ def normalize_request(request)
75
+ # Remove Procs before JSON creation since the
76
+ # procs or methods will fail for normalization.
77
+ # We know that OpenAI sends a proc on their
78
+ # request, so this is mostly for that.
79
+ cleaned = remove_procs(request)
80
+ json_obj = JSON.generate(cleaned)
81
+ JSON.parse(json_obj)
82
+ rescue StandardError
83
+ request
84
+ end
85
+
86
+ def remove_procs(obj)
87
+ case obj
88
+ when Hash
89
+ obj.transform_values { |v| remove_procs(v) }
90
+ when Array
91
+ obj.map { |v| remove_procs(v) }
92
+ when Proc, Method
93
+ true
94
+ else
95
+ # Return true object for any callable object; otherwise return obj.
96
+ obj.respond_to?(:call) || obj
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystil
4
+ VERSION = "0.5.0"
5
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+
5
+ module Crystil
6
+ module Wrappers
7
+ # Wrapper for Anthropic Ruby client
8
+ class Anthropic
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 messages method
28
+ wrap_messages_method(client)
29
+
30
+ client
31
+ end
32
+
33
+ private
34
+
35
+ def validate_client!(client)
36
+ return if client.respond_to?(:messages)
37
+
38
+ raise RegistrationError, "Client does not appear to be a valid Anthropic client (missing messages method)"
39
+ end
40
+
41
+ def wrap_messages_method(client)
42
+ # Get the messages resource
43
+ messages_resource = client.messages
44
+
45
+ # Store references on the messages resource (needed for Base module)
46
+ messages_resource.instance_variable_set(:@crystil_config, client.instance_variable_get(:@crystil_config))
47
+ messages_resource.instance_variable_set(:@crystil_collector, client.instance_variable_get(:@crystil_collector))
48
+ messages_resource.instance_variable_set(:@crystil_sentinel, client.instance_variable_get(:@crystil_sentinel))
49
+
50
+ # Store the original create method
51
+ original_create = messages_resource.method(:create)
52
+
53
+ # Wrap the create method
54
+ messages_resource.define_singleton_method(:create) do |*args, **kwargs, &block|
55
+ # Include Base module methods
56
+ extend Base unless singleton_class.include?(Base)
57
+
58
+ start_time = Time.now
59
+ version = defined?(::Anthropic::VERSION) ? ::Anthropic::VERSION : nil
60
+
61
+ # Extract parameters for sentinel and analytics
62
+ params = kwargs.any? ? kwargs : (args.first || {})
63
+ sentinel = instance_variable_get(:@crystil_sentinel)
64
+ sentinel&.raise_if_irrelevant!(
65
+ title: ANTHROPIC_CLIENT_TITLE,
66
+ request: params,
67
+ version: version
68
+ )
69
+
70
+ # Call original method
71
+ response = if kwargs.any?
72
+ original_create.call(**kwargs, &block)
73
+ else
74
+ original_create.call(*args, &block)
75
+ end
76
+
77
+ # Submit analytics
78
+ crystil_submit_analytics(
79
+ method: :create,
80
+ args: args,
81
+ kwargs: params,
82
+ response: response,
83
+ start_time: start_time,
84
+ end_time: Time.now,
85
+ title: ANTHROPIC_CLIENT_TITLE,
86
+ version: version
87
+ )
88
+
89
+ response
90
+ rescue CrystilRequestInterceptedError => e
91
+ # We don't want to send intercepts to collector
92
+ raise e
93
+ rescue StandardError => e
94
+ params = kwargs.any? ? kwargs : (args.first || {})
95
+
96
+ crystil_submit_error_analytics(
97
+ method: :create,
98
+ args: args,
99
+ kwargs: params,
100
+ error: e,
101
+ start_time: start_time,
102
+ end_time: Time.now,
103
+ title: ANTHROPIC_CLIENT_TITLE,
104
+ version: version
105
+ )
106
+
107
+ raise e
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Crystil
6
+ module Wrappers
7
+ # Base functionality for all provider wrappers
8
+ module Base
9
+ def crystil_wrap_method(method_name, _provider_name)
10
+ return if method(method_name).source_location&.first&.include?("crystil")
11
+
12
+ original_method = instance_method(method_name)
13
+
14
+ define_method(method_name) do |*args, **kwargs, &block|
15
+ start_time = Time.now
16
+
17
+ # Call original method
18
+ response = original_method.bind(self).call(*args, **kwargs, &block)
19
+
20
+ # Submit analytics asynchronously
21
+ crystil_submit_analytics(
22
+ method: method_name,
23
+ args: args,
24
+ kwargs: kwargs,
25
+ response: response,
26
+ start_time: start_time,
27
+ end_time: Time.now
28
+ )
29
+
30
+ response
31
+ rescue StandardError => e
32
+ # Submit error analytics
33
+ crystil_submit_error_analytics(
34
+ method: method_name,
35
+ args: args,
36
+ kwargs: kwargs,
37
+ error: e,
38
+ start_time: start_time,
39
+ end_time: Time.now
40
+ )
41
+
42
+ raise e
43
+ end
44
+ end
45
+
46
+ def crystil_submit_analytics(method:, args:, kwargs:, response:, start_time:,
47
+ end_time:, provider: nil, title: nil, version: nil,
48
+ status: "succeeded", exception: nil)
49
+ collector = instance_variable_get(:@crystil_collector)
50
+ config = instance_variable_get(:@crystil_config)
51
+
52
+ return unless collector && config
53
+
54
+ payload = build_payload(
55
+ query: extract_query(method, args, kwargs),
56
+ response: extract_response(response),
57
+ start_time: start_time,
58
+ end_time: end_time,
59
+ config: config,
60
+ status: status,
61
+ provider: provider,
62
+ title: title,
63
+ version: version,
64
+ exception: exception
65
+ )
66
+
67
+ collector.submit_async(payload)
68
+ end
69
+
70
+ # Submit a failed-call payload to the collector. `response:` is optional —
71
+ # when omitted, the default `{ error:, class: }` shape is used. Wrappers
72
+ # that preserve richer data on error (e.g. Groq's streaming wrapper
73
+ # merging accumulated chunks with error info via build_error_response)
74
+ # pass it explicitly; the backend extractor reads whatever fields it can
75
+ # from the supplied hash and falls back gracefully on missing keys.
76
+ def crystil_submit_error_analytics(method:, args:, kwargs:, error:, start_time:,
77
+ end_time:, provider: nil, title: nil, version: nil,
78
+ response: nil)
79
+ crystil_submit_analytics(
80
+ method: method,
81
+ args: args,
82
+ kwargs: kwargs,
83
+ response: response || { error: error.message, class: error.class.name },
84
+ start_time: start_time,
85
+ end_time: end_time,
86
+ provider: provider,
87
+ title: title,
88
+ version: version,
89
+ status: "failed",
90
+ exception: error.message
91
+ )
92
+ end
93
+
94
+ def crystil_merge_streaming_chunk(accumulated, chunk)
95
+ chunk.each do |key, value|
96
+ if accumulated.key?(key)
97
+ case accumulated[key]
98
+ when Hash
99
+ crystil_merge_streaming_chunk(accumulated[key], value) if value.is_a?(Hash)
100
+ when Array
101
+ # Concatenate arrays (matches Python SDK behavior: data[key].extend(chunk_value))
102
+ accumulated[key].concat(value) if value.is_a?(Array)
103
+ else
104
+ accumulated[key] = value
105
+ end
106
+ else
107
+ accumulated[key] = value
108
+ end
109
+ end
110
+ accumulated
111
+ end
112
+
113
+ def crystil_normalize_openai_chunk(chunk)
114
+ # Normalize OpenAI streaming chunks to match Python SDK format
115
+ # Ensure delta objects have all keys present with null values
116
+ return chunk unless chunk.is_a?(Hash)
117
+
118
+ if chunk["choices"].is_a?(Array)
119
+ chunk["choices"].each do |choice|
120
+ next unless choice.is_a?(Hash) && choice.key?("delta")
121
+
122
+ delta = choice["delta"]
123
+ next unless delta.is_a?(Hash)
124
+
125
+ # Add missing keys with nil values to match Python SDK
126
+ delta["role"] ||= nil unless delta.key?("role")
127
+ delta["content"] ||= nil unless delta.key?("content")
128
+ delta["refusal"] ||= nil unless delta.key?("refusal")
129
+ delta["tool_calls"] ||= nil unless delta.key?("tool_calls")
130
+ delta["function_call"] ||= nil unless delta.key?("function_call")
131
+ end
132
+ end
133
+
134
+ chunk
135
+ end
136
+
137
+ private
138
+
139
+ def extract_query(_method, _args, kwargs)
140
+ # Deep copy to avoid mutation issues
141
+ query = deep_copy(kwargs)
142
+
143
+ # Normalize stream parameter: convert Proc to boolean true
144
+ # This ensures streaming requests are properly serialized to JSON
145
+ query[:stream] = true if query.is_a?(Hash) && query[:stream].is_a?(Proc)
146
+
147
+ query
148
+ rescue StandardError
149
+ {}
150
+ end
151
+
152
+ def extract_response(response)
153
+ case response
154
+ when Hash, Array
155
+ deep_copy(response)
156
+ when String
157
+ { text: response }
158
+ else
159
+ # Try various serialization methods
160
+ if response.respond_to?(:as_json)
161
+ deep_copy(response.as_json)
162
+ elsif response.respond_to?(:to_h)
163
+ deep_copy(response.to_h)
164
+ elsif response.respond_to?(:to_hash)
165
+ deep_copy(response.to_hash)
166
+ else
167
+ # Extract instance variables for objects without serialization methods
168
+ extract_instance_variables(response)
169
+ end
170
+ end
171
+ rescue StandardError => e
172
+ # If extraction fails, try to provide useful debug info
173
+ { raw: response.to_s, error: e.message }
174
+ end
175
+
176
+ def extract_instance_variables(obj)
177
+ result = {}
178
+ obj.instance_variables.each do |var|
179
+ key = var.to_s.delete("@").to_sym
180
+ value = obj.instance_variable_get(var)
181
+ result[key] = serialize_value(value)
182
+ end
183
+ result
184
+ end
185
+
186
+ def serialize_value(value)
187
+ case value
188
+ when Hash
189
+ value.transform_values { |v| serialize_value(v) }
190
+ when Array
191
+ value.map { |v| serialize_value(v) }
192
+ when String, Numeric, TrueClass, FalseClass, NilClass
193
+ value
194
+ else
195
+ # Recursively extract instance variables for nested objects
196
+ if value.respond_to?(:instance_variables)
197
+ extract_instance_variables(value)
198
+ else
199
+ value.to_s
200
+ end
201
+ end
202
+ end
203
+
204
+ def deep_copy(obj)
205
+ Marshal.load(Marshal.dump(obj))
206
+ rescue StandardError
207
+ begin
208
+ obj.dup
209
+ rescue StandardError
210
+ obj
211
+ end
212
+ end
213
+
214
+ # Build the payload that is sent to the collector API which then sends the payload
215
+ # to the backend.
216
+ # - conversion.client - Routes the payload to the correct service
217
+ # in the backend. So, the title and provider values are known values. (e.g google,
218
+ # openai, and anthropic) are common title values.
219
+ # - conversion.query - Holds the request information.
220
+ # - conversion.response - Holds the response from the LLM. This is different for each LLM,
221
+ # and the different services handle them on the backend.
222
+ def build_payload(query:, response:, start_time:, end_time:, config:, status:,
223
+ provider: nil, title: nil, version: nil, exception: nil)
224
+ {
225
+ attribution: config.attribution&.to_h,
226
+ conversation: {
227
+ client: {
228
+ provider: provider,
229
+ title: title,
230
+ version: version
231
+ },
232
+ query: query,
233
+ response: response
234
+ },
235
+ meta: {
236
+ api: {
237
+ key: config.api_key
238
+ },
239
+ fnfg: {
240
+ status: status,
241
+ exc: exception
242
+ },
243
+ sdk: {
244
+ client: "ruby",
245
+ version: config.version
246
+ }
247
+ },
248
+ time: {
249
+ start: start_time.to_f,
250
+ end: end_time.to_f
251
+ },
252
+ tx: {
253
+ uuid: config.tx_uuid
254
+ }
255
+ }
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crystil
4
+ module Wrappers
5
+ # Client title constants for analytics
6
+ OPENAI_CLIENT_TITLE = "openai"
7
+ ANTHROPIC_CLIENT_TITLE = "anthropic"
8
+ GOOGLE_CLIENT_TITLE = "google"
9
+ RUBY_LLM_PROVIDER = "ruby_llm"
10
+ GROQ_PROVIDER = "groq"
11
+ end
12
+ end