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,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,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
|