llm.rb 4.3.1 → 4.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b04602c6e101c35e64efe688c889da97675ebd112293f3da5b762456a3c15673
4
- data.tar.gz: 20d63516d9d81661842d29d4e7c34325103cc60bd698f7195b59cc5be44a2ccc
3
+ metadata.gz: c8252114d7ab58f00fd2d14389e932cfa9b2f0a71d96d9ea2c261f1f8b67d721
4
+ data.tar.gz: 8c00745ba750d0e271d8a4e5d5d9418a13556847c181e912aa8a8a1e7a9344b5
5
5
  SHA512:
6
- metadata.gz: 5ff6f85d0ec1c469e9ca9e5cfe310f28293b270f974dd9974d00bbf3b1cf6eb44e52351bf4b9d37e338c33349230bbd98f35d9bdbefe891b6dc402f734721654
7
- data.tar.gz: 71dd34c25c103c4dd874a9f86ce3628b0abf81538234d3243dbbdf03f685369dab7a052b041c6ef7c088518ae024a4894b09cceaed59a569ad33b0e1af1d5186
6
+ metadata.gz: 9b45173644a3803db844c1cad679a14ec647e058bd4517ce0160b7f2f352480877375748a4abb727007d3c76e7b977a6c106a5bfa438575f69ef8cd60a5612d2
7
+ data.tar.gz: bebdb107819b1410bc6644529ad0f56544deadbae9661ab8ed2c84a420c74097b357113650de7ed7da1d0b388c601a9b7e60e30d887b62d72e7cfb84f3f0dd1a
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <p align="center">
5
5
  <a href="https://0x1eef.github.io/x/llm.rb?rebuild=1"><img src="https://img.shields.io/badge/docs-0x1eef.github.io-blue.svg" alt="RubyDoc"></a>
6
6
  <a href="https://opensource.org/license/0bsd"><img src="https://img.shields.io/badge/License-0BSD-orange.svg?" alt="License"></a>
7
- <a href="https://github.com/llmrb/llm.rb/tags"><img src="https://img.shields.io/badge/version-4.3.1-green.svg?" alt="Version"></a>
7
+ <a href="https://github.com/llmrb/llm.rb/tags"><img src="https://img.shields.io/badge/version-4.5.0-green.svg?" alt="Version"></a>
8
8
  </p>
9
9
 
10
10
  ## About
@@ -177,11 +177,47 @@ end
177
177
  ses.talk(prompt)
178
178
  ```
179
179
 
180
+ #### Threads
181
+
182
+ llm.rb is designed for threaded environments with throughput in mind.
183
+ Locks are used selectively, and localized state is preferred wherever
184
+ possible. Blanket locking across every class would help guarantee
185
+ correctness but it would also add contention, reduce throughput,
186
+ and increase complexity.
187
+
188
+ That's why we decided to optimize for both correctness and throughput
189
+ instead. An important part of that design is guaranteeing that
190
+ [LLM::Provider](https://0x1eef.github.io/x/llm.rb/LLM/Provider.html)
191
+ is safe to share across threads. [LLM::Session](https://0x1eef.github.io/x/llm.rb/LLM/Session.html) and
192
+ [LLM::Agent](https://0x1eef.github.io/x/llm.rb/LLM/Agent.html) are
193
+ stateful objects that should be kept local to a single thread. So the
194
+ recommended pattern is to keep one session or agent per thread,
195
+ and share a provider across multiple threads:
196
+
197
+ ```ruby
198
+ #!/usr/bin/env ruby
199
+ require "llm"
200
+
201
+ llm = LLM.openai(key: ENV["KEY"], persistent: true)
202
+ schema = llm.schema.object(answer: llm.schema.integer.required)
203
+
204
+ vals = 10.times.map do |x|
205
+ Thread.new do
206
+ ses = LLM::Session.new(llm, schema:)
207
+ res = ses.talk "#{x} + 5 = ?"
208
+ res.messages.find(&:assistant?).content!
209
+ end
210
+ end.map(&:value)
211
+
212
+ vals.each { |val| puts val }
213
+ ```
214
+
180
215
  ## Features
181
216
 
182
217
  #### General
183
218
  - ✅ Unified API across providers
184
219
  - 📦 Zero runtime deps (stdlib-only)
220
+ - 🧵 Thread-safe providers for multi-threaded workloads
185
221
  - 🧩 Pluggable JSON adapters (JSON, Oj, Yajl, etc)
186
222
  - 🧱 Builtin tracer API ([LLM::Tracer](https://0x1eef.github.io/x/llm.rb/LLM/Tracer.html))
187
223
 
@@ -438,18 +474,6 @@ ses2.restore(string: json)
438
474
  ses2.talk "Howdy partner. I'm back"
439
475
  ```
440
476
 
441
- #### Thread Safety
442
-
443
- The llm.rb library is thread-safe and can be used in a multi-threaded
444
- environments but it is important to keep in mind that the
445
- [LLM::Provider](https://0x1eef.github.io/x/llm.rb/LLM/Provider.html)
446
- and
447
- [LLM::Session](https://0x1eef.github.io/x/llm.rb/LLM/Session.html)
448
- classes should be instantiated once per thread, and not shared
449
- between threads. Generally the library tries to avoid global or
450
- shared state but where it exists reentrant locks are used to
451
- ensure thread-safety.
452
-
453
477
  ### Tools
454
478
 
455
479
  #### LLM::Function
data/lib/llm/provider.rb CHANGED
@@ -36,9 +36,11 @@ class LLM::Provider
36
36
  @port = port
37
37
  @timeout = timeout
38
38
  @ssl = ssl
39
- @client = persistent ? persistent_client : transient_client
39
+ @client = persistent ? persistent_client : nil
40
40
  @tracer = LLM::Tracer::Null.new(self)
41
41
  @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
42
+ @headers = {"User-Agent" => "llm.rb v#{LLM::VERSION}"}
43
+ @monitor = Monitor.new
42
44
  end
43
45
 
44
46
  ##
@@ -181,7 +183,7 @@ class LLM::Provider
181
183
  # Returns an object that can generate a JSON schema
182
184
  # @return [LLM::Schema]
183
185
  def schema
184
- @schema ||= LLM::Schema.new
186
+ LLM::Schema.new
185
187
  end
186
188
 
187
189
  ##
@@ -195,7 +197,9 @@ class LLM::Provider
195
197
  # @return [LLM::Provider]
196
198
  # Returns self
197
199
  def with(headers:)
198
- tap { (@headers ||= {}).merge!(headers) }
200
+ lock do
201
+ tap { @headers.merge!(headers) }
202
+ end
199
203
  end
200
204
 
201
205
  ##
@@ -276,10 +280,12 @@ class LLM::Provider
276
280
  # A tracer
277
281
  # @return [void]
278
282
  def tracer=(tracer)
279
- @tracer = if tracer.nil?
280
- LLM::Tracer::Null.new(self)
281
- else
282
- tracer
283
+ lock do
284
+ @tracer = if tracer.nil?
285
+ LLM::Tracer::Null.new(self)
286
+ else
287
+ tracer
288
+ end
283
289
  end
284
290
  end
285
291
 
@@ -335,10 +341,12 @@ class LLM::Provider
335
341
  # When there is a network error at the operating system level
336
342
  # @return [Net::HTTPResponse]
337
343
  def execute(request:, operation:, stream: nil, stream_parser: self.stream_parser, model: nil, &b)
338
- span = @tracer.on_request_start(operation:, model:)
339
- args = (Net::HTTP === client) ? [request] : [URI.join(base_uri, request.path), request]
344
+ tracer = @tracer
345
+ span = tracer.on_request_start(operation:, model:)
346
+ http = client || transient_client
347
+ args = (Net::HTTP === http) ? [request] : [URI.join(base_uri, request.path), request]
340
348
  res = if stream
341
- client.request(*args) do |res|
349
+ http.request(*args) do |res|
342
350
  handler = event_handler.new stream_parser.new(stream)
343
351
  parser = LLM::EventStream::Parser.new
344
352
  parser.register(handler)
@@ -352,10 +360,10 @@ class LLM::Provider
352
360
  parser&.free
353
361
  end
354
362
  else
355
- b ? client.request(*args) { (Net::HTTPSuccess === _1) ? b.call(_1) : _1 } :
356
- client.request(*args)
363
+ b ? http.request(*args) { (Net::HTTPSuccess === _1) ? b.call(_1) : _1 } :
364
+ http.request(*args)
357
365
  end
358
- [handle_response(res, span), span]
366
+ [handle_response(res, tracer, span), span, tracer]
359
367
  end
360
368
 
361
369
  ##
@@ -365,14 +373,18 @@ class LLM::Provider
365
373
  # @param [Object, nil] span
366
374
  # The span
367
375
  # @return [Net::HTTPResponse]
368
- def handle_response(res, span)
376
+ def handle_response(res, tracer, span)
369
377
  case res
370
378
  when Net::HTTPOK then res.body = parse_response(res)
371
- else error_handler.new(@tracer, span, res).raise_error!
379
+ else error_handler.new(tracer, span, res).raise_error!
372
380
  end
373
381
  res
374
382
  end
375
383
 
384
+ ##
385
+ # Parse a HTTP response
386
+ # @param [Net::HTTPResponse] res
387
+ # @return [LLM::Object, String]
376
388
  def parse_response(res)
377
389
  case res["content-type"]
378
390
  when %r|\Aapplication/json\s*| then LLM::Object.from(LLM.json.load(res.body))
@@ -417,14 +429,8 @@ class LLM::Provider
417
429
  end
418
430
 
419
431
  ##
420
- # Finalizes tracing after a response has been adapted/wrapped.
421
- # @param [String] operation
422
- # @param [String, nil] model
423
- # @param [LLM::Response] res
424
- # @param [Object, nil] span
425
- # @return [LLM::Response]
426
- def finish_trace(operation:, res:, model: nil, span: nil)
427
- @tracer.on_request_finish(operation:, model:, res:, span:)
428
- res
432
+ # @api private
433
+ def lock(&)
434
+ @monitor.synchronize(&)
429
435
  end
430
436
  end
@@ -38,9 +38,10 @@ class LLM::Anthropic
38
38
  def all(**params)
39
39
  query = URI.encode_www_form(params)
40
40
  req = Net::HTTP::Get.new("/v1/files?#{query}", headers)
41
- res, span = execute(request: req, operation: "request")
41
+ res, span, tracer = execute(request: req, operation: "request")
42
42
  res = ResponseAdapter.adapt(res, type: :enumerable)
43
- finish_trace(operation: "request", res:, span:)
43
+ tracer.on_request_finish(operation: "request", res:, span:)
44
+ res
44
45
  end
45
46
 
46
47
  ##
@@ -58,9 +59,10 @@ class LLM::Anthropic
58
59
  req = Net::HTTP::Post.new("/v1/files", headers)
59
60
  req["content-type"] = multi.content_type
60
61
  set_body_stream(req, multi.body)
61
- res, span = execute(request: req, operation: "request")
62
+ res, span, tracer = execute(request: req, operation: "request")
62
63
  res = ResponseAdapter.adapt(res, type: :file)
63
- finish_trace(operation: "request", res:, span:)
64
+ tracer.on_request_finish(operation: "request", res:, span:)
65
+ res
64
66
  end
65
67
 
66
68
  ##
@@ -78,9 +80,10 @@ class LLM::Anthropic
78
80
  file_id = file.respond_to?(:id) ? file.id : file
79
81
  query = URI.encode_www_form(params)
80
82
  req = Net::HTTP::Get.new("/v1/files/#{file_id}?#{query}", headers)
81
- res, span = execute(request: req, operation: "request")
83
+ res, span, tracer = execute(request: req, operation: "request")
82
84
  res = ResponseAdapter.adapt(res, type: :file)
83
- finish_trace(operation: "request", res:, span:)
85
+ tracer.on_request_finish(operation: "request", res:, span:)
86
+ res
84
87
  end
85
88
 
86
89
  ##
@@ -98,9 +101,10 @@ class LLM::Anthropic
98
101
  query = URI.encode_www_form(params)
99
102
  file_id = file.respond_to?(:id) ? file.id : file
100
103
  req = Net::HTTP::Get.new("/v1/files/#{file_id}?#{query}", headers)
101
- res, span = execute(request: req, operation: "request")
104
+ res, span, tracer = execute(request: req, operation: "request")
102
105
  res = ResponseAdapter.adapt(res, type: :file)
103
- finish_trace(operation: "request", res:, span:)
106
+ tracer.on_request_finish(operation: "request", res:, span:)
107
+ res
104
108
  end
105
109
  alias_method :retrieve_metadata, :get_metadata
106
110
 
@@ -117,9 +121,10 @@ class LLM::Anthropic
117
121
  def delete(file:)
118
122
  file_id = file.respond_to?(:id) ? file.id : file
119
123
  req = Net::HTTP::Delete.new("/v1/files/#{file_id}", headers)
120
- res, span = execute(request: req, operation: "request")
124
+ res, span, tracer = execute(request: req, operation: "request")
121
125
  res = LLM::Response.new(res)
122
- finish_trace(operation: "request", res:, span:)
126
+ tracer.on_request_finish(operation: "request", res:, span:)
127
+ res
123
128
  end
124
129
 
125
130
  ##
@@ -142,9 +147,10 @@ class LLM::Anthropic
142
147
  file_id = file.respond_to?(:id) ? file.id : file
143
148
  req = Net::HTTP::Get.new("/v1/files/#{file_id}/content?#{query}", headers)
144
149
  io = StringIO.new("".b)
145
- res, span = execute(request: req, operation: "request") { |res| res.read_body { |chunk| io << chunk } }
150
+ res, span, tracer = execute(request: req, operation: "request") { |res| res.read_body { |chunk| io << chunk } }
146
151
  res = LLM::Response.new(res).tap { _1.define_singleton_method(:file) { io } }
147
- finish_trace(operation: "request", res:, span:)
152
+ tracer.on_request_finish(operation: "request", res:, span:)
153
+ res
148
154
  end
149
155
 
150
156
  private
@@ -153,7 +159,7 @@ class LLM::Anthropic
153
159
  @provider.instance_variable_get(:@key)
154
160
  end
155
161
 
156
- [:headers, :execute, :set_body_stream, :finish_trace].each do |m|
162
+ [:headers, :execute, :set_body_stream].each do |m|
157
163
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
158
164
  end
159
165
  end
@@ -40,14 +40,15 @@ class LLM::Anthropic
40
40
  def all(**params)
41
41
  query = URI.encode_www_form(params)
42
42
  req = Net::HTTP::Get.new("/v1/models?#{query}", headers)
43
- res, span = execute(request: req, operation: "request")
43
+ res, span, tracer = execute(request: req, operation: "request")
44
44
  res = ResponseAdapter.adapt(res, type: :enumerable)
45
- finish_trace(operation: "request", res:, span:)
45
+ tracer.on_request_finish(operation: "request", res:, span:)
46
+ res
46
47
  end
47
48
 
48
49
  private
49
50
 
50
- [:headers, :execute, :finish_trace].each do |m|
51
+ [:headers, :execute].each do |m|
51
52
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
52
53
  end
53
54
  end
@@ -43,10 +43,11 @@ module LLM
43
43
  def complete(prompt, params = {})
44
44
  params, stream, tools, role = normalize_complete_params(params)
45
45
  req = build_complete_request(prompt, params, role)
46
- res, span = execute(request: req, stream: stream, operation: "chat", model: params[:model])
46
+ res, span, tracer = execute(request: req, stream: stream, operation: "chat", model: params[:model])
47
47
  res = ResponseAdapter.adapt(res, type: :completion)
48
48
  .extend(Module.new { define_method(:__tools__) { tools } })
49
- finish_trace(operation: "chat", model: params[:model], res:, span:)
49
+ tracer.on_request_finish(operation: "chat", model: params[:model], res:, span:)
50
+ res
50
51
  end
51
52
 
52
53
  ##
@@ -110,12 +111,14 @@ module LLM
110
111
  private
111
112
 
112
113
  def headers
113
- (@headers || {}).merge(
114
- "Content-Type" => "application/json",
115
- "x-api-key" => @key,
116
- "anthropic-version" => "2023-06-01",
117
- "anthropic-beta" => "files-api-2025-04-14"
118
- )
114
+ lock do
115
+ (@headers || {}).merge(
116
+ "Content-Type" => "application/json",
117
+ "x-api-key" => @key,
118
+ "anthropic-version" => "2023-06-01",
119
+ "anthropic-beta" => "files-api-2025-04-14"
120
+ )
121
+ end
119
122
  end
120
123
 
121
124
  def stream_parser
@@ -46,9 +46,10 @@ class LLM::Gemini
46
46
  def all(**params)
47
47
  query = URI.encode_www_form(params.merge!(key: key))
48
48
  req = Net::HTTP::Get.new("/v1beta/files?#{query}", headers)
49
- res, span = execute(request: req, operation: "request")
49
+ res, span, tracer = execute(request: req, operation: "request")
50
50
  res = ResponseAdapter.adapt(res, type: :files)
51
- finish_trace(operation: "request", res:, span:)
51
+ tracer.on_request_finish(operation: "request", res:, span:)
52
+ res
52
53
  end
53
54
 
54
55
  ##
@@ -69,9 +70,10 @@ class LLM::Gemini
69
70
  req["X-Goog-Upload-Command"] = "upload, finalize"
70
71
  file.with_io do |io|
71
72
  set_body_stream(req, io)
72
- res, span = execute(request: req, operation: "request")
73
+ res, span, tracer = execute(request: req, operation: "request")
73
74
  res = ResponseAdapter.adapt(res, type: :file)
74
- finish_trace(operation: "request", res:, span:)
75
+ tracer.on_request_finish(operation: "request", res:, span:)
76
+ res
75
77
  end
76
78
  end
77
79
 
@@ -90,9 +92,10 @@ class LLM::Gemini
90
92
  file_id = file.respond_to?(:name) ? file.name : file.to_s
91
93
  query = URI.encode_www_form(params.merge!(key: key))
92
94
  req = Net::HTTP::Get.new("/v1beta/#{file_id}?#{query}", headers)
93
- res, span = execute(request: req, operation: "request")
95
+ res, span, tracer = execute(request: req, operation: "request")
94
96
  res = ResponseAdapter.adapt(res, type: :file)
95
- finish_trace(operation: "request", res:, span:)
97
+ tracer.on_request_finish(operation: "request", res:, span:)
98
+ res
96
99
  end
97
100
 
98
101
  ##
@@ -109,9 +112,10 @@ class LLM::Gemini
109
112
  file_id = file.respond_to?(:name) ? file.name : file.to_s
110
113
  query = URI.encode_www_form(params.merge!(key: key))
111
114
  req = Net::HTTP::Delete.new("/v1beta/#{file_id}?#{query}", headers)
112
- res, span = execute(request: req, operation: "request")
115
+ res, span, tracer = execute(request: req, operation: "request")
113
116
  res = LLM::Response.new(res)
114
- finish_trace(operation: "request", res:, span:)
117
+ tracer.on_request_finish(operation: "request", res:, span:)
118
+ res
115
119
  end
116
120
 
117
121
  ##
@@ -132,8 +136,9 @@ class LLM::Gemini
132
136
  req["X-Goog-Upload-Header-Content-Length"] = file.bytesize
133
137
  req["X-Goog-Upload-Header-Content-Type"] = file.mime_type
134
138
  req.body = LLM.json.dump({file: {display_name: File.basename(file.path)}})
135
- res, span = execute(request: req, operation: "request")
136
- finish_trace(operation: "request", res: LLM::Response.new(res), span:)
139
+ res, span, tracer = execute(request: req, operation: "request")
140
+ trace_res = LLM::Response.new(res)
141
+ tracer.on_request_finish(operation: "request", res: trace_res, span:)
137
142
  res["x-goog-upload-url"]
138
143
  end
139
144
 
@@ -141,7 +146,7 @@ class LLM::Gemini
141
146
  @provider.instance_variable_get(:@key)
142
147
  end
143
148
 
144
- [:headers, :execute, :set_body_stream, :finish_trace].each do |m|
149
+ [:headers, :execute, :set_body_stream].each do |m|
145
150
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
146
151
  end
147
152
  end
@@ -51,9 +51,10 @@ class LLM::Gemini
51
51
  instances: [{prompt:}]
52
52
  })
53
53
  req.body = body
54
- res, span = execute(request: req, operation: "request")
54
+ res, span, tracer = execute(request: req, operation: "request")
55
55
  res = ResponseAdapter.adapt(res, type: :image)
56
- finish_trace(operation: "request", model:, res:, span:)
56
+ tracer.on_request_finish(operation: "request", model:, res:, span:)
57
+ res
57
58
  end
58
59
 
59
60
  ##
@@ -90,7 +91,7 @@ class LLM::Gemini
90
91
  @provider.instance_variable_get(:@key)
91
92
  end
92
93
 
93
- [:headers, :execute, :set_body_stream, :finish_trace].each do |m|
94
+ [:headers, :execute, :set_body_stream].each do |m|
94
95
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
95
96
  end
96
97
  end
@@ -42,9 +42,10 @@ class LLM::Gemini
42
42
  def all(**params)
43
43
  query = URI.encode_www_form(params.merge!(key: key))
44
44
  req = Net::HTTP::Get.new("/v1beta/models?#{query}", headers)
45
- res, span = execute(request: req, operation: "request")
45
+ res, span, tracer = execute(request: req, operation: "request")
46
46
  res = ResponseAdapter.adapt(res, type: :models)
47
- finish_trace(operation: "request", res:, span:)
47
+ tracer.on_request_finish(operation: "request", res:, span:)
48
+ res
48
49
  end
49
50
 
50
51
  private
@@ -53,7 +54,7 @@ class LLM::Gemini
53
54
  @provider.instance_variable_get(:@key)
54
55
  end
55
56
 
56
- [:headers, :execute, :finish_trace].each do |m|
57
+ [:headers, :execute].each do |m|
57
58
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
58
59
  end
59
60
  end
@@ -48,7 +48,7 @@ module LLM::Gemini::RequestAdapter
48
48
  def adapt_object(object)
49
49
  case object.kind
50
50
  when :image_url
51
- [{file_data: {mime_type: "image/*", file_uri: object.value.to_s}}]
51
+ [{file_data: {file_uri: object.value.to_s}}]
52
52
  when :local_file
53
53
  file = object.value
54
54
  [{inline_data: {mime_type: file.mime_type, data: file.to_b64}}]
@@ -49,9 +49,10 @@ module LLM
49
49
  path = ["/v1beta/models/#{model}", "embedContent?key=#{@key}"].join(":")
50
50
  req = Net::HTTP::Post.new(path, headers)
51
51
  req.body = LLM.json.dump({content: {parts: [{text: input}]}})
52
- res, span = execute(request: req, operation: "embeddings", model:)
52
+ res, span, tracer = execute(request: req, operation: "embeddings", model:)
53
53
  res = ResponseAdapter.adapt(res, type: :embedding)
54
- finish_trace(operation: "embeddings", model:, res:, span:)
54
+ tracer.on_request_finish(operation: "embeddings", model:, res:, span:)
55
+ res
55
56
  end
56
57
 
57
58
  ##
@@ -67,10 +68,11 @@ module LLM
67
68
  def complete(prompt, params = {})
68
69
  params, stream, tools, role, model = normalize_complete_params(params)
69
70
  req = build_complete_request(prompt, params, role, model, stream)
70
- res, span = execute(request: req, stream: stream, operation: "chat", model:)
71
+ res, span, tracer = execute(request: req, stream: stream, operation: "chat", model:)
71
72
  res = ResponseAdapter.adapt(res, type: :completion)
72
73
  .extend(Module.new { define_method(:__tools__) { tools } })
73
- finish_trace(operation: "chat", model:, res:, span:)
74
+ tracer.on_request_finish(operation: "chat", model:, res:, span:)
75
+ res
74
76
  end
75
77
 
76
78
  ##
@@ -167,9 +169,11 @@ module LLM
167
169
  private
168
170
 
169
171
  def headers
170
- (@headers || {}).merge(
171
- "Content-Type" => "application/json"
172
- )
172
+ lock do
173
+ (@headers || {}).merge(
174
+ "Content-Type" => "application/json"
175
+ )
176
+ end
173
177
  end
174
178
 
175
179
  def stream_parser
@@ -43,14 +43,15 @@ class LLM::Ollama
43
43
  def all(**params)
44
44
  query = URI.encode_www_form(params)
45
45
  req = Net::HTTP::Get.new("/api/tags?#{query}", headers)
46
- res, span = execute(request: req, operation: "request")
46
+ res, span, tracer = execute(request: req, operation: "request")
47
47
  res = LLM::Response.new(res)
48
- finish_trace(operation: "request", res:, span:)
48
+ tracer.on_request_finish(operation: "request", res:, span:)
49
+ res
49
50
  end
50
51
 
51
52
  private
52
53
 
53
- [:headers, :execute, :finish_trace].each do |m|
54
+ [:headers, :execute].each do |m|
54
55
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
55
56
  end
56
57
  end
@@ -43,9 +43,10 @@ module LLM
43
43
  params = {model:}.merge!(params)
44
44
  req = Net::HTTP::Post.new("/v1/embeddings", headers)
45
45
  req.body = LLM.json.dump({input:}.merge!(params))
46
- res, span = execute(request: req, operation: "embeddings", model:)
46
+ res, span, tracer = execute(request: req, operation: "embeddings", model:)
47
47
  res = ResponseAdapter.adapt(res, type: :embedding)
48
- finish_trace(operation: "embeddings", model:, res:, span:)
48
+ tracer.on_request_finish(operation: "embeddings", model:, res:, span:)
49
+ res
49
50
  end
50
51
 
51
52
  ##
@@ -61,10 +62,11 @@ module LLM
61
62
  def complete(prompt, params = {})
62
63
  params, stream, tools, role = normalize_complete_params(params)
63
64
  req = build_complete_request(prompt, params, role)
64
- res, span = execute(request: req, stream: stream, operation: "chat", model: params[:model])
65
+ res, span, tracer = execute(request: req, stream: stream, operation: "chat", model: params[:model])
65
66
  res = ResponseAdapter.adapt(res, type: :completion)
66
67
  .extend(Module.new { define_method(:__tools__) { tools } })
67
- finish_trace(operation: "chat", model: params[:model], res:, span:)
68
+ tracer.on_request_finish(operation: "chat", model: params[:model], res:, span:)
69
+ res
68
70
  end
69
71
 
70
72
  ##
@@ -92,10 +94,12 @@ module LLM
92
94
  private
93
95
 
94
96
  def headers
95
- (@headers || {}).merge(
96
- "Content-Type" => "application/json",
97
- "Authorization" => "Bearer #{@key}"
98
- )
97
+ lock do
98
+ (@headers || {}).merge(
99
+ "Content-Type" => "application/json",
100
+ "Authorization" => "Bearer #{@key}"
101
+ )
102
+ end
99
103
  end
100
104
 
101
105
  def stream_parser
@@ -35,9 +35,10 @@ class LLM::OpenAI
35
35
  req = Net::HTTP::Post.new("/v1/audio/speech", headers)
36
36
  req.body = LLM.json.dump({input:, voice:, model:, response_format:}.merge!(params))
37
37
  io = StringIO.new("".b)
38
- res, span = execute(request: req, operation: "request") { _1.read_body { |chunk| io << chunk } }
38
+ res, span, tracer = execute(request: req, operation: "request") { _1.read_body { |chunk| io << chunk } }
39
39
  res = LLM::Response.new(res).tap { _1.define_singleton_method(:audio) { io } }
40
- finish_trace(operation: "request", model:, res:, span:)
40
+ tracer.on_request_finish(operation: "request", model:, res:, span:)
41
+ res
41
42
  end
42
43
 
43
44
  ##
@@ -57,9 +58,10 @@ class LLM::OpenAI
57
58
  req = Net::HTTP::Post.new("/v1/audio/transcriptions", headers)
58
59
  req["content-type"] = multi.content_type
59
60
  set_body_stream(req, multi.body)
60
- res, span = execute(request: req, operation: "request")
61
+ res, span, tracer = execute(request: req, operation: "request")
61
62
  res = LLM::Response.new(res)
62
- finish_trace(operation: "request", model:, res:, span:)
63
+ tracer.on_request_finish(operation: "request", model:, res:, span:)
64
+ res
63
65
  end
64
66
 
65
67
  ##
@@ -80,14 +82,15 @@ class LLM::OpenAI
80
82
  req = Net::HTTP::Post.new("/v1/audio/translations", headers)
81
83
  req["content-type"] = multi.content_type
82
84
  set_body_stream(req, multi.body)
83
- res, span = execute(request: req, operation: "request")
85
+ res, span, tracer = execute(request: req, operation: "request")
84
86
  res = LLM::Response.new(res)
85
- finish_trace(operation: "request", model:, res:, span:)
87
+ tracer.on_request_finish(operation: "request", model:, res:, span:)
88
+ res
86
89
  end
87
90
 
88
91
  private
89
92
 
90
- [:headers, :execute, :set_body_stream, :finish_trace].each do |m|
93
+ [:headers, :execute, :set_body_stream].each do |m|
91
94
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
92
95
  end
93
96
  end
@@ -41,9 +41,10 @@ class LLM::OpenAI
41
41
  def all(**params)
42
42
  query = URI.encode_www_form(params)
43
43
  req = Net::HTTP::Get.new("/v1/files?#{query}", headers)
44
- res, span = execute(request: req, operation: "request")
44
+ res, span, tracer = execute(request: req, operation: "request")
45
45
  res = ResponseAdapter.adapt(res, type: :enumerable)
46
- finish_trace(operation: "request", res:, span:)
46
+ tracer.on_request_finish(operation: "request", res:, span:)
47
+ res
47
48
  end
48
49
 
49
50
  ##
@@ -62,9 +63,10 @@ class LLM::OpenAI
62
63
  req = Net::HTTP::Post.new("/v1/files", headers)
63
64
  req["content-type"] = multi.content_type
64
65
  set_body_stream(req, multi.body)
65
- res, span = execute(request: req, operation: "request")
66
+ res, span, tracer = execute(request: req, operation: "request")
66
67
  res = ResponseAdapter.adapt(res, type: :file)
67
- finish_trace(operation: "request", res:, span:)
68
+ tracer.on_request_finish(operation: "request", res:, span:)
69
+ res
68
70
  end
69
71
 
70
72
  ##
@@ -82,9 +84,10 @@ class LLM::OpenAI
82
84
  file_id = file.respond_to?(:id) ? file.id : file
83
85
  query = URI.encode_www_form(params)
84
86
  req = Net::HTTP::Get.new("/v1/files/#{file_id}?#{query}", headers)
85
- res, span = execute(request: req, operation: "request")
87
+ res, span, tracer = execute(request: req, operation: "request")
86
88
  res = ResponseAdapter.adapt(res, type: :file)
87
- finish_trace(operation: "request", res:, span:)
89
+ tracer.on_request_finish(operation: "request", res:, span:)
90
+ res
88
91
  end
89
92
 
90
93
  ##
@@ -104,9 +107,10 @@ class LLM::OpenAI
104
107
  file_id = file.respond_to?(:id) ? file.id : file
105
108
  req = Net::HTTP::Get.new("/v1/files/#{file_id}/content?#{query}", headers)
106
109
  io = StringIO.new("".b)
107
- res, span = execute(request: req, operation: "request") { |res| res.read_body { |chunk| io << chunk } }
110
+ res, span, tracer = execute(request: req, operation: "request") { |res| res.read_body { |chunk| io << chunk } }
108
111
  res = LLM::Response.new(res).tap { _1.define_singleton_method(:file) { io } }
109
- finish_trace(operation: "request", res:, span:)
112
+ tracer.on_request_finish(operation: "request", res:, span:)
113
+ res
110
114
  end
111
115
 
112
116
  ##
@@ -122,14 +126,15 @@ class LLM::OpenAI
122
126
  def delete(file:)
123
127
  file_id = file.respond_to?(:id) ? file.id : file
124
128
  req = Net::HTTP::Delete.new("/v1/files/#{file_id}", headers)
125
- res, span = execute(request: req, operation: "request")
129
+ res, span, tracer = execute(request: req, operation: "request")
126
130
  res = LLM::Response.new(res)
127
- finish_trace(operation: "request", res:, span:)
131
+ tracer.on_request_finish(operation: "request", res:, span:)
132
+ res
128
133
  end
129
134
 
130
135
  private
131
136
 
132
- [:headers, :execute, :set_body_stream, :finish_trace].each do |m|
137
+ [:headers, :execute, :set_body_stream].each do |m|
133
138
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
134
139
  end
135
140
  end
@@ -50,9 +50,10 @@ class LLM::OpenAI
50
50
  def create(prompt:, model: "dall-e-3", **params)
51
51
  req = Net::HTTP::Post.new("/v1/images/generations", headers)
52
52
  req.body = LLM.json.dump({prompt:, n: 1, model:}.merge!(params))
53
- res, span = execute(request: req, operation: "request")
53
+ res, span, tracer = execute(request: req, operation: "request")
54
54
  res = ResponseAdapter.adapt(res, type: :image)
55
- finish_trace(operation: "request", model:, res:, span:)
55
+ tracer.on_request_finish(operation: "request", model:, res:, span:)
56
+ res
56
57
  end
57
58
 
58
59
  ##
@@ -73,9 +74,10 @@ class LLM::OpenAI
73
74
  req = Net::HTTP::Post.new("/v1/images/variations", headers)
74
75
  req["content-type"] = multi.content_type
75
76
  set_body_stream(req, multi.body)
76
- res, span = execute(request: req, operation: "request")
77
+ res, span, tracer = execute(request: req, operation: "request")
77
78
  res = ResponseAdapter.adapt(res, type: :image)
78
- finish_trace(operation: "request", model:, res:, span:)
79
+ tracer.on_request_finish(operation: "request", model:, res:, span:)
80
+ res
79
81
  end
80
82
 
81
83
  ##
@@ -97,14 +99,15 @@ class LLM::OpenAI
97
99
  req = Net::HTTP::Post.new("/v1/images/edits", headers)
98
100
  req["content-type"] = multi.content_type
99
101
  set_body_stream(req, multi.body)
100
- res, span = execute(request: req, operation: "request")
102
+ res, span, tracer = execute(request: req, operation: "request")
101
103
  res = ResponseAdapter.adapt(res, type: :image)
102
- finish_trace(operation: "request", model:, res:, span:)
104
+ tracer.on_request_finish(operation: "request", model:, res:, span:)
105
+ res
103
106
  end
104
107
 
105
108
  private
106
109
 
107
- [:headers, :execute, :set_body_stream, :finish_trace].each do |m|
110
+ [:headers, :execute, :set_body_stream].each do |m|
108
111
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
109
112
  end
110
113
  end
@@ -40,14 +40,15 @@ class LLM::OpenAI
40
40
  def all(**params)
41
41
  query = URI.encode_www_form(params)
42
42
  req = Net::HTTP::Get.new("/v1/models?#{query}", headers)
43
- res, span = execute(request: req, operation: "request")
43
+ res, span, tracer = execute(request: req, operation: "request")
44
44
  res = ResponseAdapter.adapt(res, type: :enumerable)
45
- finish_trace(operation: "request", res:, span:)
45
+ tracer.on_request_finish(operation: "request", res:, span:)
46
+ res
46
47
  end
47
48
 
48
49
  private
49
50
 
50
- [:headers, :execute, :set_body_stream, :finish_trace].each do |m|
51
+ [:headers, :execute, :set_body_stream].each do |m|
51
52
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
52
53
  end
53
54
  end
@@ -50,14 +50,15 @@ class LLM::OpenAI
50
50
  req = Net::HTTP::Post.new("/v1/moderations", headers)
51
51
  input = RequestAdapter::Moderation.new(input).adapt
52
52
  req.body = LLM.json.dump({input:, model:}.merge!(params))
53
- res, span = execute(request: req, operation: "request")
53
+ res, span, tracer = execute(request: req, operation: "request")
54
54
  res = ResponseAdapter.adapt(res, type: :moderations)
55
- finish_trace(operation: "request", model:, res:, span:)
55
+ tracer.on_request_finish(operation: "request", model:, res:, span:)
56
+ res
56
57
  end
57
58
 
58
59
  private
59
60
 
60
- [:headers, :execute, :finish_trace].each do |m|
61
+ [:headers, :execute].each do |m|
61
62
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
62
63
  end
63
64
  end
@@ -44,10 +44,11 @@ class LLM::OpenAI
44
44
  messages = [*(params.delete(:input) || []), LLM::Message.new(role, prompt)]
45
45
  body = LLM.json.dump({input: [adapt(messages, mode: :response)].flatten}.merge!(params))
46
46
  set_body_stream(req, StringIO.new(body))
47
- res, span = execute(request: req, stream:, stream_parser:, operation: "chat", model: params[:model])
47
+ res, span, tracer = execute(request: req, stream:, stream_parser:, operation: "chat", model: params[:model])
48
48
  res = ResponseAdapter.adapt(res, type: :responds)
49
49
  .extend(Module.new { define_method(:__tools__) { tools } })
50
- finish_trace(operation: "chat", model: params[:model], res:, span:)
50
+ tracer.on_request_finish(operation: "chat", model: params[:model], res:, span:)
51
+ res
51
52
  end
52
53
 
53
54
  ##
@@ -60,9 +61,10 @@ class LLM::OpenAI
60
61
  response_id = response.respond_to?(:id) ? response.id : response
61
62
  query = URI.encode_www_form(params)
62
63
  req = Net::HTTP::Get.new("/v1/responses/#{response_id}?#{query}", headers)
63
- res, span = execute(request: req, operation: "request")
64
+ res, span, tracer = execute(request: req, operation: "request")
64
65
  res = ResponseAdapter.adapt(res, type: :responds)
65
- finish_trace(operation: "request", res:, span:)
66
+ tracer.on_request_finish(operation: "request", res:, span:)
67
+ res
66
68
  end
67
69
 
68
70
  ##
@@ -74,14 +76,15 @@ class LLM::OpenAI
74
76
  def delete(response)
75
77
  response_id = response.respond_to?(:id) ? response.id : response
76
78
  req = Net::HTTP::Delete.new("/v1/responses/#{response_id}", headers)
77
- res, span = execute(request: req, operation: "request")
79
+ res, span, tracer = execute(request: req, operation: "request")
78
80
  res = LLM::Response.new(res)
79
- finish_trace(operation: "request", res:, span:)
81
+ tracer.on_request_finish(operation: "request", res:, span:)
82
+ res
80
83
  end
81
84
 
82
85
  private
83
86
 
84
- [:headers, :execute, :set_body_stream, :resolve_tools, :finish_trace].each do |m|
87
+ [:headers, :execute, :set_body_stream, :resolve_tools].each do |m|
85
88
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
86
89
  end
87
90
 
@@ -32,9 +32,10 @@ class LLM::OpenAI
32
32
  def all(**params)
33
33
  query = URI.encode_www_form(params)
34
34
  req = Net::HTTP::Get.new("/v1/vector_stores?#{query}", headers)
35
- res, span = execute(request: req, operation: "request")
35
+ res, span, tracer = execute(request: req, operation: "request")
36
36
  res = ResponseAdapter.adapt(res, type: :enumerable)
37
- finish_trace(operation: "request", res:, span:)
37
+ tracer.on_request_finish(operation: "request", res:, span:)
38
+ res
38
39
  end
39
40
 
40
41
  ##
@@ -48,9 +49,10 @@ class LLM::OpenAI
48
49
  def create(name:, file_ids: nil, **params)
49
50
  req = Net::HTTP::Post.new("/v1/vector_stores", headers)
50
51
  req.body = LLM.json.dump(params.merge({name:, file_ids:}).compact)
51
- res, span = execute(request: req, operation: "request")
52
+ res, span, tracer = execute(request: req, operation: "request")
52
53
  res = LLM::Response.new(res)
53
- finish_trace(operation: "request", res:, span:)
54
+ tracer.on_request_finish(operation: "request", res:, span:)
55
+ res
54
56
  end
55
57
 
56
58
  ##
@@ -71,9 +73,10 @@ class LLM::OpenAI
71
73
  def get(vector:)
72
74
  vector_id = vector.respond_to?(:id) ? vector.id : vector
73
75
  req = Net::HTTP::Get.new("/v1/vector_stores/#{vector_id}", headers)
74
- res, span = execute(request: req, operation: "request")
76
+ res, span, tracer = execute(request: req, operation: "request")
75
77
  res = LLM::Response.new(res)
76
- finish_trace(operation: "request", res:, span:)
78
+ tracer.on_request_finish(operation: "request", res:, span:)
79
+ res
77
80
  end
78
81
 
79
82
  ##
@@ -88,9 +91,10 @@ class LLM::OpenAI
88
91
  vector_id = vector.respond_to?(:id) ? vector.id : vector
89
92
  req = Net::HTTP::Post.new("/v1/vector_stores/#{vector_id}", headers)
90
93
  req.body = LLM.json.dump(params.merge({name:}).compact)
91
- res, span = execute(request: req, operation: "request")
94
+ res, span, tracer = execute(request: req, operation: "request")
92
95
  res = LLM::Response.new(res)
93
- finish_trace(operation: "request", res:, span:)
96
+ tracer.on_request_finish(operation: "request", res:, span:)
97
+ res
94
98
  end
95
99
 
96
100
  ##
@@ -102,9 +106,10 @@ class LLM::OpenAI
102
106
  def delete(vector:)
103
107
  vector_id = vector.respond_to?(:id) ? vector.id : vector
104
108
  req = Net::HTTP::Delete.new("/v1/vector_stores/#{vector_id}", headers)
105
- res, span = execute(request: req, operation: "request")
109
+ res, span, tracer = execute(request: req, operation: "request")
106
110
  res = LLM::Response.new(res)
107
- finish_trace(operation: "request", res:, span:)
111
+ tracer.on_request_finish(operation: "request", res:, span:)
112
+ res
108
113
  end
109
114
 
110
115
  ##
@@ -119,9 +124,10 @@ class LLM::OpenAI
119
124
  vector_id = vector.respond_to?(:id) ? vector.id : vector
120
125
  req = Net::HTTP::Post.new("/v1/vector_stores/#{vector_id}/search", headers)
121
126
  req.body = LLM.json.dump(params.merge({query:}).compact)
122
- res, span = execute(request: req, operation: "retrieval")
127
+ res, span, tracer = execute(request: req, operation: "retrieval")
123
128
  res = ResponseAdapter.adapt(res, type: :enumerable)
124
- finish_trace(operation: "retrieval", res:, span:)
129
+ tracer.on_request_finish(operation: "retrieval", res:, span:)
130
+ res
125
131
  end
126
132
 
127
133
  ##
@@ -135,9 +141,10 @@ class LLM::OpenAI
135
141
  vector_id = vector.respond_to?(:id) ? vector.id : vector
136
142
  query = URI.encode_www_form(params)
137
143
  req = Net::HTTP::Get.new("/v1/vector_stores/#{vector_id}/files?#{query}", headers)
138
- res, span = execute(request: req, operation: "request")
144
+ res, span, tracer = execute(request: req, operation: "request")
139
145
  res = ResponseAdapter.adapt(res, type: :enumerable)
140
- finish_trace(operation: "request", res:, span:)
146
+ tracer.on_request_finish(operation: "request", res:, span:)
147
+ res
141
148
  end
142
149
 
143
150
  ##
@@ -154,9 +161,10 @@ class LLM::OpenAI
154
161
  file_id = file.respond_to?(:id) ? file.id : file
155
162
  req = Net::HTTP::Post.new("/v1/vector_stores/#{vector_id}/files", headers)
156
163
  req.body = LLM.json.dump(params.merge({file_id:, attributes:}).compact)
157
- res, span = execute(request: req, operation: "request")
164
+ res, span, tracer = execute(request: req, operation: "request")
158
165
  res = LLM::Response.new(res)
159
- finish_trace(operation: "request", res:, span:)
166
+ tracer.on_request_finish(operation: "request", res:, span:)
167
+ res
160
168
  end
161
169
  alias_method :create_file, :add_file
162
170
 
@@ -184,9 +192,10 @@ class LLM::OpenAI
184
192
  file_id = file.respond_to?(:id) ? file.id : file
185
193
  req = Net::HTTP::Post.new("/v1/vector_stores/#{vector_id}/files/#{file_id}", headers)
186
194
  req.body = LLM.json.dump(params.merge({attributes:}).compact)
187
- res, span = execute(request: req, operation: "request")
195
+ res, span, tracer = execute(request: req, operation: "request")
188
196
  res = LLM::Response.new(res)
189
- finish_trace(operation: "request", res:, span:)
197
+ tracer.on_request_finish(operation: "request", res:, span:)
198
+ res
190
199
  end
191
200
 
192
201
  ##
@@ -201,9 +210,10 @@ class LLM::OpenAI
201
210
  file_id = file.respond_to?(:id) ? file.id : file
202
211
  query = URI.encode_www_form(params)
203
212
  req = Net::HTTP::Get.new("/v1/vector_stores/#{vector_id}/files/#{file_id}?#{query}", headers)
204
- res, span = execute(request: req, operation: "request")
213
+ res, span, tracer = execute(request: req, operation: "request")
205
214
  res = LLM::Response.new(res)
206
- finish_trace(operation: "request", res:, span:)
215
+ tracer.on_request_finish(operation: "request", res:, span:)
216
+ res
207
217
  end
208
218
 
209
219
  ##
@@ -217,9 +227,10 @@ class LLM::OpenAI
217
227
  vector_id = vector.respond_to?(:id) ? vector.id : vector
218
228
  file_id = file.respond_to?(:id) ? file.id : file
219
229
  req = Net::HTTP::Delete.new("/v1/vector_stores/#{vector_id}/files/#{file_id}", headers)
220
- res, span = execute(request: req, operation: "request")
230
+ res, span, tracer = execute(request: req, operation: "request")
221
231
  res = LLM::Response.new(res)
222
- finish_trace(operation: "request", res:, span:)
232
+ tracer.on_request_finish(operation: "request", res:, span:)
233
+ res
223
234
  end
224
235
 
225
236
  ##
@@ -248,7 +259,7 @@ class LLM::OpenAI
248
259
 
249
260
  private
250
261
 
251
- [:headers, :execute, :set_body_stream, :finish_trace].each do |m|
262
+ [:headers, :execute, :set_body_stream].each do |m|
252
263
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
253
264
  end
254
265
  end
@@ -47,9 +47,10 @@ module LLM
47
47
  def embed(input, model: "text-embedding-3-small", **params)
48
48
  req = Net::HTTP::Post.new("/v1/embeddings", headers)
49
49
  req.body = LLM.json.dump({input:, model:}.merge!(params))
50
- res, span = execute(request: req, operation: "embeddings", model:)
50
+ res, span, tracer = execute(request: req, operation: "embeddings", model:)
51
51
  res = ResponseAdapter.adapt(res, type: :embedding)
52
- finish_trace(operation: "embeddings", model:, res:, span:)
52
+ tracer.on_request_finish(operation: "embeddings", model:, res:, span:)
53
+ res
53
54
  end
54
55
 
55
56
  ##
@@ -65,10 +66,11 @@ module LLM
65
66
  def complete(prompt, params = {})
66
67
  params, stream, tools, role = normalize_complete_params(params)
67
68
  req = build_complete_request(prompt, params, role)
68
- res, span = execute(request: req, stream: stream, operation: "chat", model: params[:model])
69
+ res, span, tracer = execute(request: req, stream: stream, operation: "chat", model: params[:model])
69
70
  res = ResponseAdapter.adapt(res, type: :completion)
70
71
  .extend(Module.new { define_method(:__tools__) { tools } })
71
- finish_trace(operation: "chat", model: params[:model], res:, span:)
72
+ tracer.on_request_finish(operation: "chat", model: params[:model], res:, span:)
73
+ res
72
74
  end
73
75
 
74
76
  ##
@@ -181,10 +183,12 @@ module LLM
181
183
  end
182
184
 
183
185
  def headers
184
- (@headers || {}).merge(
185
- "Content-Type" => "application/json",
186
- "Authorization" => "Bearer #{@key}"
187
- )
186
+ lock do
187
+ (@headers || {}).merge(
188
+ "Content-Type" => "application/json",
189
+ "Authorization" => "Bearer #{@key}"
190
+ )
191
+ end
188
192
  end
189
193
 
190
194
  def stream_parser
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module LLM
4
6
  ##
5
7
  # The {LLM::Tracer::Telemetry LLM::Tracer::Telemetry} tracer provides
@@ -49,6 +51,42 @@ module LLM
49
51
  setup!
50
52
  end
51
53
 
54
+ ##
55
+ # When +trace_group_id+ is provided, it is converted to an OpenTelemetry
56
+ # trace_id (via a deterministic 16-byte hash) so all spans until {#stop_trace}
57
+ # share that trace_id and appear as one trace in OTLP/Langfuse.
58
+ #
59
+ # @param (see LLM::Tracer#start_trace)
60
+ # @return [self]
61
+ def start_trace(trace_group_id: nil, name: "llm", attributes: {})
62
+ return self if trace_group_id.to_s.empty?
63
+
64
+ span_context = span_context_from_trace_group_id(trace_group_id.to_s)
65
+ parent_ctx = ::OpenTelemetry::Trace.context_with_span(
66
+ ::OpenTelemetry::Trace.non_recording_span(span_context)
67
+ )
68
+ attrs = attributes.compact
69
+ attrs["llm.trace_group_id"] = trace_group_id.to_s
70
+ root_span = @tracer.start_span(
71
+ name,
72
+ kind: :server,
73
+ attributes: attrs,
74
+ with_parent: parent_ctx
75
+ )
76
+ thread[thread_root_span_key] = root_span
77
+ thread[thread_root_context_key] = ::OpenTelemetry::Trace.context_with_span(root_span)
78
+ self
79
+ end
80
+
81
+ ##
82
+ # @return [self]
83
+ def stop_trace
84
+ thread[thread_root_span_key]&.finish
85
+ thread[thread_root_span_key] = nil
86
+ thread[thread_root_context_key] = nil
87
+ self
88
+ end
89
+
52
90
  ##
53
91
  # @param (see LLM::Tracer#on_request_start)
54
92
  def on_request_start(operation:, model: nil)
@@ -96,7 +134,7 @@ module LLM
96
134
  "server.port" => provider_port
97
135
  }.compact
98
136
  span_name = ["execute_tool", name].compact.join(" ")
99
- span = @tracer.start_span(span_name.empty? ? "gen_ai.tool" : span_name, kind: :client, attributes:)
137
+ span = create_span(span_name.empty? ? "gen_ai.tool" : span_name, attributes:)
100
138
  span.add_event("gen_ai.tool.start")
101
139
  span
102
140
  end
@@ -155,6 +193,49 @@ module LLM
155
193
 
156
194
  private
157
195
 
196
+ ##
197
+ # @api private
198
+ def create_span(name, kind: :client, attributes: {})
199
+ root_context = thread[thread_root_context_key]
200
+ opts = {kind:, attributes:}
201
+ opts[:with_parent] = root_context if root_context
202
+ @tracer.start_span(name, **opts)
203
+ end
204
+
205
+ ##
206
+ # @api private
207
+ def thread_root_span_key
208
+ @thread_root_span_key ||= :"llm.telemetry.root_span.#{object_id}"
209
+ end
210
+
211
+ ##
212
+ # @api private
213
+ def thread_root_context_key
214
+ @thread_root_context_key ||= :"llm.telemetry.root_context.#{object_id}"
215
+ end
216
+
217
+ ##
218
+ # @api private
219
+ def thread
220
+ Thread.current
221
+ end
222
+
223
+ ##
224
+ # Converts a string trace_group_id to an OpenTelemetry SpanContext so all
225
+ # spans created with this context share the same trace_id.
226
+ # @api private
227
+ def span_context_from_trace_group_id(trace_group_id)
228
+ trace_id = Digest::MD5.digest(trace_group_id)
229
+ trace_id = ::OpenTelemetry::Trace.generate_trace_id if trace_id == ::OpenTelemetry::Trace::INVALID_TRACE_ID
230
+ span_id = Digest::SHA256.digest(trace_group_id)[0, 8]
231
+ span_id = ::OpenTelemetry::Trace.generate_span_id if span_id == ::OpenTelemetry::Trace::INVALID_SPAN_ID
232
+ ::OpenTelemetry::Trace::SpanContext.new(
233
+ trace_id:,
234
+ span_id:,
235
+ trace_flags: ::OpenTelemetry::Trace::TraceFlags::SAMPLED
236
+ )
237
+ end
238
+
158
239
  ##
159
240
  # @api private
160
241
  def setup!
@@ -209,7 +290,7 @@ module LLM
209
290
  "server.port" => provider_port
210
291
  }.compact
211
292
  span_name = [operation, model].compact.join(" ")
212
- span = @tracer.start_span(span_name.empty? ? "gen_ai.request" : span_name, kind: :client, attributes:)
293
+ span = create_span(span_name.empty? ? "gen_ai.request" : span_name, attributes:)
213
294
  span.add_event("gen_ai.request.start")
214
295
  span
215
296
  end
@@ -221,7 +302,7 @@ module LLM
221
302
  "server.address" => provider_host,
222
303
  "server.port" => provider_port
223
304
  }.compact
224
- span = @tracer.start_span(operation, kind: :client, attributes:)
305
+ span = create_span(operation, attributes:)
225
306
  span.add_event("gen_ai.request.start")
226
307
  span
227
308
  end
data/lib/llm/tracer.rb CHANGED
@@ -89,6 +89,31 @@ module LLM
89
89
  raise NotImplementedError, "#{self.class} does not implement '#{__method__}'"
90
90
  end
91
91
 
92
+ ##
93
+ # Opens a trace group so subsequent LLM spans share the same OpenTelemetry
94
+ # trace_id (and appear as one trace in backends like Langfuse).
95
+ # When +trace_group_id+ is a string, it is used to derive the trace_id.
96
+ #
97
+ # @param [String, nil] trace_group_id
98
+ # Optional. When present, converted to a 16-byte trace_id so all spans
99
+ # created until {#stop_trace} are grouped in one trace.
100
+ # @param [String] name
101
+ # Name for the root span (e.g. "chatbot.turn").
102
+ # @param [Hash] attributes
103
+ # OpenTelemetry attributes to set on the root span.
104
+ # @return [self]
105
+ def start_trace(trace_group_id: nil, name: "llm", attributes: {})
106
+ self
107
+ end
108
+
109
+ ##
110
+ # Finishes the trace group started by {#start_trace}. Safe to call even if
111
+ # no trace is active.
112
+ # @return [self]
113
+ def stop_trace
114
+ self
115
+ end
116
+
92
117
  ##
93
118
  # @return [String]
94
119
  def inspect
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.3.1"
4
+ VERSION = "4.5.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm.rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.3.1
4
+ version: 4.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antar Azri