llm.rb 15.0.3 → 15.2.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +433 -3
  3. data/README.md +188 -71
  4. data/bin/llm.rb +50 -7
  5. data/data/alibaba.json +912 -823
  6. data/data/anthropic.json +234 -187
  7. data/data/bedrock.json +3702 -2058
  8. data/data/deepinfra.json +1288 -951
  9. data/data/deepseek.json +87 -53
  10. data/data/google.json +670 -670
  11. data/data/mistral.json +501 -460
  12. data/data/moonshot.json +43 -248
  13. data/data/openai.json +1008 -914
  14. data/data/openrouter.json +14417 -0
  15. data/data/xai.json +213 -201
  16. data/data/zai.json +242 -149
  17. data/docs/deepdive/advanced/compaction.md +5 -5
  18. data/docs/deepdive/advanced/context.md +8 -6
  19. data/docs/deepdive/advanced/guard.md +2 -2
  20. data/docs/deepdive/features/builtin_tools.md +93 -22
  21. data/docs/deepdive/features/{repl.md → console.md} +28 -28
  22. data/docs/deepdive/features/database.md +3 -3
  23. data/docs/deepdive/fundamentals/agents.md +13 -12
  24. data/docs/deepdive/fundamentals/providers.md +91 -6
  25. data/docs/deepdive/fundamentals/skills.md +14 -6
  26. data/docs/deepdive/fundamentals/stream.md +4 -4
  27. data/docs/deepdive/fundamentals/tools.md +63 -31
  28. data/docs/deepdive/reference/cost.md +2 -2
  29. data/docs/deepdive/reference/model_registry.md +2 -2
  30. data/docs/deepdive/reference/tracer.md +15 -13
  31. data/docs/deepdive.md +2 -2
  32. data/lib/llm/active_record/acts_as_agent.rb +9 -5
  33. data/lib/llm/agent.rb +40 -15
  34. data/lib/llm/{repl → console}/bar.rb +3 -3
  35. data/lib/llm/{repl → console}/buffer.rb +24 -9
  36. data/lib/llm/{repl → console}/color.rb +2 -2
  37. data/lib/llm/{repl → console}/command.rb +12 -12
  38. data/lib/llm/{repl → console}/commands/exit.rb +4 -4
  39. data/lib/llm/{repl → console}/commands/help.rb +1 -1
  40. data/lib/llm/{repl/commands/compact.rb → console/commands/keep.rb} +11 -9
  41. data/lib/llm/{repl → console}/commands/model.rb +2 -2
  42. data/lib/llm/{repl → console}/input/cache.rb +2 -2
  43. data/lib/llm/{repl → console}/input/char.rb +2 -2
  44. data/lib/llm/{repl → console}/input/row.rb +1 -1
  45. data/lib/llm/{repl → console}/input.rb +18 -10
  46. data/lib/llm/console/markdown/parser.rb +78 -0
  47. data/lib/llm/{repl → console}/markdown/table.rb +8 -5
  48. data/lib/llm/{repl → console}/markdown.rb +13 -30
  49. data/lib/llm/console/node.rb +69 -0
  50. data/lib/llm/{repl → console}/status.rb +11 -11
  51. data/lib/llm/{repl → console}/stream.rb +36 -9
  52. data/lib/llm/{repl → console}/walker.rb +1 -1
  53. data/lib/llm/{repl → console}/window.rb +17 -17
  54. data/lib/llm/{repl.rb → console.rb} +39 -19
  55. data/lib/llm/context/deserializer.rb +2 -1
  56. data/lib/llm/context.rb +29 -13
  57. data/lib/llm/cost.rb +13 -0
  58. data/lib/llm/function/async/reactor.rb +20 -1
  59. data/lib/llm/function/fork/task.rb +14 -10
  60. data/lib/llm/function.rb +1 -1
  61. data/lib/llm/json_adapter.rb +40 -28
  62. data/lib/llm/message.rb +7 -0
  63. data/lib/llm/provider.rb +31 -10
  64. data/lib/llm/providers/alibaba.rb +1 -1
  65. data/lib/llm/providers/anthropic.rb +1 -1
  66. data/lib/llm/providers/bedrock/models.rb +2 -2
  67. data/lib/llm/providers/bedrock.rb +1 -1
  68. data/lib/llm/providers/deepseek.rb +1 -1
  69. data/lib/llm/providers/google.rb +1 -1
  70. data/lib/llm/providers/ollama.rb +1 -1
  71. data/lib/llm/providers/openai/responses.rb +2 -1
  72. data/lib/llm/providers/openai.rb +2 -1
  73. data/lib/llm/providers/openrouter.rb +87 -0
  74. data/lib/llm/schema/leaf.rb +34 -2
  75. data/lib/llm/schema.rb +4 -2
  76. data/lib/llm/sequel/agent.rb +9 -5
  77. data/lib/llm/skill.rb +7 -1
  78. data/lib/llm/stream.rb +8 -3
  79. data/lib/llm/tool/param.rb +5 -1
  80. data/lib/llm/tool.rb +5 -0
  81. data/lib/llm/tools/bundle.rb +53 -0
  82. data/lib/llm/tools/edit-file.rb +7 -2
  83. data/lib/llm/tools/exec.rb +78 -0
  84. data/lib/llm/tools/git.rb +27 -26
  85. data/lib/llm/tools/mkdir.rb +12 -19
  86. data/lib/llm/tools/read_file.rb +69 -9
  87. data/lib/llm/tools/rg.rb +20 -24
  88. data/lib/llm/tools/ruby.rb +17 -25
  89. data/lib/llm/tools/utils.rb +75 -2
  90. data/lib/llm/tools/write_file.rb +4 -1
  91. data/lib/llm/tracer/logger.rb +2 -2
  92. data/lib/llm/tracer/pretty_logger.rb +4 -4
  93. data/lib/llm/tracer/telemetry.rb +2 -2
  94. data/lib/llm/tracer.rb +33 -0
  95. data/lib/llm/transport/curb.rb +5 -3
  96. data/lib/llm/transport/http.rb +5 -2
  97. data/lib/llm/transport/persistent_http.rb +6 -4
  98. data/lib/llm/transport/utils.rb +8 -6
  99. data/lib/llm/version.rb +1 -1
  100. data/lib/llm.rb +18 -12
  101. data/llm.gemspec +8 -8
  102. metadata +80 -37
  103. data/lib/llm/repl/node.rb +0 -44
  104. data/lib/llm/tools/shell.rb +0 -55
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Repl
3
+ class LLM::Console
4
4
  ##
5
- # The {LLM::Repl::Window LLM::Repl::Window} class draws the
5
+ # The {LLM::Console::Window LLM::Console::Window} class draws the
6
6
  # curses screen for the REPL.
7
7
  # @api private
8
8
  class Window
@@ -12,21 +12,21 @@ class LLM::Repl
12
12
  TOP_ROWS = 1
13
13
 
14
14
  ##
15
- # @return [LLM::Repl::Status]
15
+ # @return [LLM::Console::Status]
16
16
  attr_reader :status
17
17
 
18
18
  ##
19
- # @return [LLM::Repl::Buffer]
19
+ # @return [LLM::Console::Buffer]
20
20
  attr_reader :buffer
21
21
 
22
22
  ##
23
- # @return [LLM::Repl::Input]
23
+ # @return [LLM::Console::Input]
24
24
  attr_reader :input
25
25
 
26
26
  ##
27
- # @param [LLM::Repl] repl
27
+ # @param [LLM::Console] console
28
28
  # A read-eval-print loop.
29
- # @return [LLM::Repl::Window]
29
+ # @return [LLM::Console::Window]
30
30
  def initialize(repl)
31
31
  @repl = repl
32
32
  @status = repl.status
@@ -140,11 +140,11 @@ class LLM::Repl
140
140
  remaining = buffer.width - width
141
141
  break if remaining <= 0
142
142
  text, attrs = chunk.text.to_s, chunk.attrs
143
- clipped = text[0, remaining]
143
+ clipped = Node.slice(text, remaining)
144
144
  Curses.attron(attrs) if attrs
145
145
  Curses.addstr(clipped)
146
146
  Curses.attroff(attrs) if attrs
147
- width += clipped.length
147
+ width += Node.width(clipped)
148
148
  end
149
149
  end
150
150
  last_drawn = offset + rows.size
@@ -163,10 +163,10 @@ class LLM::Repl
163
163
  Curses.clrtoeol
164
164
  status.nodes.each { addnode(_1) }
165
165
  context = status.context_bar
166
- Curses.setpos(y, [(columns - context.length) / 2, 0].max)
166
+ Curses.setpos(y, [(columns - Node.width(context)) / 2, 0].max)
167
167
  Curses.addstr(context)
168
168
  cost = status.cost.to_s
169
- Curses.setpos(y, [columns - cost.length, 0].max)
169
+ Curses.setpos(y, [columns - Node.width(cost), 0].max)
170
170
  Curses.addstr(cost)
171
171
  end
172
172
 
@@ -178,9 +178,9 @@ class LLM::Repl
178
178
  def draw_meta(offset:)
179
179
  y = Curses.lines - offset
180
180
  fill_row(y) do
181
- addnode(status.cwd, status.cwd.text[0, columns])
182
- Curses.setpos(y, [columns - status.model.text.size, 0].max)
183
- addnode(status.model, status.model.text[0, columns])
181
+ addnode(status.cwd, Node.slice(status.cwd.text, columns))
182
+ Curses.setpos(y, [columns - status.model.size, 0].max)
183
+ addnode(status.model, Node.slice(status.model.text, columns))
184
184
  end
185
185
  end
186
186
 
@@ -239,15 +239,15 @@ class LLM::Repl
239
239
 
240
240
  ##
241
241
  # 20% offset that occupies the left margin and
242
- # helps center {LLM::Repl::Buffer LLM::Repl::Buffer}.
242
+ # helps center {LLM::Console::Buffer LLM::Console::Buffer}.
243
243
  # @return [Integer]
244
244
  def gutter
245
245
  (columns * 0.2).floor
246
246
  end
247
247
 
248
248
  ##
249
- # Adds a {LLM::Repl::Node} to the Curses window.
250
- # @param [LLM::Repl::Node] node
249
+ # Adds a {LLM::Console::Node} to the Curses window.
250
+ # @param [LLM::Console::Node] node
251
251
  # @param [String] text
252
252
  # @return [void]
253
253
  def addnode(node, text = node.text)
@@ -2,8 +2,8 @@
2
2
 
3
3
  module LLM
4
4
  ##
5
- # The {LLM::Repl LLM::Repl} class provides a small
6
- # read-eval-print loop around an instance of
5
+ # The {LLM::Console LLM::Console} class provides a small
6
+ # interactive console around an instance of
7
7
  # {LLM::Agent LLM::Agent}.
8
8
  #
9
9
  # It can be used to keep talking to an agent after it
@@ -11,21 +11,22 @@ module LLM
11
11
  # useful when you want to confirm the agent handled the
12
12
  # task correctly, or for it to correct course after a
13
13
  # mistake was made.
14
- class Repl
14
+ class Console
15
15
  LLM.require "curses", "~> 1.6"
16
16
  LLM.require "kramdown", "~> 2.5"
17
+ LLM.require "unicode/display_width", "~> 3.2"
17
18
 
18
- require_relative "repl/color"
19
- require_relative "repl/window"
20
- require_relative "repl/status"
21
- require_relative "repl/buffer"
22
- require_relative "repl/input"
23
- require_relative "repl/bar"
24
- require_relative "repl/stream"
25
- require_relative "repl/node"
26
- require_relative "repl/markdown"
27
- require_relative "repl/command"
28
- require_relative "repl/walker"
19
+ require_relative "console/color"
20
+ require_relative "console/window"
21
+ require_relative "console/status"
22
+ require_relative "console/buffer"
23
+ require_relative "console/input"
24
+ require_relative "console/bar"
25
+ require_relative "console/stream"
26
+ require_relative "console/node"
27
+ require_relative "console/markdown"
28
+ require_relative "console/command"
29
+ require_relative "console/walker"
29
30
 
30
31
  attr_reader :agent, :provider, :stream,
31
32
  :status, :buffer, :input,
@@ -42,7 +43,7 @@ module LLM
42
43
  # Zero or more tools
43
44
  # @param [Array<String>] skills
44
45
  # Zero or more skills
45
- # @return [LLM::Repl]
46
+ # @return [LLM::Console]
46
47
  def initialize(agent:, name: nil, tools: [], skills: [], path: nil)
47
48
  @path = path
48
49
  @name = name || "agent"
@@ -114,7 +115,7 @@ module LLM
114
115
  # @param [String] chars
115
116
  # @return [Array<Node>]
116
117
  def markdown(chars)
117
- LLM::Repl::Markdown.new(chars, buffer.width).ast
118
+ LLM::Console::Markdown.new(chars, buffer.width).ast
118
119
  end
119
120
 
120
121
  ##
@@ -216,9 +217,15 @@ module LLM
216
217
  write_message(sender, markdown(text))
217
218
  @thread = Thread.new do
218
219
  @queue << [:start]
219
- agent.talk(text, model:, tools:, stream:)
220
- agent.save(path:) if save?
221
- @queue << [:done]
220
+ res = agent.talk(text, model:, tools:, stream:)
221
+ @queue << [:done, res.content]
222
+ ##
223
+ # When LLM::Interrupt is raised
224
+ # on this thread enqueue the raise
225
+ # so that the file write is protected
226
+ # from an immediate cancel.
227
+ int = {LLM::Interrupt => :never}
228
+ Thread.handle_interrupt(int) { agent.save(path:) if save? }
222
229
  rescue LLM::Interrupt => e
223
230
  @queue << [:cancel, e]
224
231
  rescue => e
@@ -264,6 +271,7 @@ module LLM
264
271
  # to by a subclass of {LLM::Stream LLM::Stream}.
265
272
  # @api private
266
273
  def read!
274
+ burst, max_burst = 0, 4
267
275
  loop do
268
276
  type, value = @queue.pop(true)
269
277
  case type
@@ -271,12 +279,20 @@ module LLM
271
279
  buffer.open
272
280
  stream.clear
273
281
  when :stream
282
+ ##
283
+ # A fast model can push many small chunks faster than the
284
+ # UI can repaint. Cap the stream chunks drained per call so
285
+ # read! gives control back to the key loop: leftover chunks
286
+ # stay queued and are drained by the next read!.
274
287
  status.text = think_text if stream.tools.empty?
275
288
  write_message name, markdown(value), method: :replace
289
+ burst += 1
290
+ break if burst >= max_burst
276
291
  when :status
277
292
  self.status = value
278
293
  when :done
279
294
  status.text = "idle"
295
+ write_message name, markdown(value), method: :replace
280
296
  buffer.close
281
297
  @thread = nil
282
298
  when :cancel
@@ -312,4 +328,8 @@ module LLM
312
328
  File = ::File
313
329
  private_constant :File
314
330
  end
331
+
332
+ ##
333
+ # Alias: LLM::Repl
334
+ Repl = Console
315
335
  end
@@ -40,7 +40,8 @@ class LLM::Context
40
40
  usage = payload["usage"]
41
41
  reasoning_content = payload["reasoning_content"]
42
42
  compaction = payload["compaction"]
43
- extra = {tool_calls:, original_tool_calls:, tools: @params[:tools], usage:, reasoning_content:, compaction:}.compact
43
+ created_at = payload["created_at"]
44
+ extra = {tool_calls:, original_tool_calls:, tools: @params[:tools], usage:, reasoning_content:, compaction:, created_at:}.compact
44
45
  content = returns.nil? ? deserialize_content(payload["content"]) : returns
45
46
  LLM::Message.new(payload["role"], content, extra)
46
47
  end
data/lib/llm/context.rb CHANGED
@@ -39,6 +39,15 @@ module LLM
39
39
  include Serializer
40
40
  include Deserializer
41
41
 
42
+ TRY_ERRORS = [
43
+ "LLM::InsufficientQuotaError",
44
+ "LLM::RateLimitError",
45
+ "Net::ReadTimeout",
46
+ "Net::WriteTimeout",
47
+ "Net::OpenTimeout"
48
+ ]
49
+ private_constant :TRY_ERRORS
50
+
42
51
  ##
43
52
  # Returns the set of runtime parameters that
44
53
  # configure this context and must never be forwarded
@@ -476,7 +485,7 @@ module LLM
476
485
  # Returns the model a Context is actively using
477
486
  # @return [String]
478
487
  def model
479
- messages.find(&:assistant?)&.model || @params[:model]
488
+ messages.find(&:assistant?)&.model || @params[:model] || @llm.default_model
480
489
  end
481
490
 
482
491
  ##
@@ -559,23 +568,30 @@ module LLM
559
568
 
560
569
  ##
561
570
  ##
562
- # Runs a network call, retrying it on {LLM::RateLimitError} up to the
563
- # retry budget. Each retry notifies the stream and sleeps a growing
564
- # interval (2s, 4s, 6s, ...) rather than the server's `retry_after`.
565
- # A 429 is refused before any content streams, so retrying the same
566
- # request loses nothing. The bare `retry` below re-runs the method
567
- # body while `attempts ||= 0` keeps the count across attempts.
571
+ # Runs a network call, retrying it when the request is rate limited
572
+ # ({LLM::RateLimitError}) or times out (`Timeout::Error`, which covers
573
+ # `Net::OpenTimeout` and `Net::ReadTimeout`), up to the retry budget.
574
+ # Each retry notifies the stream and sleeps a growing interval
575
+ # (2s, 4s, 6s, ...) rather than the server's `retry_after`. A 429 is
576
+ # refused before any content streams, so retrying the same request
577
+ # loses nothing. The bare `retry` below re-runs the method body while
578
+ # `attempts ||= 0` keeps the count across attempts.
568
579
  # @api private
569
580
  # @return [Object]
570
581
  def try
571
582
  attempts ||= 0
572
583
  yield
573
- rescue LLM::RateLimitError => error
574
- raise if attempts >= retry_budget
575
- attempts += 1
576
- stream.on_rate_limit(error)
577
- sleep 2.0 * attempts
578
- retry
584
+ rescue => ex
585
+ case ex.class.to_s
586
+ when *TRY_ERRORS
587
+ raise if attempts >= retry_budget
588
+ attempts += 1
589
+ stream.on_retry(ex, attempts)
590
+ sleep 2.0 * attempts
591
+ retry
592
+ else
593
+ raise(ex)
594
+ end
579
595
  end
580
596
 
581
597
  # Executes a turn through the Responses API.
data/lib/llm/cost.rb CHANGED
@@ -6,6 +6,14 @@
6
6
  # output, input audio, output audio, input image, cache read, cache write,
7
7
  # and reasoning costs separately and can return the total.
8
8
  class LLM::Cost
9
+ ##
10
+ # Build a zero-valued cost breakdown. Every component
11
+ # is nil (treated as no cost), so the total is 0.
12
+ # @return [LLM::Cost]
13
+ def self.zero
14
+ new
15
+ end
16
+
9
17
  ##
10
18
  # Build a cost breakdown from token usage and model pricing.
11
19
  # @param [LLM::Context] ctx
@@ -13,6 +21,11 @@ class LLM::Cost
13
21
  # @return [LLM::Cost]
14
22
  def self.from(ctx)
15
23
  pricing = LLM.registry_for(ctx.llm).cost(model: ctx.model)
24
+ ##
25
+ # A model may have no known pricing (eg OpenRouter's
26
+ # `openrouter/auto` auto-router). Fall back to a zero
27
+ # cost rather than crashing on a nil pricing.
28
+ return zero if pricing.nil?
16
29
  usage = ctx.usage
17
30
  output = usage.output_tokens - usage.reasoning_tokens
18
31
  input = usage.input_tokens - usage.cache_read_tokens
@@ -25,24 +25,43 @@ module LLM::Function::Async
25
25
 
26
26
  ##
27
27
  # Stop the reactor and wait for the thread to finish.
28
+ # @return [nil]
28
29
  def stop
29
30
  @inbox << :stop
30
31
  @thread.join(5)
31
32
  @thread.kill if @thread.alive?
33
+ nil
32
34
  end
33
35
 
34
36
  private
35
37
 
38
+ ##
39
+ # Run the loop until a `:stop`, then tear down. Stopping
40
+ # cancels running children, running their ensure blocks,
41
+ # so #run returns promptly.
42
+ #
43
+ # Detach the scheduler before this thread exits. Left
44
+ # attached, Ruby calls scheduler_close on thread death,
45
+ # and its cancel path sends a non-Exception cause: through
46
+ # io-event's C #raise (not keyword-aware) into
47
+ # rb_fiber_raise, which on Ruby 4.0 raises TypeError
48
+ # (upstream async/io-event bug, not ours).
49
+ # @return [nil]
36
50
  def run
37
51
  reactor = ::Async::Reactor.new
38
52
  reactor.async do
39
53
  loop do
40
54
  work = @inbox.pop
41
- break if work == :stop
55
+ if work == :stop
56
+ reactor.stop
57
+ break
58
+ end
42
59
  reactor.async { work.call }
43
60
  end
44
61
  end
45
62
  reactor.run
63
+ ensure
64
+ ::Fiber.set_scheduler(nil)
46
65
  end
47
66
  end
48
67
  end
@@ -32,13 +32,15 @@ class LLM::Function
32
32
  @pid = Kernel.fork do
33
33
  ##
34
34
  # The child inherits the parent's terminal. When
35
- # the runtime runs under a curses REPL, the forked
36
- # tool writing to the tty would clobber the parent's
37
- # display (a blank screen). Redirect the child's
38
- # stdout/stderr to null so it keeps off the user's
39
- # terminal. A tool that genuinely needs the terminal
40
- # can reopen it via /dev/tty; the tty fd stays
41
- # available to the child.
35
+ # the runtime runs under a curses REPL, a forked
36
+ # tool reading or writing the tty would steal the
37
+ # user's input or clobber the parent's display.
38
+ # Point all three standard streams at null so the
39
+ # child keeps off the user's terminal entirely. A
40
+ # tool that genuinely needs the terminal can reopen
41
+ # it via /dev/tty; the tty fd stays available to
42
+ # the child.
43
+ $stdin.reopen(File::NULL)
42
44
  $stdout.reopen(File::NULL)
43
45
  $stderr.reopen(File::NULL)
44
46
  Fork::Job.new(@function, @ch).call
@@ -83,8 +85,10 @@ class LLM::Function
83
85
  @tracer&.on_tool_finish(result:, span: @span)
84
86
  result
85
87
  ensure
86
- reap
87
- [@ch.control, @ch.result].each { _1.close unless _1.closed? }
88
+ if @guarded.nil?
89
+ reap
90
+ [@ch.control, @ch.result].each { _1.close unless _1.closed? } if @ch
91
+ end
88
92
  end
89
93
  alias_method :value, :wait
90
94
 
@@ -97,7 +101,7 @@ class LLM::Function
97
101
  private
98
102
 
99
103
  def reap
100
- return if @waited
104
+ return if @waited || @guarded || !@pid
101
105
  ::Process.waitpid(@pid)
102
106
  @waited = true
103
107
  rescue Errno::ECHILD
data/lib/llm/function.rb CHANGED
@@ -273,7 +273,7 @@ class LLM::Function
273
273
  when :fiber
274
274
  Fiber::Task.new(self, options)
275
275
  when :fork
276
- LLM.require "xchan", "~> 0.22" unless defined?(::Chan::UNIXSocket)
276
+ LLM.require "xchan", "~> 0.23" unless defined?(::Chan::UNIXSocket)
277
277
  Fork::Task.new(self, options.merge(tracer: @tracer))
278
278
  when :ractor
279
279
  raise LLM::RactorError, "Ractor concurrency only supports class-based tools" unless Class === @runner
@@ -26,6 +26,44 @@ module LLM
26
26
  # @return [Exception]
27
27
  # Returns the error raised when parsing fails
28
28
  def self.parser_error = [StandardError]
29
+
30
+ ##
31
+ # JSON must be UTF-8 per spec, so a compliant adapter
32
+ # scrubs the strings it serializes. Walks +obj+ and
33
+ # encodes every string found into a valid UTF-8 string,
34
+ # replacing any invalid bytes. Adapters should call this
35
+ # from their +dump+ before handing the object to the
36
+ # underlying library.
37
+ # @param [Object] obj
38
+ # @return [Object]
39
+ # The object with every string normalized to valid UTF-8
40
+ def self.normalize(obj)
41
+ case obj
42
+ when String then normalize_string(obj)
43
+ when Array then obj.map { normalize(_1) }
44
+ when Hash then obj.map { [_1, normalize(_2)] }.to_h
45
+ when LLM::Object then obj.map { [_1, normalize(_2)] }.to_h
46
+ else obj
47
+ end
48
+ end
49
+ private_class_method :normalize
50
+
51
+ ##
52
+ # Normalizes a single string as a valid UTF-8 string that is
53
+ # compatible with the JSON spec. BINARY-encoded strings are
54
+ # read as UTF-8 and scrubbed when invalid; every other encoding
55
+ # is transcoded to UTF-8, replacing invalid or undefined bytes.
56
+ # @param [String] str
57
+ # @return [String]
58
+ def self.normalize_string(str)
59
+ if str.encoding == Encoding::BINARY
60
+ str = (+str).force_encoding("UTF-8")
61
+ str.valid_encoding? ? str : str.scrub
62
+ else
63
+ str.encode("UTF-8", invalid: :replace, undef: :replace)
64
+ end
65
+ end
66
+ private_class_method :normalize_string
29
67
  end
30
68
 
31
69
  ##
@@ -59,32 +97,6 @@ module LLM
59
97
  require "json" unless defined?(::JSON)
60
98
  [::JSON::ParserError]
61
99
  end
62
-
63
- ##
64
- # JSON 3.0 compat
65
- # Walks `obj` and encodes every string that is
66
- # found into a UTF-8 compatible string.
67
- def self.normalize(obj)
68
- case obj
69
- when String then normalize_string(obj)
70
- when Array then obj.map { normalize(_1) }
71
- when Hash then obj.map { [_1, normalize(_2)] }.to_h
72
- when LLM::Object then obj.map { [_1, normalize(_2)] }.to_h
73
- else obj
74
- end
75
- end
76
- private_class_method :normalize
77
-
78
- ##
79
- # JSON 3.0 compat
80
- # Normalizes a string as a UTF-8 encoded string
81
- # that's compatible with the JSON spec.
82
- def self.normalize_string(str)
83
- return str if str.encoding == Encoding::UTF_8
84
- str = (+str).force_encoding("UTF-8")
85
- str.valid_encoding? ? str : str.scrub
86
- end
87
- private_class_method :normalize_string
88
100
  end
89
101
 
90
102
  ##
@@ -95,7 +107,7 @@ module LLM
95
107
  # @return (see JSONAdapter#dump)
96
108
  def self.dump(obj, options = {})
97
109
  require "oj" unless defined?(::Oj)
98
- ::Oj.dump(obj, options.merge(mode: :compat))
110
+ ::Oj.dump(normalize(obj), options.merge(mode: :compat))
99
111
  end
100
112
 
101
113
  ##
@@ -121,7 +133,7 @@ module LLM
121
133
  # @return (see JSONAdapter#dump)
122
134
  def self.dump(obj, ...)
123
135
  require "yajl" unless defined?(::Yajl)
124
- ::Yajl::Encoder.encode(obj, ...)
136
+ ::Yajl::Encoder.encode(normalize(obj), ...)
125
137
  end
126
138
 
127
139
  ##
data/lib/llm/message.rb CHANGED
@@ -17,6 +17,11 @@ module LLM
17
17
  # @return [Hash]
18
18
  attr_reader :extra
19
19
 
20
+ ##
21
+ # Returns the time the message was created
22
+ # @return [Time]
23
+ attr_reader :created_at
24
+
20
25
  ##
21
26
  # Returns a new message
22
27
  # @param [Symbol] role
@@ -27,6 +32,7 @@ module LLM
27
32
  @role = role.to_s
28
33
  @content = content
29
34
  @extra = LLM::Object.from(extra)
35
+ @created_at = extra[:created_at] ? Time.iso8601(extra[:created_at].to_s) : Time.now.utc
30
36
  end
31
37
 
32
38
  ##
@@ -37,6 +43,7 @@ module LLM
37
43
  role:,
38
44
  content:,
39
45
  reasoning_content:,
46
+ created_at: created_at.utc.iso8601,
40
47
  compaction: extra.compaction,
41
48
  tools: extra.tool_calls&.map { LLM::Object === _1 ? _1.to_h : _1 },
42
49
  usage:,
data/lib/llm/provider.rb CHANGED
@@ -17,7 +17,12 @@ class LLM::Provider
17
17
  # @param [Integer] port
18
18
  # The port number
19
19
  # @param [Integer] timeout
20
- # The number of seconds to wait for a response
20
+ # The number of seconds to wait for a response. Also serves as the
21
+ # default read timeout.
22
+ # @param [Integer, nil] read_timeout
23
+ # The number of seconds to wait for a response. Defaults to `timeout`.
24
+ # @param [Integer] connect_timeout
25
+ # The number of seconds to wait for a TCP connection to open.
21
26
  # @param [Boolean] ssl
22
27
  # Whether to use SSL for the connection
23
28
  # @param [String] base_path
@@ -27,17 +32,27 @@ class LLM::Provider
27
32
  # Requires the net-http-persistent gem.
28
33
  # @param [LLM::Transport, Class, nil] transport
29
34
  # Optional override with any {LLM::Transport} instance or subclass.
30
- def initialize(key:, host:, port: 443, timeout: 900, ssl: true, base_path: "", persistent: false, transport: nil)
35
+ def initialize(key:, host:, port: 443, timeout: 600, read_timeout: nil, connect_timeout: 5, ssl: true, base_path: "", persistent: false, transport: nil)
31
36
  @key = key
32
37
  @host = host
33
38
  @port = port
34
- @timeout = timeout
39
+ @read_timeout = read_timeout || timeout
40
+ @timeout = @read_timeout
41
+ @connect_timeout = connect_timeout
35
42
  @ssl = ssl
36
43
  @base_path = LLM::Utils.normalize_base_path(base_path)
37
44
  @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
38
45
  @headers = {"User-Agent" => "llm.rb v#{LLM::VERSION}"}
39
- @transport = LLM::Transport::Utils.resolve_transport(host:, port:, timeout:, ssl:, transport:, persistent:)
40
46
  @monitor = Monitor.new
47
+ @transport = LLM::Transport::Utils.resolve_transport(
48
+ host:,
49
+ port:,
50
+ timeout: @read_timeout,
51
+ connect_timeout: @connect_timeout,
52
+ ssl:,
53
+ transport:,
54
+ persistent:
55
+ )
41
56
  end
42
57
 
43
58
  ##
@@ -251,13 +266,19 @@ class LLM::Provider
251
266
  # Add one or more headers to all requests
252
267
  # @example
253
268
  # llm = LLM.openai(key: ENV["KEY"])
254
- # llm.with(headers: {"OpenAI-Organization" => ENV["ORG"]})
255
- # llm.with(headers: {"OpenAI-Project" => ENV["PROJECT"]})
269
+ # llm.with("OpenAI-Organization" => ENV["ORG"])
270
+ # llm.with("OpenAI-Project" => ENV["PROJECT"])
256
271
  # @param [Hash<String,String>] headers
257
272
  # One or more headers
273
+ # @note
274
+ # For backwards compatibility, headers can be
275
+ # provided via the `headers:` keyword argument,
276
+ # or provided directly as a Hash without the
277
+ # `headers:` key namespace.
258
278
  # @return [LLM::Provider]
259
279
  # Returns self
260
- def with(headers:)
280
+ def with(**headers)
281
+ headers = headers.merge(headers.delete(:headers) || {})
261
282
  lock do
262
283
  tap { @headers.merge!(headers) }
263
284
  end
@@ -337,7 +358,7 @@ class LLM::Provider
337
358
  # whenever no scoped override is active.
338
359
  # @example
339
360
  # llm = LLM.openai(key: ENV["KEY"])
340
- # llm.tracer = LLM::Tracer::Logger.new(llm, path: "/path/to/log.txt")
361
+ # llm.tracer = LLM::Tracer.logger(llm, path: "/path/to/log.txt")
341
362
  # @param [LLM::Tracer] tracer
342
363
  # A tracer
343
364
  # @return [void]
@@ -350,7 +371,7 @@ class LLM::Provider
350
371
  # This is useful when you want per-request or per-turn tracing without
351
372
  # replacing the provider's default tracer.
352
373
  # @example
353
- # llm.with_tracer(LLM::Tracer::Logger.new(llm, io: $stdout)) do
374
+ # llm.with_tracer(LLM::Tracer.logger(llm, io: $stdout)) do
354
375
  # llm.complete("hello", model: "gpt-5.4-mini")
355
376
  # end
356
377
  # @param [LLM::Tracer] tracer
@@ -412,7 +433,7 @@ class LLM::Provider
412
433
  "#{@base_path}#{suffix}"
413
434
  end
414
435
 
415
- attr_reader :base_uri, :host, :port, :timeout, :ssl, :transport
436
+ attr_reader :base_uri, :host, :port, :timeout, :read_timeout, :connect_timeout, :ssl, :transport
416
437
 
417
438
  ##
418
439
  # The headers to include with a request
@@ -11,7 +11,7 @@ module LLM
11
11
  #
12
12
  # The default host is the pay-as-you-go DashScope
13
13
  # international endpoint (`dashscope-intl.aliyuncs.com`). Configure
14
- # a different host either globally through the `ALIBABA_API_HOST`
14
+ # a different host either globally through the `DASHSCOPE_API_HOST`
15
15
  # environment variable, or per instance through
16
16
  # `LLM.alibaba(host: "token-plan.ap-southeast-1.maas.aliyuncs.com")`
17
17
  # (for example Alibaba's Token Plan).
@@ -159,7 +159,7 @@ module LLM
159
159
  end
160
160
 
161
161
  def normalize_complete_params(params)
162
- params = {role: :user, model: default_model, max_tokens: 1024}.merge!(params)
162
+ params = {role: :user, model: params.delete(:model) || default_model, max_tokens: 1024}.merge!(params)
163
163
  tools = resolve_tools(params.delete(:tools))
164
164
  params = [params, adapt_tools(tools)].inject({}, &:merge!).compact
165
165
  role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
@@ -51,7 +51,7 @@ class LLM::Bedrock
51
51
  # @param [String] host
52
52
  # @return [LLM::Transport]
53
53
  def build_transport(host)
54
- transport.class.new(host:, port: 443, timeout:, ssl: true)
54
+ transport.class.new(host:, port: 443, timeout:, connect_timeout:, ssl: true)
55
55
  end
56
56
 
57
57
  ##
@@ -102,7 +102,7 @@ class LLM::Bedrock
102
102
  end
103
103
  end
104
104
 
105
- [:timeout, :tracer, :transport].each do |m|
105
+ [:timeout, :connect_timeout, :tracer, :transport].each do |m|
106
106
  define_method(m) { @provider.send(m) }
107
107
  end
108
108
  end