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,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nunki
|
|
4
|
+
module Providers
|
|
5
|
+
class Anthropic < Provider
|
|
6
|
+
def self.headers(api_key)
|
|
7
|
+
api_key ? {"x-api-key" => Protocol.string(api_key, "api_key", empty: false, max: 8192)} : {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(client:, model:, api_version: "2023-06-01", **options)
|
|
11
|
+
super(client: client, model: model, **options)
|
|
12
|
+
@api_version = Protocol.string(api_version, "api_version", empty: false, max: 64)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def perform_complete(prepared)
|
|
18
|
+
messages, tools, system, max_tokens = prepared
|
|
19
|
+
payload = {model: @model, messages: messages.map { |message| encode_message(message) }, max_tokens: max_tokens, stream: true}
|
|
20
|
+
payload[:system] = system if system
|
|
21
|
+
payload[:tools] = tools.map { |tool| encode_tool(tool) } unless tools.empty?
|
|
22
|
+
state = {parts: [], active: {}, usage: [0, 0], finish: nil}
|
|
23
|
+
|
|
24
|
+
result = @client.post_json(payload, headers: {
|
|
25
|
+
"Accept" => "text/event-stream, application/json",
|
|
26
|
+
"anthropic-version" => @api_version
|
|
27
|
+
}) do |event|
|
|
28
|
+
next if event.data.empty?
|
|
29
|
+
consume_stream(parse_event(event), state) { |part| yield part if block_given? }
|
|
30
|
+
end
|
|
31
|
+
return consume_response(result.body, messages) unless result.body.empty?
|
|
32
|
+
|
|
33
|
+
usage = state[:usage]
|
|
34
|
+
usage[0] = Nunki.estimate_tokens(messages) if usage[0].zero?
|
|
35
|
+
usage[1] = Nunki.estimate_tokens(state[:parts]) if usage[1].zero?
|
|
36
|
+
response(state[:parts], usage, state[:finish])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def consume_stream(message, state)
|
|
40
|
+
case message["type"]
|
|
41
|
+
when "message_start"
|
|
42
|
+
usage = Protocol.object(Protocol.object(message["message"], "message")["usage"], "usage")
|
|
43
|
+
state[:usage][0] = Protocol.uint(usage.fetch("input_tokens", 0), "input tokens")
|
|
44
|
+
when "content_block_start"
|
|
45
|
+
start_block(message, state) { |part| yield part }
|
|
46
|
+
when "content_block_delta"
|
|
47
|
+
consume_delta(message, state) { |part| yield part }
|
|
48
|
+
when "content_block_stop"
|
|
49
|
+
finish_block(message, state) { |part| yield part }
|
|
50
|
+
when "message_delta"
|
|
51
|
+
delta = Protocol.object(message["delta"], "message delta")
|
|
52
|
+
state[:finish] = Protocol.string(delta["stop_reason"], "stop reason") if delta["stop_reason"]
|
|
53
|
+
usage = Protocol.object(message.fetch("usage", {}), "usage")
|
|
54
|
+
state[:usage][1] = Protocol.uint(usage["output_tokens"], "output tokens") if usage["output_tokens"]
|
|
55
|
+
when "error"
|
|
56
|
+
error = Protocol.object(message["error"], "error")
|
|
57
|
+
raise Error, Protocol.string(error["message"], "error message")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def start_block(message, state)
|
|
62
|
+
index = Protocol.uint(message["index"], "content index")
|
|
63
|
+
block = Protocol.object(message["content_block"], "content block")
|
|
64
|
+
case block["type"]
|
|
65
|
+
when "text"
|
|
66
|
+
text = Protocol.string(block.fetch("text", ""), "text")
|
|
67
|
+
state[:active][index] = {type: :text, text: +text}
|
|
68
|
+
yield text_part(text) unless text.empty?
|
|
69
|
+
when "tool_use"
|
|
70
|
+
state[:active][index] = {
|
|
71
|
+
type: :tool_use,
|
|
72
|
+
id: Protocol.string(block["id"], "tool id", empty: false),
|
|
73
|
+
name: Protocol.string(block["name"], "tool name", empty: false),
|
|
74
|
+
input: block["input"], json: +""
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def consume_delta(message, state)
|
|
80
|
+
index = Protocol.uint(message["index"], "content index")
|
|
81
|
+
block = state[:active][index]
|
|
82
|
+
return unless block
|
|
83
|
+
delta = Protocol.object(message["delta"], "content delta")
|
|
84
|
+
if delta["type"] == "text_delta"
|
|
85
|
+
text = Protocol.string(delta["text"], "text delta")
|
|
86
|
+
block[:text] << text
|
|
87
|
+
yield text_part(text)
|
|
88
|
+
elsif delta["type"] == "input_json_delta"
|
|
89
|
+
block[:json] << Protocol.string(delta["partial_json"], "tool input delta")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def finish_block(message, state)
|
|
94
|
+
block = state[:active].delete(Protocol.uint(message["index"], "content index"))
|
|
95
|
+
return unless block
|
|
96
|
+
if block[:type] == :text
|
|
97
|
+
state[:parts] << text_part(block[:text]) unless block[:text].empty?
|
|
98
|
+
else
|
|
99
|
+
input = block[:json].empty? ? Protocol.object(block[:input], "tool input") :
|
|
100
|
+
Protocol.object(Protocol.parse(block[:json], "tool input"), "tool input")
|
|
101
|
+
part = tool_part(block[:name], input, block[:id])
|
|
102
|
+
state[:parts] << part
|
|
103
|
+
yield part
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def consume_response(body, messages)
|
|
108
|
+
value = Protocol.object(Protocol.parse(body), "response")
|
|
109
|
+
parts = Protocol.collection(value["content"], "content").map { |part| decode_part(part) }
|
|
110
|
+
usage = Protocol.object(value.fetch("usage", {}), "usage")
|
|
111
|
+
counts = [
|
|
112
|
+
usage["input_tokens"] ? Protocol.uint(usage["input_tokens"], "input tokens") : Nunki.estimate_tokens(messages),
|
|
113
|
+
usage["output_tokens"] ? Protocol.uint(usage["output_tokens"], "output tokens") : Nunki.estimate_tokens(parts)
|
|
114
|
+
]
|
|
115
|
+
response(parts, counts, value["stop_reason"])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def decode_part(value)
|
|
119
|
+
value = Protocol.object(value, "content part")
|
|
120
|
+
return text_part(Protocol.string(value["text"], "text")) if value["type"] == "text"
|
|
121
|
+
raise ProtocolError, "unsupported content type" unless value["type"] == "tool_use"
|
|
122
|
+
tool_part(
|
|
123
|
+
Protocol.string(value["name"], "tool name", empty: false),
|
|
124
|
+
Protocol.object(value["input"], "tool input"),
|
|
125
|
+
Protocol.string(value["id"], "tool id", empty: false)
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def encode_message(message)
|
|
130
|
+
{role: message.role.to_s, content: message.content.map { |part| encode_part(part) }}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def encode_part(part)
|
|
134
|
+
case part.type.to_s.to_sym
|
|
135
|
+
when :text then {type: "text", text: part.text}
|
|
136
|
+
when :tool_use then {type: "tool_use", id: part.tool_use_id, name: part.tool_name, input: part.tool_input}
|
|
137
|
+
when :tool_result then {type: "tool_result", tool_use_id: part.tool_use_id, content: part.text}
|
|
138
|
+
else raise ProtocolError, "unsupported part type: #{part.type}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def encode_tool(tool)
|
|
143
|
+
{name: tool.name, description: tool.description, input_schema: tool.input_schema}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nunki
|
|
4
|
+
module Providers
|
|
5
|
+
class OpenAI < Provider
|
|
6
|
+
def self.headers(api_key)
|
|
7
|
+
api_key ? {"Authorization" => "Bearer #{Protocol.string(api_key, "api_key", empty: false, max: 8192)}"} : {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def perform_complete(prepared)
|
|
13
|
+
messages, tools, system, max_tokens = prepared
|
|
14
|
+
messages = [Message.new(role: :system, content: [text_part(system)])] + messages if system
|
|
15
|
+
payload = {
|
|
16
|
+
model: @model,
|
|
17
|
+
messages: messages.map { |message| encode_message(message) },
|
|
18
|
+
max_tokens: max_tokens,
|
|
19
|
+
stream: true
|
|
20
|
+
}
|
|
21
|
+
payload[:tools] = tools.map { |tool| encode_tool(tool) } unless tools.empty?
|
|
22
|
+
|
|
23
|
+
state = {text: +"", tools: {}, finish: nil, usage: nil}
|
|
24
|
+
result = @client.post_json(payload, headers: {"Accept" => "text/event-stream, application/json"}) do |event|
|
|
25
|
+
next if event.data.empty? || event.data == "[DONE]"
|
|
26
|
+
consume_stream(parse_event(event), state) { |part| yield part if block_given? }
|
|
27
|
+
end
|
|
28
|
+
return consume_response(result.body, messages) unless result.body.empty?
|
|
29
|
+
|
|
30
|
+
parts = []
|
|
31
|
+
parts << text_part(state[:text]) unless state[:text].empty?
|
|
32
|
+
state[:tools].sort.each do |_index, tool|
|
|
33
|
+
part = tool_part(tool[:name], parse_tool_input(tool[:arguments]), tool[:id])
|
|
34
|
+
parts << part
|
|
35
|
+
yield part if block_given?
|
|
36
|
+
end
|
|
37
|
+
usage = state[:usage] || [Nunki.estimate_tokens(messages), Nunki.estimate_tokens(parts)]
|
|
38
|
+
response(parts, usage, state[:finish])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def consume_stream(message, state)
|
|
42
|
+
usage = message["usage"]
|
|
43
|
+
usage = Protocol.object(usage, "usage") if usage
|
|
44
|
+
state[:usage] = [usage["prompt_tokens"], usage["completion_tokens"]].map { |v| Protocol.uint(v, "usage") } if usage
|
|
45
|
+
|
|
46
|
+
Protocol.collection(message.fetch("choices", []), "choices").each do |choice|
|
|
47
|
+
choice = Protocol.object(choice, "choice")
|
|
48
|
+
state[:finish] = Protocol.string(choice["finish_reason"], "finish reason") if choice["finish_reason"]
|
|
49
|
+
delta = Protocol.object(choice.fetch("delta", {}), "delta")
|
|
50
|
+
if delta["content"]
|
|
51
|
+
text = Protocol.string(delta["content"], "content delta")
|
|
52
|
+
state[:text] << text
|
|
53
|
+
yield text_part(text)
|
|
54
|
+
end
|
|
55
|
+
consume_tool_deltas(delta["tool_calls"], state) if delta["tool_calls"]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def consume_tool_deltas(calls, state)
|
|
60
|
+
Protocol.collection(calls, "tool calls").each do |call|
|
|
61
|
+
call = Protocol.object(call, "tool call")
|
|
62
|
+
index = Protocol.uint(call["index"], "tool call index")
|
|
63
|
+
tool = state[:tools][index] ||= {id: nil, name: +"", arguments: +""}
|
|
64
|
+
tool[:id] = Protocol.string(call["id"], "tool call id") if call["id"]
|
|
65
|
+
function = Protocol.object(call.fetch("function", {}), "tool function")
|
|
66
|
+
tool[:name] << Protocol.string(function["name"], "tool name") if function["name"]
|
|
67
|
+
tool[:arguments] << Protocol.string(function["arguments"], "tool arguments") if function["arguments"]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def consume_response(body, messages)
|
|
72
|
+
message = Protocol.object(Protocol.parse(body), "response")
|
|
73
|
+
choice = Protocol.object(Protocol.collection(message["choices"], "choices").first, "choice")
|
|
74
|
+
value = Protocol.object(choice["message"], "message")
|
|
75
|
+
parts = []
|
|
76
|
+
parts << text_part(Protocol.string(value["content"], "content")) if value["content"]
|
|
77
|
+
consume_nonstream_tools(value["tool_calls"], parts) if value["tool_calls"]
|
|
78
|
+
usage = message["usage"]
|
|
79
|
+
usage = Protocol.object(usage, "usage") if usage
|
|
80
|
+
counts = usage ? [usage["prompt_tokens"], usage["completion_tokens"]].map { |v| Protocol.uint(v, "usage") } :
|
|
81
|
+
[Nunki.estimate_tokens(messages), Nunki.estimate_tokens(parts)]
|
|
82
|
+
finish = choice["finish_reason"] && Protocol.string(choice["finish_reason"], "finish reason")
|
|
83
|
+
response(parts, counts, finish)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def consume_nonstream_tools(calls, parts)
|
|
87
|
+
Protocol.collection(calls, "tool calls").each do |call|
|
|
88
|
+
call = Protocol.object(call, "tool call")
|
|
89
|
+
function = Protocol.object(call["function"], "tool function")
|
|
90
|
+
parts << tool_part(
|
|
91
|
+
Protocol.string(function["name"], "tool name", empty: false),
|
|
92
|
+
parse_tool_input(Protocol.string(function["arguments"], "tool arguments")),
|
|
93
|
+
Protocol.string(call["id"], "tool id", empty: false)
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def encode_message(message)
|
|
99
|
+
role = message.role.to_s
|
|
100
|
+
text = message.content.select { |part| part.type.to_s.to_sym == :text }.map(&:text).join
|
|
101
|
+
results = message.content.select { |part| part.type.to_s.to_sym == :tool_result }
|
|
102
|
+
if results.any?
|
|
103
|
+
raise ProtocolError, "OpenAI tool result messages must contain exactly one result" unless results.one? && text.empty?
|
|
104
|
+
return {role: "tool", tool_call_id: results.first.tool_use_id, content: results.first.text}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
encoded = {role: role, content: text.empty? ? nil : text}
|
|
108
|
+
calls = message.content.select { |part| part.type.to_s.to_sym == :tool_use }
|
|
109
|
+
encoded[:tool_calls] = calls.map do |part|
|
|
110
|
+
{id: part.tool_use_id, type: "function", function: {name: part.tool_name, arguments: JSON.generate(part.tool_input)}}
|
|
111
|
+
end unless calls.empty?
|
|
112
|
+
encoded
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def encode_tool(tool)
|
|
116
|
+
{type: "function", function: {name: tool.name, description: tool.description, parameters: tool.input_schema}}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse_tool_input(json)
|
|
120
|
+
Protocol.object(Protocol.parse(json, "tool input"), "tool input")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/nunki/sse.rb
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nunki
|
|
4
|
+
module SSE
|
|
5
|
+
Event = Value.define(:event, :data, :id, :retry)
|
|
6
|
+
|
|
7
|
+
class Parser
|
|
8
|
+
DEFAULT_MAX_EVENT_BYTES = 1_048_576
|
|
9
|
+
|
|
10
|
+
def initialize(max_event_bytes: DEFAULT_MAX_EVENT_BYTES)
|
|
11
|
+
@max_event_bytes = Protocol.uint(max_event_bytes, "max_event_bytes", positive: true)
|
|
12
|
+
@buffer = +""
|
|
13
|
+
reset_event
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def feed(chunk)
|
|
17
|
+
raise ProtocolError, "SSE chunk must be a string" unless chunk.is_a?(String)
|
|
18
|
+
@buffer << chunk
|
|
19
|
+
events = []
|
|
20
|
+
while (newline = @buffer.index("\n"))
|
|
21
|
+
line = @buffer.slice!(0, newline + 1).delete_suffix("\n").delete_suffix("\r")
|
|
22
|
+
consume(line, events)
|
|
23
|
+
end
|
|
24
|
+
raise ProtocolError, "SSE event exceeds #{@max_event_bytes} bytes" if event_bytes > @max_event_bytes
|
|
25
|
+
events
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def finish
|
|
29
|
+
events = []
|
|
30
|
+
consume(@buffer.delete_suffix("\r"), events) unless @buffer.empty?
|
|
31
|
+
dispatch(events) if @seen
|
|
32
|
+
@buffer.clear
|
|
33
|
+
events
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def consume(line, events)
|
|
39
|
+
if line.empty?
|
|
40
|
+
dispatch(events)
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
return if line.start_with?(":")
|
|
44
|
+
|
|
45
|
+
field, value = line.split(":", 2)
|
|
46
|
+
value = value&.delete_prefix(" ") || ""
|
|
47
|
+
@seen = true
|
|
48
|
+
case field
|
|
49
|
+
when "event" then @event = value
|
|
50
|
+
when "data" then @data << value
|
|
51
|
+
when "id" then @id = value unless value.include?("\0")
|
|
52
|
+
when "retry" then @retry = Integer(value, exception: false) if value.match?(/\A\d+\z/)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def dispatch(events)
|
|
57
|
+
return reset_event unless @seen
|
|
58
|
+
|
|
59
|
+
events << Event.new(event: @event, data: @data.join("\n"), id: @id, retry: @retry)
|
|
60
|
+
reset_event
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def reset_event
|
|
64
|
+
@event = nil
|
|
65
|
+
@data = []
|
|
66
|
+
@id = nil
|
|
67
|
+
@retry = nil
|
|
68
|
+
@seen = false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def event_bytes = @buffer.bytesize + @data.sum(&:bytesize)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/nunki.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "nunki/version"
|
|
4
|
+
|
|
5
|
+
module Nunki
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
class ProtocolError < Error; end
|
|
8
|
+
class Timeout < Error; end
|
|
9
|
+
class Cancelled < Error; end
|
|
10
|
+
|
|
11
|
+
class HTTPError < Error
|
|
12
|
+
attr_reader :status, :body
|
|
13
|
+
|
|
14
|
+
def initialize(status, body = nil)
|
|
15
|
+
@status = status
|
|
16
|
+
@body = body
|
|
17
|
+
super("HTTP request failed with status #{status}")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class RateLimited < HTTPError
|
|
22
|
+
attr_reader :retry_after
|
|
23
|
+
|
|
24
|
+
def initialize(body = nil, retry_after: nil)
|
|
25
|
+
@retry_after = retry_after
|
|
26
|
+
super(429, body)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
module Value
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def define(*members)
|
|
34
|
+
return Data.define(*members) if defined?(Data) && Data.respond_to?(:define)
|
|
35
|
+
|
|
36
|
+
Struct.new(*members) do
|
|
37
|
+
members.each { |member| undef_method("#{member}=") }
|
|
38
|
+
|
|
39
|
+
define_method(:initialize) do |*values, **keywords|
|
|
40
|
+
if keywords.empty?
|
|
41
|
+
raise ArgumentError, "wrong number of arguments" unless values.length == self.class.members.length
|
|
42
|
+
super(*values)
|
|
43
|
+
else
|
|
44
|
+
raise ArgumentError, "cannot mix positional and keyword arguments" unless values.empty?
|
|
45
|
+
|
|
46
|
+
missing = self.class.members - keywords.keys
|
|
47
|
+
unknown = keywords.keys - self.class.members
|
|
48
|
+
raise ArgumentError, "missing keyword: #{missing.first.inspect}" unless missing.empty?
|
|
49
|
+
raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
|
|
50
|
+
|
|
51
|
+
super(*self.class.members.map { |member| keywords.fetch(member) })
|
|
52
|
+
end
|
|
53
|
+
freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
define_method(:with) do |**changes|
|
|
57
|
+
return self if changes.empty?
|
|
58
|
+
unknown = changes.keys - self.class.members
|
|
59
|
+
raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
|
|
60
|
+
self.class.new(**to_h.merge(changes))
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Message = Value.define(:role, :content)
|
|
67
|
+
Part = Value.define(:type, :text, :tool_name, :tool_input, :tool_use_id)
|
|
68
|
+
Tool = Value.define(:name, :description, :input_schema)
|
|
69
|
+
Usage = Value.define(:input_tokens, :output_tokens)
|
|
70
|
+
Response = Value.define(:message, :usage, :finish_reason)
|
|
71
|
+
private_constant :Value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
require_relative "nunki/protocol"
|
|
75
|
+
require_relative "nunki/context"
|
|
76
|
+
require_relative "nunki/sse"
|
|
77
|
+
require_relative "nunki/http"
|
|
78
|
+
require_relative "nunki/provider"
|
|
79
|
+
require_relative "nunki/providers/open_ai"
|
|
80
|
+
require_relative "nunki/providers/anthropic"
|
|
81
|
+
require_relative "nunki/mcp"
|
data/sig/nunki.rbs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
module Nunki
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
type json = nil | bool | Integer | Float | String | Array[json] | Hash[String | Symbol, json]
|
|
5
|
+
type object = Hash[String | Symbol, json]
|
|
6
|
+
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
end
|
|
9
|
+
class ProtocolError < Error
|
|
10
|
+
end
|
|
11
|
+
class Timeout < Error
|
|
12
|
+
end
|
|
13
|
+
class Cancelled < Error
|
|
14
|
+
end
|
|
15
|
+
class HTTPError < Error
|
|
16
|
+
attr_reader status: Integer
|
|
17
|
+
attr_reader body: String?
|
|
18
|
+
def initialize: (Integer status, ?String? body) -> void
|
|
19
|
+
end
|
|
20
|
+
class RateLimited < HTTPError
|
|
21
|
+
attr_reader retry_after: Float?
|
|
22
|
+
def initialize: (?String? body, ?retry_after: Float?) -> void
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class Message
|
|
26
|
+
attr_reader role: String | Symbol
|
|
27
|
+
attr_reader content: Array[Part]
|
|
28
|
+
def self.new: (String | Symbol, Array[Part]) -> Message
|
|
29
|
+
| (role: String | Symbol, content: Array[Part]) -> Message
|
|
30
|
+
def with: (?role: String | Symbol, ?content: Array[Part]) -> Message
|
|
31
|
+
end
|
|
32
|
+
class Part
|
|
33
|
+
attr_reader type: String | Symbol
|
|
34
|
+
attr_reader text: String?
|
|
35
|
+
attr_reader tool_name: String?
|
|
36
|
+
attr_reader tool_input: object?
|
|
37
|
+
attr_reader tool_use_id: String?
|
|
38
|
+
def self.new: (String | Symbol, String?, String?, object?, String?) -> Part
|
|
39
|
+
| (type: String | Symbol, text: String?, tool_name: String?, tool_input: object?, tool_use_id: String?) -> Part
|
|
40
|
+
def with: (?type: String | Symbol, ?text: String?, ?tool_name: String?, ?tool_input: object?, ?tool_use_id: String?) -> Part
|
|
41
|
+
end
|
|
42
|
+
class Tool
|
|
43
|
+
attr_reader name: String
|
|
44
|
+
attr_reader description: String
|
|
45
|
+
attr_reader input_schema: object
|
|
46
|
+
def self.new: (String, String, object) -> Tool
|
|
47
|
+
| (name: String, description: String, input_schema: object) -> Tool
|
|
48
|
+
def with: (?name: String, ?description: String, ?input_schema: object) -> Tool
|
|
49
|
+
end
|
|
50
|
+
class Usage
|
|
51
|
+
attr_reader input_tokens: Integer
|
|
52
|
+
attr_reader output_tokens: Integer
|
|
53
|
+
def self.new: (Integer, Integer) -> Usage
|
|
54
|
+
| (input_tokens: Integer, output_tokens: Integer) -> Usage
|
|
55
|
+
def with: (?input_tokens: Integer, ?output_tokens: Integer) -> Usage
|
|
56
|
+
end
|
|
57
|
+
class Response
|
|
58
|
+
attr_reader message: Message
|
|
59
|
+
attr_reader usage: Usage
|
|
60
|
+
attr_reader finish_reason: String?
|
|
61
|
+
def self.new: (Message, Usage, String?) -> Response
|
|
62
|
+
| (message: Message, usage: Usage, finish_reason: String?) -> Response
|
|
63
|
+
def with: (?message: Message, ?usage: Usage, ?finish_reason: String?) -> Response
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.estimate_tokens: (Message | Part | Array[Message | Part] | String value) -> Integer
|
|
67
|
+
def self.truncate: (Array[Message] messages, max_tokens: Integer) -> Array[Message]
|
|
68
|
+
|
|
69
|
+
class Provider
|
|
70
|
+
def self.build: (String | Symbol kind, endpoint: String, ?api_key: String?, model: String, **untyped options) -> Provider
|
|
71
|
+
def complete: (Array[Message] messages, ?tools: Array[Tool], ?system: String?, ?max_tokens: Integer) ?{ (Part) -> untyped } -> untyped
|
|
72
|
+
def cancel: () -> untyped
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
module SSE
|
|
76
|
+
class Event
|
|
77
|
+
attr_reader event: String?
|
|
78
|
+
attr_reader data: String
|
|
79
|
+
attr_reader id: String?
|
|
80
|
+
attr_reader retry: Integer?
|
|
81
|
+
def self.new: (String?, String, String?, Integer?) -> Event
|
|
82
|
+
| (event: String?, data: String, id: String?, retry: Integer?) -> Event
|
|
83
|
+
def with: (?event: String?, ?data: String, ?id: String?, ?retry: Integer?) -> Event
|
|
84
|
+
end
|
|
85
|
+
class Parser
|
|
86
|
+
def initialize: (?max_event_bytes: Integer) -> void
|
|
87
|
+
def feed: (String chunk) -> Array[Event]
|
|
88
|
+
def finish: () -> Array[Event]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
module HTTP
|
|
93
|
+
class Result
|
|
94
|
+
attr_reader status: Integer
|
|
95
|
+
attr_reader headers: Hash[String, String]
|
|
96
|
+
attr_reader body: String
|
|
97
|
+
attr_reader events: Array[SSE::Event]
|
|
98
|
+
def self.new: (Integer, Hash[String, String], String, Array[SSE::Event]) -> Result
|
|
99
|
+
| (status: Integer, headers: Hash[String, String], body: String, events: Array[SSE::Event]) -> Result
|
|
100
|
+
def with: (?status: Integer, ?headers: Hash[String, String], ?body: String, ?events: Array[SSE::Event]) -> Result
|
|
101
|
+
end
|
|
102
|
+
class Client
|
|
103
|
+
attr_reader endpoint: untyped
|
|
104
|
+
def initialize: (endpoint: String, ?headers: Hash[String, String], ?open_timeout: Numeric, ?read_timeout: Numeric, ?retries: Integer, ?retry_base: Numeric, ?max_request_bytes: Integer, ?max_response_bytes: Integer) -> void
|
|
105
|
+
def post_json: (json payload, ?headers: Hash[String, String]) ?{ (SSE::Event, Hash[String, String]) -> untyped } -> Result
|
|
106
|
+
def delete: (?headers: Hash[String, String]) -> Result
|
|
107
|
+
def cancel: () -> untyped
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
module MCP
|
|
112
|
+
PROTOCOL_VERSION: String
|
|
113
|
+
class RemoteError < Error
|
|
114
|
+
attr_reader code: Integer
|
|
115
|
+
attr_reader data: json
|
|
116
|
+
end
|
|
117
|
+
class Client
|
|
118
|
+
attr_reader capabilities: object
|
|
119
|
+
attr_reader server_info: object
|
|
120
|
+
def self.stdio: (command: Array[String], ?env: Hash[String, String], ?timeout: Numeric, ?dispatch: untyped) -> Client
|
|
121
|
+
def self.http: (url: String, ?headers: Hash[String, String], ?timeout: Numeric, ?dispatch: untyped, **untyped options) -> Client
|
|
122
|
+
def start: () -> untyped
|
|
123
|
+
def tools: () -> untyped
|
|
124
|
+
def call_tool: (String name, object arguments) -> untyped
|
|
125
|
+
def resources: () -> untyped
|
|
126
|
+
def read_resource: (String uri) -> untyped
|
|
127
|
+
def prompts: () -> untyped
|
|
128
|
+
def get_prompt: (String name, ?object arguments) -> untyped
|
|
129
|
+
def close: () -> untyped
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: nunki
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
email:
|
|
13
|
+
- t.yudai92@gmail.com
|
|
14
|
+
executables: []
|
|
15
|
+
extensions: []
|
|
16
|
+
extra_rdoc_files: []
|
|
17
|
+
files:
|
|
18
|
+
- CHANGELOG.md
|
|
19
|
+
- LICENSE.txt
|
|
20
|
+
- README.md
|
|
21
|
+
- docs/adr/000-template.md
|
|
22
|
+
- docs/adr/001-explicit-network-boundary.md
|
|
23
|
+
- docs/adr/002-mcp-transports.md
|
|
24
|
+
- docs/adr/README.md
|
|
25
|
+
- lib/nunki.rb
|
|
26
|
+
- lib/nunki/context.rb
|
|
27
|
+
- lib/nunki/http.rb
|
|
28
|
+
- lib/nunki/mcp.rb
|
|
29
|
+
- lib/nunki/mcp/transports.rb
|
|
30
|
+
- lib/nunki/protocol.rb
|
|
31
|
+
- lib/nunki/provider.rb
|
|
32
|
+
- lib/nunki/providers/anthropic.rb
|
|
33
|
+
- lib/nunki/providers/open_ai.rb
|
|
34
|
+
- lib/nunki/sse.rb
|
|
35
|
+
- lib/nunki/version.rb
|
|
36
|
+
- sig/nunki.rbs
|
|
37
|
+
homepage: https://github.com/noxdea/nunki
|
|
38
|
+
licenses:
|
|
39
|
+
- MIT
|
|
40
|
+
metadata:
|
|
41
|
+
source_code_uri: https://github.com/noxdea/nunki
|
|
42
|
+
changelog_uri: https://github.com/noxdea/nunki/blob/main/CHANGELOG.md
|
|
43
|
+
allowed_push_host: https://rubygems.org
|
|
44
|
+
rubygems_mfa_required: 'true'
|
|
45
|
+
rdoc_options: []
|
|
46
|
+
require_paths:
|
|
47
|
+
- lib
|
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '3.1'
|
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '0'
|
|
58
|
+
requirements: []
|
|
59
|
+
rubygems_version: 4.0.16
|
|
60
|
+
specification_version: 4
|
|
61
|
+
summary: Provider-neutral LLM and MCP clients for Ruby
|
|
62
|
+
test_files: []
|