llm.rb 12.4.0 → 12.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/llm/message.rb CHANGED
@@ -120,7 +120,9 @@ module LLM
120
120
  # @return [Array<LLM::Function>]
121
121
  def functions
122
122
  @functions ||= tool_calls.filter_map do |fn|
123
- function = available_tools.find { _1.name.to_s == fn["name"] } || next
123
+ function = available_tools.find { _1.name.to_s == fn.name } ||
124
+ LLM::Function.find_by_name(fn.name) ||
125
+ function_missing(fn.name)
124
126
  function = function.dup
125
127
  function.tap { _1.id = fn.id }
126
128
  function.tap { _1.arguments = fn.arguments }
@@ -222,10 +224,18 @@ module LLM
222
224
  end
223
225
 
224
226
  def available_tools
225
- tools = extra.tools || response&.__tools__ || []
227
+ tools = extra.tools || []
228
+ tools = nil if tools.empty?
229
+ tools = tools || response&.__tools__ || []
226
230
  tools.map { _1.respond_to?(:function) ? _1.function : _1 }
227
231
  end
228
232
 
233
+ def function_missing(name)
234
+ LLM.function(name) do |fn|
235
+ fn.define { raise LLM::NoSuchToolError, "tool not found" }
236
+ end
237
+ end
238
+
229
239
  def content_items
230
240
  Array(content)
231
241
  end
data/lib/llm/provider.rb CHANGED
@@ -26,7 +26,7 @@ class LLM::Provider
26
26
  # Requires the net-http-persistent gem.
27
27
  # @param [LLM::Transport, Class, nil] transport
28
28
  # Optional override with any {LLM::Transport} instance or subclass.
29
- def initialize(key:, host:, port: 443, timeout: 180, ssl: true, base_path: "", persistent: false, transport: nil)
29
+ def initialize(key:, host:, port: 443, timeout: 900, ssl: true, base_path: "", persistent: false, transport: nil)
30
30
  @key = key
31
31
  @host = host
32
32
  @port = port
@@ -349,6 +349,16 @@ class LLM::Provider
349
349
  @key != nil && @key.to_s.strip.size > 0
350
350
  end
351
351
 
352
+ ##
353
+ # Adapt a {LLM::Function} to the provider-specific tool schema.
354
+ # @abstract
355
+ # @param [LLM::Function] fn
356
+ # @raise [NotImplementedError]
357
+ # @return [Hash]
358
+ def adapt_function(fn)
359
+ raise NotImplementedError
360
+ end
361
+
352
362
  private
353
363
 
354
364
  def path(suffix, base_path: true)
@@ -10,15 +10,11 @@ class LLM::Anthropic
10
10
  attr_reader :body
11
11
 
12
12
  ##
13
- # @param [#<<, LLM::Stream] stream
14
- # A stream sink that implements {#<<} or the {LLM::Stream} interface
13
+ # @param [LLM::Stream] stream
15
14
  # @return [LLM::Anthropic::StreamParser]
16
15
  def initialize(stream)
17
16
  @body = {"role" => "assistant", "content" => []}
18
17
  @stream = stream
19
- @can_emit_content = stream.respond_to?(:on_content)
20
- @can_emit_tool_call = stream.respond_to?(:on_tool_call)
21
- @can_push_content = stream.respond_to?(:<<)
22
18
  end
23
19
 
24
20
  ##
@@ -91,17 +87,12 @@ class LLM::Anthropic
91
87
  end
92
88
 
93
89
  def emit_content(value)
94
- if @can_emit_content
95
- @stream.on_content(value)
96
- elsif @can_push_content
97
- @stream << value
98
- end
90
+ @stream.on_content(value)
99
91
  end
100
92
 
101
93
  def emit_tool(tool)
102
- return unless @can_emit_tool_call
103
94
  function, error = resolve_tool(tool)
104
- @stream.on_tool_call(function, error)
95
+ error ? @stream.queue << error : @stream.on_tool_call(function)
105
96
  end
106
97
 
107
98
  def resolve_tool(tool)
@@ -81,6 +81,17 @@ module LLM
81
81
  "assistant"
82
82
  end
83
83
 
84
+ ##
85
+ # @param [LLM::Function] fn
86
+ # @return [Hash]
87
+ def adapt_function(fn)
88
+ {
89
+ name: fn.name,
90
+ description: fn.description,
91
+ input_schema: fn.params || {type: "object", properties: {}}
92
+ }.compact
93
+ end
94
+
84
95
  ##
85
96
  # Anthropic expects tool results to be sent as user messages
86
97
  # containing `tool_result` content blocks rather than a distinct
@@ -24,15 +24,11 @@ class LLM::Bedrock
24
24
  attr_reader :body
25
25
 
26
26
  ##
27
- # @param [#<<, LLM::Stream] stream
27
+ # @param [LLM::Stream] stream
28
28
  def initialize(stream)
29
29
  @body = {"output" => {"message" => {"role" => "assistant", "content" => []}}}
30
30
  @stream = stream
31
31
  @text_markers = {}
32
- @can_emit_content = stream.respond_to?(:on_content)
33
- @can_emit_reasoning_content = stream.respond_to?(:on_reasoning_content)
34
- @can_emit_tool_call = stream.respond_to?(:on_tool_call)
35
- @can_push_content = stream.respond_to?(:<<)
36
32
  end
37
33
 
38
34
  ##
@@ -165,21 +161,16 @@ class LLM::Bedrock
165
161
  end
166
162
 
167
163
  def emit_content(value)
168
- if @can_emit_content
169
- @stream.on_content(value)
170
- elsif @can_push_content
171
- @stream << value
172
- end
164
+ @stream.on_content(value)
173
165
  end
174
166
 
175
167
  def emit_reasoning_content(value)
176
- @stream.on_reasoning_content(value) if @can_emit_reasoning_content
168
+ @stream.on_reasoning_content(value)
177
169
  end
178
170
 
179
171
  def emit_tool(tool)
180
- return unless @can_emit_tool_call
181
172
  function, error = resolve_tool(tool)
182
- @stream.on_tool_call(function, error)
173
+ error ? @stream.queue << error : @stream.on_tool_call(function)
183
174
  end
184
175
 
185
176
  def resolve_tool(tool)
@@ -10,16 +10,12 @@ class LLM::Google
10
10
  attr_reader :body
11
11
 
12
12
  ##
13
- # @param [#<<, LLM::Stream] stream
14
- # A stream sink that implements {#<<} or the {LLM::Stream} interface
13
+ # @param [LLM::Stream] stream
15
14
  # @return [LLM::Google::StreamParser]
16
15
  def initialize(stream)
17
16
  @body = {"candidates" => []}
18
17
  @stream = stream
19
18
  @emits = {tools: []}
20
- @can_emit_content = stream.respond_to?(:on_content)
21
- @can_emit_tool_call = stream.respond_to?(:on_tool_call)
22
- @can_push_content = stream.respond_to?(:<<)
23
19
  end
24
20
 
25
21
  ##
@@ -129,21 +125,16 @@ class LLM::Google
129
125
  end
130
126
 
131
127
  def emit_content(value)
132
- if @can_emit_content
133
- @stream.on_content(value)
134
- elsif @can_push_content
135
- @stream << value
136
- end
128
+ @stream.on_content(value)
137
129
  end
138
130
 
139
131
  def emit_tool(pindex, cindex, part)
140
- return unless @can_emit_tool_call
141
132
  return unless complete_tool?(part)
142
133
  key = [cindex, pindex]
143
134
  return if @emits[:tools].include?(key)
144
135
  function, error = resolve_tool(part, cindex, pindex)
145
136
  @emits[:tools] << key
146
- @stream.on_tool_call(function, error)
137
+ error ? @stream.queue << error : @stream.on_tool_call(function)
147
138
  end
148
139
 
149
140
  def complete_tool?(part)
@@ -175,6 +175,13 @@ module LLM
175
175
  "model"
176
176
  end
177
177
 
178
+ ##
179
+ # @param [LLM::Function] fn
180
+ # @return [Hash]
181
+ def adapt_function(fn)
182
+ {name: fn.name, description: fn.description, parameters: fn.params}.compact
183
+ end
184
+
178
185
  private
179
186
 
180
187
  def headers
@@ -119,6 +119,17 @@ module LLM
119
119
  "mistral-large-latest"
120
120
  end
121
121
 
122
+ ##
123
+ # @param [LLM::Function] fn
124
+ # @return [Hash]
125
+ def adapt_function(fn)
126
+ params = fn.params || {type: "object", properties: {}}
127
+ {
128
+ type: "function",
129
+ function: {name: fn.name, description: fn.description, parameters: params}
130
+ }.compact
131
+ end
132
+
122
133
  private
123
134
 
124
135
  ##
@@ -10,11 +10,11 @@ class LLM::Ollama
10
10
  attr_reader :body
11
11
 
12
12
  ##
13
- # @return [LLM::OpenAI::Chunk]
13
+ # @param [LLM::Stream] stream
14
+ # @return [LLM::Ollama::StreamParser]
14
15
  def initialize(stream)
15
16
  @body = {}
16
17
  @stream = stream
17
- @can_push_content = stream.respond_to?(:<<)
18
18
  end
19
19
 
20
20
  ##
@@ -37,10 +37,10 @@ class LLM::Ollama
37
37
  if key == "message"
38
38
  if @body[key]
39
39
  @body[key]["content"] << value["content"]
40
- @stream << value["content"] if @can_push_content
40
+ @stream.on_content(value["content"])
41
41
  else
42
42
  @body[key] = value
43
- @stream << value["content"] if @can_push_content
43
+ @stream.on_content(value["content"])
44
44
  end
45
45
  else
46
46
  @body[key] = value
@@ -98,6 +98,17 @@ module LLM
98
98
  "qwen3:latest"
99
99
  end
100
100
 
101
+ ##
102
+ # @param [LLM::Function] fn
103
+ # @return [Hash]
104
+ def adapt_function(fn)
105
+ params = fn.params || {type: "object", properties: {}}
106
+ {
107
+ type: "function", name: fn.name,
108
+ function: {name: fn.name, description: fn.description, parameters: params}
109
+ }.compact
110
+ end
111
+
101
112
  private
102
113
 
103
114
  def headers
@@ -12,17 +12,12 @@ class LLM::OpenAI
12
12
  attr_reader :body
13
13
 
14
14
  ##
15
- # @param [#<<, LLM::Stream] stream
16
- # A stream sink that implements {#<<} or the {LLM::Stream} interface
15
+ # @param [LLM::Stream] stream
17
16
  # @return [LLM::OpenAI::Responses::StreamParser]
18
17
  def initialize(stream)
19
18
  @body = {"output" => []}
20
19
  @stream = stream
21
20
  @emits = {tools: {}}
22
- @can_emit_content = stream.respond_to?(:on_content)
23
- @can_emit_reasoning_content = stream.respond_to?(:on_reasoning_content)
24
- @can_emit_tool_call = stream.respond_to?(:on_tool_call)
25
- @can_push_content = stream.respond_to?(:<<)
26
21
  @cached_output_index = nil
27
22
  @cached_output_item = nil
28
23
  @cached_content_index = nil
@@ -240,26 +235,21 @@ class LLM::OpenAI
240
235
  # @group Emitters
241
236
 
242
237
  def emit_content(value)
243
- if @can_emit_content
244
- @stream.on_content(value)
245
- elsif @can_push_content
246
- @stream << value
247
- end
238
+ @stream.on_content(value)
248
239
  end
249
240
 
250
241
  def emit_reasoning_content(value)
251
- @stream.on_reasoning_content(value) if @can_emit_reasoning_content
242
+ @stream.on_reasoning_content(value)
252
243
  end
253
244
 
254
245
  def emit_tool(index, tool)
255
- return unless @can_emit_tool_call
256
246
  return if @emits[:tools][index]
257
247
  return unless tool["call_id"] && tool["name"]
258
248
  arguments = parse_arguments(tool["arguments"])
259
249
  return unless arguments
260
250
  function, error = resolve_tool(tool, arguments)
261
251
  @emits[:tools][index] = true
262
- @stream.on_tool_call(function, error)
252
+ error ? @stream.queue << error : @stream.on_tool_call(function)
263
253
  end
264
254
 
265
255
  ##
@@ -86,6 +86,16 @@ class LLM::OpenAI
86
86
  res
87
87
  end
88
88
 
89
+ ##
90
+ # @param [LLM::Function] fn
91
+ # @return [Hash]
92
+ def adapt_function(fn)
93
+ {
94
+ type: "function", name: fn.name, description: fn.description,
95
+ parameters: (fn.params || {type: "object", properties: {}}).to_h.merge(additionalProperties: false), strict: false
96
+ }.compact
97
+ end
98
+
89
99
  private
90
100
 
91
101
  [:path, :headers, :execute, :transport, :resolve_tools].each do |m|
@@ -12,15 +12,12 @@ class LLM::OpenAI
12
12
  attr_reader :body
13
13
 
14
14
  ##
15
- # @return [LLM::OpenAI::Chunk]
15
+ # @param [LLM::Stream] stream
16
+ # @return [LLM::OpenAI::StreamParser]
16
17
  def initialize(stream)
17
18
  @body = {}
18
19
  @stream = stream
19
20
  @emits = {tools: {}}
20
- @can_emit_content = stream.respond_to?(:on_content)
21
- @can_emit_reasoning_content = stream.respond_to?(:on_reasoning_content)
22
- @can_emit_tool_call = stream.respond_to?(:on_tool_call)
23
- @can_push_content = stream.respond_to?(:<<)
24
21
  end
25
22
 
26
23
  ##
@@ -157,21 +154,14 @@ class LLM::OpenAI
157
154
  end
158
155
 
159
156
  def emit_content(value)
160
- if @can_emit_content
161
- @stream.on_content(value)
162
- elsif @can_push_content
163
- @stream << value
164
- end
157
+ @stream.on_content(value)
165
158
  end
166
159
 
167
160
  def emit_reasoning_content(value)
168
- if @can_emit_reasoning_content
169
- @stream.on_reasoning_content(value)
170
- end
161
+ @stream.on_reasoning_content(value)
171
162
  end
172
163
 
173
164
  def emit_tool(tool, tindex)
174
- return unless @can_emit_tool_call
175
165
  return if @emits[:tools][tindex]
176
166
  function = tool["function"]
177
167
  return unless function && tool["id"] && function["name"]
@@ -180,7 +170,7 @@ class LLM::OpenAI
180
170
  return unless arguments
181
171
  function, error = resolve_tool(tool, function, arguments)
182
172
  @emits[:tools][tindex] = true
183
- @stream.on_tool_call(function, error)
173
+ error ? @stream.queue << error : @stream.on_tool_call(function)
184
174
  end
185
175
 
186
176
  def resolve_tool(tool, function, arguments)
@@ -152,6 +152,17 @@ module LLM
152
152
  "gpt-5.4-mini"
153
153
  end
154
154
 
155
+ ##
156
+ # @param [LLM::Function] fn
157
+ # @return [Hash]
158
+ def adapt_function(fn)
159
+ params = fn.params || {type: "object", properties: {}}
160
+ {
161
+ type: "function", name: fn.name,
162
+ function: {name: fn.name, description: fn.description, parameters: params}
163
+ }.compact
164
+ end
165
+
155
166
  ##
156
167
  # @note
157
168
  # This method includes certain tools that require configuration
@@ -7,9 +7,41 @@ class LLM::Repl
7
7
  # description. This basic version does not implement parameters. A
8
8
  # command is accessible via the `/` prefix: eg `/exit`.
9
9
  class Command
10
+ UNDEFINED = Object.new
11
+ SINGLETON = self
12
+ private_constant :UNDEFINED, :SINGLETON
13
+
10
14
  ##
11
15
  # @api private
12
- UNDEFINED = Object.new
16
+ Parameter = Struct.new(:name, :type, :description, :options, :index, :value) do
17
+ ##
18
+ # @return [Boolean]
19
+ def required?
20
+ options[:required] == true
21
+ end
22
+
23
+ ##
24
+ # Mark the parameter as required
25
+ # @return [void]
26
+ def required!
27
+ options[:required] = true
28
+ end
29
+
30
+ ##
31
+ # @return [Boolean]
32
+ def optional?
33
+ !required?
34
+ end
35
+
36
+ ##
37
+ # Assign a parameter value - with type checks
38
+ # @param [String] other
39
+ # @return [void]
40
+ def value=(other)
41
+ raise TypeError, "#{other.class} is not a #{type}" unless type === other
42
+ self[:value] = other
43
+ end
44
+ end
13
45
 
14
46
  ##
15
47
  # Find a command by a name, or by an input string.
@@ -26,15 +58,15 @@ class LLM::Repl
26
58
  # @param [String] input
27
59
  # @param [String] name
28
60
  # @return [LLM::Repl::Command, nil]
29
- def self.find_by(input: nil, name: nil)
30
- if input
61
+ def self.find_by(input: UNDEFINED, name: UNDEFINED)
62
+ if input != UNDEFINED
31
63
  return nil unless input[0] == "/"
32
64
  n, = input.split(" ")
33
65
  registry.find { n[1..] == _1.name }
34
- elsif name
66
+ elsif name != UNDEFINED
35
67
  registry.find { name == _1.name }
36
68
  else
37
- raise ArgumentError, "provide one of: input, name"
69
+ raise ArgumentError, "provide either an input or a name"
38
70
  end
39
71
  end
40
72
 
@@ -45,6 +77,8 @@ class LLM::Repl
45
77
  def self.inherited(command)
46
78
  LLM.lock(:inherited) do
47
79
  registry << command
80
+ command.instance_variable_set(:@parameters, {})
81
+ command.define_singleton_method(:inherited) { |command| SINGLETON.inherited(command) }
48
82
  end
49
83
  end
50
84
 
@@ -74,12 +108,94 @@ class LLM::Repl
74
108
  @description = description
75
109
  end
76
110
 
111
+ ##
112
+ # @param [Symbol] name
113
+ # @param [Class] type
114
+ # @param [String] description
115
+ # @param [Hash] options
116
+ # @return [void]
117
+ def self.parameter(name, type, description, options = {})
118
+ @parameters[name] = Parameter.new(
119
+ name, type,
120
+ description, options,
121
+ @parameters.size, nil
122
+ )
123
+ end
124
+
125
+ ##
126
+ # @return [Hash]
127
+ def self.parameters
128
+ @parameters
129
+ end
130
+
131
+ ##
132
+ # @param [Array<Symbol>] names
133
+ # One or more required names
134
+ # @return [void]
135
+ def self.required(names)
136
+ names.each do |name|
137
+ if @parameters.key?(name)
138
+ @parameters[name].required!
139
+ else
140
+ raise LLM::Error, "'#{name}' is not a known parameter"
141
+ end
142
+ end
143
+ end
144
+
145
+ ##
146
+ # @param [LLM::Repl] repl
147
+ # @return [LLM::Repl::Command]
148
+ def initialize(repl)
149
+ @repl = repl
150
+ end
151
+
152
+ ##
153
+ # Write a string to the transcript
154
+ # @param [String] str
155
+ # @return [void]
156
+ def write(str, who: "command(#{self.class.name}): ")
157
+ @repl.write(who, Curses::A_BOLD)
158
+ @repl.write(str)
159
+ end
160
+
161
+ ##
162
+ # Display a formatted help message for this command.
163
+ # Uses a single {#write} call to output the command name,
164
+ # description, and parameter details.
165
+ # @return [void]
166
+ def self.help
167
+ lines = []
168
+ lines << "Command: #{name}"
169
+ lines << "Description: #{description}"
170
+ unless parameters.empty?
171
+ lines << ""
172
+ lines << "Parameters:"
173
+ parameters.each_value do |param|
174
+ tag = param.required? ? "(required)" : "(optional)"
175
+ lines << " #{param.name} [#{param.type}] - #{param.description} #{tag}"
176
+ end
177
+ end
178
+ lines.join("\n")
179
+ end
180
+
77
181
  ##
78
182
  # This method should be implemented by subclasses.
79
183
  # @raise [NotImplementedError]
80
184
  def call(...)
81
185
  raise NotImplementedError, "#{self.class}#call is not implemented"
82
186
  end
187
+
188
+ ##
189
+ # @return [Hash<Symbol, Parameter>]
190
+ def parameters
191
+ self.class.parameters
192
+ end
193
+
83
194
  require_relative "commands/exit"
195
+ require_relative "commands/help"
84
196
  end
85
197
  end
198
+
199
+ ##
200
+ # Convenience constant
201
+ LLM::Command = LLM::Repl::Command
@@ -16,4 +16,8 @@ class LLM::Repl
16
16
  throw(:exit)
17
17
  end
18
18
  end
19
+
20
+ class Command::Quit < Command::Exit
21
+ name "quit"
22
+ end
19
23
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ class Help < Command
5
+ name "help"
6
+ description "show help for a given command"
7
+ parameter :name, String, "The name of a command"
8
+
9
+ ##
10
+ # @param [String] name
11
+ # @return [void]
12
+ def call(name: nil)
13
+ if name.nil?
14
+ write("\n#{self.class.help}\n\n")
15
+ elsif command = LLM::Command.find_by(name:)
16
+ write("\n#{command.help}\n\n")
17
+ else
18
+ write "\nNo help for #{name} was found" \
19
+ "\nThat command doesn't exist." \
20
+ "\n\n"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -19,6 +19,7 @@ class LLM::Repl
19
19
  DOWN = Curses::Key::DOWN
20
20
  LEFT = Curses::Key::LEFT
21
21
  RIGHT = Curses::Key::RIGHT
22
+ ESC = 27
22
23
  ENTER = [Curses::Key::ENTER, 10, 13]
23
24
  BACKSPACE = [Curses::Key::BACKSPACE, 127]
24
25
 
@@ -58,7 +59,9 @@ class LLM::Repl
58
59
  # @return [Symbol, nil]
59
60
  def on_char(window, char, now)
60
61
  is_paste = lambda { @last_char_at and (now - @last_char_at) < PASTE_THRESHOLD }
61
- if CTRL[:D] == char
62
+ if ESC == char
63
+ @agent.cancel!
64
+ elsif CTRL[:D] == char
62
65
  delete
63
66
  :ctrl_d
64
67
  elsif CTRL[:A] == char
@@ -28,14 +28,9 @@ class LLM::Repl
28
28
 
29
29
  ##
30
30
  # @param [LLM::Function] tool
31
- # @param [LLM::Function::Return, nil] error
32
31
  # @return [void]
33
- def on_tool_call(tool, error)
34
- if error
35
- @_queue.push [:status, "tool not found: #{tool.name}"]
36
- else
37
- @_queue.push [:status, "#{tool.name}(#{format_args(tool)})"]
38
- end
32
+ def on_tool_call(tool)
33
+ @_queue.push [:status, "#{tool.name}(#{format_args(tool)})"]
39
34
  end
40
35
 
41
36
  ##
@@ -43,7 +38,7 @@ class LLM::Repl
43
38
  # @param [LLM::Function::Return] result
44
39
  # @return [void]
45
40
  def on_tool_return(_tool, result)
46
- @_queue.push [:status, "Thinking"]
41
+ @_queue.push [:status, @repl.thinking_text]
47
42
  end
48
43
 
49
44
  ##