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.
- checksums.yaml +4 -4
- data/README.md +335 -587
- data/data/anthropic.json +770 -0
- data/data/deepseek.json +75 -0
- data/data/google.json +1050 -0
- data/data/openai.json +1421 -0
- data/data/xai.json +792 -0
- data/data/zai.json +330 -0
- data/lib/llm/agent.rb +42 -41
- data/lib/llm/bot.rb +1 -263
- data/lib/llm/buffer.rb +7 -0
- data/lib/llm/{session → context}/deserializer.rb +4 -3
- data/lib/llm/context.rb +292 -0
- data/lib/llm/cost.rb +26 -0
- data/lib/llm/error.rb +8 -0
- data/lib/llm/eventstream/parser.rb +0 -5
- data/lib/llm/function/array.rb +61 -0
- data/lib/llm/function/fiber_group.rb +91 -0
- data/lib/llm/function/task_group.rb +89 -0
- data/lib/llm/function/thread_group.rb +94 -0
- data/lib/llm/function.rb +75 -10
- data/lib/llm/mcp/command.rb +108 -0
- data/lib/llm/mcp/error.rb +31 -0
- data/lib/llm/mcp/pipe.rb +82 -0
- data/lib/llm/mcp/rpc.rb +118 -0
- data/lib/llm/mcp/transport/stdio.rb +85 -0
- data/lib/llm/mcp.rb +102 -0
- data/lib/llm/message.rb +13 -11
- data/lib/llm/model.rb +115 -0
- data/lib/llm/prompt.rb +17 -7
- data/lib/llm/provider.rb +60 -32
- data/lib/llm/providers/anthropic/error_handler.rb +1 -1
- data/lib/llm/providers/anthropic/files.rb +3 -3
- data/lib/llm/providers/anthropic/models.rb +1 -1
- data/lib/llm/providers/anthropic/request_adapter.rb +20 -3
- data/lib/llm/providers/anthropic/response_adapter/models.rb +13 -0
- data/lib/llm/providers/anthropic/response_adapter.rb +2 -0
- data/lib/llm/providers/anthropic.rb +21 -5
- data/lib/llm/providers/deepseek.rb +10 -3
- data/lib/llm/providers/{gemini → google}/audio.rb +6 -6
- data/lib/llm/providers/{gemini → google}/error_handler.rb +20 -5
- data/lib/llm/providers/{gemini → google}/files.rb +11 -11
- data/lib/llm/providers/{gemini → google}/images.rb +7 -7
- data/lib/llm/providers/{gemini → google}/models.rb +5 -5
- data/lib/llm/providers/{gemini → google}/request_adapter/completion.rb +7 -3
- data/lib/llm/providers/{gemini → google}/request_adapter.rb +1 -1
- data/lib/llm/providers/{gemini → google}/response_adapter/completion.rb +7 -7
- data/lib/llm/providers/{gemini → google}/response_adapter/embedding.rb +1 -1
- data/lib/llm/providers/{gemini → google}/response_adapter/file.rb +1 -1
- data/lib/llm/providers/{gemini → google}/response_adapter/files.rb +1 -1
- data/lib/llm/providers/{gemini → google}/response_adapter/image.rb +1 -1
- data/lib/llm/providers/google/response_adapter/models.rb +13 -0
- data/lib/llm/providers/{gemini → google}/response_adapter/web_search.rb +2 -2
- data/lib/llm/providers/{gemini → google}/response_adapter.rb +8 -8
- data/lib/llm/providers/{gemini → google}/stream_parser.rb +3 -3
- data/lib/llm/providers/{gemini.rb → google.rb} +41 -26
- data/lib/llm/providers/llamacpp.rb +10 -3
- data/lib/llm/providers/ollama/error_handler.rb +1 -1
- data/lib/llm/providers/ollama/models.rb +1 -1
- data/lib/llm/providers/ollama/response_adapter/models.rb +13 -0
- data/lib/llm/providers/ollama/response_adapter.rb +2 -0
- data/lib/llm/providers/ollama.rb +19 -4
- data/lib/llm/providers/openai/error_handler.rb +18 -3
- data/lib/llm/providers/openai/files.rb +3 -3
- data/lib/llm/providers/openai/images.rb +17 -11
- data/lib/llm/providers/openai/models.rb +1 -1
- data/lib/llm/providers/openai/response_adapter/completion.rb +9 -1
- data/lib/llm/providers/openai/response_adapter/models.rb +13 -0
- data/lib/llm/providers/openai/response_adapter/responds.rb +9 -1
- data/lib/llm/providers/openai/response_adapter.rb +2 -0
- data/lib/llm/providers/openai/responses.rb +16 -1
- data/lib/llm/providers/openai/stream_parser.rb +2 -0
- data/lib/llm/providers/openai.rb +28 -6
- data/lib/llm/providers/xai/images.rb +7 -6
- data/lib/llm/providers/xai.rb +10 -3
- data/lib/llm/providers/zai.rb +9 -2
- data/lib/llm/registry.rb +81 -0
- data/lib/llm/schema/enum.rb +16 -0
- data/lib/llm/schema/parser.rb +109 -0
- data/lib/llm/schema.rb +5 -0
- data/lib/llm/server_tool.rb +5 -5
- data/lib/llm/session.rb +10 -1
- data/lib/llm/tool/param.rb +1 -1
- data/lib/llm/tool.rb +86 -5
- data/lib/llm/tracer/langsmith.rb +144 -0
- data/lib/llm/tracer/logger.rb +9 -1
- data/lib/llm/tracer/null.rb +8 -0
- data/lib/llm/tracer/telemetry.rb +98 -78
- data/lib/llm/tracer.rb +108 -4
- data/lib/llm/usage.rb +5 -0
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +40 -6
- data/llm.gemspec +45 -8
- metadata +87 -28
- 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
|
data/lib/llm/tracer/logger.rb
CHANGED
|
@@ -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
|
data/lib/llm/tracer/null.rb
CHANGED
|
@@ -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
|
data/lib/llm/tracer/telemetry.rb
CHANGED
|
@@ -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.
|
|
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
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
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
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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!(
|
|
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 =
|
|
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
|
-
|
|
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!(
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
345
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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