Module: Crystil::Wrappers::Base

Defined in:
lib/crystil/wrappers/base.rb,
sig/crystil/wrappers/base.rbs

Overview

Base functionality for all provider wrappers

Instance Method Summary collapse

Instance Method Details

#build_payload(query:, response:, start_time:, end_time:, config:, status:, provider: nil, title: nil, version: nil, exception: nil) ⇒ Hash[Symbol, untyped]

Build the payload that is sent to the collector API which then sends the payload to the backend.

  • conversion.client - Routes the payload to the correct service in the backend. So, the title and provider values are known values. (e.g google, openai, and anthropic) are common title values.
  • conversion.query - Holds the request information.
  • conversion.response - Holds the response from the LLM. This is different for each LLM, and the different services handle them on the backend.

Parameters:

  • query: (Hash[Symbol, untyped])
  • response: (Object)
  • start_time: (Time)
  • end_time: (Time)
  • config: (Crystil::Config)
  • status: (String)
  • provider: (String, nil) (defaults to: nil)
  • title: (String, nil) (defaults to: nil)
  • version: (String, nil) (defaults to: nil)
  • exception: (String, nil) (defaults to: nil)

Returns:

  • (Hash[Symbol, untyped])


222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
# File 'lib/crystil/wrappers/base.rb', line 222

def build_payload(query:, response:, start_time:, end_time:, config:, status:,
                  provider: nil, title: nil, version: nil, exception: nil)
  {
    attribution: config.attribution&.to_h,
    conversation: {
      client: {
        provider: provider,
        title: title,
        version: version
      },
      query: query,
      response: response
    },
    meta: {
      api: {
        key: config.api_key
      },
      fnfg: {
        status: status,
        exc: exception
      },
      sdk: {
        client: "ruby",
        version: config.version
      }
    },
    time: {
      start: start_time.to_f,
      end: end_time.to_f
    },
    tx: {
      uuid: config.tx_uuid
    }
  }
end

#crystil_merge_streaming_chunk(accumulated, chunk) ⇒ Hash[String | Symbol, untyped]

Parameters:

  • accumulated (Hash[String | Symbol, untyped])
  • chunk (Hash[String | Symbol, untyped])

Returns:

  • (Hash[String | Symbol, untyped])


94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/crystil/wrappers/base.rb', line 94

def crystil_merge_streaming_chunk(accumulated, chunk)
  chunk.each do |key, value|
    if accumulated.key?(key)
      case accumulated[key]
      when Hash
        crystil_merge_streaming_chunk(accumulated[key], value) if value.is_a?(Hash)
      when Array
        # Concatenate arrays (matches Python SDK behavior: data[key].extend(chunk_value))
        accumulated[key].concat(value) if value.is_a?(Array)
      else
        accumulated[key] = value
      end
    else
      accumulated[key] = value
    end
  end
  accumulated
end

#crystil_normalize_openai_chunk(chunk) ⇒ Object

Parameters:

  • chunk (Object)

Returns:

  • (Object)


113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/crystil/wrappers/base.rb', line 113

def crystil_normalize_openai_chunk(chunk)
  # Normalize OpenAI streaming chunks to match Python SDK format
  # Ensure delta objects have all keys present with null values
  return chunk unless chunk.is_a?(Hash)

  if chunk["choices"].is_a?(Array)
    chunk["choices"].each do |choice|
      next unless choice.is_a?(Hash) && choice.key?("delta")

      delta = choice["delta"]
      next unless delta.is_a?(Hash)

      # Add missing keys with nil values to match Python SDK
      delta["role"] ||= nil unless delta.key?("role")
      delta["content"] ||= nil unless delta.key?("content")
      delta["refusal"] ||= nil unless delta.key?("refusal")
      delta["tool_calls"] ||= nil unless delta.key?("tool_calls")
      delta["function_call"] ||= nil unless delta.key?("function_call")
    end
  end

  chunk
end

#crystil_submit_analytics(method:, args:, kwargs:, response:, start_time:, end_time:, provider: nil, title: nil, version: nil, status: "succeeded", exception: nil) ⇒ void

This method returns an undefined value.

Parameters:

  • method: (Symbol)
  • args: (Array[untyped])
  • kwargs: (Hash[Symbol, untyped])
  • response: (Object)
  • start_time: (Time)
  • end_time: (Time)
  • provider: (String, nil) (defaults to: nil)
  • title: (String, nil) (defaults to: nil)
  • version: (String, nil) (defaults to: nil)
  • status: (String) (defaults to: "succeeded")
  • exception: (String, nil) (defaults to: nil)


46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/crystil/wrappers/base.rb', line 46

def crystil_submit_analytics(method:, args:, kwargs:, response:, start_time:,
                             end_time:, provider: nil, title: nil, version: nil,
                             status: "succeeded", exception: nil)
  collector = instance_variable_get(:@crystil_collector)
  config = instance_variable_get(:@crystil_config)

  return unless collector && config

  payload = build_payload(
    query: extract_query(method, args, kwargs),
    response: extract_response(response),
    start_time: start_time,
    end_time: end_time,
    config: config,
    status: status,
    provider: provider,
    title: title,
    version: version,
    exception: exception
  )

  collector.submit_async(payload)
end

#crystil_submit_error_analytics(method:, args:, kwargs:, error:, start_time:, end_time:, provider: nil, title: nil, version: nil, response: nil) ⇒ void

This method returns an undefined value.

Submit a failed-call payload to the collector. response: is optional — when omitted, the default { error:, class: } shape is used. Wrappers that preserve richer data on error (e.g. Groq's streaming wrapper merging accumulated chunks with error info via build_error_response) pass it explicitly; the backend extractor reads whatever fields it can from the supplied hash and falls back gracefully on missing keys.

Parameters:

  • method: (Symbol)
  • args: (Array[untyped])
  • kwargs: (Hash[Symbol, untyped])
  • error: (StandardError)
  • start_time: (Time)
  • end_time: (Time)
  • provider: (String, nil) (defaults to: nil)
  • title: (String, nil) (defaults to: nil)
  • version: (String, nil) (defaults to: nil)
  • response: (Object) (defaults to: nil)


76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/crystil/wrappers/base.rb', line 76

def crystil_submit_error_analytics(method:, args:, kwargs:, error:, start_time:,
                                   end_time:, provider: nil, title: nil, version: nil,
                                   response: nil)
  crystil_submit_analytics(
    method: method,
    args: args,
    kwargs: kwargs,
    response: response || { error: error.message, class: error.class.name },
    start_time: start_time,
    end_time: end_time,
    provider: provider,
    title: title,
    version: version,
    status: "failed",
    exception: error.message
  )
end

#crystil_wrap_method(method_name, _provider_name) ⇒ void

This method returns an undefined value.

Parameters:

  • method_name (Symbol)
  • _provider_name (String)


9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/crystil/wrappers/base.rb', line 9

def crystil_wrap_method(method_name, _provider_name)
  return if method(method_name).source_location&.first&.include?("crystil")

  original_method = instance_method(method_name)

  define_method(method_name) do |*args, **kwargs, &block|
    start_time = Time.now

    # Call original method
    response = original_method.bind(self).call(*args, **kwargs, &block)

    # Submit analytics asynchronously
    crystil_submit_analytics(
      method: method_name,
      args: args,
      kwargs: kwargs,
      response: response,
      start_time: start_time,
      end_time: Time.now
    )

    response
  rescue StandardError => e
    # Submit error analytics
    crystil_submit_error_analytics(
      method: method_name,
      args: args,
      kwargs: kwargs,
      error: e,
      start_time: start_time,
      end_time: Time.now
    )

    raise e
  end
end

#deep_copy(obj) ⇒ Object

Parameters:

  • obj (Object)

Returns:

  • (Object)


204
205
206
207
208
209
210
211
212
# File 'lib/crystil/wrappers/base.rb', line 204

def deep_copy(obj)
  Marshal.load(Marshal.dump(obj))
rescue StandardError
  begin
    obj.dup
  rescue StandardError
    obj
  end
end

#extract_instance_variables(obj) ⇒ Hash[Symbol, untyped]

Parameters:

  • obj (Object)

Returns:

  • (Hash[Symbol, untyped])


176
177
178
179
180
181
182
183
184
# File 'lib/crystil/wrappers/base.rb', line 176

def extract_instance_variables(obj)
  result = {}
  obj.instance_variables.each do |var|
    key = var.to_s.delete("@").to_sym
    value = obj.instance_variable_get(var)
    result[key] = serialize_value(value)
  end
  result
end

#extract_query(_method, _args, kwargs) ⇒ Hash[Symbol, untyped]

Parameters:

  • _method (Symbol)
  • _args (Array[untyped])
  • kwargs (Hash[Symbol, untyped])

Returns:

  • (Hash[Symbol, untyped])


139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/crystil/wrappers/base.rb', line 139

def extract_query(_method, _args, kwargs)
  # Deep copy to avoid mutation issues
  query = deep_copy(kwargs)

  # Normalize stream parameter: convert Proc to boolean true
  # This ensures streaming requests are properly serialized to JSON
  query[:stream] = true if query.is_a?(Hash) && query[:stream].is_a?(Proc)

  query
rescue StandardError
  {}
end

#extract_response(response) ⇒ Object

Parameters:

  • response (Object)

Returns:

  • (Object)


152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/crystil/wrappers/base.rb', line 152

def extract_response(response)
  case response
  when Hash, Array
    deep_copy(response)
  when String
    { text: response }
  else
    # Try various serialization methods
    if response.respond_to?(:as_json)
      deep_copy(response.as_json)
    elsif response.respond_to?(:to_h)
      deep_copy(response.to_h)
    elsif response.respond_to?(:to_hash)
      deep_copy(response.to_hash)
    else
      # Extract instance variables for objects without serialization methods
      extract_instance_variables(response)
    end
  end
rescue StandardError => e
  # If extraction fails, try to provide useful debug info
  { raw: response.to_s, error: e.message }
end

#serialize_value(value) ⇒ Object

Parameters:

  • value (Object)

Returns:

  • (Object)


186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/crystil/wrappers/base.rb', line 186

def serialize_value(value)
  case value
  when Hash
    value.transform_values { |v| serialize_value(v) }
  when Array
    value.map { |v| serialize_value(v) }
  when String, Numeric, TrueClass, FalseClass, NilClass
    value
  else
    # Recursively extract instance variables for nested objects
    if value.respond_to?(:instance_variables)
      extract_instance_variables(value)
    else
      value.to_s
    end
  end
end