llm.rb 13.1.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +320 -0
  3. data/README.md +340 -31
  4. data/bin/llm.rb +36 -12
  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 +47 -14
  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/async/task.rb +2 -0
  20. data/lib/llm/function/fiber/task.rb +2 -0
  21. data/lib/llm/function/fork/task.rb +2 -0
  22. data/lib/llm/function/ractor/task.rb +2 -0
  23. data/lib/llm/function/sequential/group.rb +4 -1
  24. data/lib/llm/function/sequential/task.rb +1 -1
  25. data/lib/llm/function/task.rb +4 -0
  26. data/lib/llm/function/thread/task.rb +2 -0
  27. data/lib/llm/function.rb +32 -4
  28. data/lib/llm/guard/loop.rb +89 -0
  29. data/lib/llm/guard/null.rb +19 -0
  30. data/lib/llm/guard.rb +61 -0
  31. data/lib/llm/provider.rb +36 -0
  32. data/lib/llm/providers/anthropic/stream_parser.rb +1 -0
  33. data/lib/llm/providers/anthropic.rb +1 -8
  34. data/lib/llm/providers/bedrock/stream_parser.rb +1 -0
  35. data/lib/llm/providers/bedrock.rb +1 -8
  36. data/lib/llm/providers/google/stream_parser.rb +1 -0
  37. data/lib/llm/providers/google.rb +1 -8
  38. data/lib/llm/providers/moonshot.rb +76 -0
  39. data/lib/llm/providers/ollama.rb +1 -8
  40. data/lib/llm/providers/openai/responses/stream_parser.rb +1 -0
  41. data/lib/llm/providers/openai/responses.rb +6 -8
  42. data/lib/llm/providers/openai/stream_parser.rb +1 -0
  43. data/lib/llm/providers/openai.rb +3 -10
  44. data/lib/llm/repl/bar.rb +4 -3
  45. data/lib/llm/repl/buffer.rb +42 -15
  46. data/lib/llm/repl/color.rb +78 -0
  47. data/lib/llm/repl/input/char.rb +46 -0
  48. data/lib/llm/repl/input/row.rb +39 -0
  49. data/lib/llm/repl/input.rb +251 -66
  50. data/lib/llm/repl/markdown/table.rb +6 -2
  51. data/lib/llm/repl/markdown.rb +31 -5
  52. data/lib/llm/repl/status.rb +38 -3
  53. data/lib/llm/repl/stream.rb +16 -4
  54. data/lib/llm/repl/walker.rb +3 -2
  55. data/lib/llm/repl/window.rb +25 -5
  56. data/lib/llm/repl.rb +29 -13
  57. data/lib/llm/stream.rb +8 -7
  58. data/lib/llm/tool.rb +29 -0
  59. data/lib/llm/transformer/null.rb +21 -0
  60. data/lib/llm/transformer.rb +55 -0
  61. data/lib/llm/version.rb +1 -1
  62. data/lib/llm.rb +12 -2
  63. data/llm.gemspec +1 -0
  64. data/resources/deepdive/advanced/cancellation.md +74 -0
  65. data/resources/deepdive/advanced/compaction.md +83 -0
  66. data/resources/deepdive/advanced/context.md +267 -0
  67. data/resources/deepdive/advanced/guard.md +371 -0
  68. data/resources/deepdive/advanced/tracer.md +180 -0
  69. data/resources/deepdive/advanced/transformer.md +67 -0
  70. data/resources/deepdive/advanced/transports.md +45 -0
  71. data/resources/deepdive/everything_else/audio.md +122 -0
  72. data/resources/deepdive/everything_else/cost.md +99 -0
  73. data/resources/deepdive/everything_else/images.md +89 -0
  74. data/resources/deepdive/everything_else/object.md +108 -0
  75. data/resources/deepdive/everything_else/ocr.md +48 -0
  76. data/resources/deepdive/fundamentals/agents.md +202 -0
  77. data/resources/deepdive/fundamentals/builtin_tools.md +191 -0
  78. data/resources/deepdive/fundamentals/concurrency.md +104 -0
  79. data/resources/deepdive/fundamentals/database.md +449 -0
  80. data/resources/deepdive/fundamentals/embeddings.md +157 -0
  81. data/resources/deepdive/fundamentals/repl.md +87 -0
  82. data/resources/deepdive/fundamentals/schema.md +61 -0
  83. data/resources/deepdive/fundamentals/skills.md +106 -0
  84. data/resources/deepdive/fundamentals/stream.md +110 -0
  85. data/resources/deepdive/fundamentals/tools.md +265 -0
  86. data/resources/deepdive/protocols/a2a.md +106 -0
  87. data/resources/deepdive/protocols/mcp.md +111 -0
  88. data/resources/deepdive.md +7 -1
  89. metadata +36 -3
  90. data/lib/llm/loop_guard.rb +0 -107
@@ -11,6 +11,26 @@ class LLM::Repl
11
11
  require_relative "markdown/table"
12
12
  include Table
13
13
 
14
+ ##
15
+ # Kramdown goes a bit beyond a standard markdown
16
+ # parser by representing certain characters or
17
+ # character sequences as distinct node types that are
18
+ # represented by `:typographic_sym`, and `:smart_quote`.
19
+ #
20
+ # The node's value maps back to one of the keys in
21
+ # this Hash, and the values are unicode characters
22
+ # that provide a visual representation of the node.
23
+ #
24
+ # @api private
25
+ SYMBOLS = {
26
+ hellip: "…",
27
+ ndash: "–", mdash: "—",
28
+ laquo: "«", raquo: "»",
29
+ laquo_space: "« ", raquo_space: "» ",
30
+ lsquo: "‘", rsquo: "’",
31
+ ldquo: "“", rdquo: "”"
32
+ }
33
+
14
34
  ##
15
35
  # @param [String] text
16
36
  # @param [Integer] width
@@ -65,17 +85,19 @@ class LLM::Repl
65
85
  emit("\n\n", attrs)
66
86
  when :header
67
87
  emit("\n", attrs)
68
- node.children.each { walk(_1, Curses::A_BOLD) }
88
+ node.children.each { walk(_1, Curses::A_BOLD | Color.white) }
69
89
  emit("\n", attrs)
70
90
  when :strong
71
- node.children.each { walk(_1, Curses::A_BOLD) }
91
+ node.children.each { walk(_1, Curses::A_BOLD | Color.white) }
72
92
  when :em
73
93
  node.children.each { walk(_1, Curses::A_UNDERLINE) }
74
94
  when :codespan
75
- emit(node.value, Curses::A_REVERSE)
95
+ emit(node.value, Color.green)
76
96
  when :codeblock
77
- emit(node.value, Curses::A_REVERSE)
97
+ emit(node.value, Color.green)
78
98
  emit("\n\n", attrs)
99
+ when :typographic_sym, :smart_quote
100
+ emit(symbol(node), attrs)
79
101
  when :br
80
102
  emit("\n", attrs)
81
103
  when :ul, :ol
@@ -100,7 +122,7 @@ class LLM::Repl
100
122
  emit("─" * @width, attrs)
101
123
  emit("\n\n", attrs)
102
124
  when :a
103
- node.children.each { walk(_1, Curses::A_UNDERLINE) }
125
+ node.children.each { walk(_1, Curses::A_UNDERLINE | Color.green) }
104
126
  when :img
105
127
  emit("[image: #{node.attr["alt"]}]", attrs)
106
128
  else
@@ -111,5 +133,9 @@ class LLM::Repl
111
133
  def emit(text, attrs)
112
134
  @ast.push(Node.new(text.to_s, attrs))
113
135
  end
136
+
137
+ def symbol(node)
138
+ SYMBOLS[node.value]
139
+ end
114
140
  end
115
141
  end
@@ -4,6 +4,11 @@ class LLM::Repl
4
4
  ##
5
5
  # The {LLM::Repl::Status LLM::Repl::Status} class stores
6
6
  # the small status line shown at the top of the REPL.
7
+ #
8
+ # The status text can be a plain String, a single
9
+ # {LLM::Repl::Node LLM::Repl::Node} (text plus curses attributes),
10
+ # or an Array of either. Attributes on nodes are applied when the
11
+ # window draws the status line.
7
12
  # @api private
8
13
  class Status
9
14
  ##
@@ -12,14 +17,18 @@ class LLM::Repl
12
17
  def initialize(repl)
13
18
  @agent = repl.agent
14
19
  @provider = @agent.llm.name
15
- @text = "idle"
20
+ @nodes = [Node.new("idle")]
16
21
  end
17
22
 
18
23
  ##
19
24
  # @return [String]
20
25
  def context_bar
21
26
  LLM::Repl::Bar.new(
22
- used: @agent.usage.total_tokens,
27
+ ##
28
+ # After compaction the used context is unknown until the next
29
+ # response, so the bar renders an unknown state instead of a
30
+ # stale percentage.
31
+ used: @agent.compacted? ? nil : @agent.usage.total_tokens,
23
32
  total: @agent.context_window
24
33
  ).to_s
25
34
  end
@@ -30,12 +39,38 @@ class LLM::Repl
30
39
  "$#{@agent.cost}"
31
40
  end
32
41
 
42
+ ##
43
+ # Sets the status text.
44
+ # @param [String, LLM::Repl::Node, Array<String, LLM::Repl::Node>] value
45
+ # @return [Array<LLM::Repl::Node>]
46
+ def text=(value)
47
+ @nodes =
48
+ case value
49
+ when Node then [value]
50
+ when Array then value.map { |node| Node === node ? node : Node.new(node) }
51
+ else [Node.new(value)]
52
+ end
53
+ end
54
+
33
55
  ##
34
56
  # @return [String]
35
- attr_accessor :text
57
+ def text
58
+ @nodes.map(&:text).join
59
+ end
36
60
 
37
61
  ##
38
62
  # @return [String]
39
63
  alias_method :to_s, :text
64
+
65
+ ##
66
+ # The nodes making up the status line.
67
+ # @return [Array<LLM::Repl::Node>]
68
+ def nodes
69
+ if @agent.compacted?
70
+ [Node.new("Context compacted")]
71
+ else
72
+ @nodes
73
+ end
74
+ end
40
75
  end
41
76
  end
@@ -8,6 +8,10 @@ class LLM::Repl
8
8
  # tool returns.
9
9
  # @api private
10
10
  class Stream < LLM::Stream
11
+ ##
12
+ # @return [Hash<Symbol, LLM::Tool>]
13
+ attr_reader :tools
14
+
11
15
  ##
12
16
  # @param [LLM::Repl] repl
13
17
  # @return [LLM::Repl::Stream]
@@ -15,6 +19,7 @@ class LLM::Repl
15
19
  @repl = repl
16
20
  @_queue = queue
17
21
  @buffer = +""
22
+ @tools = {}
18
23
  end
19
24
 
20
25
  ##
@@ -30,15 +35,16 @@ class LLM::Repl
30
35
  # @param [LLM::Function] tool
31
36
  # @return [void]
32
37
  def on_tool_call(tool)
33
- @_queue.push [:status, "#{tool.name}(#{format_args(tool)})"]
38
+ @tools[tool.name] = tool
39
+ @_queue.push [:status, [" ", lambda, " • #{tool.name}(#{format_args(tool)})"]]
34
40
  end
35
41
 
36
42
  ##
37
- # @param [LLM::Function] _tool
43
+ # @param [LLM::Function] tool
38
44
  # @param [LLM::Function::Return] result
39
45
  # @return [void]
40
- def on_tool_return(_tool, result)
41
- @_queue.push [:status, @repl.thinking_text]
46
+ def on_tool_return(tool, result)
47
+ @tools.delete(tool.name)
42
48
  end
43
49
 
44
50
  ##
@@ -50,6 +56,12 @@ class LLM::Repl
50
56
 
51
57
  private
52
58
 
59
+ ##
60
+ # @return [LLM::Repl::Node]
61
+ def lambda
62
+ Node.new("λ", Curses::A_BOLD | Color.red)
63
+ end
64
+
53
65
  ##
54
66
  # Formats tool arguments as compact key: value pairs
55
67
  # suitable for the status line. Strings are quoted and
@@ -18,12 +18,13 @@ class LLM::Repl
18
18
  end
19
19
 
20
20
  ##
21
- # @return [Object, nil]
21
+ # @return [Object, String, nil]
22
22
  def next
23
23
  if @items.empty?
24
24
  nil
25
25
  elsif @cursor >= @items.size - 1
26
- @items[@cursor]
26
+ @cursor = @items.size
27
+ ""
27
28
  else
28
29
  @cursor += 1
29
30
  @items[@cursor]
@@ -23,6 +23,7 @@ class LLM::Repl
23
23
  # A read-eval-print loop.
24
24
  # @return [LLM::Repl::Window]
25
25
  def initialize(repl)
26
+ @repl = repl
26
27
  @status = repl.status
27
28
  @buffer = repl.buffer
28
29
  @input = repl.input
@@ -33,6 +34,7 @@ class LLM::Repl
33
34
  # @return [void]
34
35
  def open
35
36
  Curses.init_screen
37
+ Color.enable
36
38
  Curses.cbreak
37
39
  Curses.noecho
38
40
  Curses.stdscr.keypad(true)
@@ -58,6 +60,12 @@ class LLM::Repl
58
60
  [Curses.lines - (input.height + 4), 1].max
59
61
  end
60
62
 
63
+ ##
64
+ # @return [Integer]
65
+ def columns
66
+ Curses.cols
67
+ end
68
+
61
69
  ##
62
70
  # @return [Object]
63
71
  def getch
@@ -107,11 +115,17 @@ class LLM::Repl
107
115
  rows.each.with_index(offset) do |row, index|
108
116
  Curses.setpos(index, 0)
109
117
  Curses.clrtoeol
118
+ Curses.setpos(index, gutter)
119
+ width = 0
110
120
  row.each do |chunk|
111
- text, attrs = chunk.text, chunk.attrs
121
+ remaining = buffer.width - width
122
+ break if remaining <= 0
123
+ text, attrs = chunk.text.to_s, chunk.attrs
124
+ clipped = text[0, remaining]
112
125
  Curses.attron(attrs) if attrs
113
- Curses.addstr(text)
126
+ Curses.addstr(clipped)
114
127
  Curses.attroff(attrs) if attrs
128
+ width += clipped.length
115
129
  end
116
130
  end
117
131
  last_drawn = offset + rows.size
@@ -124,7 +138,11 @@ class LLM::Repl
124
138
  def draw_status(offset:)
125
139
  Curses.setpos(Curses.lines - offset, 0)
126
140
  Curses.clrtoeol
127
- Curses.addstr(status.to_s)
141
+ status.nodes.each do |node|
142
+ Curses.attron(node.attrs) if node.attrs
143
+ Curses.addstr(node.text)
144
+ Curses.attroff(node.attrs) if node.attrs
145
+ end
128
146
  context = status.context_bar
129
147
  Curses.setpos(Curses.lines - offset, [(columns - context.length) / 2, 0].max)
130
148
  Curses.addstr(context)
@@ -151,9 +169,11 @@ class LLM::Repl
151
169
  end
152
170
 
153
171
  ##
172
+ # 20% offset that occupies the left margin and
173
+ # helps center {LLM::Repl::Buffer LLM::Repl::Buffer}.
154
174
  # @return [Integer]
155
- def columns
156
- Curses.cols
175
+ def gutter
176
+ (columns * 0.2).floor
157
177
  end
158
178
  end
159
179
  end
data/lib/llm/repl.rb CHANGED
@@ -15,6 +15,7 @@ module LLM
15
15
  # task correctly, or for it to correct course after a
16
16
  # mistake was made.
17
17
  class Repl
18
+ require_relative "repl/color"
18
19
  require_relative "repl/window"
19
20
  require_relative "repl/status"
20
21
  require_relative "repl/buffer"
@@ -29,7 +30,7 @@ module LLM
29
30
  attr_reader :agent, :provider, :stream,
30
31
  :status, :buffer, :input,
31
32
  :window, :tools, :thread,
32
- :name, :path, :width
33
+ :name, :path
33
34
 
34
35
  ##
35
36
  # @param [LLM::Agent] agent
@@ -43,14 +44,14 @@ module LLM
43
44
  # Zero or more skills
44
45
  # @return [LLM::Repl]
45
46
  def initialize(agent:, name: nil, tools: [], skills: [], path: nil)
46
- @width = 80
47
47
  @path = path
48
48
  @name = name || "agent"
49
+ @sender = "You"
49
50
  @agent = configure(agent:, path:)
50
51
  @provider = agent.llm.name
51
- @status = Status.new(self)
52
- @buffer = Buffer.new(self)
53
52
  @input = Input.new(self, height: 3)
53
+ @buffer = Buffer.new(self)
54
+ @status = Status.new(self)
54
55
  @window = Window.new(self)
55
56
  @thread = nil
56
57
  @queue = Queue.new
@@ -112,7 +113,7 @@ module LLM
112
113
  # @param [String] chars
113
114
  # @return [Array<Node>]
114
115
  def markdown(chars)
115
- LLM::Repl::Markdown.new(chars, width).ast
116
+ LLM::Repl::Markdown.new(chars, buffer.width).ast
116
117
  end
117
118
 
118
119
  ##
@@ -125,8 +126,21 @@ module LLM
125
126
 
126
127
  ##
127
128
  # @return [String]
128
- def thinking_text
129
- "thinking • Esc to cancel"
129
+ def think_text
130
+ " 🧠 • Thinking • Esc to cancel"
131
+ end
132
+
133
+ ##
134
+ # @return [String]
135
+ def connect_text
136
+ " 🌐 • Connecting • Esc to cancel"
137
+ end
138
+
139
+ ##
140
+ # Returns a label for the person who sent a message.
141
+ # @return [String]
142
+ def sender
143
+ @sender
130
144
  end
131
145
 
132
146
  private
@@ -138,11 +152,11 @@ module LLM
138
152
  def tree(messages)
139
153
  messages.flat_map do |message|
140
154
  next if message.tool_call? || message.tool_return?
141
- user = message.assistant? ? name : "user"
155
+ user = message.assistant? ? name : sender
142
156
  [
143
- Node.new("#{user}: ", Curses::A_BOLD),
144
- Node.new(message.content),
145
- Node.new(user == name ? "\n\n" : "\n")
157
+ Node.new("#{user}:\n", Curses::A_BOLD | Color.blue),
158
+ *markdown(message.content),
159
+ Node.new("\n\n")
146
160
  ]
147
161
  end.compact
148
162
  end
@@ -186,8 +200,8 @@ module LLM
186
200
  write("\n")
187
201
  in [:input, String => text]
188
202
  window.scroll_to_bottom
189
- status.text = thinking_text
190
- write_message("user", markdown(text))
203
+ status.text = connect_text
204
+ write_message(sender, markdown(text))
191
205
  @thread = Thread.new do
192
206
  @queue << [:start]
193
207
  agent.talk(text, tools:, stream:)
@@ -245,6 +259,7 @@ module LLM
245
259
  buffer.open
246
260
  stream.clear
247
261
  when :stream
262
+ status.text = think_text if stream.tools.empty?
248
263
  write_message name, markdown(value), method: :replace
249
264
  when :status
250
265
  self.status = value
@@ -254,6 +269,7 @@ module LLM
254
269
  @thread = nil
255
270
  write("\n")
256
271
  when :cancel
272
+ stream.tools.clear
257
273
  buffer.close
258
274
  status.text = "Idle"
259
275
  write_message(name, "Request cancelled")
data/lib/llm/stream.rb CHANGED
@@ -128,7 +128,10 @@ module LLM
128
128
  ##
129
129
  # Called when a streamed tool call has been fully constructed.
130
130
  # A stream implementation may start tool execution here by pushing
131
- # `@queue << tool.task(:thread)` onto {#queue}.
131
+ # `@queue << tool.task(:thread).tap(&:spawn)` onto {#queue} — `spawn`
132
+ # returns nil, so `tap` keeps the task while starting it. The task
133
+ # carries the context's guard, so it is checked before the tool runs —
134
+ # a blocked call yields its guard return without executing.
132
135
  # @param [LLM::Function] tool
133
136
  # The parsed tool call.
134
137
  # @return [nil]
@@ -151,19 +154,17 @@ module LLM
151
154
 
152
155
  ##
153
156
  # Called before a context transformer rewrites a prompt.
154
- # @param [LLM::Context] ctx
155
- # @param [#call] transformer
157
+ # @param [LLM::Transformer] transformer
156
158
  # @return [nil]
157
- def on_transform(ctx, transformer)
159
+ def on_transform(transformer)
158
160
  nil
159
161
  end
160
162
 
161
163
  ##
162
164
  # Called after a context transformer finishes rewriting a prompt.
163
- # @param [LLM::Context] ctx
164
- # @param [#call] transformer
165
+ # @param [LLM::Transformer] transformer
165
166
  # @return [nil]
166
- def on_transform_finish(ctx, transformer)
167
+ def on_transform_finish(transformer)
167
168
  nil
168
169
  end
169
170
 
data/lib/llm/tool.rb CHANGED
@@ -150,6 +150,35 @@ class LLM::Tool
150
150
  end
151
151
  end
152
152
 
153
+ ##
154
+ # Assign multiple tool properties at once.
155
+ # @example
156
+ # class Tool < LLM::Tool
157
+ # set name: "my-tool",
158
+ # description: "my tool does this and that",
159
+ # parameters: [
160
+ # [:name, String , "this and that"],
161
+ # [:age , Integer, "this and that"]
162
+ # ],
163
+ # required: %i[name],
164
+ # defaults: {age: 42}
165
+ # end
166
+ # @param [Hash] properties
167
+ # @return [void]
168
+ def self.set(properties)
169
+ properties.each do
170
+ if _1.to_s == "parameters"
171
+ _2.each { |attributes| parameter(*attributes) }
172
+ else
173
+ if respond_to?(_1)
174
+ public_send(_1, _2)
175
+ else
176
+ raise KeyError, "key not found: #{_1}"
177
+ end
178
+ end
179
+ end
180
+ end
181
+
153
182
  ##
154
183
  # Returns (or sets) the tool name
155
184
  # @param [String, nil] name The tool name
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Transformer
4
+ ##
5
+ # An {LLM::Transformer::Null LLM::Transformer::Null} is a
6
+ # transformer that does nothing. It is used as the default when
7
+ # no transformer strategy is configured.
8
+ #
9
+ # It returns the given message unchanged.
10
+ class Null < self
11
+ ##
12
+ # @param [LLM::Message] message
13
+ # The message to transform
14
+ # @param [Hash] opts
15
+ # Ignored
16
+ # @return [LLM::Message]
17
+ def call(message:, **opts)
18
+ message
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # {LLM::Transformer LLM::Transformer} is the superclass for
6
+ # message transformers in llm.rb.
7
+ #
8
+ # A transformer is bound to a context and rewrites a single
9
+ # message before it is sent to the provider. Each subclass
10
+ # implements a different transformation: it takes a message in
11
+ # {#call} and returns a message. {LLM::Transformer::Null} is a
12
+ # no-op (the default).
13
+ #
14
+ # A transformer may mutate the message in place or return a new
15
+ # one. Either way, the returned message is what gets sent.
16
+ class Transformer
17
+ require_relative "transformer/null"
18
+
19
+ ##
20
+ # @return [LLM::Context]
21
+ attr_reader :ctx
22
+
23
+ ##
24
+ # @param ctx [LLM::Context, LLM::Agent]
25
+ # @return [LLM::Transformer]
26
+ def initialize(ctx)
27
+ @ctx = LLM::Agent === ctx ? ctx.instance_variable_get(:@ctx) : ctx
28
+ end
29
+
30
+ ##
31
+ # @abstract
32
+ # @param [LLM::Message] message
33
+ # The message to transform
34
+ # @param [Hash] opts
35
+ # Per-call options
36
+ # @return [LLM::Message]
37
+ def call(message:, **opts)
38
+ raise NotImplementedError
39
+ end
40
+
41
+ private
42
+
43
+ ##
44
+ # @return [LLM::Stream]
45
+ def stream
46
+ @ctx.params[:stream]
47
+ end
48
+
49
+ ##
50
+ # @return [LLM::Buffer]
51
+ def messages
52
+ @ctx.messages
53
+ end
54
+ end
55
+ 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 = "13.1.0"
4
+ VERSION = "14.0.0"
5
5
  end
data/lib/llm.rb CHANGED
@@ -7,7 +7,7 @@
7
7
  #
8
8
  # @example The three-step workflow
9
9
  # require "llm"
10
- # llm = LLM.deepseek(key: ENV["KEY"]) # 1. pick a provider
10
+ # llm = LLM.deepseek(key: ENV["KEY"]) # 1. pick a provider
11
11
  # agent = LLM::Agent.new(llm, stream: $stdout) # 2. create an agent
12
12
  # agent.talk "Hello world" # 3. talk to it
13
13
  #
@@ -17,6 +17,7 @@ module LLM
17
17
  require "stringio"
18
18
  require "securerandom"
19
19
  require_relative "llm/compactor"
20
+ require_relative "llm/transformer"
20
21
  require_relative "llm/json_adapter"
21
22
  require_relative "llm/tracer"
22
23
  require_relative "llm/error"
@@ -40,7 +41,7 @@ module LLM
40
41
  require_relative "llm/stream"
41
42
  require_relative "llm/provider"
42
43
  require_relative "llm/context"
43
- require_relative "llm/loop_guard"
44
+ require_relative "llm/guard"
44
45
  require_relative "llm/agent"
45
46
  require_relative "llm/buffer"
46
47
  require_relative "llm/function"
@@ -218,6 +219,15 @@ module LLM
218
219
  LLM::ZAI.new(**)
219
220
  end
220
221
 
222
+ ##
223
+ # @param key (see LLM::Moonshot#initialize)
224
+ # @param host (see LLM::Moonshot#initialize)
225
+ # @return (see LLM::Moonshot#initialize)
226
+ def moonshot(**)
227
+ lock(:require) { require_relative "llm/providers/moonshot" unless defined?(LLM::Moonshot) }
228
+ LLM::Moonshot.new(**)
229
+ end
230
+
221
231
  ##
222
232
  # @param [Hash] opts
223
233
  # MCP client options
data/llm.gemspec CHANGED
@@ -30,6 +30,7 @@ DESCRIPTION
30
30
  "lib/*.rb", "lib/**/*.rb",
31
31
  "data/*.json", "CHANGELOG.md",
32
32
  "resources/deepdive.md",
33
+ "resources/deepdive/*/*.md",
33
34
  "llm.gemspec", "bin/llm.rb"
34
35
  ]
35
36
  spec.executables = ["llm.rb"]
@@ -0,0 +1,74 @@
1
+
2
+ ## Cancellation
3
+
4
+ ### Introduction
5
+
6
+ #### Overview
7
+
8
+ Cancellation lets you abort a model request mid-stream and interrupt
9
+ any tools that are currently executing. The user changes their mind.
10
+ The model goes off course. A tool hangs. In all three cases,
11
+ cancellation stops the work and reclaims the tokens.
12
+
13
+ #### How it works
14
+
15
+ When you want to cancel an active request or tool call, call
16
+ [`LLM::Agent#cancel!`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#cancel!)
17
+ or
18
+ [`LLM::Context#cancel!`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#cancel!)
19
+ from any thread. Two things
20
+ happen at once:
21
+
22
+ [`LLM::Interrupt`](https://r.uby.dev/api-docs/llm.rb/LLM/Interrupt.html)
23
+ is raised on the thread where
24
+ [`LLM::Agent#talk`](https://r.uby.dev/api-docs/llm.rb/LLM/Agent.html#talk)
25
+ or
26
+ [`LLM::Context#talk`](https://r.uby.dev/api-docs/llm.rb/LLM/Context.html#talk)
27
+ is running, so the caller
28
+ can rescue it and know the request was cancelled.
29
+
30
+ At the same time,
31
+ [`LLM::Interrupt`](https://r.uby.dev/api-docs/llm.rb/LLM/Interrupt.html)
32
+ is raised on every active tool.
33
+ A tool running in a thread gets it on that thread. A tool in a
34
+ fiber gets it on that fiber. A tool in a forked process gets it
35
+ via a message over the control channel. Pending tools (not yet
36
+ started) are cancelled through
37
+ [`LLM::Function#cancel`](https://r.uby.dev/api-docs/llm.rb/LLM/Function.html#cancel)
38
+ without ever being executed.
39
+
40
+ The transport layer also cancels the in-flight HTTP request.
41
+
42
+ ```ruby
43
+ require "llm"
44
+
45
+ llm = LLM.deepseek(key: ENV["DEEPSEEK_SECRET"])
46
+ agent = LLM::Agent.new(llm)
47
+ queue = Queue.new
48
+
49
+ Thread.new do
50
+ queue.push(nil)
51
+ sleep(2)
52
+ agent.cancel!
53
+ end
54
+
55
+ begin
56
+ queue.pop
57
+ agent.talk "write me a very long poem", stream: $stdout
58
+ rescue LLM::Interrupt
59
+ puts "request cancelled!"
60
+ end
61
+ ```
62
+
63
+ #### Why would I use it?
64
+
65
+ Cancellation prevents wasted time and tokens when the model goes
66
+ off course, the user changes their mind, or a tool hangs. A forked
67
+ tool that enters an infinite loop would run forever without it.
68
+
69
+ #### Notes
70
+
71
+ The `:ractor` strategy delivers the interrupt through ractor
72
+ message passing. The `:fork` strategy delivers it via a message
73
+ over the xchan control channel. All other strategies raise the
74
+ exception directly on the executing thread or fiber.