llm.rb 13.0.0 → 14.0.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +505 -14
  3. data/README.md +484 -50
  4. data/bin/llm.rb +148 -0
  5. data/data/anthropic.json +206 -263
  6. data/data/bedrock.json +2138 -1860
  7. data/data/deepinfra.json +1003 -624
  8. data/data/deepseek.json +38 -34
  9. data/data/google.json +1079 -371
  10. data/data/mistral.json +448 -368
  11. data/data/moonshot.json +384 -0
  12. data/data/openai.json +974 -1343
  13. data/data/xai.json +154 -126
  14. data/data/zai.json +191 -191
  15. data/lib/llm/agent.rb +123 -20
  16. data/lib/llm/context.rb +71 -88
  17. data/lib/llm/cost.rb +23 -17
  18. data/lib/llm/error.rb +0 -8
  19. data/lib/llm/function/array.rb +3 -3
  20. data/lib/llm/function/async/task.rb +2 -0
  21. data/lib/llm/function/fiber/task.rb +2 -0
  22. data/lib/llm/function/fork/task.rb +2 -0
  23. data/lib/llm/function/ractor/task.rb +2 -0
  24. data/lib/llm/function/sequential/group.rb +4 -1
  25. data/lib/llm/function/sequential/task.rb +1 -1
  26. data/lib/llm/function/task.rb +4 -0
  27. data/lib/llm/function/thread/task.rb +2 -0
  28. data/lib/llm/function.rb +33 -6
  29. data/lib/llm/guard/loop.rb +89 -0
  30. data/lib/llm/guard/null.rb +19 -0
  31. data/lib/llm/guard.rb +61 -0
  32. data/lib/llm/provider.rb +36 -0
  33. data/lib/llm/providers/anthropic/stream_parser.rb +1 -0
  34. data/lib/llm/providers/anthropic.rb +2 -9
  35. data/lib/llm/providers/bedrock/request_adapter.rb +1 -1
  36. data/lib/llm/providers/bedrock/stream_parser.rb +1 -0
  37. data/lib/llm/providers/bedrock.rb +1 -8
  38. data/lib/llm/providers/google/stream_parser.rb +1 -0
  39. data/lib/llm/providers/google.rb +1 -8
  40. data/lib/llm/providers/mistral.rb +1 -1
  41. data/lib/llm/providers/moonshot.rb +76 -0
  42. data/lib/llm/providers/ollama.rb +2 -9
  43. data/lib/llm/providers/openai/responses/stream_parser.rb +1 -0
  44. data/lib/llm/providers/openai/responses.rb +7 -9
  45. data/lib/llm/providers/openai/stream_parser.rb +1 -0
  46. data/lib/llm/providers/openai.rb +4 -11
  47. data/lib/llm/repl/bar.rb +4 -3
  48. data/lib/llm/repl/{transcript.rb → buffer.rb} +69 -29
  49. data/lib/llm/repl/color.rb +78 -0
  50. data/lib/llm/repl/command.rb +12 -5
  51. data/lib/llm/repl/commands/compact.rb +2 -2
  52. data/lib/llm/repl/commands/help.rb +3 -5
  53. data/lib/llm/repl/input/char.rb +46 -0
  54. data/lib/llm/repl/input/row.rb +39 -0
  55. data/lib/llm/repl/input.rb +251 -66
  56. data/lib/llm/repl/markdown/table.rb +11 -3
  57. data/lib/llm/repl/markdown.rb +34 -8
  58. data/lib/llm/repl/node.rb +37 -0
  59. data/lib/llm/repl/status.rb +42 -7
  60. data/lib/llm/repl/stream.rb +18 -6
  61. data/lib/llm/repl/walker.rb +3 -2
  62. data/lib/llm/repl/window.rb +54 -35
  63. data/lib/llm/repl.rb +74 -32
  64. data/lib/llm/skill.rb +20 -4
  65. data/lib/llm/stream.rb +8 -7
  66. data/lib/llm/tool.rb +29 -0
  67. data/lib/llm/tools/{swap_text.rb → edit-file.rb} +3 -3
  68. data/lib/llm/tools/git.rb +3 -0
  69. data/lib/llm/tools/mkdir.rb +3 -0
  70. data/lib/llm/tools/rg.rb +3 -0
  71. data/lib/llm/tools/ruby.rb +46 -0
  72. data/lib/llm/tools/shell.rb +3 -0
  73. data/lib/llm/tracer/pretty_logger.rb +127 -0
  74. data/lib/llm/tracer.rb +1 -0
  75. data/lib/llm/transformer/null.rb +21 -0
  76. data/lib/llm/transformer.rb +55 -0
  77. data/lib/llm/version.rb +1 -1
  78. data/lib/llm.rb +12 -2
  79. data/llm.gemspec +9 -2
  80. data/resources/deepdive/advanced/cancellation.md +74 -0
  81. data/resources/deepdive/advanced/compaction.md +83 -0
  82. data/resources/deepdive/advanced/context.md +267 -0
  83. data/resources/deepdive/advanced/guard.md +371 -0
  84. data/resources/deepdive/advanced/tracer.md +180 -0
  85. data/resources/deepdive/advanced/transformer.md +67 -0
  86. data/resources/deepdive/advanced/transports.md +45 -0
  87. data/resources/deepdive/everything_else/audio.md +122 -0
  88. data/resources/deepdive/everything_else/cost.md +99 -0
  89. data/resources/deepdive/everything_else/images.md +89 -0
  90. data/resources/deepdive/everything_else/object.md +108 -0
  91. data/resources/deepdive/everything_else/ocr.md +48 -0
  92. data/resources/deepdive/fundamentals/agents.md +202 -0
  93. data/resources/deepdive/fundamentals/builtin_tools.md +191 -0
  94. data/resources/deepdive/fundamentals/concurrency.md +104 -0
  95. data/resources/deepdive/fundamentals/database.md +449 -0
  96. data/resources/deepdive/fundamentals/embeddings.md +157 -0
  97. data/resources/deepdive/fundamentals/repl.md +87 -0
  98. data/resources/deepdive/fundamentals/schema.md +61 -0
  99. data/resources/deepdive/fundamentals/skills.md +106 -0
  100. data/resources/deepdive/fundamentals/stream.md +110 -0
  101. data/resources/deepdive/fundamentals/tools.md +265 -0
  102. data/resources/deepdive/protocols/a2a.md +106 -0
  103. data/resources/deepdive/protocols/mcp.md +111 -0
  104. data/resources/deepdive.md +58 -1792
  105. metadata +51 -7
  106. data/lib/llm/loop_guard.rb +0 -107
@@ -146,17 +146,17 @@ module LLM
146
146
 
147
147
  ##
148
148
  # Returns the default model for chat completions
149
- # @see https://platform.openai.com/docs/models/gpt-5.4-mini gpt-5.4-mini
149
+ # @see https://platform.openai.com/docs/models/gpt-5.6-luna gpt-5.6-luna
150
150
  # @return [String]
151
151
  def default_model
152
- "gpt-5.4-mini"
152
+ "gpt-5.6-luna"
153
153
  end
154
154
 
155
155
  ##
156
156
  # @param [LLM::Function] fn
157
157
  # @return [Hash]
158
158
  def adapt_function(fn)
159
- params = fn.params || {type: "object", properties: {}}
159
+ params = fn.params.to_h
160
160
  {
161
161
  type: "function", name: fn.name,
162
162
  function: {name: fn.name, description: fn.description, parameters: params}
@@ -230,20 +230,13 @@ module LLM
230
230
  end
231
231
 
232
232
  def build_complete_request(prompt, params, role)
233
- messages = build_complete_messages(prompt, params, role)
233
+ messages = build_messages(prompt, params, role)
234
234
  body = LLM.json.dump({messages: adapt(messages, mode: :complete).flatten}.merge!(params))
235
235
  req = LLM::Transport::Request.post(completions_path, headers)
236
236
  transport.set_body_stream(req, StringIO.new(body))
237
237
  [req, messages]
238
238
  end
239
239
 
240
- def build_complete_messages(prompt, params, role)
241
- if LLM::Prompt === prompt
242
- [*(params.delete(:messages) || []), *prompt]
243
- else
244
- [*(params.delete(:messages) || []), Message.new(role, prompt)]
245
- end
246
- end
247
240
 
248
241
  def extract_user_input(messages, fallback:)
249
242
  message = messages.reverse.find(&:user?) || messages.last
data/lib/llm/repl/bar.rb CHANGED
@@ -17,7 +17,8 @@ class LLM::Repl
17
17
  FREE = " "
18
18
 
19
19
  ##
20
- # @param [Integer] used
20
+ # @param [Integer, nil] used
21
+ # The used context, or nil when it is unknown (eg after compaction).
21
22
  # @param [Integer] total
22
23
  # @param [Integer] width
23
24
  # @return [LLM::Repl::Bar]
@@ -36,11 +37,11 @@ class LLM::Repl
36
37
  private
37
38
 
38
39
  ##
39
- # @param [Integer] used
40
+ # @param [Integer, nil] used
40
41
  # @param [Integer] total
41
42
  # @return [[String, Integer]]
42
43
  def remainder(used, total)
43
- return ["???", width] if total <= 0
44
+ return ["???", width] if used.nil? || total <= 0
44
45
  diff = total - used
45
46
  return ["0%", 0] if diff <= 0
46
47
  remaining = (diff.fdiv(total) * 100).round(2)
@@ -14,12 +14,14 @@ class LLM::Repl
14
14
  # It also maintains a cursor that tracks the active row
15
15
  # by its index number. The streaming path reuses a single
16
16
  # row by overwriting its contents repeatedly.
17
- class Transcript
18
- WIDTH = 80
19
-
17
+ class Buffer
20
18
  ##
21
- # @return [LLM::Repl::Transcript]
22
- def initialize
19
+ # @param [LLM::Repl] repl
20
+ # An instance of {LLM::Repl LLM::Repl}.
21
+ # @return [LLM::Repl::Buffer]
22
+ def initialize(repl)
23
+ @repl = repl
24
+ @window = repl.window
23
25
  @rows = [[]]
24
26
  @cursor = nil
25
27
  @snapshot = nil
@@ -27,37 +29,45 @@ class LLM::Repl
27
29
  end
28
30
 
29
31
  ##
30
- # @param [String] chars
32
+ # @param [String, Array] chars
31
33
  # @param [Object] attrs
32
34
  # @param [Symbol] method
33
35
  # @return [void]
34
36
  def write(chars, attrs = nil, method: :append)
35
- chunks = [{text: chars.to_s, attrs:}.compact]
37
+ case chars
38
+ when Array then chunks = chars
39
+ else chunks = [Node.new(chars.to_s, attrs)]
40
+ end
36
41
  self.method(method).call(chunks)
37
42
  end
38
43
 
39
44
  ##
40
- # Appends Markdown to the transcript.
41
- # @param [String] chars
45
+ # @param [String] user
46
+ # @param [String, Array] content
42
47
  # @param [Symbol] method
43
48
  # @return [void]
44
- def markdown(chars, method: :append)
45
- chunks = LLM::Repl::Markdown.new(chars, WIDTH).ast
46
- self.method(method).call(chunks)
49
+ def write_message(user, content, method: :append)
50
+ chunks = [Node.new("#{user}:\n", Curses::A_BOLD | Color.blue)]
51
+ case content
52
+ when Array then chunks.concat(content)
53
+ else chunks.push(Node.new(content))
54
+ end
55
+ chunks.push(Node.new("\n\n"))
56
+ write(chunks, method:)
47
57
  end
48
58
 
49
59
  ##
50
- # Start the transcript.
60
+ # Open the buffer.
51
61
  # @return [void]
52
- def start
62
+ def open
53
63
  @cursor = @rows.size - 1
54
64
  @snapshot = @rows.map(&:dup)
55
65
  end
56
66
 
57
67
  ##
58
- # Finish the transcript.
68
+ # Close the buffer.
59
69
  # @return [void]
60
- def finish
70
+ def close
61
71
  @cursor = nil
62
72
  @snapshot = nil
63
73
  end
@@ -81,6 +91,15 @@ class LLM::Repl
81
91
  @offset = 0
82
92
  end
83
93
 
94
+ ##
95
+ # The width of the centered content area -
96
+ # with 20% on each side. The rest makes up
97
+ # the width of the buffer.
98
+ # @return [Integer]
99
+ def width
100
+ (Curses.cols * 0.6).floor
101
+ end
102
+
84
103
  ##
85
104
  # @param [Integer] height
86
105
  # @return [Array<String>]
@@ -93,9 +112,13 @@ class LLM::Repl
93
112
 
94
113
  private
95
114
 
115
+ ##
116
+ # @api private
117
+ attr_reader :repl, :window
118
+
96
119
  ##
97
120
  # Appends a new row
98
- # @param [Array<{text: String, attrs?: Integer}>] chunks
121
+ # @param [Array<Node>] chunks
99
122
  # One or more chunks.
100
123
  # @return [void]
101
124
  def append(chunks)
@@ -104,29 +127,46 @@ class LLM::Repl
104
127
 
105
128
  ##
106
129
  # Replaces the content of the active row
107
- # @param [Array<{text: String, attrs?: Integer}>] chunks
130
+ # @param [Array<Node>] chunks
108
131
  # One or more chunks.
109
132
  # @return [void]
110
133
  def replace(chunks)
111
134
  @rows = @snapshot.map(&:dup)
112
- append(chunks)
135
+ chunks.each { wrap(_1, @rows) }
113
136
  end
114
137
 
115
138
  ##
116
- # Given a chunk this method wraps text at
117
- # around 80 columns: a new row starts when
118
- # the current character is " " and the sum
119
- # of all characters in that row is greater
120
- # than 80 columns.
139
+ # Given a chunk this method wraps text to the buffer's
140
+ # width. The text is split into words; a word that does
141
+ # not fit on the current row moves to the next. Only a
142
+ # single word longer than the whole width is hard-broken,
143
+ # so text is never clipped by the window.
121
144
  def wrap(chunk, rows)
122
145
  attrs = chunk[:attrs]
123
- chunk[:text].to_s.each_char do |char|
124
- if char == "\n"
125
- rows << []
126
- elsif char == " " and sum(rows.last) >= WIDTH
146
+ chunk[:text].to_s.scan(/\n|[^\s]+|\s+/) do |token|
147
+ if token == "\n"
127
148
  rows << []
149
+ elsif token.match?(/\s/)
150
+ if sum(rows.last) + token.length <= width
151
+ rows.last << Node.new(token, attrs)
152
+ end
153
+ elsif sum(rows.last) + token.length > width
154
+ if sum(rows.last) == 0
155
+ ##
156
+ # A single word wider than the row: break it
157
+ # every width characters.
158
+ token.scan(/.{1,#{width}}/).each do |piece|
159
+ rows.last << Node.new(piece, attrs)
160
+ if sum(rows.last) >= width
161
+ rows << []
162
+ end
163
+ end
164
+ else
165
+ rows << []
166
+ rows.last << Node.new(token, attrs)
167
+ end
128
168
  else
129
- rows.last << {text: char, attrs:}.compact
169
+ rows.last << Node.new(token, attrs)
130
170
  end
131
171
  end
132
172
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Color LLM::Repl::Color} module returns
6
+ # bitmasks that are understood as colors by the Curses
7
+ # library. They can be bitwise OR'ed with other attributes,
8
+ # such as Curses::A_BOLD.
9
+ module Color
10
+ ##
11
+ # Enable color and initialize 8 color pairs.
12
+ # @return [void]
13
+ def self.enable
14
+ Curses.start_color
15
+ ##
16
+ # The first argument is the color pairs ID, and
17
+ # it is mapped back to a bitmask via {Curses.color_pair}.
18
+ # The second argument is the foreground color, and
19
+ # the third argument is the background color.
20
+ Curses.init_pair(1, Curses::COLOR_BLACK , Curses::COLOR_WHITE)
21
+ Curses.init_pair(2, Curses::COLOR_BLUE , Curses::COLOR_BLACK)
22
+ Curses.init_pair(3, Curses::COLOR_CYAN , Curses::COLOR_BLACK)
23
+ Curses.init_pair(4, Curses::COLOR_GREEN , Curses::COLOR_BLACK)
24
+ Curses.init_pair(5, Curses::COLOR_MAGENTA , Curses::COLOR_BLACK)
25
+ Curses.init_pair(6, Curses::COLOR_RED , Curses::COLOR_BLACK)
26
+ Curses.init_pair(7, Curses::COLOR_WHITE , Curses::COLOR_BLACK)
27
+ Curses.init_pair(8, Curses::COLOR_YELLOW , Curses::COLOR_BLACK)
28
+ end
29
+
30
+ ##
31
+ # @return [Integer]
32
+ def self.black
33
+ Curses.color_pair(1)
34
+ end
35
+
36
+ ##
37
+ # @return [Integer]
38
+ def self.blue
39
+ Curses.color_pair(2)
40
+ end
41
+
42
+ ##
43
+ # @return [Integer]
44
+ def self.cyan
45
+ Curses.color_pair(3)
46
+ end
47
+
48
+ ##
49
+ # @return [Integer]
50
+ def self.green
51
+ Curses.color_pair(4)
52
+ end
53
+
54
+ ##
55
+ # @return [Integer]
56
+ def self.magneta
57
+ Curses.color_pair(5)
58
+ end
59
+
60
+ ##
61
+ # @return [Integer]
62
+ def self.red
63
+ Curses.color_pair(6)
64
+ end
65
+
66
+ ##
67
+ # @return [Integer]
68
+ def self.white
69
+ Curses.color_pair(7)
70
+ end
71
+
72
+ ##
73
+ # @return [Integer]
74
+ def self.yellow
75
+ Curses.color_pair(8)
76
+ end
77
+ end
78
+ end
@@ -176,12 +176,19 @@ class LLM::Repl
176
176
  end
177
177
 
178
178
  ##
179
- # Write a string to the transcript
180
- # @param [String] str
179
+ # Write a string to the buffer
180
+ # @param [String] content
181
+ # @return [void]
182
+ def write(content)
183
+ write_message "command(#{self.class.name})", content
184
+ end
185
+
186
+ ##
187
+ # @param [String] user
188
+ # @param [String] content
181
189
  # @return [void]
182
- def write(str, who: "command(#{self.class.name}): ")
183
- @repl.write(who, Curses::A_BOLD)
184
- @repl.write(str)
190
+ def write_message(user, content)
191
+ @repl.write_message(user, content)
185
192
  end
186
193
 
187
194
  ##
@@ -17,9 +17,9 @@ class LLM::Repl
17
17
  ##
18
18
  # @return [void]
19
19
  def call(n: 128)
20
- write("compact in progress\n")
20
+ write "compact in progress"
21
21
  compactor.call(keep: n)
22
- write("compact complete\n\n")
22
+ write "compact complete"
23
23
  end
24
24
 
25
25
  private
@@ -11,13 +11,11 @@ class LLM::Repl
11
11
  # @return [void]
12
12
  def call(name: nil)
13
13
  if name.nil?
14
- write("\n#{self.class.help}\n\n")
14
+ write(self.class.help)
15
15
  elsif command = LLM::Command.find_by(name:)
16
- write("\n#{command.help}\n\n")
16
+ write(command.help)
17
17
  else
18
- write "\nNo help for #{name} was found" \
19
- "\nThat command doesn't exist." \
20
- "\n\n"
18
+ write "no help for #{name} was found"
21
19
  end
22
20
  end
23
21
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl::Input
4
+ ##
5
+ # A single editable character of the input.
6
+ class Char
7
+ ##
8
+ # @param [String] char
9
+ # @return [LLM::Repl::Input::Char]
10
+ def initialize(char)
11
+ @char = char
12
+ end
13
+
14
+ ##
15
+ # Returns true when both strings returned
16
+ # by the #{to_s} method are considered equal
17
+ # by {String#==}.
18
+ # @param [#to_s] other
19
+ # @return [Boolean]
20
+ def ==(other)
21
+ to_s == other.to_s
22
+ end
23
+ alias_method :eql?, :==
24
+
25
+ ##
26
+ # Returns a hash consistent with {#eql?},
27
+ # so equal chars behave as the same key in
28
+ # a Hash or Set.
29
+ # @return [Integer]
30
+ def hash
31
+ to_s.hash
32
+ end
33
+
34
+ ##
35
+ # @return [Boolean]
36
+ def empty?
37
+ @char == ""
38
+ end
39
+
40
+ ##
41
+ # @return [String]
42
+ def to_s
43
+ @char.dup
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl::Input
4
+ ##
5
+ # One line of the input. `chars` holds the editable characters.
6
+ # `break_type` records how this line got started: an auto-wrap
7
+ # (`:space`) or a real newline (`:newline`). The first row has no
8
+ # break type. It decides how the input is flattened on {Input#take}.
9
+ class Row
10
+ ##
11
+ # @return [Array<Input::Char>]
12
+ attr_reader :chars
13
+
14
+ ##
15
+ # @return [Symbol, nil]
16
+ # `:space` or `:newline`, or nil for the first row.
17
+ attr_accessor :break_type
18
+
19
+ ##
20
+ # @param [Symbol, nil] break_type
21
+ # How this line was started.
22
+ def initialize(break_type = nil)
23
+ @chars = []
24
+ @break_type = break_type
25
+ end
26
+
27
+ ##
28
+ # @return [String]
29
+ def to_s
30
+ chars.map(&:to_s).join
31
+ end
32
+
33
+ ##
34
+ # @return [Boolean]
35
+ def empty?
36
+ chars.empty?
37
+ end
38
+ end
39
+ end