llm.rb 4.0.0 → 4.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +2 -2
  3. data/README.md +226 -192
  4. data/lib/llm/agent.rb +226 -0
  5. data/lib/llm/bot.rb +57 -28
  6. data/lib/llm/error.rb +4 -0
  7. data/lib/llm/function/tracing.rb +19 -0
  8. data/lib/llm/function.rb +16 -3
  9. data/lib/llm/json_adapter.rb +1 -1
  10. data/lib/llm/message.rb +7 -0
  11. data/lib/llm/prompt.rb +85 -0
  12. data/lib/llm/provider.rb +74 -10
  13. data/lib/llm/providers/anthropic/error_handler.rb +27 -5
  14. data/lib/llm/providers/anthropic/files.rb +22 -16
  15. data/lib/llm/providers/anthropic/models.rb +4 -3
  16. data/lib/llm/providers/anthropic.rb +6 -5
  17. data/lib/llm/providers/deepseek.rb +3 -3
  18. data/lib/llm/providers/gemini/error_handler.rb +34 -12
  19. data/lib/llm/providers/gemini/files.rb +18 -13
  20. data/lib/llm/providers/gemini/images.rb +4 -3
  21. data/lib/llm/providers/gemini/models.rb +4 -3
  22. data/lib/llm/providers/gemini.rb +36 -13
  23. data/lib/llm/providers/llamacpp.rb +3 -3
  24. data/lib/llm/providers/ollama/error_handler.rb +28 -6
  25. data/lib/llm/providers/ollama/models.rb +4 -3
  26. data/lib/llm/providers/ollama.rb +9 -7
  27. data/lib/llm/providers/openai/audio.rb +10 -7
  28. data/lib/llm/providers/openai/error_handler.rb +41 -14
  29. data/lib/llm/providers/openai/files.rb +19 -14
  30. data/lib/llm/providers/openai/images.rb +10 -7
  31. data/lib/llm/providers/openai/models.rb +4 -3
  32. data/lib/llm/providers/openai/moderations.rb +4 -3
  33. data/lib/llm/providers/openai/responses.rb +10 -7
  34. data/lib/llm/providers/openai/vector_stores.rb +34 -23
  35. data/lib/llm/providers/openai.rb +9 -7
  36. data/lib/llm/providers/xai.rb +3 -3
  37. data/lib/llm/providers/zai.rb +2 -2
  38. data/lib/llm/schema/object.rb +2 -2
  39. data/lib/llm/schema.rb +16 -2
  40. data/lib/llm/server_tool.rb +3 -3
  41. data/lib/llm/session.rb +3 -0
  42. data/lib/llm/tracer/logger.rb +192 -0
  43. data/lib/llm/tracer/null.rb +49 -0
  44. data/lib/llm/tracer/telemetry.rb +255 -0
  45. data/lib/llm/tracer.rb +134 -0
  46. data/lib/llm/version.rb +1 -1
  47. data/lib/llm.rb +5 -3
  48. data/llm.gemspec +4 -1
  49. metadata +39 -3
  50. data/lib/llm/builder.rb +0 -61
data/lib/llm/agent.rb ADDED
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ ##
5
+ # {LLM::Agent LLM::Agent} provides a class-level DSL for defining
6
+ # reusable, preconfigured assistants with defaults for model,
7
+ # tools, schema, and instructions.
8
+ #
9
+ # **Notes:**
10
+ # * Instructions are injected only on the first request.
11
+ # * An agent will automatically execute tool calls (unlike {LLM::Session LLM::Session}).
12
+ # * The idea originally came from RubyLLM and was adapted to llm.rb.
13
+ #
14
+ # @example
15
+ # class SystemAdmin < LLM::Agent
16
+ # model "gpt-4.1-nano"
17
+ # instructions "You are a Linux system admin"
18
+ # tools Shell
19
+ # schema Result
20
+ # end
21
+ #
22
+ # llm = LLM.openai(key: ENV["KEY"])
23
+ # agent = SystemAdmin.new(llm)
24
+ # agent.talk("Run 'date'")
25
+ class Agent
26
+ ##
27
+ # Set or get the default model
28
+ # @param [String, nil] model
29
+ # The model identifier
30
+ # @return [String, nil]
31
+ # Returns the current model when no argument is provided
32
+ def self.model(model = nil)
33
+ return @model if model.nil?
34
+ @model = model
35
+ end
36
+
37
+ ##
38
+ # Set or get the default tools
39
+ # @param [Array<LLM::Function>, nil] tools
40
+ # One or more tools
41
+ # @return [Array<LLM::Function>]
42
+ # Returns the current tools when no argument is provided
43
+ def self.tools(*tools)
44
+ return @tools || [] if tools.empty?
45
+ @tools = tools.flatten
46
+ end
47
+
48
+ ##
49
+ # Set or get the default schema
50
+ # @param [#to_json, nil] schema
51
+ # The schema
52
+ # @return [#to_json, nil]
53
+ # Returns the current schema when no argument is provided
54
+ def self.schema(schema = nil)
55
+ return @schema if schema.nil?
56
+ @schema = schema
57
+ end
58
+
59
+ ##
60
+ # Set or get the default instructions
61
+ # @param [String, nil] instructions
62
+ # The system instructions
63
+ # @return [String, nil]
64
+ # Returns the current instructions when no argument is provided
65
+ def self.instructions(instructions = nil)
66
+ return @instructions if instructions.nil?
67
+ @instructions = instructions
68
+ end
69
+
70
+ ##
71
+ # @param [LLM::Provider] provider
72
+ # A provider
73
+ # @param [Hash] params
74
+ # The parameters to maintain throughout the conversation.
75
+ # Any parameter the provider supports can be included and
76
+ # not only those listed here.
77
+ # @option params [String] :model Defaults to the provider's default model
78
+ # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
79
+ # @option params [#to_json, nil] :schema Defaults to nil
80
+ def initialize(provider, params = {})
81
+ defaults = {model: self.class.model, tools: self.class.tools, schema: self.class.schema}.compact
82
+ @provider = provider
83
+ @ses = LLM::Session.new(provider, defaults.merge(params))
84
+ @instructions_applied = false
85
+ end
86
+
87
+ ##
88
+ # Maintain a conversation via the chat completions API.
89
+ # This method immediately sends a request to the LLM and returns the response.
90
+ #
91
+ # @param prompt (see LLM::Provider#complete)
92
+ # @param [Hash] params The params passed to the provider, including optional :stream, :tools, :schema etc.
93
+ # @option params [Integer] :max_tool_rounds The maxinum number of tool call iterations (default 10)
94
+ # @return [LLM::Response] Returns the LLM's response for this turn.
95
+ # @example
96
+ # llm = LLM.openai(key: ENV["KEY"])
97
+ # agent = LLM::Agent.new(llm)
98
+ # response = agent.talk("Hello, what is your name?")
99
+ # puts response.choices[0].content
100
+ def talk(prompt, params = {})
101
+ i, max = 0, Integer(params.delete(:max_tool_rounds) || 10)
102
+ res = @ses.talk(apply_instructions(prompt), params)
103
+ until @ses.functions.empty?
104
+ raise LLM::ToolLoopError, "pending tool calls remain" if i >= max
105
+ res = @ses.talk @ses.functions.map(&:call), params
106
+ i += 1
107
+ end
108
+ @instructions_applied = true
109
+ res
110
+ end
111
+ alias_method :chat, :talk
112
+
113
+ ##
114
+ # Maintain a conversation via the responses API.
115
+ # This method immediately sends a request to the LLM and returns the response.
116
+ #
117
+ # @note Not all LLM providers support this API
118
+ # @param prompt (see LLM::Provider#complete)
119
+ # @param [Hash] params The params passed to the provider, including optional :stream, :tools, :schema etc.
120
+ # @option params [Integer] :max_tool_rounds The maxinum number of tool call iterations (default 10)
121
+ # @return [LLM::Response] Returns the LLM's response for this turn.
122
+ # @example
123
+ # llm = LLM.openai(key: ENV["KEY"])
124
+ # agent = LLM::Agent.new(llm)
125
+ # res = agent.respond("What is the capital of France?")
126
+ # puts res.output_text
127
+ def respond(prompt, params = {})
128
+ i, max = 0, Integer(params.delete(:max_tool_rounds) || 10)
129
+ res = @ses.respond(apply_instructions(prompt), params)
130
+ until @ses.functions.empty?
131
+ raise LLM::ToolLoopError, "pending tool calls remain" if i >= max
132
+ res = @ses.respond @ses.functions.map(&:call), params
133
+ i += 1
134
+ end
135
+ @instructions_applied = true
136
+ res
137
+ end
138
+
139
+ ##
140
+ # @return [LLM::Buffer<LLM::Message>]
141
+ def messages
142
+ @ses.messages
143
+ end
144
+
145
+ ##
146
+ # @return [Array<LLM::Function>]
147
+ def functions
148
+ @ses.functions
149
+ end
150
+
151
+ ##
152
+ # @return [LLM::Object]
153
+ def usage
154
+ @ses.usage
155
+ end
156
+
157
+ ##
158
+ # @param (see LLM::Session#prompt)
159
+ # @return (see LLM::Session#prompt)
160
+ # @see LLM::Session#prompt
161
+ def prompt(&b)
162
+ @ses.prompt(&b)
163
+ end
164
+ alias_method :build_prompt, :prompt
165
+
166
+ ##
167
+ # @param [String] url
168
+ # The URL
169
+ # @return [LLM::Object]
170
+ # Returns a tagged object
171
+ def image_url(url)
172
+ @ses.image_url(url)
173
+ end
174
+
175
+ ##
176
+ # @param [String] path
177
+ # The path
178
+ # @return [LLM::Object]
179
+ # Returns a tagged object
180
+ def local_file(path)
181
+ @ses.local_file(path)
182
+ end
183
+
184
+ ##
185
+ # @param [LLM::Response] res
186
+ # The response
187
+ # @return [LLM::Object]
188
+ # Returns a tagged object
189
+ def remote_file(res)
190
+ @ses.remote_file(res)
191
+ end
192
+
193
+ ##
194
+ # @return [LLM::Tracer]
195
+ # Returns an LLM tracer
196
+ def tracer
197
+ @ses.tracer
198
+ end
199
+
200
+ ##
201
+ # Returns the model an Agent is actively using
202
+ # @return [String]
203
+ def model
204
+ @ses.model
205
+ end
206
+
207
+ private
208
+
209
+ def apply_instructions(prompt)
210
+ instr = self.class.instructions
211
+ return prompt unless instr
212
+ if LLM::Prompt === prompt
213
+ messages = prompt.to_a
214
+ prompt = LLM::Prompt.new(@provider)
215
+ prompt.system instr unless @instructions_applied
216
+ messages.each { |msg| prompt.talk(msg.content, role: msg.role) }
217
+ prompt
218
+ else
219
+ prompt do
220
+ system instr unless @instructions_applied
221
+ user prompt
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
data/lib/llm/bot.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module LLM
4
4
  ##
5
- # {LLM::Bot LLM::Bot} provides an object that can maintain a
5
+ # {LLM::Session LLM::Session} provides an object that can maintain a
6
6
  # conversation. A conversation can use the chat completions API
7
7
  # that all LLM providers support or the responses API that currently
8
8
  # only OpenAI supports.
@@ -11,20 +11,18 @@ module LLM
11
11
  # #!/usr/bin/env ruby
12
12
  # require "llm"
13
13
  #
14
- # llm = LLM.openai(key: ENV["KEY"])
15
- # bot = LLM::Bot.new(llm)
16
- # url = "https://upload.wikimedia.org/wikipedia/commons/c/c7/Lisc_lipy.jpg"
14
+ # llm = LLM.openai(key: ENV["KEY"])
15
+ # ses = LLM::Session.new(llm)
17
16
  #
18
- # prompt = bot.build_prompt do
19
- # it.system "Your task is to answer all user queries"
20
- # it.user ["Tell me about this URL", bot.image_url(url)]
21
- # it.user ["Tell me about this PDF", bot.local_file("handbook.pdf")]
17
+ # prompt = LLM::Prompt.new(llm) do
18
+ # system "Be concise and show your reasoning briefly."
19
+ # user "If a train goes 60 mph for 1.5 hours, how far does it travel?"
20
+ # user "Now double the speed for the same time."
22
21
  # end
23
- # bot.chat(prompt)
24
22
  #
25
- # # The full conversation history is in bot.messages
26
- # bot.messages.each { print "[#{_1.role}] ", _1.content, "\n" }
27
- class Bot
23
+ # ses.talk(prompt)
24
+ # ses.messages.each { |m| puts "[#{m.role}] #{m.content}" }
25
+ class Session
28
26
  ##
29
27
  # Returns an Enumerable for the messages in a conversation
30
28
  # @return [LLM::Buffer<LLM::Message>]
@@ -54,10 +52,10 @@ module LLM
54
52
  # @return [LLM::Response] Returns the LLM's response for this turn.
55
53
  # @example
56
54
  # llm = LLM.openai(key: ENV["KEY"])
57
- # bot = LLM::Bot.new(llm)
58
- # response = bot.chat("Hello, what is your name?")
59
- # puts response.choices[0].content
60
- def chat(prompt, params = {})
55
+ # ses = LLM::Session.new(llm)
56
+ # res = ses.talk("Hello, what is your name?")
57
+ # puts res.messages[0].content
58
+ def talk(prompt, params = {})
61
59
  prompt, params, messages = fetch(prompt, params)
62
60
  params = params.merge(messages: [*@messages.to_a, *messages])
63
61
  params = @params.merge(params)
@@ -67,6 +65,7 @@ module LLM
67
65
  @messages.concat [res.choices[-1]]
68
66
  res
69
67
  end
68
+ alias_method :chat, :talk
70
69
 
71
70
  ##
72
71
  # Maintain a conversation via the responses API.
@@ -78,8 +77,8 @@ module LLM
78
77
  # @return [LLM::Response] Returns the LLM's response for this turn.
79
78
  # @example
80
79
  # llm = LLM.openai(key: ENV["KEY"])
81
- # bot = LLM::Bot.new(llm)
82
- # res = bot.respond("What is the capital of France?")
80
+ # ses = LLM::Session.new(llm)
81
+ # res = ses.respond("What is the capital of France?")
83
82
  # puts res.output_text
84
83
  def respond(prompt, params = {})
85
84
  prompt, params, messages = fetch(prompt, params)
@@ -107,8 +106,13 @@ module LLM
107
106
  def functions
108
107
  @messages
109
108
  .select(&:assistant?)
110
- .flat_map(&:functions)
111
- .select(&:pending?)
109
+ .flat_map do |msg|
110
+ fns = msg.functions.select(&:pending?)
111
+ fns.each do |fn|
112
+ fn.tracer = tracer
113
+ fn.model = msg.model
114
+ end
115
+ end
112
116
  end
113
117
 
114
118
  ##
@@ -123,16 +127,24 @@ module LLM
123
127
  end
124
128
 
125
129
  ##
126
- # Build a prompt
130
+ # Build a role-aware prompt for a single request.
131
+ #
132
+ # Prefer this method over {#build_prompt}. The older
133
+ # method name is kept for backward compatibility.
127
134
  # @example
128
- # prompt = bot.build_prompt do
129
- # it.system "Your task is to assist the user"
130
- # it.user "Hello, can you assist me?"
135
+ # prompt = ses.prompt do
136
+ # system "Your task is to assist the user"
137
+ # user "Hello, can you assist me?"
131
138
  # end
132
- # bot.chat(prompt)
133
- def build_prompt(&)
134
- LLM::Builder.new(&).tap(&:call)
139
+ # ses.talk(prompt)
140
+ # @param [Proc] b
141
+ # A block that composes messages. If it takes one argument,
142
+ # it receives the prompt object. Otherwise it runs in prompt context.
143
+ # @return [LLM::Prompt]
144
+ def prompt(&b)
145
+ LLM::Prompt.new(@provider, &b)
135
146
  end
147
+ alias_method :build_prompt, :prompt
136
148
 
137
149
  ##
138
150
  # Recongize an object as a URL to an image
@@ -164,14 +176,31 @@ module LLM
164
176
  LLM::Object.from(value: res, kind: :remote_file)
165
177
  end
166
178
 
179
+ ##
180
+ # @return [LLM::Tracer]
181
+ # Returns an LLM tracer
182
+ def tracer
183
+ @provider.tracer
184
+ end
185
+
186
+ ##
187
+ # Returns the model a Session is actively using
188
+ # @return [String]
189
+ def model
190
+ messages.find(&:assistant?)&.model || @params[:model]
191
+ end
192
+
167
193
  private
168
194
 
169
195
  def fetch(prompt, params)
170
- return [prompt, params, []] unless LLM::Builder === prompt
196
+ return [prompt, params, []] unless LLM::Prompt === prompt
171
197
  messages = prompt.to_a
172
198
  prompt = messages.shift
173
199
  params.merge!(role: prompt.role)
174
200
  [prompt.content, params, messages]
175
201
  end
176
202
  end
203
+
204
+ # Backward-compatible alias
205
+ Bot = Session
177
206
  end
data/lib/llm/error.rb CHANGED
@@ -50,4 +50,8 @@ module LLM
50
50
  ##
51
51
  # When the context window is exceeded
52
52
  ContextWindowError = Class.new(InvalidRequestError)
53
+
54
+ ##
55
+ # When stuck in a tool call loop
56
+ ToolLoopError = Class.new(Error)
53
57
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Function
4
+ ##
5
+ # The {LLM::Function::Tracing LLM::Function::Tracing} module patches
6
+ # an LLM function (or tool) in order to add tracing support.
7
+ module Tracing
8
+ def call(...)
9
+ return super unless @tracer
10
+ span = @tracer.on_tool_start(id:, name:, arguments:, model:)
11
+ result = super
12
+ @tracer.on_tool_finish(result:, span:)
13
+ result
14
+ rescue => ex
15
+ @tracer.on_tool_error(ex:, span:)
16
+ raise(ex)
17
+ end
18
+ end
19
+ end
data/lib/llm/function.rb CHANGED
@@ -29,6 +29,9 @@
29
29
  # end
30
30
  # end
31
31
  class LLM::Function
32
+ require_relative "function/tracing"
33
+ prepend LLM::Function::Tracing
34
+
32
35
  class Return < Struct.new(:id, :name, :value)
33
36
  end
34
37
 
@@ -42,6 +45,16 @@ class LLM::Function
42
45
  # @return [Array, nil]
43
46
  attr_accessor :arguments
44
47
 
48
+ ##
49
+ # Returns a tracer, or nil
50
+ # @return [LLM::Tracer, nil]
51
+ attr_accessor :tracer
52
+
53
+ ##
54
+ # Returns a model name, or nil
55
+ # @return [String, nil]
56
+ attr_accessor :model
57
+
45
58
  ##
46
59
  # @param [String] name The function name
47
60
  # @yieldparam [LLM::Function] self The function object
@@ -116,9 +129,9 @@ class LLM::Function
116
129
  # Returns a value that communicates that the function call was cancelled
117
130
  # @example
118
131
  # llm = LLM.openai(key: ENV["KEY"])
119
- # bot = LLM::Bot.new(llm, tools: [fn1, fn2])
120
- # bot.chat "I want to run the functions"
121
- # bot.chat bot.functions.map(&:cancel)
132
+ # ses = LLM::Session.new(llm, tools: [fn1, fn2])
133
+ # ses.talk "I want to run the functions"
134
+ # ses.talk ses.functions.map(&:cancel)
122
135
  # @return [LLM::Function::Return]
123
136
  def cancel(reason: "function call cancelled")
124
137
  Return.new(id, name, {cancelled: true, reason:})
@@ -63,7 +63,7 @@ module LLM
63
63
  # @return (see JSONAdapter#dump)
64
64
  def self.dump(obj)
65
65
  require "oj" unless defined?(::Oj)
66
- ::Oj.dump(obj)
66
+ ::Oj.dump(obj, mode: :compat)
67
67
  end
68
68
 
69
69
  ##
data/lib/llm/message.rb CHANGED
@@ -136,6 +136,13 @@ module LLM
136
136
  end
137
137
  alias_method :token_usage, :usage
138
138
 
139
+ ##
140
+ # @return [String, nil]
141
+ # Returns the model associated with a message
142
+ def model
143
+ response&.model
144
+ end
145
+
139
146
  ##
140
147
  # Returns a string representation of the message
141
148
  # @return [String]
data/lib/llm/prompt.rb ADDED
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # {LLM::Prompt LLM::Prompt} is a small object for composing
5
+ # a single request from multiple role-aware messages.
6
+ # A prompt is not just a string. It is an ordered chain of
7
+ # messages with explicit roles (for example `system` and `user`).
8
+ # Use {LLM::Session#prompt} when building a prompt inside a session.
9
+ # Use `LLM::Prompt.new(provider)` directly when you want to construct
10
+ # or pass prompt objects around explicitly.
11
+ #
12
+ # @example
13
+ # llm = LLM.openai(key: ENV["KEY"])
14
+ # ses = LLM::Session.new(llm)
15
+ #
16
+ # prompt = ses.prompt do
17
+ # system "Your task is to assist the user"
18
+ # user "Hello. Can you assist me?"
19
+ # end
20
+ #
21
+ # res = ses.talk(prompt)
22
+ class LLM::Prompt
23
+ ##
24
+ # @param [LLM::Provider] provider
25
+ # A provider used to resolve provider-specific role names.
26
+ # @param [Proc] b
27
+ # A block that composes messages. If the block takes one argument,
28
+ # it receives the prompt object. Otherwise the block runs in the
29
+ # prompt context via `instance_eval`.
30
+ def initialize(provider, &b)
31
+ @provider = provider
32
+ @buffer = []
33
+ unless b.nil?
34
+ (b.arity == 1) ? b.call(self) : instance_eval(&b)
35
+ end
36
+ end
37
+
38
+ ##
39
+ # @param [String] content
40
+ # The message
41
+ # @param [Symbol] role
42
+ # The role (eg user, system)
43
+ # @return [void]
44
+ def talk(content, role: @provider.user_role)
45
+ role = case role.to_sym
46
+ when :system then @provider.system_role
47
+ when :user then @provider.user_role
48
+ when :developer then @provider.developer_role
49
+ else role
50
+ end
51
+ @buffer << LLM::Message.new(role, content)
52
+ end
53
+ alias_method :chat, :talk
54
+
55
+ ##
56
+ # @param [String] content
57
+ # The message content
58
+ # @return [void]
59
+ def user(content)
60
+ chat(content, role: @provider.user_role)
61
+ end
62
+
63
+ ##
64
+ # @param [String] content
65
+ # The message content
66
+ # @return [void]
67
+ def system(content)
68
+ chat(content, role: @provider.system_role)
69
+ end
70
+
71
+ ##
72
+ # @param [String] content
73
+ # The message content
74
+ # @return [void]
75
+ def developer(content)
76
+ chat(content, role: @provider.developer_role)
77
+ end
78
+
79
+ ##
80
+ # @return [Array<LLM::Message>]
81
+ # Returns the prompt messages in order.
82
+ def to_a
83
+ @buffer.dup
84
+ end
85
+ end