nunki 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +117 -0
- data/docs/adr/000-template.md +16 -0
- data/docs/adr/001-explicit-network-boundary.md +23 -0
- data/docs/adr/002-mcp-transports.md +23 -0
- data/docs/adr/README.md +3 -0
- data/lib/nunki/context.rb +65 -0
- data/lib/nunki/http.rb +198 -0
- data/lib/nunki/mcp/transports.rb +215 -0
- data/lib/nunki/mcp.rb +227 -0
- data/lib/nunki/protocol.rb +80 -0
- data/lib/nunki/provider.rb +93 -0
- data/lib/nunki/providers/anthropic.rb +147 -0
- data/lib/nunki/providers/open_ai.rb +124 -0
- data/lib/nunki/sse.rb +74 -0
- data/lib/nunki/version.rb +5 -0
- data/lib/nunki.rb +81 -0
- data/sig/nunki.rbs +132 -0
- metadata +62 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Nunki
|
|
6
|
+
module MCP
|
|
7
|
+
module Transports
|
|
8
|
+
class Stdio
|
|
9
|
+
MAX_MESSAGE_BYTES = 4_194_304
|
|
10
|
+
|
|
11
|
+
def initialize(command:, env: {})
|
|
12
|
+
@command = validate_command(command)
|
|
13
|
+
@env = validate_env(env)
|
|
14
|
+
@request_lock = Mutex.new
|
|
15
|
+
@write_lock = Mutex.new
|
|
16
|
+
@buffer = +""
|
|
17
|
+
@closed = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def protocol_version=(_version); end
|
|
21
|
+
|
|
22
|
+
def request(message, timeout:)
|
|
23
|
+
@request_lock.synchronize do
|
|
24
|
+
ensure_open
|
|
25
|
+
write(message)
|
|
26
|
+
deadline = monotonic + timeout
|
|
27
|
+
loop do
|
|
28
|
+
response = read_message(deadline)
|
|
29
|
+
return response if response["id"] == message["id"]
|
|
30
|
+
reply_to_server(response) if response["method"] && response.key?("id")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def notify(message)
|
|
36
|
+
ensure_open
|
|
37
|
+
write(message)
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def close
|
|
42
|
+
return if @closed
|
|
43
|
+
@closed = true
|
|
44
|
+
@stdin&.close unless @stdin&.closed?
|
|
45
|
+
stop_process
|
|
46
|
+
[@stdout, @stderr].compact.each { |io| io.close unless io.closed? }
|
|
47
|
+
@stderr_thread&.join(0.2)
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def ensure_open
|
|
54
|
+
raise Error, "MCP transport is closed" if @closed
|
|
55
|
+
return if @waiter
|
|
56
|
+
|
|
57
|
+
@stdin, @stdout, @stderr, @waiter = Open3.popen3(@env, *@command)
|
|
58
|
+
@stdin.binmode
|
|
59
|
+
@stdout.binmode
|
|
60
|
+
@stderr_thread = Thread.new do
|
|
61
|
+
loop { break unless @stderr.read(16_384) }
|
|
62
|
+
rescue IOError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
rescue SystemCallError => error
|
|
66
|
+
raise Error, "failed to start MCP server: #{error.message}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def write(message)
|
|
70
|
+
source = JSON.generate(Protocol.json(message))
|
|
71
|
+
raise ProtocolError, "MCP message exceeds #{MAX_MESSAGE_BYTES} bytes" if source.bytesize > MAX_MESSAGE_BYTES
|
|
72
|
+
@write_lock.synchronize do
|
|
73
|
+
@stdin.write(source, "\n")
|
|
74
|
+
@stdin.flush
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def read_message(deadline)
|
|
79
|
+
loop do
|
|
80
|
+
if (newline = @buffer.index("\n"))
|
|
81
|
+
source = @buffer.slice!(0, newline + 1).delete_suffix("\n").delete_suffix("\r")
|
|
82
|
+
next if source.empty?
|
|
83
|
+
return Protocol.object(Protocol.parse(source, "MCP message"), "MCP message")
|
|
84
|
+
end
|
|
85
|
+
wait = deadline - monotonic
|
|
86
|
+
raise Timeout, "MCP request timed out" unless wait.positive? && IO.select([@stdout], nil, nil, wait)
|
|
87
|
+
chunk = @stdout.read_nonblock(16_384, exception: false)
|
|
88
|
+
raise Error, "MCP server closed its output" if chunk.nil?
|
|
89
|
+
next if chunk == :wait_readable
|
|
90
|
+
@buffer << chunk
|
|
91
|
+
raise ProtocolError, "MCP message exceeds #{MAX_MESSAGE_BYTES} bytes" if @buffer.bytesize > MAX_MESSAGE_BYTES
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def reply_to_server(message)
|
|
96
|
+
result = message["method"] == "ping" ? {} : nil
|
|
97
|
+
reply = {"jsonrpc" => "2.0", "id" => message["id"]}
|
|
98
|
+
if result
|
|
99
|
+
reply["result"] = result
|
|
100
|
+
else
|
|
101
|
+
reply["error"] = {"code" => -32_601, "message" => "Method not found"}
|
|
102
|
+
end
|
|
103
|
+
write(reply)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def stop_process
|
|
107
|
+
return unless @waiter
|
|
108
|
+
return if @waiter.join(0.5)
|
|
109
|
+
["TERM", "KILL"].each do |signal|
|
|
110
|
+
begin
|
|
111
|
+
signal_process(signal, @waiter.pid)
|
|
112
|
+
rescue Errno::EINVAL
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
return if @waiter.join(0.5)
|
|
116
|
+
end
|
|
117
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def signal_process(signal, pid) = Process.kill(signal, pid)
|
|
122
|
+
|
|
123
|
+
def validate_command(command)
|
|
124
|
+
raise ProtocolError, "command must be a non-empty array" unless command.is_a?(Array) && command.any?
|
|
125
|
+
command.map { |part| Protocol.string(part, "command argument", empty: false, max: 32_768) }.freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_env(env)
|
|
129
|
+
raise ProtocolError, "env must be an object" unless env.is_a?(Hash)
|
|
130
|
+
env.to_h do |key, value|
|
|
131
|
+
[Protocol.string(key, "environment name", empty: false, max: 1024),
|
|
132
|
+
Protocol.string(value, "environment value", max: 32_768)]
|
|
133
|
+
end.freeze
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
class HTTP
|
|
140
|
+
def initialize(url:, headers: {}, **options)
|
|
141
|
+
@client = Nunki::HTTP::Client.new(endpoint: url, headers: headers, **options)
|
|
142
|
+
@session_id = nil
|
|
143
|
+
@protocol_version = nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def protocol_version=(version)
|
|
147
|
+
@protocol_version = version
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def request(message, timeout:)
|
|
151
|
+
matched = nil
|
|
152
|
+
response = @client.post_json(message, headers: request_headers) do |event, response_headers|
|
|
153
|
+
@session_id ||= response_headers["mcp-session-id"]
|
|
154
|
+
matched ||= receive(parse_message(event.data), message["id"])
|
|
155
|
+
end
|
|
156
|
+
@session_id ||= response.headers["mcp-session-id"]
|
|
157
|
+
matched ||= receive(parse_message(response.body), message["id"]) unless response.body.empty?
|
|
158
|
+
matched ||
|
|
159
|
+
raise(ProtocolError, "MCP HTTP response did not contain the request id")
|
|
160
|
+
rescue Nunki::Timeout
|
|
161
|
+
raise
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def notify(message)
|
|
165
|
+
@client.post_json(message, headers: request_headers)
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def close
|
|
170
|
+
begin
|
|
171
|
+
@client.delete(headers: request_headers) if @session_id
|
|
172
|
+
rescue HTTPError => error
|
|
173
|
+
raise unless error.status == 405
|
|
174
|
+
ensure
|
|
175
|
+
@client.cancel
|
|
176
|
+
end
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def request_headers
|
|
183
|
+
headers = {"Accept" => "application/json, text/event-stream"}
|
|
184
|
+
headers["MCP-Session-Id"] = @session_id if @session_id
|
|
185
|
+
headers["MCP-Protocol-Version"] = @protocol_version if @protocol_version
|
|
186
|
+
headers
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def parse_message(source)
|
|
190
|
+
return nil if source.empty?
|
|
191
|
+
Protocol.object(Protocol.parse(source, "MCP HTTP response"), "MCP HTTP response")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def receive(message, request_id)
|
|
195
|
+
return unless message
|
|
196
|
+
raise ProtocolError, "invalid JSON-RPC version" unless message["jsonrpc"] == "2.0"
|
|
197
|
+
return message if !message.key?("method") && message["id"] == request_id
|
|
198
|
+
reply_to_server(message) if message["method"] && message.key?("id")
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def reply_to_server(message)
|
|
203
|
+
result = message["method"] == "ping" ? {} : nil
|
|
204
|
+
reply = {"jsonrpc" => "2.0", "id" => message["id"]}
|
|
205
|
+
if result
|
|
206
|
+
reply["result"] = result
|
|
207
|
+
else
|
|
208
|
+
reply["error"] = {"code" => -32_601, "message" => "Method not found"}
|
|
209
|
+
end
|
|
210
|
+
@client.post_json(reply, headers: request_headers)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
data/lib/nunki/mcp.rb
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nunki
|
|
4
|
+
module MCP
|
|
5
|
+
PROTOCOL_VERSION = "2025-11-25"
|
|
6
|
+
|
|
7
|
+
class RemoteError < Error
|
|
8
|
+
attr_reader :code, :data
|
|
9
|
+
|
|
10
|
+
def initialize(code, message, data = nil)
|
|
11
|
+
@code = code
|
|
12
|
+
@data = data
|
|
13
|
+
super(message)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class Client
|
|
18
|
+
def self.stdio(command:, env: {}, **options)
|
|
19
|
+
timeout = options.delete(:timeout) || 30
|
|
20
|
+
dispatch = options.delete(:dispatch) || ->(&block) { block.call }
|
|
21
|
+
raise ProtocolError, "unknown options: #{options.keys.join(", ")}" unless options.empty?
|
|
22
|
+
new(Transports::Stdio.new(command: command, env: env), timeout: timeout, dispatch: dispatch)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.http(url:, headers: {}, **options)
|
|
26
|
+
timeout = options.delete(:timeout) || 30
|
|
27
|
+
dispatch = options.delete(:dispatch) || ->(&block) { block.call }
|
|
28
|
+
options[:read_timeout] ||= timeout
|
|
29
|
+
options[:retries] = 0 unless options.key?(:retries)
|
|
30
|
+
new(Transports::HTTP.new(url: url, headers: headers, **options), timeout: timeout, dispatch: dispatch)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :capabilities, :server_info
|
|
34
|
+
|
|
35
|
+
def initialize(transport, timeout:, dispatch:)
|
|
36
|
+
@transport = transport
|
|
37
|
+
@timeout = positive_timeout(timeout)
|
|
38
|
+
raise ProtocolError, "dispatch must respond to call" unless dispatch.respond_to?(:call)
|
|
39
|
+
@dispatch = dispatch
|
|
40
|
+
@sequence = 0
|
|
41
|
+
@request_lock = Mutex.new
|
|
42
|
+
@state_lock = Mutex.new
|
|
43
|
+
@state = :created
|
|
44
|
+
@capabilities = {}.freeze
|
|
45
|
+
@server_info = {}.freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def start
|
|
49
|
+
@dispatch.call do
|
|
50
|
+
begin_start
|
|
51
|
+
begin
|
|
52
|
+
result = request_now("initialize", {
|
|
53
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
54
|
+
capabilities: {},
|
|
55
|
+
clientInfo: {name: "nunki", version: VERSION}
|
|
56
|
+
}, initializing: true)
|
|
57
|
+
validate_initialization(result)
|
|
58
|
+
@transport.protocol_version = PROTOCOL_VERSION
|
|
59
|
+
@transport.notify(notification("notifications/initialized"))
|
|
60
|
+
@state_lock.synchronize do
|
|
61
|
+
raise Error, "MCP client was closed during initialization" unless @state == :starting
|
|
62
|
+
@state = :started
|
|
63
|
+
end
|
|
64
|
+
result
|
|
65
|
+
rescue StandardError
|
|
66
|
+
close_after_failed_start
|
|
67
|
+
raise
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def tools = schedule { list_all("tools/list", "tools", :tools) }
|
|
73
|
+
def resources = schedule { list_all("resources/list", "resources", :resources) }
|
|
74
|
+
def prompts = schedule { list_all("prompts/list", "prompts", :prompts) }
|
|
75
|
+
|
|
76
|
+
def call_tool(name, arguments)
|
|
77
|
+
schedule do
|
|
78
|
+
name = Protocol.string(name, "tool name", empty: false, max: 256)
|
|
79
|
+
arguments = Protocol.object(arguments, "tool arguments")
|
|
80
|
+
result = request_now("tools/call", {name: name, arguments: arguments})
|
|
81
|
+
validate_result_collection(result, "content")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def read_resource(uri)
|
|
86
|
+
schedule do
|
|
87
|
+
uri = Protocol.string(uri, "resource URI", empty: false, max: 4096)
|
|
88
|
+
result = request_now("resources/read", {uri: uri})
|
|
89
|
+
validate_result_collection(result, "contents")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def get_prompt(name, arguments = {})
|
|
94
|
+
schedule do
|
|
95
|
+
name = Protocol.string(name, "prompt name", empty: false, max: 256)
|
|
96
|
+
arguments = Protocol.object(arguments, "prompt arguments")
|
|
97
|
+
result = request_now("prompts/get", {name: name, arguments: arguments})
|
|
98
|
+
validate_result_collection(result, "messages")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def close
|
|
103
|
+
@dispatch.call do
|
|
104
|
+
should_close = @state_lock.synchronize do
|
|
105
|
+
next false if %i[closing closed].include?(@state)
|
|
106
|
+
@state = :closing
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
next nil unless should_close
|
|
110
|
+
begin
|
|
111
|
+
@transport.close
|
|
112
|
+
ensure
|
|
113
|
+
@state_lock.synchronize { @state = :closed }
|
|
114
|
+
end
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def schedule(&block)
|
|
122
|
+
@dispatch.call do
|
|
123
|
+
raise Error, "MCP client is not started" unless @state_lock.synchronize { @state == :started }
|
|
124
|
+
block.call
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def begin_start
|
|
129
|
+
@state_lock.synchronize do
|
|
130
|
+
raise Error, "MCP client has already started" unless @state == :created
|
|
131
|
+
@state = :starting
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def close_after_failed_start
|
|
136
|
+
@transport.close
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
ensure
|
|
140
|
+
@capabilities = {}.freeze
|
|
141
|
+
@server_info = {}.freeze
|
|
142
|
+
@state_lock.synchronize { @state = :closed }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def request_now(method, params, initializing: false)
|
|
146
|
+
request_id = nil
|
|
147
|
+
@request_lock.synchronize do
|
|
148
|
+
request_id = @sequence += 1
|
|
149
|
+
message = {"jsonrpc" => "2.0", "id" => request_id, "method" => method, "params" => params}
|
|
150
|
+
response = Protocol.object(@transport.request(message, timeout: @timeout), "JSON-RPC response")
|
|
151
|
+
raise ProtocolError, "invalid JSON-RPC version" unless response["jsonrpc"] == "2.0"
|
|
152
|
+
raise ProtocolError, "mismatched JSON-RPC response id" unless response["id"] == request_id
|
|
153
|
+
raise_remote(response["error"]) if response["error"]
|
|
154
|
+
Protocol.deep_freeze(Protocol.object(response["result"], "JSON-RPC result").dup)
|
|
155
|
+
end
|
|
156
|
+
rescue Timeout
|
|
157
|
+
unless initializing
|
|
158
|
+
begin
|
|
159
|
+
@transport.notify(notification("notifications/cancelled", requestId: request_id, reason: "request timed out"))
|
|
160
|
+
rescue StandardError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
raise
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def validate_initialization(result)
|
|
168
|
+
version = Protocol.string(result["protocolVersion"], "protocol version", empty: false, max: 64)
|
|
169
|
+
raise ProtocolError, "unsupported MCP protocol version: #{version}" unless version == PROTOCOL_VERSION
|
|
170
|
+
@capabilities = Protocol.deep_freeze(Protocol.object(result["capabilities"], "server capabilities").dup)
|
|
171
|
+
@server_info = Protocol.deep_freeze(Protocol.object(result.fetch("serverInfo", {}), "server info").dup)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def list_all(method, key, capability)
|
|
175
|
+
raise ProtocolError, "server does not advertise #{capability}" unless @capabilities.key?(capability.to_s)
|
|
176
|
+
items = []
|
|
177
|
+
cursor = nil
|
|
178
|
+
100.times do
|
|
179
|
+
result = request_now(method, cursor ? {cursor: cursor} : {})
|
|
180
|
+
values = Protocol.collection(result[key], key)
|
|
181
|
+
values.each { |value| items << validate_descriptor(value, capability) }
|
|
182
|
+
raise ProtocolError, "#{key} list is too large" if items.length > Protocol::MAX_COLLECTION
|
|
183
|
+
cursor = result["nextCursor"]
|
|
184
|
+
break unless cursor
|
|
185
|
+
Protocol.string(cursor, "next cursor", empty: false, max: 4096)
|
|
186
|
+
end
|
|
187
|
+
raise ProtocolError, "#{key} pagination exceeds 100 pages" if cursor
|
|
188
|
+
Protocol.deep_freeze(items)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def validate_descriptor(value, capability)
|
|
192
|
+
value = Protocol.object(value, "#{capability} descriptor").dup
|
|
193
|
+
key = capability == :resources ? "uri" : "name"
|
|
194
|
+
Protocol.string(value[key], "#{capability} #{key}", empty: false, max: 4096)
|
|
195
|
+
Protocol.object(value["inputSchema"], "tool input schema") if capability == :tools
|
|
196
|
+
value
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def validate_result_collection(result, key)
|
|
200
|
+
Protocol.collection(result[key], key).each { |value| Protocol.json(value) }
|
|
201
|
+
result
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def raise_remote(error)
|
|
205
|
+
error = Protocol.object(error, "JSON-RPC error")
|
|
206
|
+
code = error["code"]
|
|
207
|
+
raise ProtocolError, "JSON-RPC error code must be an integer" unless code.is_a?(Integer)
|
|
208
|
+
message = Protocol.string(error["message"], "JSON-RPC error message", empty: false)
|
|
209
|
+
Protocol.json(error["data"]) if error.key?("data")
|
|
210
|
+
raise RemoteError.new(code, message, error["data"])
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def notification(method, **params)
|
|
214
|
+
message = {"jsonrpc" => "2.0", "method" => method}
|
|
215
|
+
message["params"] = params unless params.empty?
|
|
216
|
+
message
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def positive_timeout(value)
|
|
220
|
+
raise ProtocolError, "timeout must be positive" unless value.is_a?(Numeric) && value.positive?
|
|
221
|
+
value
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
require_relative "mcp/transports"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Nunki
|
|
6
|
+
module Protocol
|
|
7
|
+
MAX_DEPTH = 32
|
|
8
|
+
MAX_COLLECTION = 10_000
|
|
9
|
+
MAX_STRING = 1_048_576
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def json(value, depth = 0)
|
|
14
|
+
raise ProtocolError, "JSON nesting exceeds #{MAX_DEPTH}" if depth > MAX_DEPTH
|
|
15
|
+
|
|
16
|
+
case value
|
|
17
|
+
when nil, true, false, Integer
|
|
18
|
+
value
|
|
19
|
+
when Float
|
|
20
|
+
raise ProtocolError, "JSON number must be finite" unless value.finite?
|
|
21
|
+
when String
|
|
22
|
+
string(value, "JSON string")
|
|
23
|
+
when Array
|
|
24
|
+
collection(value, "JSON array").each { |item| json(item, depth + 1) }
|
|
25
|
+
when Hash
|
|
26
|
+
object(value, "JSON object").each do |key, item|
|
|
27
|
+
raise ProtocolError, "JSON object keys must be strings or symbols" unless key.is_a?(String) || key.is_a?(Symbol)
|
|
28
|
+
json(item, depth + 1)
|
|
29
|
+
end
|
|
30
|
+
else
|
|
31
|
+
raise ProtocolError, "unsupported JSON value: #{value.class}"
|
|
32
|
+
end
|
|
33
|
+
value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse(source, name = "response")
|
|
37
|
+
string(source, name, max: nil)
|
|
38
|
+
json(JSON.parse(source, max_nesting: MAX_DEPTH))
|
|
39
|
+
rescue JSON::ParserError => error
|
|
40
|
+
raise ProtocolError, "invalid #{name}: #{error.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def object(value, name)
|
|
44
|
+
raise ProtocolError, "#{name} must be an object" unless value.is_a?(Hash)
|
|
45
|
+
raise ProtocolError, "#{name} has too many members" if value.length > MAX_COLLECTION
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def collection(value, name)
|
|
50
|
+
raise ProtocolError, "#{name} must be an array" unless value.is_a?(Array)
|
|
51
|
+
raise ProtocolError, "#{name} has too many items" if value.length > MAX_COLLECTION
|
|
52
|
+
value
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def string(value, name, empty: true, max: MAX_STRING)
|
|
56
|
+
raise ProtocolError, "#{name} must be a string" unless value.is_a?(String)
|
|
57
|
+
raise ProtocolError, "#{name} must not be empty" if !empty && value.empty?
|
|
58
|
+
raise ProtocolError, "#{name} is too large" if max && value.bytesize > max
|
|
59
|
+
encoded = value.encode(Encoding::UTF_8)
|
|
60
|
+
raise EncodingError unless encoded.valid_encoding?
|
|
61
|
+
encoded
|
|
62
|
+
rescue EncodingError
|
|
63
|
+
raise ProtocolError, "#{name} must be valid UTF-8"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def uint(value, name, positive: false)
|
|
67
|
+
minimum = positive ? 1 : 0
|
|
68
|
+
raise ProtocolError, "#{name} must be an integer >= #{minimum}" unless value.is_a?(Integer) && value >= minimum
|
|
69
|
+
value
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def deep_freeze(value)
|
|
73
|
+
case value
|
|
74
|
+
when Array then value.each { |item| deep_freeze(item) }
|
|
75
|
+
when Hash then value.each { |key, item| deep_freeze(key); deep_freeze(item) }
|
|
76
|
+
end
|
|
77
|
+
value.freeze
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nunki
|
|
4
|
+
class Provider
|
|
5
|
+
HTTP_OPTIONS = %i[headers open_timeout read_timeout retries retry_base max_request_bytes max_response_bytes].freeze
|
|
6
|
+
|
|
7
|
+
def self.build(kind, endpoint:, api_key: nil, model:, **options)
|
|
8
|
+
kind = kind.to_s.tr("-", "_").to_sym
|
|
9
|
+
provider = case kind
|
|
10
|
+
when :openai, :openai_compatible, :local then Providers::OpenAI
|
|
11
|
+
when :anthropic then Providers::Anthropic
|
|
12
|
+
else raise ProtocolError, "unknown provider kind: #{kind}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
http_options = options.select { |key, _| HTTP_OPTIONS.include?(key) }
|
|
16
|
+
provider_options = options.reject { |key, _| HTTP_OPTIONS.include?(key) }
|
|
17
|
+
headers = http_options.delete(:headers) || {}
|
|
18
|
+
client = HTTP::Client.new(endpoint: endpoint, headers: headers.merge(provider.headers(api_key)), **http_options)
|
|
19
|
+
provider.new(client: client, model: model, **provider_options)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(client:, model:, dispatch: ->(&block) { block.call }, context_limit: nil)
|
|
23
|
+
@client = client
|
|
24
|
+
@model = Protocol.string(model, "model", empty: false, max: 256)
|
|
25
|
+
raise ProtocolError, "dispatch must respond to call" unless dispatch.respond_to?(:call)
|
|
26
|
+
@dispatch = dispatch
|
|
27
|
+
@context_limit = context_limit && Protocol.uint(context_limit, "context_limit", positive: true)
|
|
28
|
+
@call_lock = Mutex.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def complete(messages, tools: [], system: nil, max_tokens: 1024, &stream)
|
|
32
|
+
@dispatch.call do
|
|
33
|
+
raise Error, "a completion is already in progress" unless @call_lock.try_lock
|
|
34
|
+
begin
|
|
35
|
+
perform_complete(prepare(messages, tools, system, max_tokens), &stream)
|
|
36
|
+
ensure
|
|
37
|
+
@call_lock.unlock
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def cancel = @client.cancel
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def prepare(messages, tools, system, max_tokens)
|
|
47
|
+
Context.validate_messages(Protocol.collection(messages, "messages"))
|
|
48
|
+
tools = validate_tools(Protocol.collection(tools, "tools"))
|
|
49
|
+
system = Protocol.string(system, "system") if system
|
|
50
|
+
max_tokens = Protocol.uint(max_tokens, "max_tokens", positive: true)
|
|
51
|
+
if @context_limit
|
|
52
|
+
raise ProtocolError, "context_limit must exceed max_tokens" unless @context_limit > max_tokens
|
|
53
|
+
messages = Context.truncate(messages, max_tokens: @context_limit - max_tokens)
|
|
54
|
+
end
|
|
55
|
+
[messages, tools, system, max_tokens]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate_tools(tools)
|
|
59
|
+
tools.each do |tool|
|
|
60
|
+
raise ProtocolError, "tool must be a Nunki::Tool" unless tool.is_a?(Tool)
|
|
61
|
+
Protocol.string(tool.name, "tool name", empty: false, max: 256)
|
|
62
|
+
Protocol.string(tool.description, "tool description", max: 16_384)
|
|
63
|
+
Protocol.json(Protocol.object(tool.input_schema, "tool input schema"))
|
|
64
|
+
end
|
|
65
|
+
tools
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_event(event)
|
|
69
|
+
Protocol.object(Protocol.parse(event.data, "SSE data"), "SSE data")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def response(parts, usage, finish_reason)
|
|
73
|
+
Response.new(
|
|
74
|
+
message: Message.new(role: :assistant, content: parts.freeze),
|
|
75
|
+
usage: Usage.new(input_tokens: usage[0], output_tokens: usage[1]),
|
|
76
|
+
finish_reason: finish_reason
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def text_part(text)
|
|
81
|
+
Part.new(type: :text, text: text, tool_name: nil, tool_input: nil, tool_use_id: nil)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def tool_part(name, input, id)
|
|
85
|
+
name = Protocol.string(name, "tool name", empty: false, max: 256)
|
|
86
|
+
id = Protocol.string(id, "tool id", empty: false, max: 1024)
|
|
87
|
+
input = Protocol.object(input, "tool input")
|
|
88
|
+
Part.new(type: :tool_use, text: nil, tool_name: name, tool_input: input, tool_use_id: id)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
module Providers; end
|
|
93
|
+
end
|