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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 017ee258c9b1ee3339e0f394431890fc96069100ca3aaee503b4da00ff293ab6
4
+ data.tar.gz: 67fa32687f3fdb13e5c9c153ec3e6db77509aac900bfcf5b4c0ff9a7c55aa1e0
5
+ SHA512:
6
+ metadata.gz: c232b4ea39676c16571b92cfbe8cc01374f4eb2d822c2d70ec44696e8c2ac9b13d8922f5bb7b8892fe1c02e3a26dde04d6e3b7d518d9b10b9d504525bb8e739d
7
+ data.tar.gz: 2b125428bf1c14490169d76a8e05e456e5384b82d1cadcb0b2d18f6c956a979562f8e6e08f51c4a9511326ef04d61f9e0894c07a9279c6c0323af744d0258ff7
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-16
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # Nunki
2
+
3
+ Nunki is a pure Ruby, provider-neutral client for streaming LLM APIs and the
4
+ Model Context Protocol (MCP). It supplies protocol and transport behavior while
5
+ leaving prompts, UI, context selection, and edits to the host application.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bundle add nunki
11
+ ```
12
+
13
+ ## LLM providers
14
+
15
+ The endpoint is always explicit. Pass the full completion/messages URL supplied
16
+ by your service configuration.
17
+
18
+ ```ruby
19
+ require "nunki"
20
+
21
+ provider = Nunki::Provider.build(
22
+ :openai,
23
+ endpoint: ENV.fetch("LLM_ENDPOINT"),
24
+ api_key: ENV.fetch("LLM_API_KEY"),
25
+ model: "configured-model"
26
+ )
27
+
28
+ text = Nunki::Part.new(
29
+ type: :text, text: "Explain this method",
30
+ tool_name: nil, tool_input: nil, tool_use_id: nil
31
+ )
32
+ message = Nunki::Message.new(role: :user, content: [text])
33
+
34
+ response = provider.complete([message]) do |part|
35
+ print part.text if part.type == :text
36
+ end
37
+ ```
38
+
39
+ Supported kinds are `:openai`, `:openai_compatible`, `:local`, and
40
+ `:anthropic`. The OpenAI-compatible local kind does not add an authorization
41
+ header unless an API key is supplied. A completion returns `Nunki::Response`;
42
+ tool requests appear as `:tool_use` parts containing `tool_name`, `tool_input`,
43
+ and `tool_use_id`. Send results back as `:tool_result` parts.
44
+
45
+ `provider.cancel` interrupts streaming or a retry wait. HTTP 429 and 5xx
46
+ responses are retried with exponential backoff. `dispatch:` may move blocking
47
+ work onto an application-owned worker:
48
+
49
+ ```ruby
50
+ provider = Nunki::Provider.build(
51
+ :local,
52
+ endpoint: settings.fetch("endpoint"),
53
+ model: settings.fetch("model"),
54
+ dispatch: ->(&work) { Thread.new(&work) }
55
+ )
56
+ ```
57
+
58
+ `Nunki.estimate_tokens(value)` provides a byte-based estimate.
59
+ `Nunki.truncate(messages, max_tokens:)` drops the oldest messages while always
60
+ retaining the newest one, or raises if that message alone exceeds the budget.
61
+
62
+ ## MCP
63
+
64
+ Nunki implements MCP 2025-11-25 over newline-delimited stdio and Streamable
65
+ HTTP. Both transports support tools, resources, prompts, pagination, request
66
+ timeouts, and protocol errors.
67
+
68
+ ```ruby
69
+ client = Nunki::MCP::Client.stdio(command: ["my-mcp-server"])
70
+ # Or: Nunki::MCP::Client.http(url: settings.fetch("mcp_url"), headers: {...})
71
+
72
+ client.start
73
+ client.tools
74
+ client.call_tool("lookup", {"query" => "Ruby"})
75
+ client.resources
76
+ client.read_resource("file:///project/README.md")
77
+ client.prompts
78
+ client.get_prompt("review", {"language" => "ruby"})
79
+ client.close
80
+ ```
81
+
82
+ `close` closes a stdio server's input, waits briefly, then uses TERM and KILL if
83
+ needed. HTTP sessions are ended with DELETE when the server issued a session ID.
84
+
85
+ ## Limits and security
86
+
87
+ Default limits are 1 MiB per HTTP request, 8 MiB per HTTP response/SSE event,
88
+ and 4 MiB per stdio MCP message. Provider HTTP defaults are a 10-second connect
89
+ timeout and 60-second read timeout; MCP requests default to 30 seconds. All are
90
+ configurable at construction.
91
+
92
+ Nunki deliberately accepts local and remote HTTP(S) endpoints because every
93
+ endpoint is configuration-derived. This means an untrusted endpoint setting is
94
+ an SSRF capability: hosts must validate it against their own trust policy.
95
+ Nunki has no default endpoints and never persists credentials; supplied keys
96
+ exist only in the client instance and request headers.
97
+
98
+ Nunki does not choose prompts or context, render UI, save credentials, execute
99
+ tool requests without a caller decision, or apply model-produced diffs.
100
+
101
+ ## Development
102
+
103
+ ```bash
104
+ bundle install
105
+ bundle exec rake test
106
+ bundle exec rbs -I sig validate
107
+ BUDGET=1 bundle exec rake bench
108
+ gem build --strict nunki.gemspec
109
+ ```
110
+
111
+ ## Contributing
112
+
113
+ Bug reports and pull requests are welcome at https://github.com/noxdea/nunki.
114
+
115
+ ## License
116
+
117
+ The gem is available under the [MIT License](LICENSE.txt).
@@ -0,0 +1,16 @@
1
+ # ADR NNN: Implementation decision title
2
+
3
+ - Status: Proposed
4
+ - Date: YYYY-MM-DD
5
+
6
+ ## Context
7
+
8
+ Describe the concrete implementation question and its constraints.
9
+
10
+ ## Decision
11
+
12
+ Describe the durable boundary or architecture choice.
13
+
14
+ ## Consequences
15
+
16
+ Describe the important trade-offs and when to revisit the decision.
@@ -0,0 +1,23 @@
1
+ # ADR 001: Keep network destinations explicit
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-15
5
+
6
+ ## Context
7
+
8
+ LLM vendors and local servers expose compatible APIs at different URLs. A
9
+ library-selected default would couple provider protocol support to product
10
+ policy and could silently send data to an unintended service.
11
+
12
+ ## Decision
13
+
14
+ Require a complete HTTP(S) endpoint for every provider and MCP HTTP client.
15
+ Adapters add protocol headers and request shapes but never select a host. API
16
+ keys remain in memory and are not persisted. The host application owns endpoint
17
+ trust and authorization policy.
18
+
19
+ ## Consequences
20
+
21
+ Nunki supports hosted and local services without product-specific settings.
22
+ Callers must treat endpoint configuration as an SSRF-capable input and validate
23
+ it before constructing a client.
@@ -0,0 +1,23 @@
1
+ # ADR 002: Implement the stable MCP transports directly
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-15
5
+
6
+ ## Context
7
+
8
+ Nunki needs only the MCP client lifecycle plus tools, resources, and prompts.
9
+ Adding a full protocol framework would expand dependencies and expose unrelated
10
+ host features.
11
+
12
+ ## Decision
13
+
14
+ Implement MCP 2025-11-25 JSON-RPC over its stable newline-delimited stdio and
15
+ Streamable HTTP transports. Validate and bound every message. Serialize client
16
+ requests, enforce timeouts, negotiate the exact supported version, and cleanly
17
+ terminate stdio children and HTTP sessions.
18
+
19
+ ## Consequences
20
+
21
+ The core has no runtime dependencies. Legacy HTTP+SSE, server-side features,
22
+ sampling, elicitation, tasks, and subscriptions remain outside 0.1.0 and can be
23
+ added only when a host needs them.
@@ -0,0 +1,3 @@
1
+ # Architecture decision records
2
+
3
+ These records document Nunki's durable boundaries.
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nunki
4
+ module Context
5
+ module_function
6
+
7
+ def estimate_tokens(value)
8
+ case value
9
+ when Message
10
+ estimate_string(value.role.to_s) + estimate_tokens(value.content)
11
+ when Part
12
+ estimate_string([value.type, value.text, value.tool_name, value.tool_input, value.tool_use_id].compact.join)
13
+ when Array
14
+ value.sum { |item| estimate_tokens(item) }
15
+ else
16
+ estimate_string(value.to_s)
17
+ end
18
+ end
19
+
20
+ def truncate(messages, max_tokens:)
21
+ Protocol.uint(max_tokens, "max_tokens", positive: true)
22
+ messages = Protocol.collection(messages, "messages")
23
+ validate_messages(messages)
24
+
25
+ kept = messages.dup
26
+ kept.shift while kept.length > 1 && estimate_tokens(kept) > max_tokens
27
+ raise ProtocolError, "latest message exceeds token budget" if estimate_tokens(kept) > max_tokens
28
+
29
+ kept.freeze
30
+ end
31
+
32
+ def validate_messages(messages)
33
+ messages.each do |message|
34
+ raise ProtocolError, "message must be a Nunki::Message" unless message.is_a?(Message)
35
+ role = Protocol.string(message.role.to_s, "message role", empty: false, max: 32)
36
+ raise ProtocolError, "unsupported message role: #{role}" unless %w[system user assistant tool].include?(role)
37
+ Protocol.collection(message.content, "message content").each do |part|
38
+ raise ProtocolError, "message content must contain Nunki::Part values" unless part.is_a?(Part)
39
+ validate_part(part)
40
+ end
41
+ end
42
+ end
43
+
44
+ def validate_part(part)
45
+ case part.type.to_s.to_sym
46
+ when :text
47
+ Protocol.string(part.text, "text part")
48
+ when :tool_use
49
+ Protocol.string(part.tool_name, "tool name", empty: false, max: 256)
50
+ Protocol.string(part.tool_use_id, "tool use id", empty: false, max: 1024)
51
+ Protocol.json(Protocol.object(part.tool_input, "tool input"))
52
+ when :tool_result
53
+ Protocol.string(part.tool_use_id, "tool use id", empty: false, max: 1024)
54
+ Protocol.string(part.text, "tool result")
55
+ else
56
+ raise ProtocolError, "unsupported part type: #{part.type}"
57
+ end
58
+ end
59
+
60
+ def estimate_string(text) = [(text.bytesize + 3) / 4, 1].max
61
+ end
62
+
63
+ def self.estimate_tokens(value) = Context.estimate_tokens(value)
64
+ def self.truncate(messages, max_tokens:) = Context.truncate(messages, max_tokens: max_tokens)
65
+ end
data/lib/nunki/http.rb ADDED
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "timeout"
5
+ require "uri"
6
+
7
+ module Nunki
8
+ module HTTP
9
+ Result = Value.define(:status, :headers, :body, :events)
10
+
11
+ class Client
12
+ DEFAULT_MAX_REQUEST_BYTES = 1_048_576
13
+ DEFAULT_MAX_RESPONSE_BYTES = 8_388_608
14
+ RETRY_STATUSES = [429, *(500..599)].freeze
15
+
16
+ attr_reader :endpoint
17
+
18
+ def initialize(endpoint:, headers: {}, open_timeout: 10, read_timeout: 60, retries: 2,
19
+ retry_base: 0.25, max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
20
+ max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
21
+ @endpoint = parse_endpoint(endpoint)
22
+ @headers = validate_headers(headers)
23
+ @open_timeout = positive_number(open_timeout, "open_timeout")
24
+ @read_timeout = positive_number(read_timeout, "read_timeout")
25
+ @retries = Protocol.uint(retries, "retries")
26
+ @retry_base = nonnegative_number(retry_base, "retry_base")
27
+ @max_request_bytes = Protocol.uint(max_request_bytes, "max_request_bytes", positive: true)
28
+ @max_response_bytes = Protocol.uint(max_response_bytes, "max_response_bytes", positive: true)
29
+ @state_lock = Mutex.new
30
+ @state_changed = ConditionVariable.new
31
+ @cancel_epoch = 0
32
+ @active = []
33
+ end
34
+
35
+ def post_json(payload, headers: {}, &event_handler)
36
+ body = JSON.generate(Protocol.json(payload))
37
+ raise ProtocolError, "request exceeds #{@max_request_bytes} bytes" if body.bytesize > @max_request_bytes
38
+
39
+ request(Net::HTTP::Post, body, headers, &event_handler)
40
+ end
41
+
42
+ def delete(headers: {}) = request(Net::HTTP::Delete, nil, headers)
43
+
44
+ def cancel
45
+ connections = @state_lock.synchronize do
46
+ @cancel_epoch += 1
47
+ @state_changed.broadcast
48
+ @active.dup
49
+ end
50
+ connections.each do |http|
51
+ http.finish if http.active?
52
+ rescue IOError, SystemCallError
53
+ nil
54
+ end
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ def request(request_class, body, headers)
61
+ epoch = @state_lock.synchronize { @cancel_epoch }
62
+ attempts = 0
63
+ loop do
64
+ response = perform(request_class, body, validate_headers(headers), epoch) do |event, response_headers|
65
+ yield event, response_headers if block_given?
66
+ end
67
+ if RETRY_STATUSES.include?(response.status) && attempts < @retries
68
+ attempts += 1
69
+ wait_retry(@retry_base * (2**(attempts - 1)), epoch)
70
+ next
71
+ end
72
+
73
+ raise_http_error(response) unless (200..299).cover?(response.status)
74
+ return response
75
+ end
76
+ rescue ::Timeout::Error => error
77
+ raise Cancelled, "request cancelled" if cancelled?(epoch)
78
+ raise Timeout, error.message
79
+ rescue IOError, EOFError, SocketError, SystemCallError => error
80
+ raise Cancelled, "request cancelled" if cancelled?(epoch)
81
+ raise Error, error.message
82
+ end
83
+
84
+ def perform(request_class, body, extra_headers, epoch)
85
+ raise Cancelled, "request cancelled" if cancelled?(epoch)
86
+
87
+ http = Net::HTTP.new(@endpoint.host, @endpoint.port)
88
+ http.use_ssl = @endpoint.scheme == "https"
89
+ http.open_timeout = @open_timeout
90
+ http.read_timeout = @read_timeout
91
+ @state_lock.synchronize do
92
+ raise Cancelled, "request cancelled" unless @cancel_epoch == epoch
93
+ @active << http
94
+ end
95
+
96
+ request = request_class.new(request_target)
97
+ @headers.merge(extra_headers).each { |key, value| request[key] = value }
98
+ if body
99
+ request["Content-Type"] ||= "application/json"
100
+ request.body = body
101
+ end
102
+
103
+ result = nil
104
+ http.start do
105
+ raise Cancelled, "request cancelled" if cancelled?(epoch)
106
+ http.request(request) do |response|
107
+ result = read_response(response, epoch) do |event, response_headers|
108
+ yield event, response_headers if block_given?
109
+ end
110
+ end
111
+ end
112
+ result
113
+ ensure
114
+ @state_lock.synchronize { @active.delete(http) } if http
115
+ end
116
+
117
+ def read_response(response, epoch)
118
+ headers = response.each_header.to_h.freeze
119
+ parser = SSE::Parser.new(max_event_bytes: @max_response_bytes) if sse?(headers)
120
+ bytes = 0
121
+ body = +""
122
+ events = []
123
+
124
+ response.read_body do |chunk|
125
+ raise Cancelled, "request cancelled" if cancelled?(epoch)
126
+ bytes += chunk.bytesize
127
+ raise ProtocolError, "response exceeds #{@max_response_bytes} bytes" if bytes > @max_response_bytes
128
+ if parser
129
+ parser.feed(chunk).each do |event|
130
+ events << event
131
+ yield event, headers if block_given? && response.is_a?(Net::HTTPSuccess)
132
+ end
133
+ else
134
+ body << chunk
135
+ end
136
+ end
137
+ parser&.finish&.each do |event|
138
+ events << event
139
+ yield event, headers if block_given? && response.is_a?(Net::HTTPSuccess)
140
+ end
141
+ Result.new(status: response.code.to_i, headers: headers, body: body.freeze, events: events.freeze)
142
+ end
143
+
144
+ def raise_http_error(result)
145
+ retry_after = Float(result.headers["retry-after"], exception: false)
146
+ raise RateLimited.new(result.body, retry_after: retry_after) if result.status == 429
147
+ raise HTTPError.new(result.status, result.body)
148
+ end
149
+
150
+ def wait_retry(seconds, epoch)
151
+ @state_lock.synchronize do
152
+ @state_changed.wait(@state_lock, seconds) if @cancel_epoch == epoch && !seconds.zero?
153
+ raise Cancelled, "request cancelled" unless @cancel_epoch == epoch
154
+ end
155
+ end
156
+
157
+ def cancelled?(epoch) = @state_lock.synchronize { @cancel_epoch != epoch }
158
+
159
+ def sse?(headers)
160
+ headers.fetch("content-type", "").downcase.start_with?("text/event-stream")
161
+ end
162
+
163
+ def request_target
164
+ path = @endpoint.path.empty? ? "/" : @endpoint.path
165
+ @endpoint.query ? "#{path}?#{@endpoint.query}" : path
166
+ end
167
+
168
+ def parse_endpoint(endpoint)
169
+ uri = URI.parse(Protocol.string(endpoint, "endpoint", empty: false, max: 4096))
170
+ valid = %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo && !uri.fragment
171
+ raise ProtocolError, "endpoint must be an HTTP(S) URL without credentials or fragment" unless valid
172
+ uri
173
+ rescue URI::InvalidURIError => error
174
+ raise ProtocolError, "invalid endpoint: #{error.message}"
175
+ end
176
+
177
+ def validate_headers(headers)
178
+ raise ProtocolError, "headers must be an object" unless headers.is_a?(Hash)
179
+ headers.to_h do |key, value|
180
+ key = Protocol.string(key.to_s, "header name", empty: false, max: 256)
181
+ value = Protocol.string(value, "header value", max: 8192)
182
+ raise ProtocolError, "headers must not contain newlines" if key.match?(/[\r\n]/) || value.match?(/[\r\n]/)
183
+ [key, value]
184
+ end.freeze
185
+ end
186
+
187
+ def positive_number(value, name)
188
+ raise ProtocolError, "#{name} must be positive" unless value.is_a?(Numeric) && value.positive?
189
+ value
190
+ end
191
+
192
+ def nonnegative_number(value, name)
193
+ raise ProtocolError, "#{name} must not be negative" unless value.is_a?(Numeric) && value >= 0
194
+ value
195
+ end
196
+ end
197
+ end
198
+ end