Class: Crystil::Wrappers::RubyLLM

Inherits:
Object
  • Object
show all
Defined in:
lib/crystil/wrappers/ruby_llm.rb,
sig/crystil/wrappers/ruby_llm.rbs

Overview

Wrapper for RubyLLM (ruby_llm gem) Supports any provider available via RubyLLM (Anthropic, OpenAI, Google, etc.) Returns raw response and raw formatted query to backend so that the existing extractors can be used.

Instance Method Summary collapse

Constructor Details

#initialize(config, collector, sentinel = nil) ⇒ RubyLLM

Returns a new instance of RubyLLM.

Parameters:



12
13
14
15
16
# File 'lib/crystil/wrappers/ruby_llm.rb', line 12

def initialize(config, collector, sentinel = nil)
  @config = config
  @collector = collector
  @sentinel = sentinel
end

Instance Method Details

#build_google_query(model_id, messages, tools) ⇒ Hash[Symbol, untyped]

Builds a Google-native query with contents/systemInstruction structure.

Parameters:

  • model_id (String)
  • messages (Array[Hash[Symbol, untyped]])
  • tools (Hash[Symbol, untyped])

Returns:

  • (Hash[Symbol, untyped])


143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/crystil/wrappers/ruby_llm.rb', line 143

def build_google_query(model_id, messages, tools)
  system_msgs = messages.select { |m| m[:role] == "system" }
  content_msgs = messages.reject { |m| m[:role] == "system" }

  query = {
    model: model_id,
    contents: content_msgs.map do |m|
      google_role = m[:role] == "assistant" ? "model" : m[:role]
      { role: google_role, parts: [{ text: m[:content] }] }
    end
  }

  if system_msgs.any?
    system_text = system_msgs.map { |m| m[:content] }.join("\n")
    query[:systemInstruction] = { parts: [{ text: system_text }] }
  end

  unless tools.empty?
    query[:tools] = tools.values.map do |t|
      { name: t.name, description: t.description, parameters: t.params_schema }
    end
  end

  query
end

#build_messages_query(model_id, messages, tools) ⇒ Hash[Symbol, untyped]

Builds an OpenAI-compatible query (used for Anthropic and OpenAI providers).

Parameters:

  • model_id (String)
  • messages (Array[Hash[Symbol, untyped]])
  • tools (Hash[Symbol, untyped])

Returns:

  • (Hash[Symbol, untyped])


132
133
134
135
136
137
138
139
140
# File 'lib/crystil/wrappers/ruby_llm.rb', line 132

def build_messages_query(model_id, messages, tools)
  query = { model: model_id, messages: messages }
  unless tools.empty?
    query[:tools] = tools.values.map do |t|
      { name: t.name, description: t.description, parameters: t.params_schema }
    end
  end
  query
end

#build_query(provider, model_id, messages, tools) ⇒ Hash[Symbol, untyped]

Dispatches to the appropriate provider-native query builder.

Parameters:

  • provider (String)
  • model_id (String)
  • messages (Array[Hash[Symbol, untyped]])
  • tools (Hash[Symbol, untyped])

Returns:

  • (Hash[Symbol, untyped])


122
123
124
125
126
127
128
129
# File 'lib/crystil/wrappers/ruby_llm.rb', line 122

def build_query(provider, model_id, messages, tools)
  if provider == "gemini"
    build_google_query(model_id, messages, tools)
  else
    # Anthropic and OpenAI have the same query format
    build_messages_query(model_id, messages, tools)
  end
end

#register(client) ⇒ Object

Parameters:

  • client (Object)

Returns:

  • (Object)


18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/crystil/wrappers/ruby_llm.rb', line 18

def register(client)
  validate_client!(client)

  # Prevent double registration
  return client if client.instance_variable_defined?(:@crystil_registered)

  # Store references in the client instance
  client.instance_variable_set(:@crystil_config, @config)
  client.instance_variable_set(:@crystil_collector, @collector)
  client.instance_variable_set(:@crystil_sentinel, @sentinel)
  client.instance_variable_set(:@crystil_registered, true)

  # Ask method handles both streaming and non-streaming cases unlike some others.
  wrap_ask_method(client)

  client
end

#validate_client!(client) ⇒ void

This method returns an undefined value.

Parameters:

  • client (Object)

Raises:



38
39
40
41
42
43
# File 'lib/crystil/wrappers/ruby_llm.rb', line 38

def validate_client!(client)
  return if client.respond_to?(:ask)

  raise RegistrationError,
        "Client does not appear to be a valid RubyLLM client (missing ask method)"
end

#wrap_ask_method(client) ⇒ void

This method returns an undefined value.

Parameters:

  • client (Object)


45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/crystil/wrappers/ruby_llm.rb', line 45

def wrap_ask_method(client)
  # Capture the wrapper instance in a local variable so it's accessible as a closure
  # inside the block, where self will no longer refer to it.
  wrapper = self

  client.singleton_class.class_eval do
    include Base

    alias_method :original_ask, :ask

    define_method(:ask) do |message, with: nil, &block|
      start_time = Time.now
      query = {} # Fallback for error analytics if an exception occurs before build_query completes
      # Version reported is RubyLLM's, not the underlying provider's — RubyLLM is the
      # meta-wrapper Crystil sees; the native provider gem may not even be loaded.
      version = defined?(::RubyLLM::VERSION) ? ::RubyLLM::VERSION : nil

      sentinel = instance_variable_get(:@crystil_sentinel)
      provider = model.provider
      # Telemetry invariant: `conversation.client.title` is never nil.
      # `model.provider` should always return a non-empty string for
      # well-formed RubyLLM models, but fall back to the meta-wrapper
      # name (matches JS's pattern of "fallback to the SDK family
      # name") if it ever isn't.
      title = case provider
              when "gemini" then GOOGLE_CLIENT_TITLE
              when nil, "" then RUBY_LLM_PROVIDER
              else provider
              end

      # Capture existing messages (e.g. system instruction) before the call
      # adds the new user message to history.
      existing_msgs = messages.map { |m| { role: m.role.to_s, content: m.content.to_s } }
      all_msgs = existing_msgs + [{ role: "user", content: message.to_s }]

      # Build a query in the provider's native format so the backend extractor can parse it.
      query = wrapper.send(:build_query, provider, model.id, all_msgs, tools)

      # Pass the actual provider title (e.g. "anthropic", "openai", "google") so the
      # sentinel backend can route to the correct classifier for this request format.
      sentinel&.raise_if_irrelevant!(title: title, request: query, version: version)

      response = original_ask(message, with: with, &block)

      crystil_submit_analytics(
        method: :ask,
        args: [],
        kwargs: query,
        response: response.raw&.body,
        start_time: start_time,
        end_time: Time.now,
        title: title,
        provider: RUBY_LLM_PROVIDER,
        version: version
      )

      response
    rescue CrystilRequestInterceptedError => e
      raise e
    rescue StandardError => e
      crystil_submit_error_analytics(
        method: :ask,
        args: [],
        kwargs: query,
        error: e,
        start_time: start_time,
        end_time: Time.now,
        title: title,
        provider: RUBY_LLM_PROVIDER,
        version: version
      )
      raise e
    end
  end
end