llm.rb 4.7.0 → 4.9.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +335 -587
  3. data/data/anthropic.json +770 -0
  4. data/data/deepseek.json +75 -0
  5. data/data/google.json +1050 -0
  6. data/data/openai.json +1421 -0
  7. data/data/xai.json +792 -0
  8. data/data/zai.json +330 -0
  9. data/lib/llm/agent.rb +42 -41
  10. data/lib/llm/bot.rb +1 -263
  11. data/lib/llm/buffer.rb +7 -0
  12. data/lib/llm/{session → context}/deserializer.rb +4 -3
  13. data/lib/llm/context.rb +292 -0
  14. data/lib/llm/cost.rb +26 -0
  15. data/lib/llm/error.rb +8 -0
  16. data/lib/llm/eventstream/parser.rb +0 -5
  17. data/lib/llm/function/array.rb +61 -0
  18. data/lib/llm/function/fiber_group.rb +91 -0
  19. data/lib/llm/function/task_group.rb +89 -0
  20. data/lib/llm/function/thread_group.rb +94 -0
  21. data/lib/llm/function.rb +75 -10
  22. data/lib/llm/mcp/command.rb +108 -0
  23. data/lib/llm/mcp/error.rb +31 -0
  24. data/lib/llm/mcp/pipe.rb +82 -0
  25. data/lib/llm/mcp/rpc.rb +118 -0
  26. data/lib/llm/mcp/transport/stdio.rb +85 -0
  27. data/lib/llm/mcp.rb +102 -0
  28. data/lib/llm/message.rb +13 -11
  29. data/lib/llm/model.rb +115 -0
  30. data/lib/llm/prompt.rb +17 -7
  31. data/lib/llm/provider.rb +60 -32
  32. data/lib/llm/providers/anthropic/error_handler.rb +1 -1
  33. data/lib/llm/providers/anthropic/files.rb +3 -3
  34. data/lib/llm/providers/anthropic/models.rb +1 -1
  35. data/lib/llm/providers/anthropic/request_adapter.rb +20 -3
  36. data/lib/llm/providers/anthropic/response_adapter/models.rb +13 -0
  37. data/lib/llm/providers/anthropic/response_adapter.rb +2 -0
  38. data/lib/llm/providers/anthropic.rb +21 -5
  39. data/lib/llm/providers/deepseek.rb +10 -3
  40. data/lib/llm/providers/{gemini → google}/audio.rb +6 -6
  41. data/lib/llm/providers/{gemini → google}/error_handler.rb +20 -5
  42. data/lib/llm/providers/{gemini → google}/files.rb +11 -11
  43. data/lib/llm/providers/{gemini → google}/images.rb +7 -7
  44. data/lib/llm/providers/{gemini → google}/models.rb +5 -5
  45. data/lib/llm/providers/{gemini → google}/request_adapter/completion.rb +7 -3
  46. data/lib/llm/providers/{gemini → google}/request_adapter.rb +1 -1
  47. data/lib/llm/providers/{gemini → google}/response_adapter/completion.rb +7 -7
  48. data/lib/llm/providers/{gemini → google}/response_adapter/embedding.rb +1 -1
  49. data/lib/llm/providers/{gemini → google}/response_adapter/file.rb +1 -1
  50. data/lib/llm/providers/{gemini → google}/response_adapter/files.rb +1 -1
  51. data/lib/llm/providers/{gemini → google}/response_adapter/image.rb +1 -1
  52. data/lib/llm/providers/google/response_adapter/models.rb +13 -0
  53. data/lib/llm/providers/{gemini → google}/response_adapter/web_search.rb +2 -2
  54. data/lib/llm/providers/{gemini → google}/response_adapter.rb +8 -8
  55. data/lib/llm/providers/{gemini → google}/stream_parser.rb +3 -3
  56. data/lib/llm/providers/{gemini.rb → google.rb} +41 -26
  57. data/lib/llm/providers/llamacpp.rb +10 -3
  58. data/lib/llm/providers/ollama/error_handler.rb +1 -1
  59. data/lib/llm/providers/ollama/models.rb +1 -1
  60. data/lib/llm/providers/ollama/response_adapter/models.rb +13 -0
  61. data/lib/llm/providers/ollama/response_adapter.rb +2 -0
  62. data/lib/llm/providers/ollama.rb +19 -4
  63. data/lib/llm/providers/openai/error_handler.rb +18 -3
  64. data/lib/llm/providers/openai/files.rb +3 -3
  65. data/lib/llm/providers/openai/images.rb +17 -11
  66. data/lib/llm/providers/openai/models.rb +1 -1
  67. data/lib/llm/providers/openai/response_adapter/completion.rb +9 -1
  68. data/lib/llm/providers/openai/response_adapter/models.rb +13 -0
  69. data/lib/llm/providers/openai/response_adapter/responds.rb +9 -1
  70. data/lib/llm/providers/openai/response_adapter.rb +2 -0
  71. data/lib/llm/providers/openai/responses.rb +16 -1
  72. data/lib/llm/providers/openai/stream_parser.rb +2 -0
  73. data/lib/llm/providers/openai.rb +28 -6
  74. data/lib/llm/providers/xai/images.rb +7 -6
  75. data/lib/llm/providers/xai.rb +10 -3
  76. data/lib/llm/providers/zai.rb +9 -2
  77. data/lib/llm/registry.rb +81 -0
  78. data/lib/llm/schema/enum.rb +16 -0
  79. data/lib/llm/schema/parser.rb +109 -0
  80. data/lib/llm/schema.rb +5 -0
  81. data/lib/llm/server_tool.rb +5 -5
  82. data/lib/llm/session.rb +10 -1
  83. data/lib/llm/tool/param.rb +1 -1
  84. data/lib/llm/tool.rb +86 -5
  85. data/lib/llm/tracer/langsmith.rb +144 -0
  86. data/lib/llm/tracer/logger.rb +9 -1
  87. data/lib/llm/tracer/null.rb +8 -0
  88. data/lib/llm/tracer/telemetry.rb +98 -78
  89. data/lib/llm/tracer.rb +108 -4
  90. data/lib/llm/usage.rb +5 -0
  91. data/lib/llm/version.rb +1 -1
  92. data/lib/llm.rb +40 -6
  93. data/llm.gemspec +45 -8
  94. metadata +87 -28
  95. data/lib/llm/providers/gemini/response_adapter/models.rb +0 -15
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # LangSmith-specific tracer built on top of Telemetry. Supports extra
6
+ # inputs/outputs and metadata on traces and spans via {#merge_extra} and
7
+ # {#start_trace}(metadata:).
8
+ #
9
+ # @example Constructor metadata and tags
10
+ # llm.tracer = LLM::Tracer::Langsmith.new(
11
+ # llm,
12
+ # session_id: "123e4567-e89b-12d3-a456-426614174000",
13
+ # metadata: {env: "dev"},
14
+ # tags: ["changelog"]
15
+ # )
16
+ #
17
+ # @example Per-request extra metadata and inputs (e.g. from chatbot)
18
+ # tracer.merge_extra(
19
+ # metadata: { turn_id: turn.id, component: "chatbot_message_stream" },
20
+ # inputs: { "gen_ai.input.messages" => messages_json }
21
+ # )
22
+ # bot.chat(prompt)
23
+ #
24
+ # @example Trace-level metadata via start_trace
25
+ # tracer.start_trace(trace_group_id: turn.id, name: "chatbot.turn", metadata: { turn_id: turn.id })
26
+ class Tracer::Langsmith < Tracer::Telemetry
27
+ THREAD_EXTRA_KEY = :llm_langsmith_extra
28
+
29
+ UUID = /\A
30
+ [0-9a-f]{8}-
31
+ [0-9a-f]{4}-
32
+ [1-5][0-9a-f]{3}-
33
+ [89ab][0-9a-f]{3}-
34
+ [0-9a-f]{12}
35
+ \z/ix
36
+
37
+ def initialize(provider, options = {})
38
+ super
39
+ setup_langsmith!(options)
40
+ end
41
+
42
+ def start_trace(trace_group_id: nil, name: "llm", attributes: {}, metadata: nil)
43
+ merge_extra(metadata: metadata) if metadata && !metadata.empty?
44
+ super
45
+ end
46
+
47
+ def stop_trace
48
+ clear_thread_extra!
49
+ super
50
+ end
51
+
52
+ def merge_extra(metadata: nil, inputs: nil, outputs: nil)
53
+ store = thread_extra
54
+ store[:metadata].merge!(metadata) if metadata && !metadata.empty?
55
+ store[:inputs].merge!(inputs) if inputs && !inputs.empty?
56
+ store[:outputs].merge!(outputs) if outputs && !outputs.empty?
57
+ self
58
+ end
59
+
60
+ def current_extra
61
+ store = thread_extra
62
+ {
63
+ metadata: store[:metadata].dup,
64
+ inputs: store[:inputs].dup,
65
+ outputs: store[:outputs].dup
66
+ }
67
+ end
68
+
69
+ def consume_extra_inputs
70
+ thread_extra[:inputs].tap { thread_extra[:inputs] = {} }
71
+ end
72
+
73
+ def consume_extra_outputs
74
+ thread_extra[:outputs].tap { thread_extra[:outputs] = {} }
75
+ end
76
+
77
+ private
78
+
79
+ def trace_attributes(span_kind:)
80
+ attributes = {}
81
+ unless @langsmith_session_id.to_s.empty?
82
+ attributes["langsmith.trace.session_id"] = @langsmith_session_id
83
+ end
84
+ merged_metadata = @langsmith_metadata.merge(thread_extra[:metadata])
85
+ merged_metadata.each do |key, value|
86
+ next if value.nil?
87
+
88
+ attr_key = key.to_s.start_with?("langsmith.metadata.") ? key.to_s : "langsmith.metadata.#{key}"
89
+ attributes[attr_key] = serialize_langsmith_value(value)
90
+ end
91
+ unless @langsmith_tags.empty?
92
+ attributes["langsmith.span.tags"] = @langsmith_tags.map(&:to_s).join(",")
93
+ end
94
+ attributes["langsmith.span.kind"] = span_kind
95
+ attributes
96
+ end
97
+
98
+ def thread_extra
99
+ Thread.current[THREAD_EXTRA_KEY] ||= {
100
+ metadata: {},
101
+ inputs: {},
102
+ outputs: {}
103
+ }
104
+ end
105
+
106
+ def clear_thread_extra!
107
+ Thread.current[THREAD_EXTRA_KEY] = nil
108
+ end
109
+
110
+ def setup_langsmith!(options)
111
+ options ||= {}
112
+ @langsmith_metadata = options[:metadata] || {}
113
+ @langsmith_session_id = normalize_langsmith_session_id(
114
+ options[:session_id],
115
+ metadata: @langsmith_metadata
116
+ )
117
+ @langsmith_tags = options[:tags] || []
118
+ end
119
+
120
+ def serialize_langsmith_value(value)
121
+ case value
122
+ when String, Numeric, TrueClass, FalseClass
123
+ value
124
+ else
125
+ LLM.json.dump(value)
126
+ end
127
+ end
128
+
129
+ def normalize_langsmith_session_id(session_id, metadata:)
130
+ raw = session_id&.to_s
131
+ return nil if raw.to_s.empty?
132
+ return raw if uuid?(raw)
133
+
134
+ # Keep arbitrary identifiers in metadata instead of forcing
135
+ # them into langsmith.trace.session_id, which expects a UUID.
136
+ metadata[:session_id] ||= raw
137
+ nil
138
+ end
139
+
140
+ def uuid?(value)
141
+ value.match?(UUID)
142
+ end
143
+ end
144
+ end
@@ -23,7 +23,7 @@ module LLM
23
23
  ##
24
24
  # @param (see LLM::Tracer#on_request_start)
25
25
  # @return [void]
26
- def on_request_start(operation:, model: nil)
26
+ def on_request_start(operation:, model: nil, **)
27
27
  case operation
28
28
  when "chat" then start_chat(operation:, model:)
29
29
  when "retrieval" then start_retrieval(operation:)
@@ -188,5 +188,13 @@ module LLM
188
188
  **finish_attributes(operation, res)
189
189
  )
190
190
  end
191
+
192
+ ##
193
+ # @param (see LLM::Tracer#set_finish_metadata_proc)
194
+ # @return [self]
195
+ def set_finish_metadata_proc(_proc = nil)
196
+ Thread.current[LLM::Tracer::FINISH_METADATA_PROC_KEY] = nil
197
+ self
198
+ end
191
199
  end
192
200
  end
@@ -45,5 +45,13 @@ module LLM
45
45
  def on_tool_error(**)
46
46
  nil
47
47
  end
48
+
49
+ ##
50
+ # @param (see LLM::Tracer#set_finish_metadata_proc)
51
+ # @return [self]
52
+ def set_finish_metadata_proc(_proc = nil)
53
+ Thread.current[LLM::Tracer::FINISH_METADATA_PROC_KEY] = nil
54
+ self
55
+ end
48
56
  end
49
57
  end
@@ -7,8 +7,7 @@ module LLM
7
7
  # The {LLM::Tracer::Telemetry LLM::Tracer::Telemetry} tracer provides
8
8
  # telemetry support through the [opentelemetry-ruby](https://github.com/open-telemetry/opentelemetry-ruby)
9
9
  # RubyGem. The gem should be installed separately since this feature is opt-in
10
- # and disabled by default. This feature exists to support integration with tools
11
- # like [LangSmith](https://www.langsmith.com).
10
+ # and disabled by default.
12
11
  #
13
12
  # @see https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai Telemetry specs (index)
14
13
  # @see https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/openai.md Telemetry specs (OpenAI)
@@ -21,10 +20,10 @@ module LLM
21
20
  # llm = LLM.openai(key: ENV["KEY"])
22
21
  # llm.tracer = LLM::Tracer::Telemetry.new(llm)
23
22
  #
24
- # ses = LLM::Session.new(llm)
25
- # ses.talk "hello"
26
- # ses.talk "how are you?"
27
- # ses.tracer.spans.each { |span| pp span }
23
+ # ctx = LLM::Context.new(llm)
24
+ # ctx.talk "hello"
25
+ # ctx.talk "how are you?"
26
+ # ctx.tracer.spans.each { |span| pp span }
28
27
  #
29
28
  # @example OTLP export
30
29
  # #!/usr/bin/env ruby
@@ -37,9 +36,9 @@ module LLM
37
36
  # llm = LLM.openai(key: ENV["KEY"])
38
37
  # llm.tracer = LLM::Tracer::Telemetry.new(llm, exporter:)
39
38
  #
40
- # ses = LLM::Session.new(llm)
41
- # ses.talk "hello"
42
- # ses.talk "how are you?"
39
+ # ctx = LLM::Context.new(llm)
40
+ # ctx.talk "hello"
41
+ # ctx.talk "how are you?"
43
42
  class Tracer::Telemetry < Tracer
44
43
  ##
45
44
  # param [LLM::Provider] provider
@@ -48,7 +47,6 @@ module LLM
48
47
  def initialize(provider, options = {})
49
48
  super
50
49
  @exporter = options.delete(:exporter)
51
- setup_langsmith!(options.delete(:langsmith))
52
50
  setup!
53
51
  end
54
52
 
@@ -59,7 +57,7 @@ module LLM
59
57
  #
60
58
  # @param (see LLM::Tracer#start_trace)
61
59
  # @return [self]
62
- def start_trace(trace_group_id: nil, name: "llm", attributes: {})
60
+ def start_trace(trace_group_id: nil, name: "llm", attributes: {}, metadata: nil)
63
61
  return self if trace_group_id.to_s.empty?
64
62
 
65
63
  span_context = span_context_from_trace_group_id(trace_group_id.to_s)
@@ -74,25 +72,25 @@ module LLM
74
72
  attributes: attrs,
75
73
  with_parent: parent_ctx
76
74
  )
77
- thread[thread_root_span_key] = root_span
78
- thread[thread_root_context_key] = ::OpenTelemetry::Trace.context_with_span(root_span)
75
+ @root_span = root_span
76
+ @root_context = ::OpenTelemetry::Trace.context_with_span(root_span)
79
77
  self
80
78
  end
81
79
 
82
80
  ##
83
81
  # @return [self]
84
82
  def stop_trace
85
- thread[thread_root_span_key]&.finish
86
- thread[thread_root_span_key] = nil
87
- thread[thread_root_context_key] = nil
83
+ @root_span&.finish
84
+ @root_span = nil
85
+ @root_context = nil
88
86
  self
89
87
  end
90
88
 
91
89
  ##
92
90
  # @param (see LLM::Tracer#on_request_start)
93
- def on_request_start(operation:, model: nil)
91
+ def on_request_start(operation:, model: nil, inputs: nil)
94
92
  case operation
95
- when "chat" then start_chat(operation:, model:)
93
+ when "chat" then start_chat(operation:, model:, inputs:)
96
94
  when "retrieval" then start_retrieval(operation:)
97
95
  else nil
98
96
  end
@@ -100,10 +98,10 @@ module LLM
100
98
 
101
99
  ##
102
100
  # @param (see LLM::Tracer#on_request_finish)
103
- def on_request_finish(operation:, res:, model: nil, span: nil)
101
+ def on_request_finish(operation:, res:, model: nil, span: nil, outputs: nil, metadata: nil)
104
102
  return nil unless span
105
103
  case operation
106
- when "chat" then finish_chat(operation:, model:, res:, span:)
104
+ when "chat" then finish_chat(operation:, model:, res:, span:, outputs:, metadata:)
107
105
  when "retrieval" then finish_retrieval(operation:, res:, span:)
108
106
  else nil
109
107
  end
@@ -133,7 +131,7 @@ module LLM
133
131
  "gen_ai.provider.name" => provider_name,
134
132
  "server.address" => provider_host,
135
133
  "server.port" => provider_port
136
- }.merge!(langsmith_attributes(span_kind: "tool")).compact
134
+ }.merge!(trace_attributes(span_kind: "tool")).compact
137
135
  span_name = ["execute_tool", name].compact.join(" ")
138
136
  span = create_span(span_name.empty? ? "gen_ai.tool" : span_name, attributes:)
139
137
  span.add_event("gen_ai.tool.start")
@@ -197,30 +195,12 @@ module LLM
197
195
  ##
198
196
  # @api private
199
197
  def create_span(name, kind: :client, attributes: {})
200
- root_context = thread[thread_root_context_key]
198
+ root_context = @root_context
201
199
  opts = {kind:, attributes:}
202
200
  opts[:with_parent] = root_context if root_context
203
201
  @tracer.start_span(name, **opts)
204
202
  end
205
203
 
206
- ##
207
- # @api private
208
- def thread_root_span_key
209
- @thread_root_span_key ||= :"llm.telemetry.root_span.#{object_id}"
210
- end
211
-
212
- ##
213
- # @api private
214
- def thread_root_context_key
215
- @thread_root_context_key ||= :"llm.telemetry.root_context.#{object_id}"
216
- end
217
-
218
- ##
219
- # @api private
220
- def thread
221
- Thread.current
222
- end
223
-
224
204
  ##
225
205
  # Converts a string trace_group_id to an OpenTelemetry SpanContext so all
226
206
  # spans created with this context share the same trace_id.
@@ -282,16 +262,20 @@ module LLM
282
262
  ##
283
263
  # start_*
284
264
 
285
- def start_chat(operation:, model:)
265
+ def start_chat(operation:, model:, inputs: nil)
266
+ request_metadata = consume_request_metadata
267
+ input_value = request_metadata[:user_input]
286
268
  attributes = {
287
269
  "gen_ai.operation.name" => operation,
288
270
  "gen_ai.request.model" => model,
289
271
  "gen_ai.provider.name" => provider_name,
290
272
  "server.address" => provider_host,
291
- "server.port" => provider_port
292
- }.merge!(langsmith_attributes(span_kind: "llm")).compact
273
+ "server.port" => provider_port,
274
+ "input.value" => serialize_request_value(input_value)
275
+ }.merge!(trace_attributes(span_kind: "llm")).compact
293
276
  span_name = [operation, model].compact.join(" ")
294
277
  span = create_span(span_name.empty? ? "gen_ai.request" : span_name, attributes:)
278
+ set_span_attributes(span, consume_extra_inputs.merge(inputs || {}))
295
279
  span.add_event("gen_ai.request.start")
296
280
  span
297
281
  end
@@ -302,7 +286,7 @@ module LLM
302
286
  "gen_ai.provider.name" => provider_name,
303
287
  "server.address" => provider_host,
304
288
  "server.port" => provider_port
305
- }.merge!(langsmith_attributes(span_kind: "retriever")).compact
289
+ }.merge!(trace_attributes(span_kind: "retriever")).compact
306
290
  span = create_span(operation, attributes:)
307
291
  span.add_event("gen_ai.request.start")
308
292
  span
@@ -311,16 +295,26 @@ module LLM
311
295
  ##
312
296
  # finish_*
313
297
 
314
- def finish_chat(operation:, model:, res:, span:)
298
+ def finish_chat(operation:, model:, res:, span:, outputs: nil, metadata: nil)
299
+ output_value = if res.respond_to?(:output_text)
300
+ res.output_text
301
+ else
302
+ (res.respond_to?(:content) ? res.content : nil)
303
+ end
315
304
  attributes = {
316
305
  "gen_ai.operation.name" => operation,
317
306
  "gen_ai.request.model" => model,
318
307
  "gen_ai.response.id" => res.id,
319
308
  "gen_ai.response.model" => model,
320
309
  "gen_ai.usage.input_tokens" => res.usage.input_tokens,
321
- "gen_ai.usage.output_tokens" => res.usage.output_tokens
310
+ "gen_ai.usage.output_tokens" => res.usage.output_tokens,
311
+ "output.value" => serialize_request_value(output_value)
322
312
  }.merge!(finish_attributes(operation, res)).compact
323
313
  attributes.each { span.set_attribute(_1, _2) }
314
+ set_span_attributes(span, consume_extra_outputs.merge(outputs || {}))
315
+ finish_metadata = consume_finish_metadata_proc(res)
316
+ metadata = (metadata || {}).merge(finish_metadata || {})
317
+ set_span_attributes(span, metadata.transform_keys { "langsmith.metadata.#{_1}" })
324
318
  span.add_event("gen_ai.request.finish")
325
319
  span.tap(&:finish)
326
320
  end
@@ -329,57 +323,83 @@ module LLM
329
323
  attributes = {
330
324
  "gen_ai.operation.name" => operation
331
325
  }.merge!(finish_attributes(operation, res)).compact
326
+ chunks_json = retrieval_chunks_json(res)
327
+ attributes["langsmith.metadata.chunks"] = chunks_json if chunks_json
332
328
  attributes.each { span.set_attribute(_1, _2) }
333
329
  span.add_event("gen_ai.request.finish")
334
330
  span.tap(&:finish)
335
331
  end
336
332
 
337
- def setup_langsmith!(options)
338
- options ||= {}
339
- @langsmith_metadata = options[:metadata] || {}
340
- @langsmith_session_id = normalize_langsmith_session_id(options[:session_id], metadata: @langsmith_metadata)
341
- @langsmith_tags = options[:tags] || []
333
+ ##
334
+ # @api private
335
+ # Serialize retrieval response chunks for span attributes (e.g. langsmith.metadata.chunks).
336
+ # Returns a JSON string or nil when res has no data.
337
+ def consume_finish_metadata_proc(res)
338
+ key = LLM::Tracer::FINISH_METADATA_PROC_KEY
339
+ proc = Thread.current[key]
340
+ Thread.current[key] = nil
341
+ return {} unless proc.respond_to?(:call)
342
+
343
+ proc.call(res) || {}
344
+ rescue
345
+ {}
342
346
  end
343
347
 
344
- def langsmith_attributes(span_kind:)
345
- attributes = {}
346
- unless @langsmith_session_id.to_s.empty?
347
- attributes["langsmith.trace.session_id"] = @langsmith_session_id
348
- end
349
- @langsmith_metadata.each do |key, value|
350
- next if value.nil?
348
+ def retrieval_chunks_json(res)
349
+ return nil unless res.respond_to?(:data)
351
350
 
352
- attributes["langsmith.metadata.#{key}"] = serialize_langsmith_value(value)
353
- end
354
- unless @langsmith_tags.empty?
355
- attributes["langsmith.span.tags"] = @langsmith_tags.map(&:to_s).join(",")
351
+ data = res.data
352
+ return nil unless data.is_a?(Array)
353
+
354
+ payload = data.map { |c| c.respond_to?(:to_h) ? c.to_h : c }
355
+ LLM.json.dump(payload)
356
+ rescue
357
+ nil
358
+ end
359
+
360
+ ##
361
+ # @api private
362
+ # Hook for tracer-specific span attributes.
363
+ # Subclasses can override this to inject provider-agnostic tags.
364
+ def trace_attributes(span_kind:)
365
+ {}
366
+ end
367
+
368
+ ##
369
+ # @api private
370
+ # Sets attribute key-value pairs on the span, serializing non-primitive values to JSON.
371
+ def set_span_attributes(span, attrs)
372
+ return if attrs.nil? || attrs.empty?
373
+
374
+ attrs.each do |key, value|
375
+ span.set_attribute(key.to_s, serialize_span_value(value))
356
376
  end
357
- attributes["langsmith.span.kind"] = span_kind
358
- attributes
359
377
  end
360
378
 
361
- def serialize_langsmith_value(value)
379
+ ##
380
+ # @api private
381
+ # OpenTelemetry attributes accept String, Numeric, Boolean, or Array of those.
382
+ # Complex values (hashes, arrays of objects) are serialized to JSON strings.
383
+ def serialize_span_value(value)
362
384
  case value
363
385
  when String, Numeric, TrueClass, FalseClass
364
386
  value
387
+ when Array
388
+ value.all? { |v| v.is_a?(String) || v.is_a?(Numeric) || v == true || v == false } ? value : LLM.json.dump(value)
365
389
  else
366
390
  LLM.json.dump(value)
367
391
  end
368
392
  end
369
393
 
370
- def normalize_langsmith_session_id(session_id, metadata:)
371
- raw = session_id&.to_s
372
- return nil if raw.to_s.empty?
373
- return raw if uuid?(raw)
374
-
375
- # Keep arbitrary thread identifiers in metadata instead of forcing
376
- # them into langsmith.trace.session_id, which expects a known UUID.
377
- metadata[:session_id] ||= raw
378
- nil
379
- end
380
-
381
- def uuid?(value)
382
- value.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i)
394
+ def serialize_request_value(value)
395
+ case value
396
+ when nil
397
+ nil
398
+ when String
399
+ value
400
+ else
401
+ LLM.json.dump(value)
402
+ end
383
403
  end
384
404
  end
385
405
  end
data/lib/llm/tracer.rb CHANGED
@@ -11,6 +11,7 @@ module LLM
11
11
  class Tracer
12
12
  require_relative "tracer/logger"
13
13
  require_relative "tracer/telemetry"
14
+ require_relative "tracer/langsmith"
14
15
  require_relative "tracer/null"
15
16
 
16
17
  ##
@@ -27,19 +28,22 @@ module LLM
27
28
  # Called before an LLM provider request is executed.
28
29
  # @param [String] operation
29
30
  # @param [String] model
31
+ # @param [Hash, nil] inputs Optional span attributes (e.g. gen_ai.input.messages) from llm.rb or caller.
30
32
  # @return [void]
31
- def on_request_start(operation:, model: nil)
33
+ def on_request_start(operation:, model: nil, inputs: nil)
32
34
  raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
33
35
  end
34
36
 
35
37
  ##
36
38
  # Called after an LLM provider request succeeds.
37
39
  # @param [String] operation
38
- # @param [String] model
39
40
  # @param [LLM::Response] res
40
41
  # @param [Object, nil] span
42
+ # @param [String] model
43
+ # @param [Hash, nil] outputs Optional span attributes (e.g. gen_ai.output.messages) from llm.rb or caller.
44
+ # @param [Hash, nil] metadata Optional metadata (emitted as langsmith.metadata.*) from llm.rb or caller.
41
45
  # @return [void]
42
- def on_request_finish(operation:, res:, model: nil, span: nil)
46
+ def on_request_finish(operation:, res:, model: nil, span: nil, outputs: nil, metadata: nil)
43
47
  raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
44
48
  end
45
49
 
@@ -101,8 +105,11 @@ module LLM
101
105
  # Name for the root span (e.g. "chatbot.turn").
102
106
  # @param [Hash] attributes
103
107
  # OpenTelemetry attributes to set on the root span.
108
+ # @param [Hash, nil] metadata
109
+ # Optional. Trace-level metadata merged into the trace (e.g. langsmith.metadata.*).
110
+ # Only used by tracers that support it (e.g. {LLM::Tracer::Langsmith}).
104
111
  # @return [self]
105
- def start_trace(trace_group_id: nil, name: "llm", attributes: {})
112
+ def start_trace(trace_group_id: nil, name: "llm", attributes: {}, metadata: nil)
106
113
  self
107
114
  end
108
115
 
@@ -136,8 +143,105 @@ module LLM
136
143
  nil
137
144
  end
138
145
 
146
+ ##
147
+ # Merges extra attributes for the current trace/span. Used by applications
148
+ # (e.g. chatbot) to add metadata, span inputs, or span outputs to the next
149
+ # span or to the trace. No-op by default; {LLM::Tracer::Langsmith} merges
150
+ # into fiber-local storage and emits them as langsmith/GenAI attributes.
151
+ #
152
+ # @param [Hash, nil] metadata
153
+ # Key-value pairs merged into trace/span metadata (e.g. langsmith.metadata.*).
154
+ # @param [Hash, nil] inputs
155
+ # Key-value pairs set on the next span at start (e.g. gen_ai.input.messages).
156
+ # Consumed when the span is created.
157
+ # @param [Hash, nil] outputs
158
+ # Key-value pairs set on the current span at finish (e.g. gen_ai.output.messages).
159
+ # Must be set before the request finishes (e.g. in a block passed to the provider).
160
+ # @return [self]
161
+ def merge_extra(metadata: nil, inputs: nil, outputs: nil)
162
+ self
163
+ end
164
+
165
+ ##
166
+ # Optional: set a proc to supply metadata when the next chat span finishes.
167
+ # The proc is called with the response (res) and should return a Hash of
168
+ # metadata (e.g. { intent: "...", confidence: 1.0 }) to merge onto the span
169
+ # as langsmith.metadata.*. Cleared after use. Used by apps to attach
170
+ # routing/intent that is only known after the response.
171
+ #
172
+ # @param [Proc, nil] proc (res) -> Hash or nil
173
+ # @return [self]
174
+ def set_finish_metadata_proc(proc)
175
+ thread[FINISH_METADATA_PROC_KEY] = proc
176
+ self
177
+ end
178
+
179
+ FINISH_METADATA_PROC_KEY = :"llm.tracer.finish_metadata_proc"
180
+
181
+ ##
182
+ # Returns the current extra bag (metadata, inputs, outputs) for the current
183
+ # thread/trace. Used by subclasses; default returns empty hashes.
184
+ #
185
+ # @return [Hash] { metadata: {}, inputs: {}, outputs: {} }
186
+ def current_extra
187
+ {}
188
+ end
189
+
190
+ ##
191
+ # Returns and clears extra inputs for the next span. Called by the telemetry
192
+ # tracer when starting a span. Subclasses (e.g. Langsmith) override to
193
+ # return fiber-local inputs; default returns {}.
194
+ #
195
+ # @return [Hash] Attribute key => value to set on the span at start
196
+ def consume_extra_inputs
197
+ {}
198
+ end
199
+
200
+ ##
201
+ # Returns and clears extra outputs for the current span. Called by the
202
+ # telemetry tracer when finishing a span. Subclasses override to return
203
+ # fiber-local outputs; default returns {}.
204
+ #
205
+ # @return [Hash] Attribute key => value to set on the span at finish
206
+ def consume_extra_outputs
207
+ {}
208
+ end
209
+
210
+ ##
211
+ # Store per-request metadata (e.g. user_input) to be consumed by tracers
212
+ # when starting the next span. Used for plain-text input.value / output.value.
213
+ #
214
+ # @param [Hash] metadata e.g. { user_input: "the user question" }
215
+ # @return [nil]
216
+ def set_request_metadata(metadata)
217
+ return nil unless metadata && !metadata.empty?
218
+ key = thread_request_metadata_key
219
+ current = thread[key] || {}
220
+ thread[key] = current.merge(metadata.compact)
221
+ nil
222
+ end
223
+
224
+ ##
225
+ # Consume and clear per-request metadata. Called by the telemetry tracer at span start.
226
+ #
227
+ # @return [Hash]
228
+ def consume_request_metadata
229
+ key = thread_request_metadata_key
230
+ data = thread[key] || {}
231
+ thread[key] = nil
232
+ data
233
+ end
234
+
139
235
  private
140
236
 
237
+ def thread_request_metadata_key
238
+ @thread_request_metadata_key ||= :"llm.tracer.request_metadata.#{object_id}"
239
+ end
240
+
241
+ def thread
242
+ Thread.current
243
+ end
244
+
141
245
  ##
142
246
  # @return [String]
143
247
  def provider_name
data/lib/llm/usage.rb CHANGED
@@ -8,4 +8,9 @@
8
8
  # It can also help track usage of the context window (which may
9
9
  # vary by model).
10
10
  class LLM::Usage < Struct.new(:input_tokens, :output_tokens, :reasoning_tokens, :total_tokens, keyword_init: true)
11
+ ##
12
+ # @return [String]
13
+ def to_json(...)
14
+ LLM.json.dump({input_tokens:, output_tokens:, reasoning_tokens:, total_tokens:})
15
+ end
11
16
  end
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "4.7.0"
4
+ VERSION = "4.9.0"
5
5
  end