llm.rb 4.8.0 → 4.10.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/README.md +356 -583
- data/data/anthropic.json +770 -0
- data/data/deepseek.json +75 -0
- data/data/google.json +1050 -0
- data/data/openai.json +1421 -0
- data/data/xai.json +792 -0
- data/data/zai.json +330 -0
- data/lib/llm/agent.rb +42 -41
- data/lib/llm/bot.rb +1 -263
- data/lib/llm/buffer.rb +7 -0
- data/lib/llm/{session → context}/deserializer.rb +4 -3
- data/lib/llm/context.rb +292 -0
- data/lib/llm/cost.rb +26 -0
- data/lib/llm/error.rb +8 -0
- data/lib/llm/function/array.rb +61 -0
- data/lib/llm/function/fiber_group.rb +91 -0
- data/lib/llm/function/task_group.rb +89 -0
- data/lib/llm/function/thread_group.rb +94 -0
- data/lib/llm/function.rb +75 -10
- data/lib/llm/mcp/command.rb +108 -0
- data/lib/llm/mcp/error.rb +31 -0
- data/lib/llm/mcp/pipe.rb +82 -0
- data/lib/llm/mcp/rpc.rb +118 -0
- data/lib/llm/mcp/transport/http/event_handler.rb +66 -0
- data/lib/llm/mcp/transport/http.rb +122 -0
- data/lib/llm/mcp/transport/stdio.rb +85 -0
- data/lib/llm/mcp.rb +116 -0
- data/lib/llm/message.rb +13 -11
- data/lib/llm/model.rb +2 -2
- data/lib/llm/prompt.rb +17 -7
- data/lib/llm/provider.rb +32 -17
- data/lib/llm/providers/anthropic/files.rb +3 -3
- data/lib/llm/providers/anthropic.rb +19 -4
- data/lib/llm/providers/deepseek.rb +10 -3
- data/lib/llm/providers/{gemini → google}/audio.rb +6 -6
- data/lib/llm/providers/{gemini → google}/error_handler.rb +2 -2
- data/lib/llm/providers/{gemini → google}/files.rb +11 -11
- data/lib/llm/providers/{gemini → google}/images.rb +7 -7
- data/lib/llm/providers/{gemini → google}/models.rb +5 -5
- data/lib/llm/providers/{gemini → google}/request_adapter/completion.rb +7 -3
- data/lib/llm/providers/{gemini → google}/request_adapter.rb +1 -1
- data/lib/llm/providers/{gemini → google}/response_adapter/completion.rb +7 -7
- data/lib/llm/providers/{gemini → google}/response_adapter/embedding.rb +1 -1
- data/lib/llm/providers/{gemini → google}/response_adapter/file.rb +1 -1
- data/lib/llm/providers/{gemini → google}/response_adapter/files.rb +1 -1
- data/lib/llm/providers/{gemini → google}/response_adapter/image.rb +1 -1
- data/lib/llm/providers/{gemini → google}/response_adapter/models.rb +1 -1
- data/lib/llm/providers/{gemini → google}/response_adapter/web_search.rb +2 -2
- data/lib/llm/providers/{gemini → google}/response_adapter.rb +8 -8
- data/lib/llm/providers/{gemini → google}/stream_parser.rb +3 -3
- data/lib/llm/providers/{gemini.rb → google.rb} +41 -26
- data/lib/llm/providers/llamacpp.rb +10 -3
- data/lib/llm/providers/ollama.rb +19 -4
- data/lib/llm/providers/openai/files.rb +3 -3
- data/lib/llm/providers/openai/response_adapter/completion.rb +9 -1
- data/lib/llm/providers/openai/response_adapter/responds.rb +9 -1
- data/lib/llm/providers/openai/responses.rb +9 -1
- data/lib/llm/providers/openai/stream_parser.rb +2 -0
- data/lib/llm/providers/openai.rb +19 -4
- data/lib/llm/providers/xai.rb +10 -3
- data/lib/llm/providers/zai.rb +9 -2
- data/lib/llm/registry.rb +81 -0
- data/lib/llm/schema/all_of.rb +31 -0
- data/lib/llm/schema/any_of.rb +31 -0
- data/lib/llm/schema/one_of.rb +31 -0
- data/lib/llm/schema/parser.rb +145 -0
- data/lib/llm/schema.rb +49 -8
- data/lib/llm/server_tool.rb +5 -5
- data/lib/llm/session.rb +10 -1
- data/lib/llm/tool.rb +88 -6
- data/lib/llm/tracer/logger.rb +1 -1
- data/lib/llm/tracer/telemetry.rb +7 -7
- data/lib/llm/tracer.rb +3 -3
- data/lib/llm/usage.rb +5 -0
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +39 -6
- data/llm.gemspec +45 -8
- metadata +86 -28
data/lib/llm/mcp/rpc.rb
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class LLM::MCP
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::MCP::RPC} module provides the JSON-RPC interface used by
|
|
6
|
+
# {LLM::MCP}. MCP uses JSON-RPC to exchange messages between a client
|
|
7
|
+
# and a server. A client sends a method name and its parameters as a
|
|
8
|
+
# request, and the server replies with either a result or an error.
|
|
9
|
+
#
|
|
10
|
+
# This module is responsible for composing those requests, applying
|
|
11
|
+
# the defaults needed by built-in MCP methods such as initialize,
|
|
12
|
+
# and reading responses for request methods. Notifications are sent
|
|
13
|
+
# without waiting for a response, and errors are raised as
|
|
14
|
+
# {LLM::MCP::Error}.
|
|
15
|
+
# @private
|
|
16
|
+
module RPC
|
|
17
|
+
##
|
|
18
|
+
# Sends a method over the transport.
|
|
19
|
+
# @param [LLM::MCP::Transport] transport
|
|
20
|
+
# The transport to write to
|
|
21
|
+
# @param [String] method
|
|
22
|
+
# The method name to call
|
|
23
|
+
# @param [Hash] params
|
|
24
|
+
# The parameters to send with the method call
|
|
25
|
+
# @return [Object, nil]
|
|
26
|
+
# The result of the method call, or nil if it's a notification
|
|
27
|
+
def call(transport, method, params = {})
|
|
28
|
+
message = {jsonrpc: "2.0", method:, params: default_params(method).merge(params)}
|
|
29
|
+
if notification?(method)
|
|
30
|
+
transport.write(message)
|
|
31
|
+
nil
|
|
32
|
+
else
|
|
33
|
+
@request_id = (@request_id || -1) + 1
|
|
34
|
+
id = @request_id
|
|
35
|
+
transport.write(message.merge(id:))
|
|
36
|
+
recv(transport, id)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# Reads a response from the transport.
|
|
44
|
+
# @param [LLM::MCP::Transport] transport
|
|
45
|
+
# The transport to read from
|
|
46
|
+
# @param [Integer] id
|
|
47
|
+
# The request id to wait for
|
|
48
|
+
# @raise [LLM::MCP::Error]
|
|
49
|
+
# When the MCP process returns an error
|
|
50
|
+
# @return [Object, nil]
|
|
51
|
+
# The result returned by the MCP process
|
|
52
|
+
def recv(transport, id)
|
|
53
|
+
poll(timeout:, ex: [IO::WaitReadable]) do
|
|
54
|
+
loop do
|
|
55
|
+
res = transport.read_nonblock
|
|
56
|
+
next unless res["id"] == id
|
|
57
|
+
if res["error"]
|
|
58
|
+
raise LLM::MCP::Error.from(response: res)
|
|
59
|
+
else
|
|
60
|
+
break res["result"]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
##
|
|
67
|
+
# Returns default parameters for built-in methods.
|
|
68
|
+
# @param [String] method
|
|
69
|
+
# The method name
|
|
70
|
+
# @return [Hash]
|
|
71
|
+
def default_params(method)
|
|
72
|
+
case method
|
|
73
|
+
when "initialize"
|
|
74
|
+
{protocolVersion: "2025-03-26", capabilities: {}}
|
|
75
|
+
else
|
|
76
|
+
{}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
##
|
|
81
|
+
# Returns true when the method is a notification.
|
|
82
|
+
# @param [String] method
|
|
83
|
+
# The method name
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def notification?(method)
|
|
86
|
+
method.to_s.start_with?("notifications/")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
##
|
|
90
|
+
# Returns the maximum amount of time to wait when reading from an MCP process.
|
|
91
|
+
# @return [Integer]
|
|
92
|
+
def timeout
|
|
93
|
+
@timeout ||= 5
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
##
|
|
97
|
+
# Runs a block until it succeeds, times out, or raises an unhandled exception.
|
|
98
|
+
# @param [Integer] timeout
|
|
99
|
+
# The timeout for the block, in seconds
|
|
100
|
+
# @param [Array<Class>] ex
|
|
101
|
+
# The exceptions to retry when raised
|
|
102
|
+
# @yield
|
|
103
|
+
# The block to run
|
|
104
|
+
# @raise [LLM::MCP::TimeoutError]
|
|
105
|
+
# When the block takes longer than the timeout
|
|
106
|
+
# @return [Object]
|
|
107
|
+
def poll(timeout:, ex: [])
|
|
108
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
109
|
+
loop do
|
|
110
|
+
return yield
|
|
111
|
+
rescue *ex
|
|
112
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
113
|
+
raise LLM::MCP::TimeoutError, "MCP process timed out" if duration > timeout
|
|
114
|
+
sleep 0.05
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM::MCP::Transport
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::MCP::Transport::HTTP::EventHandler LLM::MCP::Transport::HTTP::EventHandler}
|
|
6
|
+
# class adapts generic server-sent event callbacks into decoded JSON-RPC
|
|
7
|
+
# messages for {LLM::MCP::Transport::HTTP LLM::MCP::Transport::HTTP}.
|
|
8
|
+
# It accumulates event data until a blank line terminates the current
|
|
9
|
+
# event, then parses the payload as JSON and yields it to the callback
|
|
10
|
+
# given at initialization.
|
|
11
|
+
# @private
|
|
12
|
+
class HTTP::EventHandler
|
|
13
|
+
##
|
|
14
|
+
# @yieldparam [Hash] message
|
|
15
|
+
# A decoded JSON-RPC message
|
|
16
|
+
# @return [LLM::MCP::Transport::HTTP::EventHandler]
|
|
17
|
+
def initialize(&on_message)
|
|
18
|
+
@on_message = on_message
|
|
19
|
+
reset
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# Receives the SSE event name.
|
|
24
|
+
# @param [LLM::EventStream::Event] event
|
|
25
|
+
# The event stream event
|
|
26
|
+
# @return [void]
|
|
27
|
+
def on_event(event)
|
|
28
|
+
@event = event.value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# Receives one line of SSE data.
|
|
33
|
+
# @param [LLM::EventStream::Event] event
|
|
34
|
+
# The event stream event
|
|
35
|
+
# @return [void]
|
|
36
|
+
def on_data(event)
|
|
37
|
+
@data << event.value.to_s
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The generic event stream parser dispatches one line at a time.
|
|
41
|
+
# A blank line terminates the current SSE event.
|
|
42
|
+
# @param [LLM::EventStream::Event] event
|
|
43
|
+
# The event stream event
|
|
44
|
+
# @return [void]
|
|
45
|
+
def on_chunk(event)
|
|
46
|
+
flush if event.chunk == "\n"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def flush
|
|
52
|
+
return reset if @data.empty? && @event.nil?
|
|
53
|
+
payload = @data.join("\n")
|
|
54
|
+
reset
|
|
55
|
+
return if payload.empty? || payload == "[DONE]"
|
|
56
|
+
@on_message.call(LLM.json.load(payload))
|
|
57
|
+
rescue *LLM.json.parser_error
|
|
58
|
+
reset
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def reset
|
|
62
|
+
@event = nil
|
|
63
|
+
@data = []
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM::MCP::Transport
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::MCP::Transport::HTTP LLM::MCP::Transport::HTTP} class
|
|
6
|
+
# provides an HTTP transport for {LLM::MCP LLM::MCP}. It sends
|
|
7
|
+
# JSON-RPC messages with HTTP POST requests and buffers response
|
|
8
|
+
# messages for non-blocking reads.
|
|
9
|
+
class HTTP
|
|
10
|
+
require_relative "http/event_handler"
|
|
11
|
+
|
|
12
|
+
##
|
|
13
|
+
# @param [String] url
|
|
14
|
+
# The URL for the MCP HTTP endpoint
|
|
15
|
+
# @param [Hash] headers
|
|
16
|
+
# Extra headers to send with requests
|
|
17
|
+
# @param [Integer, nil] timeout
|
|
18
|
+
# The timeout in seconds. Defaults to nil
|
|
19
|
+
# @return [LLM::MCP::Transport::HTTP]
|
|
20
|
+
def initialize(url:, headers: {}, timeout: nil)
|
|
21
|
+
@uri = URI.parse(url)
|
|
22
|
+
@use_ssl = @uri.scheme == "https"
|
|
23
|
+
@headers = headers
|
|
24
|
+
@timeout = timeout
|
|
25
|
+
@queue = []
|
|
26
|
+
@monitor = Monitor.new
|
|
27
|
+
@running = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# Starts the HTTP transport.
|
|
32
|
+
# @raise [LLM::MCP::Error]
|
|
33
|
+
# When the transport is already running
|
|
34
|
+
# @return [void]
|
|
35
|
+
def start
|
|
36
|
+
lock do
|
|
37
|
+
raise LLM::MCP::Error, "MCP transport is already running" if running?
|
|
38
|
+
@queue.clear
|
|
39
|
+
@running = true
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
##
|
|
44
|
+
# Stops the HTTP transport and closes the connection.
|
|
45
|
+
# This method is idempotent.
|
|
46
|
+
# @return [void]
|
|
47
|
+
def stop
|
|
48
|
+
lock do
|
|
49
|
+
return nil unless running?
|
|
50
|
+
@running = false
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
##
|
|
56
|
+
# Writes a JSON-RPC message via HTTP POST.
|
|
57
|
+
# @param [Hash] message
|
|
58
|
+
# The JSON-RPC message
|
|
59
|
+
# @raise [LLM::MCP::Error]
|
|
60
|
+
# When the transport is not running or the HTTP request fails
|
|
61
|
+
# @return [void]
|
|
62
|
+
def write(message)
|
|
63
|
+
raise LLM::MCP::Error, "MCP transport is not running" unless running?
|
|
64
|
+
http = Net::HTTP.start(uri.host, uri.port, use_ssl:, open_timeout: timeout, read_timeout: timeout)
|
|
65
|
+
req = Net::HTTP::Post.new(uri.path, headers.merge("content-type" => "application/json"))
|
|
66
|
+
req.body = LLM.json.dump(message)
|
|
67
|
+
http.request(req) do |res|
|
|
68
|
+
unless Net::HTTPSuccess === res
|
|
69
|
+
raise LLM::MCP::Error, "MCP transport write failed with HTTP #{res.code}"
|
|
70
|
+
end
|
|
71
|
+
read(res)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
##
|
|
76
|
+
# Reads the next queued message without blocking.
|
|
77
|
+
# @raise [LLM::MCP::Error]
|
|
78
|
+
# When the transport is not running
|
|
79
|
+
# @raise [IO::WaitReadable]
|
|
80
|
+
# When no complete message is available to read
|
|
81
|
+
# @return [Hash]
|
|
82
|
+
def read_nonblock
|
|
83
|
+
lock do
|
|
84
|
+
raise LLM::MCP::Error, "MCP transport is not running" unless running?
|
|
85
|
+
raise IO::WaitReadable if @queue.empty?
|
|
86
|
+
@queue.shift
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
##
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
# Returns true when the MCP server connection is alive
|
|
93
|
+
def running?
|
|
94
|
+
@running
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
attr_reader :uri, :use_ssl, :headers, :timeout
|
|
100
|
+
|
|
101
|
+
def enqueue(message)
|
|
102
|
+
lock { @queue << message }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def read(res)
|
|
106
|
+
if res["content-type"].to_s.include?("text/event-stream")
|
|
107
|
+
parser = LLM::EventStream::Parser.new
|
|
108
|
+
parser.register EventHandler.new { enqueue(_1) }
|
|
109
|
+
res.read_body { parser << _1 }
|
|
110
|
+
parser.free
|
|
111
|
+
else
|
|
112
|
+
body = +""
|
|
113
|
+
res.read_body { body << _1 }
|
|
114
|
+
enqueue(LLM.json.load(body)) unless body.empty?
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def lock(&)
|
|
119
|
+
@monitor.synchronize(&)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM::MCP::Transport
|
|
4
|
+
##
|
|
5
|
+
# The {LLM::MCP::Transport::Stdio LLM::MCP::Transport::Stdio} class
|
|
6
|
+
# provides a stdio transport for {LLM::MCP LLM::MCP}. It sends JSON-RPC
|
|
7
|
+
# messages to an MCP process over stdin and stdout and delegates process
|
|
8
|
+
# lifecycle management to {LLM::MCP::Command LLM::MCP::Command}.
|
|
9
|
+
class Stdio
|
|
10
|
+
##
|
|
11
|
+
# Returns a new Stdio transport instance.
|
|
12
|
+
# @param command [LLM::MCP::Command]
|
|
13
|
+
# The command to run for the MCP process
|
|
14
|
+
# @return [LLM::MCP::Transport::Stdio]
|
|
15
|
+
def initialize(command:)
|
|
16
|
+
@command = command
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
##
|
|
20
|
+
# Starts an MCP process over a stdio transport.
|
|
21
|
+
# This method is non-blocking and returns immediately.
|
|
22
|
+
# @raise [LLM::Error]
|
|
23
|
+
# When the transport is already running
|
|
24
|
+
# @return [void]
|
|
25
|
+
def start
|
|
26
|
+
if command.alive?
|
|
27
|
+
raise LLM::MCP::Error, "MCP transport is already running"
|
|
28
|
+
else
|
|
29
|
+
command.start
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
##
|
|
34
|
+
# Closes the connection to the MCP process.
|
|
35
|
+
# This method is idempotent and can be called multiple times without error.
|
|
36
|
+
# @return [void]
|
|
37
|
+
def stop
|
|
38
|
+
command.stop
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
##
|
|
42
|
+
# Writes a message to the MCP process.
|
|
43
|
+
# @param [Hash] message
|
|
44
|
+
# The message to write
|
|
45
|
+
# @raise [LLM::Error]
|
|
46
|
+
# When the transport is not running
|
|
47
|
+
# @return [void]
|
|
48
|
+
def write(message)
|
|
49
|
+
if command.alive?
|
|
50
|
+
command.write(LLM.json.dump(message))
|
|
51
|
+
else
|
|
52
|
+
raise LLM::MCP::Error, "MCP transport is not running"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Reads a message from the MCP process without blocking.
|
|
58
|
+
# @raise [LLM::Error]
|
|
59
|
+
# When the transport is not running
|
|
60
|
+
# @raise [IO::WaitReadable]
|
|
61
|
+
# When no complete message is available to read
|
|
62
|
+
# @return [Hash]
|
|
63
|
+
# The next message from the MCP process
|
|
64
|
+
def read_nonblock
|
|
65
|
+
if command.alive?
|
|
66
|
+
LLM.json.load(command.read_nonblock)
|
|
67
|
+
else
|
|
68
|
+
raise LLM::MCP::Error, "MCP transport is not running"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
##
|
|
73
|
+
# Waits for the command to exit.
|
|
74
|
+
# This method is blocking and will return only after the
|
|
75
|
+
# process has exited.
|
|
76
|
+
# @return [void]
|
|
77
|
+
def wait
|
|
78
|
+
command.wait
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
attr_reader :command, :stdin, :stdout, :stderr
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/llm/mcp.rb
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
##
|
|
4
|
+
# The {LLM::MCP LLM::MCP} class provides access to servers that
|
|
5
|
+
# implement the Model Context Protocol. MCP defines a standard way for
|
|
6
|
+
# clients and servers to exchange capabilities such as tools, prompts,
|
|
7
|
+
# resources, and other structured interactions.
|
|
8
|
+
#
|
|
9
|
+
# In llm.rb, {LLM::MCP LLM::MCP} currently supports stdio and HTTP
|
|
10
|
+
# transports and focuses on discovering tools that can be used through
|
|
11
|
+
# {LLM::Context LLM::Context} and {LLM::Agent LLM::Agent}.
|
|
12
|
+
class LLM::MCP
|
|
13
|
+
require "monitor"
|
|
14
|
+
require_relative "mcp/error"
|
|
15
|
+
require_relative "mcp/command"
|
|
16
|
+
require_relative "mcp/rpc"
|
|
17
|
+
require_relative "mcp/pipe"
|
|
18
|
+
require_relative "mcp/transport/http"
|
|
19
|
+
require_relative "mcp/transport/stdio"
|
|
20
|
+
|
|
21
|
+
include RPC
|
|
22
|
+
|
|
23
|
+
##
|
|
24
|
+
# @param [LLM::Provider, nil] llm
|
|
25
|
+
# The provider to use for MCP transports that need one
|
|
26
|
+
# @param [Hash, nil] stdio The configuration for the stdio transport
|
|
27
|
+
# @option stdio [Array<String>] :argv
|
|
28
|
+
# The command to run for the MCP process
|
|
29
|
+
# @option stdio [Hash] :env
|
|
30
|
+
# The environment variables to set for the MCP process
|
|
31
|
+
# @option stdio [String, nil] :cwd
|
|
32
|
+
# The working directory for the MCP process
|
|
33
|
+
# @param [Hash, nil] http The configuration for the HTTP transport
|
|
34
|
+
# @option http [String] :url
|
|
35
|
+
# The URL for the MCP HTTP endpoint
|
|
36
|
+
# @option http [Hash] :headers
|
|
37
|
+
# Extra headers for requests
|
|
38
|
+
# @param [Integer] timeout The maximum amount of time to wait when reading from an MCP process
|
|
39
|
+
# @return [LLM::MCP] A new MCP instance
|
|
40
|
+
def initialize(llm = nil, stdio: nil, http: nil, timeout: 30)
|
|
41
|
+
@llm = llm
|
|
42
|
+
@monitor = Monitor.new
|
|
43
|
+
@timeout = timeout
|
|
44
|
+
if stdio && http
|
|
45
|
+
raise ArgumentError, "stdio and http are mutually exclusive"
|
|
46
|
+
elsif stdio
|
|
47
|
+
@command = Command.new(**stdio)
|
|
48
|
+
@transport = Transport::Stdio.new(command:)
|
|
49
|
+
elsif http
|
|
50
|
+
@transport = Transport::HTTP.new(**http, timeout:)
|
|
51
|
+
else
|
|
52
|
+
raise ArgumentError, "stdio or http is required"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
##
|
|
57
|
+
# Starts the MCP process.
|
|
58
|
+
# @return [void]
|
|
59
|
+
def start
|
|
60
|
+
lock do
|
|
61
|
+
transport.start
|
|
62
|
+
call(transport, "initialize", {clientInfo: {name: "llm.rb", version: LLM::VERSION}})
|
|
63
|
+
call(transport, "notifications/initialized")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
##
|
|
68
|
+
# Stops the MCP process.
|
|
69
|
+
# @return [void]
|
|
70
|
+
def stop
|
|
71
|
+
lock do
|
|
72
|
+
transport.stop
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
##
|
|
78
|
+
# Returns the tools provided by the MCP process.
|
|
79
|
+
# @return [Array<Class<LLM::Tool>>]
|
|
80
|
+
def tools
|
|
81
|
+
lock do
|
|
82
|
+
res = call(transport, "tools/list")
|
|
83
|
+
res["tools"].map { LLM::Tool.mcp(self, _1) }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
##
|
|
88
|
+
# Calls a tool by name with the given arguments
|
|
89
|
+
# @param [String] name The name of the tool to call
|
|
90
|
+
# @param [Hash] arguments The arguments to pass to the tool
|
|
91
|
+
# @return [Object] The result of the tool call
|
|
92
|
+
def call_tool(name, arguments = {})
|
|
93
|
+
lock do
|
|
94
|
+
res = call(transport, "tools/call", {name:, arguments:})
|
|
95
|
+
adapt_tool_result(res)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
attr_reader :llm, :command, :transport, :timeout
|
|
102
|
+
|
|
103
|
+
def adapt_tool_result(result)
|
|
104
|
+
if result["structuredContent"]
|
|
105
|
+
result["structuredContent"]
|
|
106
|
+
elsif result["content"]
|
|
107
|
+
{content: result["content"]}
|
|
108
|
+
else
|
|
109
|
+
result
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def lock(&)
|
|
114
|
+
@monitor.synchronize(&)
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/llm/message.rb
CHANGED
|
@@ -26,7 +26,7 @@ module LLM
|
|
|
26
26
|
def initialize(role, content, extra = {})
|
|
27
27
|
@role = role.to_s
|
|
28
28
|
@content = content
|
|
29
|
-
@extra = extra
|
|
29
|
+
@extra = LLM::Object.from(extra)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
##
|
|
@@ -34,8 +34,9 @@ module LLM
|
|
|
34
34
|
# @return [Hash]
|
|
35
35
|
def to_h
|
|
36
36
|
{role:, content:,
|
|
37
|
-
tools:
|
|
38
|
-
|
|
37
|
+
tools: extra.tool_calls,
|
|
38
|
+
usage:,
|
|
39
|
+
original_tool_calls: extra.original_tool_calls}.compact
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
##
|
|
@@ -69,8 +70,9 @@ module LLM
|
|
|
69
70
|
##
|
|
70
71
|
# @return [Array<LLM::Function>]
|
|
71
72
|
def functions
|
|
72
|
-
@functions ||= tool_calls.
|
|
73
|
-
function = available_tools.find { _1.name.to_s == fn["name"] }
|
|
73
|
+
@functions ||= tool_calls.filter_map do |fn|
|
|
74
|
+
function = available_tools.find { _1.name.to_s == fn["name"] } || next
|
|
75
|
+
function = function.dup
|
|
74
76
|
function.tap { _1.id = fn.id }
|
|
75
77
|
function.tap { _1.arguments = fn.arguments }
|
|
76
78
|
end
|
|
@@ -119,7 +121,7 @@ module LLM
|
|
|
119
121
|
# @return [LLM::Response, nil]
|
|
120
122
|
# Returns the response associated with the message, or nil
|
|
121
123
|
def response
|
|
122
|
-
extra
|
|
124
|
+
extra.response
|
|
123
125
|
end
|
|
124
126
|
|
|
125
127
|
##
|
|
@@ -129,7 +131,7 @@ module LLM
|
|
|
129
131
|
# Returns annotations associated with the message
|
|
130
132
|
# @return [Array<LLM::Object>]
|
|
131
133
|
def annotations
|
|
132
|
-
@annotations ||= LLM::Object.from(extra
|
|
134
|
+
@annotations ||= LLM::Object.from(extra.annotations || [])
|
|
133
135
|
end
|
|
134
136
|
|
|
135
137
|
##
|
|
@@ -139,8 +141,7 @@ module LLM
|
|
|
139
141
|
# Returns token usage statistics
|
|
140
142
|
# @return [LLM::Object, nil]
|
|
141
143
|
def usage
|
|
142
|
-
|
|
143
|
-
@usage ||= response.usage
|
|
144
|
+
@usage ||= extra.usage || response&.usage
|
|
144
145
|
end
|
|
145
146
|
alias_method :token_usage, :usage
|
|
146
147
|
|
|
@@ -163,11 +164,12 @@ module LLM
|
|
|
163
164
|
private
|
|
164
165
|
|
|
165
166
|
def tool_calls
|
|
166
|
-
@tool_calls ||= LLM::Object.from(
|
|
167
|
+
@tool_calls ||= LLM::Object.from(extra.tool_calls || [])
|
|
167
168
|
end
|
|
168
169
|
|
|
169
170
|
def available_tools
|
|
170
|
-
response&.__tools__ || []
|
|
171
|
+
tools = extra.tools || response&.__tools__ || []
|
|
172
|
+
tools.map { _1.respond_to?(:function) ? _1.function : _1 }
|
|
171
173
|
end
|
|
172
174
|
end
|
|
173
175
|
end
|
data/lib/llm/model.rb
CHANGED
|
@@ -34,7 +34,7 @@ class LLM::Model
|
|
|
34
34
|
# @return [Boolean]
|
|
35
35
|
def chat?
|
|
36
36
|
return true if anthropic?
|
|
37
|
-
return [*(raw.supportedGenerationMethods || [])].include?("generateContent") if
|
|
37
|
+
return [*(raw.supportedGenerationMethods || [])].include?("generateContent") if google?
|
|
38
38
|
openai_compatible_chat?
|
|
39
39
|
end
|
|
40
40
|
|
|
@@ -96,7 +96,7 @@ class LLM::Model
|
|
|
96
96
|
raw.type == "model" && raw.key?(:display_name) && raw.key?(:created_at)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
def
|
|
99
|
+
def google?
|
|
100
100
|
raw.key?(:supportedGenerationMethods)
|
|
101
101
|
end
|
|
102
102
|
|
data/lib/llm/prompt.rb
CHANGED
|
@@ -5,20 +5,20 @@
|
|
|
5
5
|
# a single request from multiple role-aware messages.
|
|
6
6
|
# A prompt is not just a string. It is an ordered chain of
|
|
7
7
|
# messages with explicit roles (for example `system` and `user`).
|
|
8
|
-
# Use {LLM::
|
|
8
|
+
# Use {LLM::Context#prompt} when building a prompt inside a session.
|
|
9
9
|
# Use `LLM::Prompt.new(provider)` directly when you want to construct
|
|
10
10
|
# or pass prompt objects around explicitly.
|
|
11
11
|
#
|
|
12
12
|
# @example
|
|
13
13
|
# llm = LLM.openai(key: ENV["KEY"])
|
|
14
|
-
#
|
|
14
|
+
# ctx = LLM::Context.new(llm)
|
|
15
15
|
#
|
|
16
|
-
# prompt =
|
|
16
|
+
# prompt = ctx.prompt do
|
|
17
17
|
# system "Your task is to assist the user"
|
|
18
18
|
# user "Hello. Can you assist me?"
|
|
19
19
|
# end
|
|
20
20
|
#
|
|
21
|
-
# res =
|
|
21
|
+
# res = ctx.talk(prompt)
|
|
22
22
|
class LLM::Prompt
|
|
23
23
|
##
|
|
24
24
|
# @param [LLM::Provider] provider
|
|
@@ -57,7 +57,7 @@ class LLM::Prompt
|
|
|
57
57
|
# The message content
|
|
58
58
|
# @return [void]
|
|
59
59
|
def user(content)
|
|
60
|
-
|
|
60
|
+
talk(content, role: @provider.user_role)
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
##
|
|
@@ -65,7 +65,7 @@ class LLM::Prompt
|
|
|
65
65
|
# The message content
|
|
66
66
|
# @return [void]
|
|
67
67
|
def system(content)
|
|
68
|
-
|
|
68
|
+
talk(content, role: @provider.system_role)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
##
|
|
@@ -73,7 +73,7 @@ class LLM::Prompt
|
|
|
73
73
|
# The message content
|
|
74
74
|
# @return [void]
|
|
75
75
|
def developer(content)
|
|
76
|
-
|
|
76
|
+
talk(content, role: @provider.developer_role)
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
##
|
|
@@ -82,4 +82,14 @@ class LLM::Prompt
|
|
|
82
82
|
def to_a
|
|
83
83
|
@buffer.dup
|
|
84
84
|
end
|
|
85
|
+
|
|
86
|
+
##
|
|
87
|
+
# Returns true when two prompts have the same buffer
|
|
88
|
+
# @param [LLM::Prompt] other
|
|
89
|
+
# @return [Boolean]
|
|
90
|
+
def ==(other)
|
|
91
|
+
return false unless LLM::Prompt === other
|
|
92
|
+
@buffer == other.to_a
|
|
93
|
+
end
|
|
94
|
+
alias_method :eql?, :==
|
|
85
95
|
end
|