Module: Foam::Otel::LLM::RubyLLMShim

Defined in:
lib/foam/otel/llm/ruby_llm_shim.rb

Overview

Gap-filler for the ruby_llm multi-provider client: ONE seam — RubyLLM::Provider#complete, the single synchronous round trip every chat/ask call funnels through — covers ALL its providers (OpenAI, Anthropic, Gemini, and the rest) with the identical gen_ai span the per-SDK shims emit. Streaming rides the same seam (ruby_llm consumes the stream inside #complete and returns the accumulated Message).

Defined Under Namespace

Modules: ProviderPatch

Constant Summary collapse

SUPPORTED =
Gem::Requirement.new(">= 1.3", "< 2")
PROVIDER_NAMES =

ruby_llm provider slugs → gen_ai.provider.name well-known values. Unlisted slugs pass through verbatim (never dropped).

{
  "openai" => "openai",
  "anthropic" => "anthropic",
  "gemini" => "gcp.gemini",
  "vertexai" => "gcp.vertex_ai",
  "bedrock" => "aws.bedrock",
  "mistral" => "mistral_ai",
  "deepseek" => "deepseek",
  "perplexity" => "perplexity",
}.freeze
RESPONSE =

The returned RubyLLM::Message → response attributes. A tool-call turn (the assistant asking for a tool run) keeps its tool names + arguments on the output side too — without them the agent loop's model turns are invisible activity.

lambda do |message|
  next nil unless message.respond_to?(:content)

  input_tokens = message.respond_to?(:input_tokens) ? message.input_tokens : nil
  output_tokens = message.respond_to?(:output_tokens) ? message.output_tokens : nil
  output_message = {
    "role" => (message.respond_to?(:role) ? message.role.to_s : "assistant"),
    "content" => RubyLLMShim.content_of(message),
  }
  tool_calls = RubyLLMShim.serialize_tool_calls(message)
  output_message["tool_calls"] = tool_calls if tool_calls
  LLM.response_attributes(
    model: message.respond_to?(:model_id) ? message.model_id : nil,
    input_tokens: input_tokens.is_a?(Numeric) ? input_tokens.to_i : nil,
    output_tokens: output_tokens.is_a?(Numeric) ? output_tokens.to_i : nil,
    output_messages: [output_message]
  )
end

Class Method Summary collapse

Class Method Details

.content_of(message) ⇒ Object



118
119
120
121
122
123
124
125
# File 'lib/foam/otel/llm/ruby_llm_shim.rb', line 118

def content_of(message)
  content = message.respond_to?(:content) ? message.content : message
  return content if content.is_a?(String)

  content.respond_to?(:text) && content.text ? content.text : content.to_s
rescue StandardError, SystemStackError
  nil
end

.install!Object



35
36
37
38
39
40
41
# File 'lib/foam/otel/llm/ruby_llm_shim.rb', line 35

def install!
  return true if @installed

  ::RubyLLM::Provider.prepend(ProviderPatch)
  Diagnostics.info("LLM shim installed: ruby_llm (Provider#complete)")
  @installed = true
end

.installed?Boolean

Returns:

  • (Boolean)


43
# File 'lib/foam/otel/llm/ruby_llm_shim.rb', line 43

def installed? = @installed

.present?Boolean

Returns:

  • (Boolean)


29
30
31
32
33
# File 'lib/foam/otel/llm/ruby_llm_shim.rb', line 29

def present?
  defined?(::RubyLLM::Provider) &&
    ::RubyLLM::Provider.method_defined?(:complete) &&
    supported_version?
end

.provider_name(provider) ⇒ Object



60
61
62
63
64
65
# File 'lib/foam/otel/llm/ruby_llm_shim.rb', line 60

def provider_name(provider)
  slug = provider.respond_to?(:slug) ? provider.slug.to_s : provider.class.to_s
  PROVIDER_NAMES.fetch(slug, slug)
rescue StandardError
  "ruby_llm"
end

.request(provider, messages, model) ⇒ Object



67
68
69
70
71
72
73
74
# File 'lib/foam/otel/llm/ruby_llm_shim.rb', line 67

def request(provider, messages, model)
  model_id = model.respond_to?(:id) ? model.id : model
  LLM.request_attributes(
    provider: provider_name(provider), operation: "chat",
    model: model_id,
    input_messages: serialize_messages(messages)
  )
end

.serialize_messages(messages) ⇒ Object

RubyLLM::Message list → plain message hashes for the RAW input.messages JSON (Content objects degrade to_s, never dropped). Agent-loop turns keep their tool activity (rule 8a/8c floor — tool names/arguments are activity signal): an assistant tool-call turn carries "tool_calls" (id/name/arguments verbatim) and a tool-result turn carries its "tool_call_id" linkage.



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/foam/otel/llm/ruby_llm_shim.rb', line 82

def serialize_messages(messages)
  Array(messages).map do |message|
    entry = {
      "role" => (message.respond_to?(:role) ? message.role.to_s : "user"),
      "content" => content_of(message),
    }
    tool_calls = serialize_tool_calls(message)
    entry["tool_calls"] = tool_calls if tool_calls
    tool_call_id = message.respond_to?(:tool_call_id) ? message.tool_call_id : nil
    entry["tool_call_id"] = tool_call_id.to_s if tool_call_id && !tool_call_id.to_s.empty?
    entry
  end
rescue StandardError, SystemStackError
  nil
end

.serialize_tool_calls(message) ⇒ Object

RubyLLM::Message#tool_calls is a Hash=> ToolCall — each call rides as name:, arguments: verbatim. nil when absent/empty (the key is omitted, matching the non-agentic message shape).



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/foam/otel/llm/ruby_llm_shim.rb', line 101

def serialize_tool_calls(message)
  return nil unless message.respond_to?(:tool_calls)

  calls = message.tool_calls
  calls = calls.values if calls.is_a?(Hash)
  serialized = Array(calls).map do |call|
    {
      "id" => (call.respond_to?(:id) ? call.id : nil),
      "name" => (call.respond_to?(:name) ? call.name : nil),
      "arguments" => (call.respond_to?(:arguments) ? call.arguments : nil),
    }
  end
  serialized.empty? ? nil : serialized
rescue StandardError, SystemStackError
  nil
end

.supported_version?Boolean

Returns:

  • (Boolean)


45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/foam/otel/llm/ruby_llm_shim.rb', line 45

def supported_version?
  spec = Gem.loaded_specs["ruby_llm"]
  return true if spec.nil?

  supported = SUPPORTED.satisfied_by?(spec.version)
  unless supported || @version_warned
    @version_warned = true
    Diagnostics.warn("ruby_llm #{spec.version} is outside foam's LLM-shim window (#{SUPPORTED}) " \
                     "— the ruby_llm shim stays dark; file this with foam support")
  end
  supported
rescue StandardError
  true
end