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
@@ -2,37 +2,97 @@
2
2
 
3
3
  class LLM::Tool
4
4
  ##
5
- # The {LLM::Tool::ReadFile LLM::Tool::ReadFile} class implements
6
- # a tool that can read the contents of a file. The tool accepts
7
- # two optional offsets: a start line, and a stop line. Without
8
- # either the entire file contents are read into memory.
5
+ # The {LLM::Tool::ReadFile} class implements a tool that
6
+ # can read the contents of a file. It returns the content
7
+ # as structured lines ({lineno:, content:}), so the model can
8
+ # reference line numbers when requesting a narrower range.
9
9
  class ReadFile < self
10
+ require_relative "utils"
11
+ include Utils
12
+
10
13
  name "read-file"
11
14
  description "read the contents of a file"
12
15
  parameter :path, String, "the path to the file"
13
16
  parameter :start, Integer, "start line number"
14
17
  parameter :stop, Integer, "stop line number"
18
+ parameter :max_bytes, Integer, "the max number of bytes to return"
15
19
  required %i[path]
16
20
 
21
+ ##
22
+ # Returns (or sets) the advisory maximum number of bytes
23
+ # this tool returns to the model.
24
+ # @param [Integer, nil] bytes
25
+ # When given, sets the maximum
26
+ # @return [Integer]
27
+ def self.max_bytes(bytes = UNDEFINED)
28
+ if bytes.equal?(UNDEFINED)
29
+ @max_bytes || 75_000
30
+ else
31
+ @max_bytes = bytes
32
+ end
33
+ end
34
+
17
35
  ##
18
36
  # @param [String] path
19
37
  # @param [Integer] start
20
38
  # @param [Integer] stop
39
+ # @param [Integer] max_bytes
21
40
  # @return [Hash]
22
- def call(path:, start: 1, stop: -1)
23
- content, cursor = nil, 1
41
+ def call(path:, start: 1, stop: -1, max_bytes: self.class.max_bytes)
42
+ start, stop = swap_if_reversed(start, stop)
43
+ content = read(path, start, stop)
44
+ to_lines(content, start:, max_bytes:)
45
+ end
46
+
47
+ private
48
+
49
+ ##
50
+ # When start is greater than stop, swap them so the
51
+ # range reads in the right direction (e.g. start: 10,
52
+ # stop: 5 => reads lines 5-10).
53
+ # @param [Integer] start
54
+ # @param [Integer] stop
55
+ # @return [[Integer, Integer]]
56
+ def swap_if_reversed(start, stop)
57
+ stop != -1 && start > stop ? [stop, start] : [start, stop]
58
+ end
59
+
60
+ ##
61
+ # Returns the file content within the start..stop range.
62
+ # @param [String] path
63
+ # @param [Integer] start
64
+ # @param [Integer] stop
65
+ # @return [String]
66
+ def read(path, start, stop)
24
67
  File.open(path, "r") do |f|
68
+ cursor = 1
25
69
  while cursor < start
26
70
  f.gets
27
71
  cursor += 1
28
72
  end
29
73
  if stop == -1
30
- content = f.read
74
+ f.read
31
75
  else
32
- content = start.upto(stop).map { f.gets }.join
76
+ start.upto(stop).map { f.gets }.join
33
77
  end
34
78
  end
35
- {ok: true, content:}
79
+ end
80
+
81
+ ##
82
+ # Builds {lineno:, content:} lines from the content, capped at
83
+ # max_bytes via the shared truncate!. The truncation marker
84
+ # is kept out of the lines (it is not a real file line); the
85
+ # truncated flag communicates that content was cut.
86
+ # @param [String] content
87
+ # @param [Integer] start
88
+ # @param [Integer] max_bytes
89
+ # @return [Hash]
90
+ def to_lines(content, start:, max_bytes:)
91
+ body, truncated = truncate!(content, max_bytes:)
92
+ lines = body.lines.each_with_index.map do |line, index|
93
+ {lineno: start + index, content: line}
94
+ end
95
+ {ok: true, lines:, truncated:}
36
96
  end
37
97
  end
38
98
  end
data/lib/llm/tools/rg.rb CHANGED
@@ -7,49 +7,45 @@ class LLM::Tool
7
7
  # recursively search the current working directory
8
8
  # for one or more patterns.
9
9
  class Rg < self
10
- require_relative "utils"
11
- include Utils
10
+ require_relative "exec"
12
11
 
13
12
  name "rg"
14
- description "recursively search the current directory for lines matching a pattern"
13
+ description "Search the current directory for lines matching a pattern\n" \
14
+ "This command (rg) is spawned without a shell"
15
15
  parameter :patterns, Array[String], "one or more search patterns"
16
16
  parameter :path, String, "the path where the search is performed (default is cwd)"
17
17
  parameter :timeout, Integer, "the number of seconds to wait before cancelling the action"
18
+ parameter :max_count, Integer, "the max number of results per file"
19
+ parameter :max_bytes, Integer, "the max number of bytes to return"
18
20
  required %i[patterns]
19
- defaults path: Dir.getwd, timeout: 5
21
+ defaults path: Dir.getwd, timeout: 5, max_count: 10, max_bytes: -> { Exec.max_bytes }
20
22
 
21
23
  ##
22
24
  # @param [Array<String>] patterns
23
25
  # @param [String] path
26
+ # @param [Integer] timeout
27
+ # @param [Integer] max_count
28
+ # @param [Integer] max_bytes
24
29
  # @return [Hash]
25
- def call(patterns:, path: Dir.getwd, timeout: 5)
26
- validate!(patterns:, path:)
27
- command = spawn(patterns:, path:)
28
- wait(command:, timeout:)
29
- {ok: command.success?, stdout: command.stdout, stderr: command.stderr}
30
- rescue LLM::Interrupt
31
- command.kill! if command&.running?
32
- raise
30
+ def call(patterns:, path: Dir.getwd, timeout: 5, max_count: 10, max_bytes: Exec.max_bytes)
31
+ validate!(patterns:, path:, max_count:)
32
+ Exec.new.call(
33
+ arguments: ["rg", "-m", max_count, *[*patterns].flat_map { ["-e", _1] }, path],
34
+ timeout:,
35
+ max_bytes:
36
+ )
33
37
  end
34
38
 
35
39
  private
36
40
 
37
- def validate!(patterns:, path:)
38
- if path == "/"
41
+ def validate!(patterns:, path:, max_count:)
42
+ if !(::Integer === max_count) || max_count.zero? || max_count.negative?
43
+ raise RuntimeError, "max_count must be a positive integer"
44
+ elsif path == "/"
39
45
  raise RuntimeError, "you can't search from the root of the filesystem"
40
46
  elsif patterns == ["."]
41
47
  raise RuntimeError, "narrow your search"
42
48
  end
43
49
  end
44
-
45
- def spawn(patterns:, path:)
46
- Command
47
- .new("rg")
48
- .argv(*[*patterns].flat_map { ["-e", _1] }, path)
49
- .spawn
50
- end
51
-
52
- LLM.require "test-cmd.rb", "~> 1.1"
53
- Command = Test::Cmd
54
50
  end
55
51
  end
@@ -4,43 +4,35 @@ class LLM::Tool
4
4
  ##
5
5
  # The {LLM::Tool::Ruby LLM::Tool::Ruby} class implements
6
6
  # a tool that can execute an arbitrary string of Ruby code
7
- # and return the result. It uses `test-cmd.rb` under the
8
- # hood for process management.
7
+ # and return the result. It routes through {LLM::Tool::Exec}
8
+ # for process management.
9
9
  class Ruby < self
10
- require_relative "utils"
11
- include Utils
10
+ require "rbconfig"
11
+ require_relative "exec"
12
12
 
13
13
  name "ruby"
14
- description "runs a string of ruby code"
14
+ description "Runs a string of ruby code\n" \
15
+ "This command (ruby) is spawned without a shell"
15
16
  parameter :code, String, "a string of ruby code"
16
17
  parameter :timeout, Integer, "maximum runtime before timeout"
18
+ parameter :max_bytes, Integer, "max number of bytes to emit"
17
19
  required %i[code]
18
- defaults timeout: 15
20
+ defaults timeout: 15, max_bytes: -> { Exec.max_bytes }
19
21
 
20
22
  ##
21
23
  # @param [String] code
22
24
  # Ruby code
23
25
  # @param [Integer] timeout
24
26
  # Runtime timeout
25
- def call(code:, timeout: 15)
26
- command = spawn(code:)
27
- wait(command:, timeout:)
28
- {ok: command.success?, stdout: command.stdout, stderr: command.stderr}
29
- rescue LLM::Interrupt
30
- command.kill! if command&.running?
31
- raise
32
- end
33
-
34
- private
35
-
36
- def spawn(code:)
37
- Command
38
- .new(RbConfig.ruby)
39
- .argv("-e", code)
27
+ # @param [Integer] max_bytes
28
+ # Max bytes to emit
29
+ # @return [Hash]
30
+ def call(code:, timeout: 15, max_bytes: Exec.max_bytes)
31
+ Exec.new.call(
32
+ arguments: [RbConfig.ruby, "-e", code],
33
+ timeout:,
34
+ max_bytes:
35
+ )
40
36
  end
41
-
42
- require "rbconfig"
43
- LLM.require "test-cmd.rb", "~> 1.1"
44
- Command = Test::Cmd
45
37
  end
46
38
  end
@@ -2,13 +2,53 @@
2
2
 
3
3
  class LLM::Tool
4
4
  ##
5
- # Shared utilities for tool implementations.
5
+ # Tool utils.
6
6
  module Utils
7
+ ##
8
+ # Truncates a string so a tool return stays bounded.
9
+ # Appends a marker when truncated so the model knows
10
+ # more content was available. Bounds each individual
11
+ # component (string field) a tool returns: a tool that
12
+ # returns multiple strings (eg stdout and stderr) caps
13
+ # each separately.
14
+ # @param [String] content
15
+ # @param [Integer] max_bytes
16
+ # The max number of bytes to keep
17
+ # @return [String]
18
+ def truncate(content, max_bytes:)
19
+ body, truncated = truncate!(content, max_bytes:)
20
+ truncated ? "#{body}\n...\n[truncated: more than #{max_bytes} bytes]" : body
21
+ end
22
+
23
+ ##
24
+ # Performs the truncation and returns a tuple where the
25
+ # first element is the content, which can be either
26
+ # truncated or left intact. The second element is a
27
+ # boolean that indicates whether truncation took place.
28
+ #
29
+ # The difference between {#truncate truncate} and this
30
+ # method is that {#truncate truncate} appends a marker
31
+ # to the truncated content that indicates truncation
32
+ # took place, while this method leaves the content bare
33
+ # so a caller can structure it itself (eg
34
+ # {LLM::Tool::ReadFile}) without parsing the marker back
35
+ # out.
36
+ # @param [String] content
37
+ # @param [Integer] max_bytes
38
+ # The max number of bytes to keep
39
+ # @return [[String, Boolean]]
40
+ # A tuple of the content and whether truncation took place
41
+ def truncate!(content, max_bytes:)
42
+ s = content.to_s
43
+ return [s, false] if s.bytesize <= max_bytes
44
+ [s.byteslice(0, max_bytes), true]
45
+ end
46
+
7
47
  ##
8
48
  # Wait for a command to finish, or abort
9
49
  # with an error when it exceeds the
10
50
  # specified timeout.
11
- # @param [Test::Cmd] command
51
+ # @param [Test::Command] command
12
52
  # @param [Integer] timeout
13
53
  # @return [void]
14
54
  def wait(command:, timeout:)
@@ -22,10 +62,43 @@ class LLM::Tool
22
62
  end
23
63
  end
24
64
 
65
+ ##
66
+ # Spawn a command from a name and arguments,
67
+ # without going through a shell. The command's
68
+ # stdout and stderr are each capped at `max_bytes`.
69
+ # @param [String] name
70
+ # The command name
71
+ # @param [Array<String>] arguments
72
+ # One or more arguments
73
+ # @param [Hash] env
74
+ # Extra environment variables to set for the command
75
+ # @param [Integer] max_bytes
76
+ # The max number of bytes to keep per stream.
77
+ # Defaults to the including tool's `self.class.max_bytes`.
78
+ # @raise [ArgumentError]
79
+ # When `max_bytes` is nil
80
+ # @return [Test::Command]
81
+ def spawn(name:, arguments:, env: {}, max_bytes: self.class.max_bytes)
82
+ if Integer(max_bytes, exception: false).nil?
83
+ raise ArgumentError, "max_bytes cannot be nil"
84
+ end
85
+ Command
86
+ .new(name)
87
+ .env(env)
88
+ .arguments(*[*arguments])
89
+ .limit(stdout: max_bytes, stderr: max_bytes)
90
+ .spawn
91
+ end
92
+
25
93
  ##
26
94
  # @return [Numeric]
27
95
  def now
28
96
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
29
97
  end
98
+
99
+ ##
100
+ # requires test-cmd.rb
101
+ LLM.require "test-cmd.rb", "~> 2.7.1"
102
+ Command = Test::Command
30
103
  end
31
104
  end
@@ -10,13 +10,16 @@ class LLM::Tool
10
10
  description "write to a file"
11
11
  parameter :path, String, "The file path"
12
12
  parameter :content, String, "The file content"
13
+ parameter :newline, Boolean, "insert final newline (yes or no)"
13
14
  required %i[path content]
15
+ defaults newline: true
14
16
 
15
17
  ##
16
18
  # @param [String] path
17
19
  # @param [String] content
18
20
  # @return [Hash]
19
- def call(path:, content:)
21
+ def call(path:, content:, newline: true)
22
+ content = content.end_with?("\n") ? content : "#{content}\n" if newline
20
23
  File.open(path, "w") { _1.write(content) }
21
24
  {ok: true}
22
25
  end
@@ -9,9 +9,9 @@ module LLM
9
9
  # @example
10
10
  # llm = LLM.openai(key: ENV["KEY"])
11
11
  # # Log to a file
12
- # llm.tracer = LLM::Tracer::Logger.new(llm, path: "/tmp/log.txt")
12
+ # llm.tracer = LLM::Tracer.logger(llm, path: "/tmp/log.txt")
13
13
  # # Log to $stdout (default)
14
- # llm.tracer = LLM::Tracer::Logger.new(llm, io: $stdout)
14
+ # llm.tracer = LLM::Tracer.logger(llm, io: $stdout)
15
15
  class Tracer::Logger < Tracer
16
16
  ##
17
17
  # @param (see LLM::Tracer#initialize)
@@ -9,10 +9,10 @@ module LLM
9
9
  #
10
10
  # @example
11
11
  # llm = LLM.openai(key: ENV["KEY"])
12
- # llm.tracer = LLM::Tracer::PrettyLogger.new(llm)
12
+ # llm.tracer = LLM::Tracer.pretty_logger(llm)
13
13
  #
14
14
  # @example Writing to a file
15
- # llm.tracer = LLM::Tracer::PrettyLogger.new(llm, io: File.open("log.txt", "a"))
15
+ # llm.tracer = LLM::Tracer.pretty_logger(llm, path: "log.txt")
16
16
  class Tracer::PrettyLogger < Tracer
17
17
  ##
18
18
  # @param (see LLM::Tracer#initialize)
@@ -73,8 +73,8 @@ module LLM
73
73
 
74
74
  private
75
75
 
76
- def setup!(io: $stderr)
77
- @io = io
76
+ def setup!(io: $stderr, path: nil)
77
+ @io = path ? ::File.open(path, "a") : io
78
78
  @start = nil
79
79
  end
80
80
 
@@ -18,7 +18,7 @@ module LLM
18
18
  # require "pp"
19
19
  #
20
20
  # llm = LLM.openai(key: ENV["KEY"])
21
- # llm.tracer = LLM::Tracer::Telemetry.new(llm)
21
+ # llm.tracer = LLM::Tracer.telemetry(llm)
22
22
  #
23
23
  # ctx = LLM::Context.new(llm)
24
24
  # ctx.talk "hello"
@@ -33,7 +33,7 @@ module LLM
33
33
  # exporter = OpenTelemetry::Exporter::OTLP::Exporter.new
34
34
  #
35
35
  # llm = LLM.openai(key: ENV["KEY"])
36
- # llm.tracer = LLM::Tracer::Telemetry.new(llm, exporter:)
36
+ # llm.tracer = LLM::Tracer.telemetry(llm, exporter:)
37
37
  #
38
38
  # ctx = LLM::Context.new(llm)
39
39
  # ctx.talk "hello"
data/lib/llm/tracer.rb CHANGED
@@ -14,6 +14,39 @@ module LLM
14
14
  require_relative "tracer/null"
15
15
  require_relative "tracer/pretty_logger"
16
16
 
17
+ ##
18
+ # Builds a {LLM::Tracer::PrettyLogger} for a provider.
19
+ # @param [LLM::Provider] llm
20
+ # A provider
21
+ # @param [Hash] options
22
+ # Forwarded to {LLM::Tracer::PrettyLogger} (eg `io:`)
23
+ # @return [LLM::Tracer::PrettyLogger]
24
+ def self.pretty_logger(llm, **options)
25
+ PrettyLogger.new(llm, **options)
26
+ end
27
+
28
+ ##
29
+ # Builds a {LLM::Tracer::Logger} for a provider.
30
+ # @param [LLM::Provider] llm
31
+ # A provider
32
+ # @param [Hash] options
33
+ # Forwarded to {LLM::Tracer::Logger} (eg `path:` or `io:`)
34
+ # @return [LLM::Tracer::Logger]
35
+ def self.logger(llm, **options)
36
+ Logger.new(llm, **options)
37
+ end
38
+
39
+ ##
40
+ # Builds a {LLM::Tracer::Telemetry} for a provider.
41
+ # @param [LLM::Provider] llm
42
+ # A provider
43
+ # @param [Hash] options
44
+ # Forwarded to {LLM::Tracer::Telemetry} (eg `exporter:`)
45
+ # @return [LLM::Tracer::Telemetry]
46
+ def self.telemetry(llm, **options)
47
+ Telemetry.new(llm, **options)
48
+ end
49
+
17
50
  ##
18
51
  # @return [LLM::Provider]
19
52
  attr_reader :llm
@@ -28,12 +28,14 @@ class LLM::Transport
28
28
  # @param [String] host
29
29
  # @param [Integer] port
30
30
  # @param [Integer] timeout
31
+ # @param [Integer] connect_timeout
31
32
  # @param [Boolean] ssl
32
33
  # @return [LLM::Transport::Curb]
33
- def initialize(host:, port:, timeout:, ssl:)
34
+ def initialize(host:, port:, timeout:, connect_timeout:, ssl:)
34
35
  @host = host
35
36
  @port = port
36
37
  @timeout = timeout
38
+ @connect_timeout = connect_timeout
37
39
  @ssl = ssl
38
40
  @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
39
41
  @monitor = Monitor.new
@@ -112,13 +114,13 @@ class LLM::Transport
112
114
 
113
115
  private
114
116
 
115
- attr_reader :host, :port, :timeout, :ssl, :base_uri
117
+ attr_reader :host, :port, :timeout, :connect_timeout, :ssl, :base_uri
116
118
 
117
119
  def build_easy(request)
118
120
  LLM.require "curb" unless defined?(::Curl)
119
121
  easy = ::Curl::Easy.new(request_url(request))
120
122
  easy.timeout = timeout
121
- easy.connect_timeout = timeout
123
+ easy.connect_timeout = connect_timeout
122
124
  request.headers.each { |k, v| easy.headers[k] = v }
123
125
  easy.follow_location = true
124
126
  easy.ssl_verify_peer = false if !ssl
@@ -20,12 +20,14 @@ class LLM::Transport
20
20
  # @param [String] host
21
21
  # @param [Integer] port
22
22
  # @param [Integer] timeout
23
+ # @param [Integer] connect_timeout
23
24
  # @param [Boolean] ssl
24
25
  # @return [LLM::Transport::HTTP]
25
- def initialize(host:, port:, timeout:, ssl:)
26
+ def initialize(host:, port:, timeout:, connect_timeout:, ssl:)
26
27
  @host = host
27
28
  @port = port
28
29
  @timeout = timeout
30
+ @connect_timeout = connect_timeout
29
31
  @ssl = ssl
30
32
  @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
31
33
  @monitor = Monitor.new
@@ -93,10 +95,11 @@ class LLM::Transport
93
95
 
94
96
  private
95
97
 
96
- attr_reader :host, :port, :timeout, :ssl, :base_uri
98
+ attr_reader :host, :port, :timeout, :connect_timeout, :ssl, :base_uri
97
99
 
98
100
  def client
99
101
  client = Net::HTTP.new(host, port)
102
+ client.open_timeout = connect_timeout
100
103
  client.read_timeout = timeout
101
104
  client.use_ssl = ssl
102
105
  client
@@ -32,12 +32,14 @@ class LLM::Transport
32
32
  # @param [String] host
33
33
  # @param [Integer] port
34
34
  # @param [Integer] timeout
35
+ # @param [Integer] connect_timeout
35
36
  # @param [Boolean] ssl
36
37
  # @return [LLM::Transport::PersistentHTTP]
37
- def initialize(host:, port:, timeout:, ssl:)
38
+ def initialize(host:, port:, timeout:, connect_timeout:, ssl:)
38
39
  @host = host
39
40
  @port = port
40
41
  @timeout = timeout
42
+ @connect_timeout = connect_timeout
41
43
  @ssl = ssl
42
44
  @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
43
45
  @monitor = Monitor.new
@@ -100,7 +102,7 @@ class LLM::Transport
100
102
 
101
103
  private
102
104
 
103
- attr_reader :host, :port, :timeout, :ssl, :base_uri
105
+ attr_reader :host, :port, :timeout, :connect_timeout, :ssl, :base_uri
104
106
 
105
107
  def client
106
108
  self.class.lock do
@@ -110,14 +112,14 @@ class LLM::Transport
110
112
  LLM.require "net/http/persistent" unless defined?(Net::HTTP::Persistent)
111
113
  client = Net::HTTP::Persistent.new(name: self.class.name)
112
114
  client.read_timeout = timeout
113
- client.open_timeout = timeout
115
+ client.open_timeout = connect_timeout
114
116
  self.class.registry[client_id] = client
115
117
  end
116
118
  end
117
119
  end
118
120
 
119
121
  def client_id
120
- "#{host}:#{port}:#{timeout}:#{ssl}"
122
+ "#{host}:#{port}:#{timeout}:#{connect_timeout}:#{ssl}"
121
123
  end
122
124
 
123
125
  def close_socket(http)
@@ -24,18 +24,19 @@ class LLM::Transport
24
24
  # @param [String] host
25
25
  # @param [Integer] port
26
26
  # @param [Integer, nil] timeout
27
+ # @param [Integer] connect_timeout
27
28
  # @param [Boolean] ssl
28
29
  # @param [Boolean] persistent
29
30
  # @param [LLM::Transport, Class, Symbol, nil] transport
30
31
  # @return [LLM::Transport]
31
- def resolve_transport(host:, port:, timeout:, ssl:, persistent:, transport:)
32
+ def resolve_transport(host:, port:, timeout:, ssl:, persistent:, transport:, connect_timeout: 5)
32
33
  if transport.nil?
33
- default_transport(host:, port:, timeout:, ssl:, persistent:)
34
+ default_transport(host:, port:, timeout:, connect_timeout:, ssl:, persistent:)
34
35
  elsif Class === transport && transport <= LLM::Transport
35
- transport.new(host:, port:, timeout:, ssl:)
36
+ transport.new(host:, port:, timeout:, connect_timeout:, ssl:)
36
37
  elsif Symbol === transport
37
38
  transport = LLM::Transport.public_send(transport)
38
- transport.new(host:, port:, timeout:, ssl:)
39
+ transport.new(host:, port:, timeout:, connect_timeout:, ssl:)
39
40
  else
40
41
  transport
41
42
  end
@@ -49,12 +50,13 @@ class LLM::Transport
49
50
  # @param [String] host
50
51
  # @param [Integer] port
51
52
  # @param [Integer, nil] timeout
53
+ # @param [Integer] connect_timeout
52
54
  # @param [Boolean] ssl
53
55
  # @param [Boolean] persistent
54
56
  # @return [LLM::Transport]
55
- def default_transport(host:, port:, timeout:, ssl:, persistent:)
57
+ def default_transport(host:, port:, timeout:, connect_timeout:, ssl:, persistent:)
56
58
  target = persistent ? LLM::Transport::PersistentHTTP : LLM::Transport::HTTP
57
- target.new(host:, port:, timeout:, ssl:)
59
+ target.new(host:, port:, timeout:, connect_timeout:, ssl:)
58
60
  end
59
61
  end
60
62
  end
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "15.0.3"
4
+ VERSION = "15.2.0"
5
5
  end
data/lib/llm.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  ##
4
- # llm.rb is a zero-dependency AI runtime for Ruby. Thirteen providers, six
4
+ # llm.rb is a zero-dependency AI runtime for Ruby. Fourteen providers, six
5
5
  # concurrency strategies, MCP and A2A, streaming tool calls with
6
6
  # cancellation, context compaction, and ORM persistence.
7
7
  #
@@ -18,6 +18,7 @@ module LLM
18
18
 
19
19
  require "stringio"
20
20
  require "securerandom"
21
+ require "time"
21
22
  require_relative "llm/compactor"
22
23
  require_relative "llm/transformer"
23
24
  require_relative "llm/json_adapter"
@@ -77,7 +78,9 @@ module LLM
77
78
  # @raise [LLM::LoadError]
78
79
  # When the dependency cannot be loaded
79
80
  def self.require(name, version = nil)
80
- names = {"xchan" => "xchan.rb", "net/http/persistent" => "net-http-persistent"}
81
+ names = {"xchan" => "xchan.rb",
82
+ "net/http/persistent" => "net-http-persistent",
83
+ "unicode/display_width" => "unicode-display_width"}
81
84
  gem(names[name] || name, version) if version
82
85
  super(name)
83
86
  rescue ::LoadError
@@ -260,6 +263,19 @@ module LLM
260
263
  end
261
264
  end
262
265
 
266
+ ##
267
+ # @param key (see LLM::OpenRouter#initialize)
268
+ # @param host (see LLM::OpenRouter#initialize)
269
+ # @return (see LLM::OpenRouter#initialize)
270
+ def openrouter(key: UNDEFINED, **)
271
+ lock(:require) { require_relative "llm/providers/openrouter" unless defined?(LLM::OpenRouter) }
272
+ if key == UNDEFINED
273
+ LLM::OpenRouter.new(key: key(name: __method__), **)
274
+ else
275
+ LLM::OpenRouter.new(key:, **)
276
+ end
277
+ end
278
+
263
279
  ##
264
280
  # @param key (see LLM::Alibaba#initialize)
265
281
  # @param host (see LLM::Alibaba#initialize)
@@ -346,16 +362,6 @@ module LLM
346
362
  LLM::Function.new(key, &b)
347
363
  end
348
364
 
349
- ##
350
- # @param [LLM::Provider] llm
351
- # A provider
352
- # @param [Hash] params
353
- # Forwarded to LLM::Tracer::Logger
354
- # @return [LLM::Tracer::Logger]
355
- def logger(llm, **params)
356
- LLM::Tracer::Logger.new(llm, params)
357
- end
358
-
359
365
  ##
360
366
  # Provides a thread-safe lock
361
367
  # @param [Symbol] name The name of the lock