llm.rb 12.2.0 → 12.3.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.
@@ -205,9 +205,8 @@ module LLM
205
205
  params = {role: :user, model: default_model, max_tokens: 2048}.merge!(params)
206
206
  tools = resolve_tools(params.delete(:tools))
207
207
  params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
208
- role, stream = params.delete(:role), params.delete(:stream)
209
- params[:stream] = true if streamable?(stream) || stream == true
210
- [params, stream, tools, role]
208
+ role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
209
+ [params.merge!(stream: stream.enabled?), stream, tools, role]
211
210
  end
212
211
 
213
212
  def build_complete_request(prompt, params, role, stream: nil)
@@ -215,8 +214,8 @@ module LLM
215
214
  model_id = params.delete(:model) || default_model
216
215
  payload = build_converse_payload(messages, params)
217
216
  body = LLM.json.dump(payload)
218
- path = stream ? "/model/#{model_id}/converse-stream" \
219
- : "/model/#{model_id}/converse"
217
+ path = stream&.enabled? ? "/model/#{model_id}/converse-stream" \
218
+ : "/model/#{model_id}/converse"
220
219
  req = LLM::Transport::Request.post(path, headers)
221
220
  transport.set_body_stream(req, StringIO.new(body))
222
221
  [req, messages, body]
@@ -199,8 +199,10 @@ module LLM
199
199
  tools = resolve_tools(params.delete(:tools))
200
200
  config = adapt_generation_config(params.except(*except))
201
201
  params = [params.except(:schema), config, adapt_tools(tools)].inject({}, &:merge!).compact
202
- role, model, stream = [:role, :model, :stream].map { params.delete(_1) }
203
- [params, stream, tools, role, model]
202
+ role, model, stream = params.delete(:role),
203
+ params.delete(:model),
204
+ LLM::Stream.try(params.delete(:stream))
205
+ [params.merge!(stream: stream.enabled?), stream, tools, role, model]
204
206
  end
205
207
 
206
208
  def build_complete_request(prompt, params, role, model, stream)
@@ -56,6 +56,32 @@ module LLM
56
56
  super
57
57
  end
58
58
 
59
+ ##
60
+ # Runs OCR on a remote image or document URL.
61
+ # @see https://docs.mistral.ai/api/endpoint/ocr#operation-ocr_v1_ocr_post Mistral OCR docs
62
+ # @param [String, nil] image_url
63
+ # A remote HTTP(S) URL to the image
64
+ # @param [String, nil] document_url
65
+ # A remote HTTP(S) URL to the document
66
+ # @param [String] model
67
+ # The OCR model to use
68
+ # @param [Hash] params
69
+ # Additional OCR parameters
70
+ # @raise (see LLM::Provider#request)
71
+ # @return [LLM::Response]
72
+ def ocr(image_url: nil, document_url: nil, model: "mistral-ocr-latest", **params)
73
+ if [image_url, document_url].all?(&:nil?)
74
+ raise ArgumentError, "must provide one of: image_url, document_url"
75
+ elsif [image_url, document_url].compact.size > 1
76
+ raise ArgumentError, "must provide one of: image_url, document_url"
77
+ end
78
+ document = parse_document(image_url, document_url)
79
+ req = LLM::Transport::Request.post("/v1/ocr", headers)
80
+ req.body = LLM.json.dump({model:, document:}.merge!(params))
81
+ res, = execute(request: req, operation: "ocr", model:)
82
+ LLM::Response.new(res)
83
+ end
84
+
59
85
  ##
60
86
  # @raise [NotImplementedError]
61
87
  def responses
@@ -92,5 +118,28 @@ module LLM
92
118
  def default_model
93
119
  "mistral-large-latest"
94
120
  end
121
+
122
+ private
123
+
124
+ ##
125
+ # @api private
126
+ def headers
127
+ lock do
128
+ (@headers || {}).merge(
129
+ "Authorization" => "Bearer #{@key}",
130
+ "Content-Type" => "application/json"
131
+ )
132
+ end
133
+ end
134
+
135
+ ##
136
+ # @api private
137
+ def parse_document(image_url, document_url)
138
+ if image_url
139
+ {type: "image_url", image_url:}
140
+ elsif document_url
141
+ {type: "document_url", document_url:}
142
+ end
143
+ end
95
144
  end
96
145
  end
@@ -9,6 +9,16 @@ module LLM::Ollama::ResponseAdapter
9
9
  end
10
10
  alias_method :choices, :messages
11
11
 
12
+ ##
13
+ # Returns the response body, parsing NDJSON when the
14
+ # transport returned a raw string (non-streaming path).
15
+ # @return [LLM::Object]
16
+ def body
17
+ raw = super
18
+ return raw unless String === raw
19
+ parse_ndjson(raw)
20
+ end
21
+
12
22
  ##
13
23
  # (see LLM::Contract::Completion#input_tokens)
14
24
  def input_tokens
@@ -110,6 +120,35 @@ module LLM::Ollama::ResponseAdapter
110
120
  end
111
121
  end
112
122
 
123
+ ##
124
+ # Parses a raw NDJSON string into an LLM::Object.
125
+ # Ollama's streaming API returns application/x-ndjson,
126
+ # but the transport only parses application/json. When
127
+ # the response body arrives as a raw string, we merge
128
+ # the NDJSON lines here.
129
+ # @param [String] raw
130
+ # @return [LLM::Object]
131
+ def parse_ndjson(raw)
132
+ lines = raw.split("\n").reject(&:empty?)
133
+ merged = lines.each_with_object({}) do |line, hash|
134
+ parsed = LLM.json.load(line)
135
+ next unless parsed.is_a?(Hash)
136
+ parsed.each do |key, value|
137
+ if key == "message" && value.is_a?(Hash)
138
+ if hash.key?("message")
139
+ hash["message"]["content"] << value["content"].to_s
140
+ else
141
+ hash["message"] = {"role" => value["role"], "content" => value["content"].to_s}
142
+ hash["message"]["tool_calls"] = value["tool_calls"] if value.key?("tool_calls")
143
+ end
144
+ else
145
+ hash[key] = value
146
+ end
147
+ end
148
+ end
149
+ LLM::Object.from(merged)
150
+ end
151
+
113
152
  include LLM::Contract::Completion
114
153
  end
115
154
  end
@@ -121,9 +121,8 @@ module LLM
121
121
  params = {role: :user, model: default_model, stream: true}.merge!(params)
122
122
  tools = resolve_tools(params.delete(:tools))
123
123
  params = [params, {format: params[:schema]}, adapt_tools(tools)].inject({}, &:merge!).compact
124
- role, stream = params.delete(:role), params.delete(:stream)
125
- params[:stream] = true if streamable?(stream) || stream == true
126
- [params, stream, tools, role]
124
+ role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
125
+ [params.merge!(stream: stream.enabled?), stream, tools, role]
127
126
  end
128
127
 
129
128
  def build_complete_request(prompt, params, role)
@@ -36,10 +36,13 @@ class LLM::OpenAI
36
36
  # @return [LLM::Response]
37
37
  def create(prompt, params = {})
38
38
  params = {role: :user, model: @provider.default_model}.merge!(params)
39
+ role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
39
40
  tools = resolve_tools(params.delete(:tools))
40
- params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
41
- role, stream = params.delete(:role), params.delete(:stream)
42
- params[:stream] = true if @provider.streamable?(stream) || stream == true
41
+ params = [
42
+ params.merge!(stream: stream.enabled?),
43
+ adapt_schema(params),
44
+ adapt_tools(tools)
45
+ ].inject({}, &:merge!).compact
43
46
  req = LLM::Transport::Request.post(path("/responses"), headers)
44
47
  messages = build_complete_messages(prompt, params, role)
45
48
  @provider.tracer.set_request_metadata(user_input: extract_user_input(messages, fallback: prompt))
@@ -211,12 +211,11 @@ module LLM
211
211
  params = {role: :user, model: default_model}.merge!(params)
212
212
  tools = resolve_tools(params.delete(:tools))
213
213
  params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
214
- role, stream = params.delete(:role), params.delete(:stream)
215
- params[:stream] = true if streamable?(stream) || stream == true
214
+ role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
216
215
  if params[:stream]
217
216
  params[:stream_options] = {include_usage: true}.merge!(params[:stream_options] || {})
218
217
  end
219
- [params, stream, tools, role]
218
+ [params.merge!(stream: stream.enabled?), stream, tools, role]
220
219
  end
221
220
 
222
221
  def build_complete_request(prompt, params, role)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Bar LLM::Repl::Bar} class renders a
6
+ # small progress bar for the REPL. It is used to show
7
+ # the remaining size of the model's context window in
8
+ # a compact form near the input line.
9
+ # @api private
10
+ class Bar
11
+ ##
12
+ # @return [String]
13
+ OCCUPIED = "█"
14
+
15
+ ##
16
+ # @return [String]
17
+ FREE = " "
18
+
19
+ ##
20
+ # @param [Integer] used
21
+ # @param [Integer] total
22
+ # @param [Integer] width
23
+ # @return [LLM::Repl::Bar]
24
+ def initialize(used:, total:, width: 10)
25
+ @width = width
26
+ @label, @filled = remainder(used, total)
27
+ end
28
+
29
+ ##
30
+ # @return [String]
31
+ def to_s
32
+ bar = "#{OCCUPIED * filled}#{FREE * (width - filled)}"
33
+ "│#{bar}│ #{label}"
34
+ end
35
+
36
+ private
37
+
38
+ ##
39
+ # @param [Integer] used
40
+ # @param [Integer] total
41
+ # @return [[String, Integer]]
42
+ def remainder(used, total)
43
+ return ["???", width] if total <= 0
44
+ diff = total - used
45
+ return ["0%", 0] if diff <= 0
46
+ remaining = (diff.fdiv(total) * 100).round(2)
47
+ ["#{remaining}%", ((remaining / 100) * width).round]
48
+ end
49
+
50
+ attr_reader :label, :filled, :width
51
+ end
52
+ end
@@ -6,59 +6,172 @@ class LLM::Repl
6
6
  # the editable input line shown at the bottom of the REPL.
7
7
  # @api private
8
8
  class Input
9
+ CTRL_A = 1
10
+ CTRL_E = 5
9
11
  UP = Curses::Key::UP
10
12
  DOWN = Curses::Key::DOWN
13
+ LEFT = Curses::Key::LEFT
14
+ RIGHT = Curses::Key::RIGHT
11
15
  ENTER = [Curses::Key::ENTER, 10, 13]
12
16
  BACKSPACE = [Curses::Key::BACKSPACE, 127]
13
- EOF = [nil, 4]
17
+ EOF = [4]
14
18
 
15
19
  ##
16
- # @param [String, Symbol] provider
20
+ # @param [LLM::Agent] agent
17
21
  # @return [LLM::Repl::Input]
18
- def initialize(provider)
19
- @provider = provider
22
+ def initialize(agent, options = {})
23
+ @agent = agent
24
+ @provider = agent.llm.name
20
25
  @buffer = +""
26
+ @cursor = 0
27
+ @scroll = 0
28
+ @height = options.fetch(:height, 3)
21
29
  end
22
30
 
23
31
  ##
24
32
  # @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
-
33
+ # @param [Object] char
34
+ # @return [Symbol, nil]
44
35
  def on_char(window, char)
45
36
  if EOF.include?(char)
46
- throw(:done, nil)
37
+ :exit
47
38
  elsif BACKSPACE.include?(char)
48
- @buffer.chop!
39
+ backspace
40
+ :backspace
49
41
  elsif ENTER.include?(char)
50
- buf = @buffer.dup
51
- @buffer.clear
52
- throw(:done, buf)
42
+ :submit
53
43
  elsif char == UP
54
44
  window.scroll_up
45
+ :up
55
46
  elsif char == DOWN
56
47
  window.scroll_down
48
+ :down
49
+ elsif char == CTRL_A
50
+ move_start
51
+ :ctrl_a
52
+ elsif char == CTRL_E
53
+ move_end
54
+ :ctrl_e
55
+ elsif char == LEFT
56
+ move_left
57
+ :left
58
+ elsif char == RIGHT
59
+ move_right
60
+ :right
57
61
  elsif String === char
58
- @buffer << char
62
+ insert(char)
63
+ :char
59
64
  else
60
- # ???
65
+ nil
61
66
  end
62
67
  end
68
+
69
+ ##
70
+ # @return [String]
71
+ def to_s
72
+ "#{@provider}> #{@buffer}"
73
+ end
74
+
75
+ ##
76
+ # @return [Integer]
77
+ def cursor
78
+ prompt.length + @cursor
79
+ end
80
+
81
+ ##
82
+ # @return [Integer]
83
+ def height
84
+ @height
85
+ end
86
+
87
+ ##
88
+ # Returns the visible lines of the input buffer,
89
+ # wrapped at the given column width. The viewport
90
+ # follows the cursor so the cursor line is always
91
+ # visible.
92
+ # @param [Integer] cols
93
+ # @return [Array<String>]
94
+ def lines(cols)
95
+ sync_scroll(cols)
96
+ text = to_s
97
+ chunks = text.chars.each_slice(cols).map(&:join)
98
+ chunks = [""] if chunks.empty?
99
+ chunks[@scroll, height] || []
100
+ end
101
+
102
+ ##
103
+ # Returns the cursor position as [line, column] within
104
+ # the visible viewport.
105
+ # @param [Integer] cols
106
+ # @return [Array(Integer, Integer)]
107
+ def cursor_pos(cols)
108
+ sync_scroll(cols)
109
+ [(cursor / cols) - @scroll, cursor % cols]
110
+ end
111
+
112
+ ##
113
+ # @return [void]
114
+ def move_start
115
+ @cursor = 0
116
+ end
117
+
118
+ ##
119
+ # @return [void]
120
+ def move_end
121
+ @cursor = [0, @buffer.size].max
122
+ end
123
+
124
+ ##
125
+ # @return [void]
126
+ def move_left
127
+ @cursor = [@cursor - 1, 0].max
128
+ end
129
+
130
+ ##
131
+ # @return [void]
132
+ def move_right
133
+ @cursor = [@cursor + 1, @buffer.size].min
134
+ end
135
+
136
+ ##
137
+ # @return [String]
138
+ def take
139
+ @buffer.dup.tap do
140
+ @buffer.clear
141
+ @cursor = 0
142
+ @scroll = 0
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ ##
149
+ # Adjusts @scroll so the cursor line is visible within
150
+ # the viewport.
151
+ def sync_scroll(cols)
152
+ total_lines = [1, (to_s.length.to_f / cols).ceil].max
153
+ cursor_line = cursor / cols
154
+ if cursor_line < @scroll
155
+ @scroll = cursor_line
156
+ elsif cursor_line >= (@scroll + height)
157
+ @scroll = (cursor_line - height) + 1
158
+ end
159
+ @scroll = [[@scroll, (total_lines - height)].min, 0].max
160
+ end
161
+
162
+ def prompt
163
+ "#{@provider}> "
164
+ end
165
+
166
+ def insert(char)
167
+ @buffer.insert(@cursor, char)
168
+ @cursor += char.length
169
+ end
170
+
171
+ def backspace
172
+ return if @cursor <= 0
173
+ @buffer.slice!(@cursor - 1)
174
+ @cursor -= 1
175
+ end
63
176
  end
64
177
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # This class is designed to represent a markdown
6
+ # string (typically from a model's response) as a
7
+ # tree of objects where each object contains a piece
8
+ # of text, and also optional style information for
9
+ # that text (eg bold, underscore, ...)
10
+ class Markdown
11
+ ##
12
+ # @param [String] text
13
+ # @return [LLM::Repl::Markdown]
14
+ def initialize(text)
15
+ @doc = Kramdown::Document.new(text)
16
+ @ast = []
17
+ end
18
+
19
+ ##
20
+ # @return [Array<Hash>]
21
+ def ast
22
+ @ast.tap do
23
+ ##
24
+ # Recurisvely travels the markdown document and
25
+ # populates the `@ast` variable along the way.
26
+ # The AST is composed of structured data that
27
+ # carries both text and styling information that
28
+ # is applied by the UI thread.
29
+ walk(@doc.root)
30
+
31
+ ##
32
+ # This is required because the AST collects
33
+ # empty nodes towards the end of the tree.
34
+ # If we don't pop them we end up with excessive
35
+ # amount of newlines between turns.
36
+ last = @ast.last
37
+ while last and last[:text].to_s.strip.empty?
38
+ @ast.pop
39
+ last = @ast.last
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ ##
47
+ # Recursively walk from the head node to the
48
+ # tail node. This method mutates the `@ast`
49
+ # variable. A future refactor might be worthwhile
50
+ # since this method is implemented with side effects,
51
+ # but it probably could return the ast instead.
52
+ def walk(node, attrs = nil)
53
+ case node.type
54
+ when :root
55
+ node.children.each { walk(_1, attrs) }
56
+ when :text
57
+ emit(node.value.to_s, attrs)
58
+ when :p
59
+ node.children.each { walk(_1, attrs) }
60
+ emit("\n\n", attrs)
61
+ when :header
62
+ emit("\n", attrs)
63
+ node.children.each { walk(_1, Curses::A_BOLD) }
64
+ emit("\n", attrs)
65
+ when :strong
66
+ node.children.each { walk(_1, Curses::A_BOLD) }
67
+ when :em
68
+ node.children.each { walk(_1, Curses::A_UNDERLINE) }
69
+ when :codespan
70
+ emit(node.value, Curses::A_REVERSE)
71
+ when :codeblock
72
+ emit(node.value, Curses::A_REVERSE)
73
+ emit("\n\n", attrs)
74
+ when :br
75
+ emit("\n", attrs)
76
+ else
77
+ node.children.each { walk(_1, attrs) }
78
+ end
79
+ end
80
+
81
+ def emit(text, attrs)
82
+ @ast.push({text: text.to_s, attrs:}.compact)
83
+ end
84
+ end
85
+ end
@@ -9,18 +9,29 @@ class LLM::Repl
9
9
  ##
10
10
  # @param [String, Symbol] provider
11
11
  # @return [LLM::Repl::Status]
12
- def initialize(provider)
13
- @provider = provider
12
+ def initialize(agent)
13
+ @agent = agent
14
+ @provider = agent.llm.name
14
15
  @text = "idle"
15
16
  end
16
17
 
17
18
  ##
18
19
  # @return [String]
19
- attr_reader :provider
20
+ def context_bar
21
+ LLM::Repl::Bar.new(
22
+ used: @agent.usage.total_tokens,
23
+ total: @agent.context_window
24
+ ).to_s
25
+ end
26
+
27
+ ##
28
+ # @return [String]
29
+ def cost
30
+ "$#{@agent.cost}"
31
+ end
20
32
 
21
33
  ##
22
- # @param [String] value
23
- # @return [void]
34
+ # @return [String]
24
35
  attr_accessor :text
25
36
 
26
37
  ##
@@ -11,8 +11,10 @@ class LLM::Repl
11
11
  ##
12
12
  # @param [LLM::Repl] repl
13
13
  # @return [LLM::Repl::Stream]
14
- def initialize(repl)
14
+ def initialize(repl, queue)
15
15
  @repl = repl
16
+ @_queue = queue
17
+ @buffer = +""
16
18
  end
17
19
 
18
20
  ##
@@ -20,7 +22,8 @@ class LLM::Repl
20
22
  # One or more chars
21
23
  # @return [void]
22
24
  def on_content(chars)
23
- @repl.write(chars)
25
+ @buffer << chars
26
+ @_queue.push [:stream, @buffer]
24
27
  end
25
28
 
26
29
  ##
@@ -29,9 +32,9 @@ class LLM::Repl
29
32
  # @return [void]
30
33
  def on_tool_call(tool, error)
31
34
  if error
32
- @repl.status = "tool error: #{tool.name}"
35
+ @_queue.push [:status, "tool not found: #{tool.name}"]
33
36
  else
34
- @repl.status = "tool: #{tool.name}"
37
+ @_queue.push [:status, "tool: #{tool.name}"]
35
38
  end
36
39
  end
37
40
 
@@ -40,7 +43,14 @@ class LLM::Repl
40
43
  # @param [LLM::Function::Return] result
41
44
  # @return [void]
42
45
  def on_tool_return(_tool, result)
43
- @repl.status = "tool done: #{result.name}"
46
+ @_queue.push [:status, "tool done: #{result.name}"]
47
+ end
48
+
49
+ ##
50
+ # Empty the accumulated buffer
51
+ # @return [void]
52
+ def empty!
53
+ @buffer.clear
44
54
  end
45
55
  end
46
56
  end