llm.rb 4.10.0 → 4.11.1

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/README.md +265 -113
  4. data/data/anthropic.json +209 -242
  5. data/data/deepseek.json +15 -15
  6. data/data/google.json +553 -403
  7. data/data/openai.json +740 -535
  8. data/data/xai.json +250 -253
  9. data/data/zai.json +157 -90
  10. data/lib/llm/context/deserializer.rb +2 -1
  11. data/lib/llm/context.rb +58 -2
  12. data/lib/llm/contract/completion.rb +7 -0
  13. data/lib/llm/error.rb +4 -0
  14. data/lib/llm/eventhandler.rb +7 -0
  15. data/lib/llm/function/registry.rb +106 -0
  16. data/lib/llm/function/task.rb +39 -0
  17. data/lib/llm/function.rb +12 -7
  18. data/lib/llm/mcp/transport/http.rb +40 -6
  19. data/lib/llm/mcp/transport/stdio.rb +7 -0
  20. data/lib/llm/mcp.rb +54 -24
  21. data/lib/llm/message.rb +9 -2
  22. data/lib/llm/provider.rb +10 -0
  23. data/lib/llm/providers/anthropic/response_adapter/completion.rb +6 -0
  24. data/lib/llm/providers/anthropic/stream_parser.rb +37 -4
  25. data/lib/llm/providers/anthropic.rb +1 -1
  26. data/lib/llm/providers/google/response_adapter/completion.rb +12 -5
  27. data/lib/llm/providers/google/stream_parser.rb +54 -11
  28. data/lib/llm/providers/google/utils.rb +30 -0
  29. data/lib/llm/providers/google.rb +2 -0
  30. data/lib/llm/providers/ollama/response_adapter/completion.rb +6 -0
  31. data/lib/llm/providers/ollama/stream_parser.rb +10 -4
  32. data/lib/llm/providers/ollama.rb +1 -1
  33. data/lib/llm/providers/openai/response_adapter/completion.rb +7 -0
  34. data/lib/llm/providers/openai/response_adapter/responds.rb +84 -10
  35. data/lib/llm/providers/openai/responses/stream_parser.rb +63 -4
  36. data/lib/llm/providers/openai/responses.rb +1 -1
  37. data/lib/llm/providers/openai/stream_parser.rb +68 -4
  38. data/lib/llm/providers/openai.rb +1 -1
  39. data/lib/llm/stream/queue.rb +51 -0
  40. data/lib/llm/stream.rb +102 -0
  41. data/lib/llm/tool.rb +50 -45
  42. data/lib/llm/tracer/telemetry.rb +2 -2
  43. data/lib/llm/version.rb +1 -1
  44. data/lib/llm.rb +3 -2
  45. data/llm.gemspec +2 -2
  46. metadata +7 -1
@@ -18,6 +18,7 @@ module LLM
18
18
  # ctx.talk ["Tell me about this photo", ctx.local_file("/images/photo.png")]
19
19
  # ctx.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
20
20
  class Google < Provider
21
+ require_relative "google/utils"
21
22
  require_relative "google/error_handler"
22
23
  require_relative "google/request_adapter"
23
24
  require_relative "google/response_adapter"
@@ -28,6 +29,7 @@ module LLM
28
29
  require_relative "google/files"
29
30
 
30
31
  include RequestAdapter
32
+ extend Utils
31
33
 
32
34
  HOST = "generativelanguage.googleapis.com"
33
35
 
@@ -51,6 +51,12 @@ module LLM::Ollama::ResponseAdapter
51
51
  super
52
52
  end
53
53
 
54
+ ##
55
+ # (see LLM::Contract::Completion#reasoning_content)
56
+ def reasoning_content
57
+ super
58
+ end
59
+
54
60
  ##
55
61
  # (see LLM::Contract::Completion#content!)
56
62
  def content!
@@ -11,9 +11,9 @@ class LLM::Ollama
11
11
 
12
12
  ##
13
13
  # @return [LLM::OpenAI::Chunk]
14
- def initialize(io)
14
+ def initialize(stream)
15
15
  @body = {}
16
- @io = io
16
+ @stream = stream
17
17
  end
18
18
 
19
19
  ##
@@ -23,6 +23,12 @@ class LLM::Ollama
23
23
  tap { merge!(chunk) }
24
24
  end
25
25
 
26
+ ##
27
+ # Frees internal parser state used during streaming.
28
+ # @return [void]
29
+ def free
30
+ end
31
+
26
32
  private
27
33
 
28
34
  def merge!(chunk)
@@ -30,10 +36,10 @@ class LLM::Ollama
30
36
  if key == "message"
31
37
  if @body[key]
32
38
  @body[key]["content"] << value["content"]
33
- @io << value["content"] if @io.respond_to?(:<<)
39
+ @stream << value["content"] if @stream.respond_to?(:<<)
34
40
  else
35
41
  @body[key] = value
36
- @io << value["content"] if @io.respond_to?(:<<)
42
+ @stream << value["content"] if @stream.respond_to?(:<<)
37
43
  end
38
44
  else
39
45
  @body[key] = value
@@ -122,7 +122,7 @@ module LLM
122
122
  tools = resolve_tools(params.delete(:tools))
123
123
  params = [params, {format: params[:schema]}, adapt_tools(tools)].inject({}, &:merge!).compact
124
124
  role, stream = params.delete(:role), params.delete(:stream)
125
- params[:stream] = true if stream.respond_to?(:<<) || stream == true
125
+ params[:stream] = true if streamable?(stream) || stream == true
126
126
  [params, stream, tools, role]
127
127
  end
128
128
 
@@ -10,6 +10,7 @@ module LLM::OpenAI::ResponseAdapter
10
10
  extra = {
11
11
  index:, response: self,
12
12
  logprobs: choice.logprobs,
13
+ reasoning_content: message.reasoning_content,
13
14
  tool_calls: adapt_tool_calls(message.tool_calls),
14
15
  original_tool_calls: message.tool_calls
15
16
  }
@@ -63,6 +64,12 @@ module LLM::OpenAI::ResponseAdapter
63
64
  super
64
65
  end
65
66
 
67
+ ##
68
+ # (see LLM::Contract::Completion#reasoning_content)
69
+ def reasoning_content
70
+ super
71
+ end
72
+
66
73
  ##
67
74
  # (see LLM::Contract::Completion#content!)
68
75
  def content!
@@ -2,29 +2,101 @@
2
2
 
3
3
  module LLM::OpenAI::ResponseAdapter
4
4
  module Responds
5
- def model = body.model
6
- def response_id = respond_to?(:response) ? response["id"] : id
7
- def choices = [adapt_message]
8
- def annotations = choices[0].annotations
5
+ ##
6
+ # (see LLM::Contract::Completion#messages)
7
+ def messages
8
+ [adapt_message]
9
+ end
10
+ alias_method :choices, :messages
11
+
12
+ ##
13
+ # @return [String]
14
+ def response_id
15
+ respond_to?(:response) ? response["id"] : id
16
+ end
17
+
18
+ ##
19
+ # @return [Array<Hash>]
20
+ def annotations = messages[0].annotations
21
+
22
+ ##
23
+ # (see LLM::Contract::Completion#input_tokens)
24
+ def input_tokens
25
+ body.usage&.input_tokens || 0
26
+ end
27
+ alias_method :prompt_tokens, :input_tokens
28
+
29
+ ##
30
+ # (see LLM::Contract::Completion#output_tokens)
31
+ def output_tokens
32
+ body.usage&.output_tokens || 0
33
+ end
34
+ alias_method :completion_tokens, :output_tokens
35
+
36
+ ##
37
+ # (see LLM::Contract::Completion#reasoning_tokens)
38
+ def reasoning_tokens
39
+ body
40
+ .usage
41
+ &.output_tokens_details
42
+ &.reasoning_tokens || 0
43
+ end
44
+
45
+ ##
46
+ # (see LLM::Contract::Completion#total_tokens)
47
+ def total_tokens
48
+ body.usage&.total_tokens || 0
49
+ end
9
50
 
10
- def prompt_tokens = body.usage&.input_tokens
11
- def completion_tokens = body.usage&.output_tokens
12
- def total_tokens = body.usage&.total_tokens
51
+ ##
52
+ # (see LLM::Contract::Completion#usage)
53
+ def usage
54
+ super
55
+ end
56
+
57
+ ##
58
+ # (see LLM::Contract::Completion#model)
59
+ def model
60
+ body.model
61
+ end
13
62
 
14
63
  ##
15
64
  # Returns the aggregated text content from the response outputs.
16
65
  # @return [String]
17
66
  def output_text
18
- choices.find(&:assistant?).content || ""
67
+ content
68
+ end
69
+
70
+ ##
71
+ # (see LLM::Contract::Completion#content)
72
+ def content
73
+ super || ""
74
+ end
75
+
76
+ ##
77
+ # (see LLM::Contract::Completion#content!)
78
+ def content!
79
+ super
80
+ end
81
+
82
+ ##
83
+ # (see LLM::Contract::Completion#reasoning_content)
84
+ def reasoning_content
85
+ super
19
86
  end
20
87
 
21
88
  private
22
89
 
23
90
  def adapt_message
24
- message = LLM::Message.new("assistant", +"", {response: self, tool_calls: []})
25
- output.each.with_index do |choice, index|
91
+ message = LLM::Message.new("assistant", +"", {response: self, tool_calls: [], reasoning_content: +""})
92
+ output.each do |choice|
26
93
  if choice.type == "function_call"
27
94
  message.extra[:tool_calls] << adapt_tool(choice)
95
+ elsif choice.type == "reasoning"
96
+ (choice.summary || []).each do |summary|
97
+ next unless summary["type"] == "summary_text"
98
+ message.extra["reasoning_content"] << summary["text"]
99
+ end
28
100
  elsif choice.content
29
101
  choice.content.each do |c|
30
102
  next unless c["type"] == "output_text"
@@ -48,5 +120,7 @@ module LLM::OpenAI::ResponseAdapter
48
120
  rescue *LLM.json.parser_error
49
121
  {}
50
122
  end
123
+
124
+ include LLM::Contract::Completion
51
125
  end
52
126
  end
@@ -10,11 +10,13 @@ class LLM::OpenAI
10
10
  attr_reader :body
11
11
 
12
12
  ##
13
- # @param [#<<] io An IO-like object
13
+ # @param [#<<, LLM::Stream] stream
14
+ # A stream sink that implements {#<<} or the {LLM::Stream} interface
14
15
  # @return [LLM::OpenAI::Responses::StreamParser]
15
- def initialize(io)
16
+ def initialize(stream)
16
17
  @body = {"output" => []}
17
- @io = io
18
+ @stream = stream
19
+ @emits = {tools: []}
18
20
  end
19
21
 
20
22
  ##
@@ -24,6 +26,13 @@ class LLM::OpenAI
24
26
  tap { handle_event(chunk) }
25
27
  end
26
28
 
29
+ ##
30
+ # Frees internal parser state used during streaming.
31
+ # @return [void]
32
+ def free
33
+ @emits.clear
34
+ end
35
+
27
36
  private
28
37
 
29
38
  def handle_event(chunk)
@@ -56,9 +65,21 @@ class LLM::OpenAI
56
65
  if content_part && content_part["type"] == "output_text"
57
66
  content_part["text"] ||= ""
58
67
  content_part["text"] << delta_text
59
- @io << delta_text if @io.respond_to?(:<<)
68
+ emit_content(delta_text)
60
69
  end
61
70
  end
71
+ when "response.function_call_arguments.delta"
72
+ output_item = @body["output"][chunk["output_index"]]
73
+ if output_item && output_item["type"] == "function_call"
74
+ output_item["arguments"] ||= +""
75
+ output_item["arguments"] << chunk["delta"]
76
+ end
77
+ when "response.function_call_arguments.done"
78
+ output_item = @body["output"][chunk["output_index"]]
79
+ if output_item && output_item["type"] == "function_call"
80
+ output_item["arguments"] = chunk["arguments"]
81
+ emit_tool(chunk["output_index"], output_item)
82
+ end
62
83
  when "response.output_item.done"
63
84
  output_index = chunk["output_index"]
64
85
  item = chunk["item"]
@@ -72,5 +93,43 @@ class LLM::OpenAI
72
93
  @body["output"][output_index]["content"][content_index] = part
73
94
  end
74
95
  end
96
+
97
+ def emit_content(value)
98
+ if @stream.respond_to?(:on_content)
99
+ @stream.on_content(value)
100
+ elsif @stream.respond_to?(:<<)
101
+ @stream << value
102
+ end
103
+ end
104
+
105
+ def emit_tool(index, tool)
106
+ return unless @stream.respond_to?(:on_tool_call)
107
+ return unless complete_tool?(tool)
108
+ return if @emits[:tools].include?(index)
109
+ function, error = resolve_tool(tool)
110
+ @emits[:tools] << index
111
+ @stream.on_tool_call(function, error)
112
+ end
113
+
114
+ def complete_tool?(tool)
115
+ tool["call_id"] && tool["name"] && parse_arguments(tool["arguments"])
116
+ end
117
+
118
+ def resolve_tool(tool)
119
+ registered = LLM::Function.find_by_name(tool["name"])
120
+ fn = (registered || LLM::Function.new(tool["name"])).dup.tap do |fn|
121
+ fn.id = tool["call_id"]
122
+ fn.arguments = parse_arguments(tool["arguments"])
123
+ end
124
+ [fn, (registered ? nil : @stream.tool_not_found(fn))]
125
+ end
126
+
127
+ def parse_arguments(arguments)
128
+ return nil if arguments.to_s.empty?
129
+ parsed = LLM.json.load(arguments)
130
+ Hash === parsed ? parsed : nil
131
+ rescue *LLM.json.parser_error
132
+ nil
133
+ end
75
134
  end
76
135
  end
@@ -39,7 +39,7 @@ class LLM::OpenAI
39
39
  tools = resolve_tools(params.delete(:tools))
40
40
  params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
41
41
  role, stream = params.delete(:role), params.delete(:stream)
42
- params[:stream] = true if stream.respond_to?(:<<) || stream == true
42
+ params[:stream] = true if @provider.streamable?(stream) || stream == true
43
43
  req = Net::HTTP::Post.new("/v1/responses", headers)
44
44
  messages = build_complete_messages(prompt, params, role)
45
45
  @provider.tracer.set_request_metadata(user_input: extract_user_input(messages, fallback: prompt))
@@ -11,9 +11,10 @@ class LLM::OpenAI
11
11
 
12
12
  ##
13
13
  # @return [LLM::OpenAI::Chunk]
14
- def initialize(io)
14
+ def initialize(stream)
15
15
  @body = {}
16
- @io = io
16
+ @stream = stream
17
+ @emits = {tools: []}
17
18
  end
18
19
 
19
20
  ##
@@ -23,6 +24,13 @@ class LLM::OpenAI
23
24
  tap { merge!(chunk) }
24
25
  end
25
26
 
27
+ ##
28
+ # Frees internal parser state used during streaming.
29
+ # @return [void]
30
+ def free
31
+ @emits.clear
32
+ end
33
+
26
34
  private
27
35
 
28
36
  def merge!(chunk)
@@ -47,7 +55,11 @@ class LLM::OpenAI
47
55
  if key == "content"
48
56
  target_message[key] ||= +""
49
57
  target_message[key] << value
50
- @io << value if @io.respond_to?(:<<)
58
+ emit_content(value)
59
+ elsif key == "reasoning_content"
60
+ target_message[key] ||= +""
61
+ target_message[key] << value
62
+ emit_reasoning_content(value)
51
63
  elsif key == "tool_calls"
52
64
  merge_tools!(target_message, value)
53
65
  else
@@ -60,8 +72,13 @@ class LLM::OpenAI
60
72
  (choice["delta"] || {}).each do |key, value|
61
73
  next if value.nil?
62
74
  if key == "content"
63
- @io << value if @io.respond_to?(:<<)
75
+ emit_content(value)
76
+ message_hash[key] = value
77
+ elsif key == "reasoning_content"
78
+ emit_reasoning_content(value)
64
79
  message_hash[key] = value
80
+ elsif key == "tool_calls"
81
+ merge_tools!(message_hash, value)
65
82
  else
66
83
  message_hash[key] = value
67
84
  end
@@ -85,7 +102,54 @@ class LLM::OpenAI
85
102
  else
86
103
  target["tool_calls"][tindex] = toola
87
104
  end
105
+ emit_tool(target["tool_calls"][tindex], tindex)
106
+ end
107
+ end
108
+
109
+ def emit_content(value)
110
+ if @stream.respond_to?(:on_content)
111
+ @stream.on_content(value)
112
+ elsif @stream.respond_to?(:<<)
113
+ @stream << value
114
+ end
115
+ end
116
+
117
+ def emit_reasoning_content(value)
118
+ if @stream.respond_to?(:on_reasoning_content)
119
+ @stream.on_reasoning_content(value)
88
120
  end
89
121
  end
122
+
123
+ def emit_tool(tool, tindex)
124
+ return unless @stream.respond_to?(:on_tool_call)
125
+ return unless complete_tool?(tool)
126
+ return if @emits[:tools].include?(tindex)
127
+ function, error = resolve_tool(tool)
128
+ @emits[:tools] << tindex
129
+ @stream.on_tool_call(function, error)
130
+ end
131
+
132
+ def complete_tool?(tool)
133
+ function = tool["function"]
134
+ function && tool["id"] && function["name"] && parse_arguments(function["arguments"])
135
+ end
136
+
137
+ def resolve_tool(tool)
138
+ function = tool["function"]
139
+ registered = LLM::Function.find_by_name(function["name"])
140
+ fn = (registered || LLM::Function.new(function["name"])).dup.tap do |fn|
141
+ fn.id = tool["id"]
142
+ fn.arguments = parse_arguments(function["arguments"])
143
+ end
144
+ [fn, (registered ? nil : @stream.tool_not_found(fn))]
145
+ end
146
+
147
+ def parse_arguments(arguments)
148
+ return nil if arguments.to_s.empty?
149
+ parsed = LLM.json.load(arguments)
150
+ Hash === parsed ? parsed : nil
151
+ rescue *LLM.json.parser_error
152
+ nil
153
+ end
90
154
  end
91
155
  end
@@ -212,7 +212,7 @@ module LLM
212
212
  tools = resolve_tools(params.delete(:tools))
213
213
  params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
214
214
  role, stream = params.delete(:role), params.delete(:stream)
215
- params[:stream] = true if stream.respond_to?(:<<) || stream == true
215
+ params[:stream] = true if streamable?(stream) || stream == true
216
216
  if params[:stream]
217
217
  params[:stream_options] = {include_usage: true}.merge!(params[:stream_options] || {})
218
218
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Stream
4
+ ##
5
+ # A small queue for collecting streamed tool work. Values can be immediate
6
+ # {LLM::Function::Return} objects or concurrent handles returned by
7
+ # {LLM::Function#spawn}. Calling {#wait(strategy)} resolves queued work and
8
+ # returns an array of {LLM::Function::Return} values.
9
+ class Queue
10
+ ##
11
+ # @return [LLM::Stream::Queue]
12
+ def initialize
13
+ @items = []
14
+ end
15
+
16
+ ##
17
+ # Enqueue a function return or spawned task.
18
+ # @param [LLM::Function::Return, Thread, Async::Task, Fiber] item
19
+ # @return [LLM::Stream::Queue]
20
+ def <<(item)
21
+ @items << item
22
+ self
23
+ end
24
+
25
+ ##
26
+ # Returns true when the queue is empty.
27
+ # @return [Boolean]
28
+ def empty?
29
+ @items.empty?
30
+ end
31
+
32
+ ##
33
+ # Waits for queued work to finish and returns function results.
34
+ # @param [Symbol] strategy
35
+ # Controls concurrency strategy:
36
+ # - `:thread`: Use threads
37
+ # - `:task`: Use async tasks (requires async gem)
38
+ # - `:fiber`: Use raw fibers
39
+ # @return [Array<LLM::Function::Return>]
40
+ def wait(strategy)
41
+ returns, tasks = @items.shift(@items.length).partition { LLM::Function::Return === _1 }
42
+ returns.concat case strategy
43
+ when :thread then LLM::Function::ThreadGroup.new(tasks).wait
44
+ when :task then LLM::Function::TaskGroup.new(tasks).wait
45
+ when :fiber then LLM::Function::FiberGroup.new(tasks).wait
46
+ else raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :thread, :task, or :fiber"
47
+ end
48
+ end
49
+ alias_method :value, :wait
50
+ end
51
+ end
data/lib/llm/stream.rb ADDED
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # The {LLM::Stream LLM::Stream} class provides the callback interface for
6
+ # streamed model output in llm.rb.
7
+ #
8
+ # A stream object can be an instance of {LLM::Stream LLM::Stream}, a
9
+ # subclass that overrides the callbacks it needs, or any other object that
10
+ # implements some or all of the same interface. {#queue} provides a small
11
+ # helper for collecting asynchronous tool work started from a callback, and
12
+ # {#tool_not_found} returns an in-band tool error when a streamed tool
13
+ # cannot be resolved.
14
+ #
15
+ # @note The `on_*` callbacks run inline with the streaming parser. They
16
+ # therefore block streaming progress and should generally return as
17
+ # quickly as possible.
18
+ #
19
+ # The most common callback is {#on_content}, which also maps to {#<<} for
20
+ # compatibility with `StringIO`-style objects. Providers may also call
21
+ # {#on_reasoning_content} and {#on_tool_call} when that data is available.
22
+ class Stream
23
+ require_relative "stream/queue"
24
+
25
+ ##
26
+ # Returns a lazily-initialized queue for tool results or spawned work.
27
+ # @return [LLM::Stream::Queue]
28
+ def queue
29
+ @queue ||= Queue.new
30
+ end
31
+
32
+ ##
33
+ # Waits for queued tool work to finish and returns function results.
34
+ # @param [Symbol] strategy
35
+ # The concurrency strategy to use
36
+ # @return [Array<LLM::Function::Return>]
37
+ def wait(strategy)
38
+ queue.wait(strategy)
39
+ end
40
+
41
+ # @group Public callbacks
42
+
43
+ ##
44
+ # Called when visible assistant output is streamed.
45
+ # @param [String] content
46
+ # A chunk of assistant-visible text.
47
+ # @return [nil]
48
+ def on_content(content)
49
+ nil
50
+ end
51
+ alias_method :<<, :on_content
52
+
53
+ ##
54
+ # Called when reasoning output is streamed separately from visible content.
55
+ # @param [String] content
56
+ # A chunk of reasoning text.
57
+ # @return [nil]
58
+ def on_reasoning_content(content)
59
+ nil
60
+ end
61
+
62
+ ##
63
+ # Called when a streamed tool call has been fully constructed.
64
+ # @note A stream implementation may start tool execution here, for
65
+ # example by pushing `tool.spawn(:thread)`, `tool.spawn(:fiber)`, or
66
+ # `tool.spawn(:task)` onto {#queue}. When a streamed tool cannot be
67
+ # resolved, `error` is passed as an {LLM::Function::Return}. It can be
68
+ # sent back to the model, allowing the tool-call path to recover and the
69
+ # session to continue. Tool resolution depends on
70
+ # {LLM::Function.registry}, which includes {LLM::Tool LLM::Tool}
71
+ # subclasses, including MCP tools, but not functions defined with
72
+ # {LLM.function}.
73
+ # @param [LLM::Function] tool
74
+ # The parsed tool call.
75
+ # @param [LLM::Function::Return, nil] error
76
+ # An in-band tool error for unresolved tool calls.
77
+ # @return [nil]
78
+ def on_tool_call(tool, error)
79
+ nil
80
+ end
81
+
82
+ # @endgroup
83
+
84
+ # @group Error handlers
85
+
86
+ ##
87
+ # Returns a function return describing a streamed tool that could not
88
+ # be resolved.
89
+ # @note This is mainly useful as a fallback from {#on_tool_call}. It
90
+ # should be uncommon in normal use, since streamed tool callbacks only
91
+ # run for tools already defined in the context.
92
+ # @param [LLM::Function] tool
93
+ # @return [LLM::Function::Return]
94
+ def tool_not_found(tool)
95
+ LLM::Function::Return.new(tool.id, tool.name, {
96
+ error: true, type: LLM::NoSuchToolError.name, message: "tool not found"
97
+ })
98
+ end
99
+
100
+ # @endgroup
101
+ end
102
+ end