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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +193 -1
- data/LICENSE +5 -4
- data/README.md +56 -18
- data/data/anthropic.json +85 -372
- data/data/bedrock.json +498 -3
- data/data/deepinfra.json +567 -47
- data/data/deepseek.json +4 -0
- data/data/google.json +54 -0
- data/data/mistral.json +968 -0
- data/data/openai.json +51 -0
- data/data/xai.json +67 -0
- data/data/zai.json +14 -0
- data/lib/llm/agent.rb +41 -1
- data/lib/llm/context.rb +9 -1
- data/lib/llm/function.rb +6 -0
- data/lib/llm/json_adapter.rb +29 -3
- data/lib/llm/provider.rb +3 -3
- data/lib/llm/providers/anthropic.rb +2 -2
- data/lib/llm/providers/google.rb +3 -1
- data/lib/llm/providers/llamacpp.rb +5 -5
- data/lib/llm/providers/mistral/request_adapter/completion.rb +122 -0
- data/lib/llm/providers/mistral/request_adapter.rb +20 -0
- data/lib/llm/providers/mistral.rb +96 -0
- data/lib/llm/repl/input.rb +64 -0
- data/lib/llm/repl/status.rb +30 -0
- data/lib/llm/repl/stream.rb +46 -0
- data/lib/llm/repl/transcript.rb +61 -0
- data/lib/llm/repl/window.rb +107 -0
- data/lib/llm/repl.rb +78 -0
- data/lib/llm/tools/chdir.rb +23 -0
- data/lib/llm/tools/git.rb +41 -0
- data/lib/llm/tools/mkdir.rb +32 -0
- data/lib/llm/tools/pwd.rb +20 -0
- data/lib/llm/tools/read_file.rb +40 -0
- data/lib/llm/tools/rg.rb +46 -0
- data/lib/llm/tools/shell.rb +48 -0
- data/lib/llm/tools/swap_text.rb +25 -0
- data/lib/llm/tools/write_file.rb +24 -0
- data/lib/llm/tools.rb +5 -0
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +9 -0
- data/llm.gemspec +1 -1
- data/resources/deepdive.md +32 -0
- 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
|
|
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
|
{
|
data/lib/llm/json_adapter.rb
CHANGED
|
@@ -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:
|
|
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-
|
|
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-
|
|
98
|
+
"claude-opus-4-8"
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
##
|
data/lib/llm/providers/google.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
75
|
-
#
|
|
76
|
-
# @return [
|
|
74
|
+
# Returns nil.
|
|
75
|
+
# Whatever model is served by llamacpp acts as the default.
|
|
76
|
+
# @return [nil]
|
|
77
77
|
def default_model
|
|
78
|
-
|
|
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
|