llm.rb 12.0.0 → 12.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +193 -1
  3. data/LICENSE +5 -4
  4. data/README.md +56 -18
  5. data/data/anthropic.json +85 -372
  6. data/data/bedrock.json +498 -3
  7. data/data/deepinfra.json +567 -47
  8. data/data/deepseek.json +4 -0
  9. data/data/google.json +54 -0
  10. data/data/mistral.json +968 -0
  11. data/data/openai.json +51 -0
  12. data/data/xai.json +67 -0
  13. data/data/zai.json +14 -0
  14. data/lib/llm/agent.rb +41 -1
  15. data/lib/llm/context.rb +9 -1
  16. data/lib/llm/function.rb +6 -0
  17. data/lib/llm/json_adapter.rb +29 -3
  18. data/lib/llm/provider.rb +3 -3
  19. data/lib/llm/providers/anthropic.rb +2 -2
  20. data/lib/llm/providers/google.rb +3 -1
  21. data/lib/llm/providers/llamacpp.rb +5 -5
  22. data/lib/llm/providers/mistral/request_adapter/completion.rb +122 -0
  23. data/lib/llm/providers/mistral/request_adapter.rb +20 -0
  24. data/lib/llm/providers/mistral.rb +96 -0
  25. data/lib/llm/repl/input.rb +64 -0
  26. data/lib/llm/repl/status.rb +30 -0
  27. data/lib/llm/repl/stream.rb +46 -0
  28. data/lib/llm/repl/transcript.rb +61 -0
  29. data/lib/llm/repl/window.rb +107 -0
  30. data/lib/llm/repl.rb +78 -0
  31. data/lib/llm/tools/chdir.rb +23 -0
  32. data/lib/llm/tools/git.rb +41 -0
  33. data/lib/llm/tools/mkdir.rb +32 -0
  34. data/lib/llm/tools/pwd.rb +20 -0
  35. data/lib/llm/tools/read_file.rb +40 -0
  36. data/lib/llm/tools/rg.rb +46 -0
  37. data/lib/llm/tools/shell.rb +48 -0
  38. data/lib/llm/tools/swap_text.rb +25 -0
  39. data/lib/llm/tools/write_file.rb +24 -0
  40. data/lib/llm/tools.rb +5 -0
  41. data/lib/llm/version.rb +1 -1
  42. data/lib/llm.rb +9 -0
  43. data/llm.gemspec +1 -1
  44. data/resources/deepdive.md +32 -0
  45. metadata +22 -2
data/lib/llm/context.rb CHANGED
@@ -421,6 +421,14 @@ module LLM
421
421
  @llm.tracer
422
422
  end
423
423
 
424
+ ##
425
+ # @param [LLM::Tracer, nil] other
426
+ # A tracer, or nil.
427
+ # @return [void]
428
+ def tracer=(other)
429
+ @llm.tracer = other || LLM::Tracer::Null.new(@llm)
430
+ end
431
+
424
432
  ##
425
433
  # @return [LLM::Stream, #<<, nil]
426
434
  # Returns a stream object, or nil
@@ -596,7 +604,7 @@ module LLM
596
604
  def repair!(messages, prompt)
597
605
  message = messages.last
598
606
  return unless message&.tool_call?
599
- returns = self.returns + [*prompt].grep(LLM::Function::Return)
607
+ returns = [self.returns, prompt].flatten.grep(LLM::Function::Return)
600
608
  cancelled = []
601
609
  [*message.extra.tool_calls].each do |tool|
602
610
  next if returns.any? { _1.id == tool[:id] }
data/lib/llm/function.rb CHANGED
@@ -390,6 +390,12 @@ class LLM::Function
390
390
  type: "function", name: @name, description: @description,
391
391
  parameters: (@params || {type: "object", properties: {}}).to_h.merge(additionalProperties: false), strict: false
392
392
  }.compact
393
+ when "LLM::Mistral"
394
+ params = @params || {type: "object", properties: {}}
395
+ {
396
+ type: "function",
397
+ function: {name: @name, description: @description, parameters: params}
398
+ }.compact
393
399
  else
394
400
  params = @params || {type: "object", properties: {}}
395
401
  {
@@ -38,11 +38,11 @@ module LLM
38
38
  def self.dump(obj, state = nil, **options)
39
39
  require "json" unless defined?(::JSON)
40
40
  if ::JSON::State === state
41
- ::JSON.generate(obj, state)
41
+ ::JSON.generate(normalize(obj), state)
42
42
  elsif state
43
- ::JSON.dump(obj, state, **options)
43
+ ::JSON.dump(normalize(obj), state, **options)
44
44
  else
45
- ::JSON.dump(obj, **options)
45
+ ::JSON.dump(normalize(obj), **options)
46
46
  end
47
47
  end
48
48
 
@@ -59,6 +59,32 @@ module LLM
59
59
  require "json" unless defined?(::JSON)
60
60
  [::JSON::ParserError]
61
61
  end
62
+
63
+ ##
64
+ # JSON 3.0 compat
65
+ # Walks `obj` and encodes every string that is
66
+ # found into a UTF-8 compatible string.
67
+ def self.normalize(obj)
68
+ case obj
69
+ when String then normalize_string(obj)
70
+ when Array then obj.map { normalize(_1) }
71
+ when Hash then obj.map { [_1, normalize(_2)] }.to_h
72
+ when LLM::Object then obj.map { [_1, normalize(_2)] }.to_h
73
+ else obj
74
+ end
75
+ end
76
+ private_class_method :normalize
77
+
78
+ ##
79
+ # JSON 3.0 compat
80
+ # Normalizes a string as a UTF-8 encoded string
81
+ # that's compatible with the JSON spec.
82
+ def self.normalize_string(str)
83
+ return str if str.encoding == Encoding::UTF_8
84
+ str = (+str).force_encoding("UTF-8")
85
+ str.valid_encoding? ? str : str.scrub
86
+ end
87
+ private_class_method :normalize_string
62
88
  end
63
89
 
64
90
  ##
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: 60, ssl: true, base_path: "", persistent: false, transport: nil)
29
+ def initialize(key:, host:, port: 443, timeout: 180, ssl: true, base_path: "", persistent: false, transport: nil)
30
30
  @key = key
31
31
  @host = host
32
32
  @port = port
@@ -286,7 +286,7 @@ class LLM::Provider
286
286
  # A tracer
287
287
  # @return [void]
288
288
  def tracer=(tracer)
289
- @tracer = tracer
289
+ @tracer = tracer || LLM::Tracer::Null.new(self)
290
290
  end
291
291
 
292
292
  ##
@@ -303,7 +303,7 @@ class LLM::Provider
303
303
  def with_tracer(tracer)
304
304
  had_override = weakmap.key?(self)
305
305
  previous = weakmap[self]
306
- weakmap[self] = tracer
306
+ weakmap[self] = tracer || LLM::Tracer::Null.new(self)
307
307
  yield
308
308
  ensure
309
309
  if had_override
@@ -92,10 +92,10 @@ module LLM
92
92
 
93
93
  ##
94
94
  # Returns the default model for chat completions
95
- # @see https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-comparison-table claude-sonnet-4-20250514
95
+ # @see https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-comparison-table claude-opus-4-8
96
96
  # @return [String]
97
97
  def default_model
98
- "claude-sonnet-4-20250514"
98
+ "claude-opus-4-8"
99
99
  end
100
100
 
101
101
  ##
@@ -194,9 +194,11 @@ module LLM
194
194
  end
195
195
 
196
196
  def normalize_complete_params(params)
197
+ except = %i[role model messages stream]
197
198
  params = {role: :user, model: default_model}.merge!(params)
198
199
  tools = resolve_tools(params.delete(:tools))
199
- params = [params, adapt_generation_config(params), adapt_tools(tools)].inject({}, &:merge!).compact
200
+ config = adapt_generation_config(params.except(*except))
201
+ params = [params.except(:schema), config, adapt_tools(tools)].inject({}, &:merge!).compact
200
202
  role, model, stream = [:role, :model, :stream].map { params.delete(_1) }
201
203
  [params, stream, tools, role, model]
202
204
  end
@@ -23,7 +23,7 @@ module LLM
23
23
  ##
24
24
  # @param (see LLM::Provider#initialize)
25
25
  # @return [LLM::LlamaCpp]
26
- def initialize(host: "localhost", port: 8080, ssl: false, **)
26
+ def initialize(host: "localhost", port: 8013, ssl: false, **)
27
27
  super
28
28
  end
29
29
 
@@ -71,11 +71,11 @@ module LLM
71
71
  end
72
72
 
73
73
  ##
74
- # Returns the default model for chat completions
75
- # @see https://ollama.com/library/qwen3 qwen3
76
- # @return [String]
74
+ # Returns nil.
75
+ # Whatever model is served by llamacpp acts as the default.
76
+ # @return [nil]
77
77
  def default_model
78
- "qwen3"
78
+ nil
79
79
  end
80
80
  end
81
81
  end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Mistral::RequestAdapter
4
+ ##
5
+ # @private
6
+ class Completion
7
+ ##
8
+ # @param [LLM::Message, Hash] message
9
+ # The message to format
10
+ def initialize(message)
11
+ @message = message
12
+ end
13
+
14
+ ##
15
+ # Adapts the message for the Mistral chat completions API
16
+ # @return [Hash]
17
+ def adapt
18
+ catch(:abort) do
19
+ if Hash === message
20
+ {role: message[:role], content: adapt_content(message[:content])}
21
+ elsif message.tool_call?
22
+ {role: message.role, content: nil, tool_calls: message.extra[:original_tool_calls]}
23
+ else
24
+ adapt_message
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def adapt_message
32
+ case content
33
+ when Array
34
+ adapt_array
35
+ else
36
+ {role: message.role, content: adapt_content(content)}
37
+ end
38
+ end
39
+
40
+ def adapt_array
41
+ if content.empty?
42
+ nil
43
+ elsif returns.any?
44
+ returns.map do
45
+ {
46
+ role: "tool",
47
+ name: _1.name,
48
+ tool_call_id: _1.id,
49
+ content: LLM.json.dump(_1.value)
50
+ }
51
+ end
52
+ else
53
+ {role: message.role, content: content.flat_map { adapt_content(_1) }}
54
+ end
55
+ end
56
+
57
+ def adapt_content(content)
58
+ case content
59
+ when LLM::Object
60
+ adapt_object(content)
61
+ when String
62
+ [{type: :text, text: content.to_s}]
63
+ when LLM::Response
64
+ adapt_remote_file(content)
65
+ when LLM::Message
66
+ adapt_content(content.content)
67
+ when LLM::Function::Return
68
+ throw(:abort, {
69
+ role: "tool",
70
+ name: content.name,
71
+ tool_call_id: content.id,
72
+ content: LLM.json.dump(content.value)
73
+ })
74
+ else
75
+ prompt_error!(content)
76
+ end
77
+ end
78
+
79
+ def adapt_object(object)
80
+ case object.kind
81
+ when :image_url
82
+ [{type: :image_url, image_url: {url: object.value}}]
83
+ when :local_file
84
+ adapt_local_file(object.value)
85
+ when :remote_file
86
+ adapt_remote_file(object.value)
87
+ else
88
+ prompt_error!(object)
89
+ end
90
+ end
91
+
92
+ def adapt_local_file(file)
93
+ if file.image?
94
+ [{type: :image_url, image_url: {url: file.to_data_uri}}]
95
+ else
96
+ [{type: :file, file: {filename: file.basename, file_data: file.to_data_uri}}]
97
+ end
98
+ end
99
+
100
+ def adapt_remote_file(file)
101
+ if file.file?
102
+ [{type: :file, file: {file_id: file.id}}]
103
+ else
104
+ prompt_error!(file)
105
+ end
106
+ end
107
+
108
+ def prompt_error!(content)
109
+ if LLM::Object === content
110
+ raise LLM::PromptError, "The given LLM::Object with kind '#{content.kind}' is not " \
111
+ "supported by the Mistral chat completions API."
112
+ else
113
+ raise LLM::PromptError, "The given object (an instance of #{content.class}) " \
114
+ "is not supported by the Mistral chat completions API."
115
+ end
116
+ end
117
+
118
+ def message = @message
119
+ def content = message.content
120
+ def returns = content.grep(LLM::Function::Return)
121
+ end
122
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Mistral
4
+ ##
5
+ # @private
6
+ module RequestAdapter
7
+ require_relative "request_adapter/completion"
8
+ include LLM::OpenAI::RequestAdapter
9
+
10
+ ##
11
+ # @param [Array<LLM::Message>] messages
12
+ # The messages to adapt
13
+ # @return [Array<Hash>]
14
+ def adapt(messages, mode: nil)
15
+ messages.filter_map do |message|
16
+ Completion.new(message).adapt
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openai" unless defined?(LLM::OpenAI)
4
+
5
+ module LLM
6
+ ##
7
+ # The Mistral class implements a provider for
8
+ # [Mistral](https://mistral.ai) through its
9
+ # OpenAI-compatible API.
10
+ #
11
+ # @example
12
+ # #!/usr/bin/env ruby
13
+ # require "llm"
14
+ #
15
+ # llm = LLM.mistral(key: ENV["KEY"])
16
+ # ctx = LLM::Context.new(llm)
17
+ # ctx.talk "Hello"
18
+ # ctx.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
19
+ class Mistral < OpenAI
20
+ require_relative "mistral/request_adapter"
21
+ include Mistral::RequestAdapter
22
+
23
+ HOST = "api.mistral.ai"
24
+ BASE_PATH = "/v1/"
25
+
26
+ ##
27
+ # @param key (see LLM::Provider#initialize)
28
+ # @param host (see LLM::Provider#initialize)
29
+ # @param base_path (see LLM::Provider#initialize)
30
+ # @return [LLM::Mistral]
31
+ def initialize(host: HOST, base_path: BASE_PATH, **)
32
+ super
33
+ end
34
+
35
+ ##
36
+ # @return [Symbol]
37
+ # Returns the provider's name
38
+ def name
39
+ :mistral
40
+ end
41
+
42
+ ##
43
+ # @return [NotImplementedError]
44
+ def images
45
+ raise NotImplementedError
46
+ end
47
+
48
+ ##
49
+ # Provides an embedding.
50
+ # @param input (see LLM::Provider#embed)
51
+ # @param model (see LLM::Provider#embed)
52
+ # @param params (see LLM::Provider#embed)
53
+ # @raise (see LLM::Provider#request)
54
+ # @return (see LLM::Provider#embed)
55
+ def embed(input, model: "mistral-embed", **params)
56
+ super
57
+ end
58
+
59
+ ##
60
+ # @raise [NotImplementedError]
61
+ def responses
62
+ raise NotImplementedError
63
+ end
64
+
65
+ ##
66
+ # @return [LLM::Mistral::Audio]
67
+ def audio
68
+ raise NotImplementedError
69
+ end
70
+
71
+ ##
72
+ # @raise [NotImplementedError]
73
+ def files
74
+ raise NotImplementedError
75
+ end
76
+
77
+ ##
78
+ # @raise [NotImplementedError]
79
+ def moderations
80
+ raise NotImplementedError
81
+ end
82
+
83
+ ##
84
+ # @raise [NotImplementedError]
85
+ def vector_stores
86
+ raise NotImplementedError
87
+ end
88
+
89
+ ##
90
+ # Returns the default model for chat completions
91
+ # @return [String]
92
+ def default_model
93
+ "mistral-large-latest"
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Input LLM::Repl::Input} class manages
6
+ # the editable input line shown at the bottom of the REPL.
7
+ # @api private
8
+ class Input
9
+ UP = Curses::Key::UP
10
+ DOWN = Curses::Key::DOWN
11
+ ENTER = [Curses::Key::ENTER, 10, 13]
12
+ BACKSPACE = [Curses::Key::BACKSPACE, 127]
13
+ EOF = [nil, 4]
14
+
15
+ ##
16
+ # @param [String, Symbol] provider
17
+ # @return [LLM::Repl::Input]
18
+ def initialize(provider)
19
+ @provider = provider
20
+ @buffer = +""
21
+ end
22
+
23
+ ##
24
+ # @param [LLM::Repl::Window] window
25
+ # @return [String, nil]
26
+ def readline(window)
27
+ catch(:done) do
28
+ @buffer.clear
29
+ loop do
30
+ on_char(window, window.getch)
31
+ window.redraw
32
+ end
33
+ end
34
+ end
35
+
36
+ ##
37
+ # @return [String]
38
+ def to_s
39
+ "> #{@buffer}"
40
+ end
41
+
42
+ private
43
+
44
+ def on_char(window, char)
45
+ if EOF.include?(char)
46
+ throw(:done, nil)
47
+ elsif BACKSPACE.include?(char)
48
+ @buffer.chop!
49
+ elsif ENTER.include?(char)
50
+ buf = @buffer.dup
51
+ @buffer.clear
52
+ throw(:done, buf)
53
+ elsif char == UP
54
+ window.scroll_up
55
+ elsif char == DOWN
56
+ window.scroll_down
57
+ elsif String === char
58
+ @buffer << char
59
+ else
60
+ # ???
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Status LLM::Repl::Status} class stores
6
+ # the small status line shown at the top of the REPL.
7
+ # @api private
8
+ class Status
9
+ ##
10
+ # @param [String, Symbol] provider
11
+ # @return [LLM::Repl::Status]
12
+ def initialize(provider)
13
+ @provider = provider
14
+ @text = "idle"
15
+ end
16
+
17
+ ##
18
+ # @return [String]
19
+ attr_reader :provider
20
+
21
+ ##
22
+ # @param [String] value
23
+ # @return [void]
24
+ attr_accessor :text
25
+
26
+ ##
27
+ # @return [String]
28
+ alias_method :to_s, :text
29
+ end
30
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Stream LLM::Repl::Stream} class manages
6
+ # the stream for the {LLM::Repl LLM::Repl} class. This class
7
+ # has defined hooks that receive text, tool calls, and
8
+ # tool returns.
9
+ # @api private
10
+ class Stream < LLM::Stream
11
+ ##
12
+ # @param [LLM::Repl] repl
13
+ # @return [LLM::Repl::Stream]
14
+ def initialize(repl)
15
+ @repl = repl
16
+ end
17
+
18
+ ##
19
+ # @param [String] chars
20
+ # One or more chars
21
+ # @return [void]
22
+ def on_content(chars)
23
+ @repl.write(chars)
24
+ end
25
+
26
+ ##
27
+ # @param [LLM::Function] tool
28
+ # @param [LLM::Function::Return, nil] error
29
+ # @return [void]
30
+ def on_tool_call(tool, error)
31
+ if error
32
+ @repl.status = "tool error: #{tool.name}"
33
+ else
34
+ @repl.status = "tool: #{tool.name}"
35
+ end
36
+ end
37
+
38
+ ##
39
+ # @param [LLM::Function] _tool
40
+ # @param [LLM::Function::Return] result
41
+ # @return [void]
42
+ def on_tool_return(_tool, result)
43
+ @repl.status = "tool done: #{result.name}"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Transcript LLM::Repl::Transcript} class
6
+ # stores streamed output for the REPL.
7
+ # @api private
8
+ class Transcript
9
+ WIDTH = 80
10
+
11
+ ##
12
+ # @return [LLM::Repl::Transcript]
13
+ def initialize
14
+ @lines = [+""]
15
+ @offset = 0
16
+ end
17
+
18
+ ##
19
+ # @param [String] chars
20
+ # @return [void]
21
+ def write(chars)
22
+ chars.each_char { write_char(_1) }
23
+ end
24
+
25
+ ##
26
+ # @return [void]
27
+ def scroll_up(height)
28
+ max = [@lines.size - height, 0].max
29
+ @offset = [@offset + 1, max].min
30
+ end
31
+
32
+ ##
33
+ # @return [void]
34
+ def scroll_down
35
+ @offset = [@offset - 1, 0].max
36
+ end
37
+
38
+ ##
39
+ # @param [Integer] height
40
+ # @return [Array<String>]
41
+ def visible(height)
42
+ last = @lines.size - 1 - @offset
43
+ first = [last - height + 1, 0].max
44
+ @lines[first..last] || []
45
+ end
46
+
47
+ private
48
+
49
+ def write_char(char)
50
+ if char == "\n"
51
+ @offset += 1 if @offset > 0
52
+ @lines << +""
53
+ elsif char == " " and @lines.last.length >= WIDTH
54
+ @offset += 1 if @offset > 0
55
+ @lines << +""
56
+ else
57
+ @lines.last << char
58
+ end
59
+ end
60
+ end
61
+ end