zuno 0.1.4 → 0.1.6
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 +38 -0
- data/lib/zuno/version.rb +1 -1
- data/lib/zuno.rb +678 -10
- metadata +28 -29
- data/lib/providers/openai.rb +0 -58
- data/lib/zuno/chat.rb +0 -39
- data/lib/zuno/configuration.rb +0 -11
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e0b1a60f13b8d0f649d5a00df65846d3a35597f6298927c2e07b36f6f042528a
|
|
4
|
+
data.tar.gz: a6312c76748d3fe27bc50e70fac5c7922f240ac7b61eee6ed6ab1e7e90639e2a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d9d1ce4c9e83825b6a227f6442865d7983a43fc31ca7956d09c38214a428ce1ea8c83f8852f24e66302f6906824f288d6fb0a8fbbad4253fd0d1d920fe16e603
|
|
7
|
+
data.tar.gz: 54110db116e05eed86ac94eebd7416c2955a78fe691cafcff7ad39a733fd7e54245bfc659261aeb09dbeb20f26d5a836f433f1deefd6deda8242166947ac39d8
|
data/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Zuno
|
|
2
|
+
|
|
3
|
+
Standalone Ruby SDK for:
|
|
4
|
+
|
|
5
|
+
- provider/model abstraction
|
|
6
|
+
- tool calling with iterative loop execution
|
|
7
|
+
- streaming via SSE
|
|
8
|
+
|
|
9
|
+
## Install (local development)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bundle install
|
|
13
|
+
bundle exec rspec
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Basic usage
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require "zuno"
|
|
20
|
+
|
|
21
|
+
result = Zuno.generate(
|
|
22
|
+
model: "openai/gpt-5-mini",
|
|
23
|
+
prompt: "Say hello"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
puts result[:text]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Callbacks
|
|
30
|
+
|
|
31
|
+
`generate` supports:
|
|
32
|
+
|
|
33
|
+
- `before_generation`
|
|
34
|
+
- `after_generation`
|
|
35
|
+
- `before_iteration`
|
|
36
|
+
- `after_iteration`
|
|
37
|
+
- `before_tool_execution`
|
|
38
|
+
- `after_tool_execution`
|
data/lib/zuno/version.rb
CHANGED
data/lib/zuno.rb
CHANGED
|
@@ -1,19 +1,687 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "typhoeus"
|
|
6
|
+
|
|
3
7
|
require_relative "zuno/version"
|
|
4
|
-
require_relative "zuno/configuration"
|
|
5
|
-
require "zuno/chat"
|
|
6
|
-
require "zuno/transcription"
|
|
7
|
-
require "zuno/translation"
|
|
8
|
-
require "faraday"
|
|
9
8
|
|
|
10
9
|
module Zuno
|
|
11
|
-
class
|
|
12
|
-
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
class ProviderError < Error; end
|
|
12
|
+
class ToolError < Error; end
|
|
13
|
+
class MaxStepsExceeded < Error; end
|
|
14
|
+
class StreamingError < Error; end
|
|
15
|
+
|
|
16
|
+
ModelDescriptor = Struct.new(:id, :provider, keyword_init: true) do
|
|
17
|
+
def initialize(id:, provider:)
|
|
18
|
+
super(id: id.to_s, provider: provider.to_sym)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
ToolDefinition = Struct.new(:name, :description, :input_schema, :execute_proc, keyword_init: true) do
|
|
23
|
+
def initialize(name:, description:, input_schema:, execute_proc:)
|
|
24
|
+
super(
|
|
25
|
+
name: name.to_s,
|
|
26
|
+
description: description.to_s,
|
|
27
|
+
input_schema: input_schema.is_a?(Hash) ? input_schema : {},
|
|
28
|
+
execute_proc: execute_proc
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def as_provider_tool
|
|
33
|
+
{
|
|
34
|
+
type: "function",
|
|
35
|
+
function: {
|
|
36
|
+
name: name,
|
|
37
|
+
description: description,
|
|
38
|
+
parameters: input_schema
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def execute(arguments)
|
|
44
|
+
raise ToolError, "Tool '#{name}' is missing an execute block" unless execute_proc.respond_to?(:call)
|
|
45
|
+
|
|
46
|
+
symbolized_args = arguments.each_with_object({}) { |(key, value), acc| acc[key.to_sym] = value }
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
execute_proc.call(arguments)
|
|
50
|
+
rescue ArgumentError, TypeError
|
|
51
|
+
execute_proc.call(**symbolized_args)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
ADAPTER_CONFIG_KEYS = %i[api_key app_url title timeout].freeze
|
|
57
|
+
DEFAULT_MAX_STEPS = 8
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
def model(id, provider: :openrouter)
|
|
62
|
+
ModelDescriptor.new(id: id, provider: provider)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def tool(name:, description:, input_schema:, &execute)
|
|
66
|
+
raise ToolError, "A block is required for tool '#{name}'" unless block_given?
|
|
67
|
+
|
|
68
|
+
ToolDefinition.new(
|
|
69
|
+
name: name,
|
|
70
|
+
description: description,
|
|
71
|
+
input_schema: input_schema,
|
|
72
|
+
execute_proc: execute
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def generate(
|
|
77
|
+
model:,
|
|
78
|
+
messages: nil,
|
|
79
|
+
system: nil,
|
|
80
|
+
prompt: nil,
|
|
81
|
+
tools: {},
|
|
82
|
+
max_steps: DEFAULT_MAX_STEPS,
|
|
83
|
+
temperature: nil,
|
|
84
|
+
max_tokens: nil,
|
|
85
|
+
provider_options: {},
|
|
86
|
+
before_tool_execution: nil,
|
|
87
|
+
after_tool_execution: nil,
|
|
88
|
+
before_iteration: nil,
|
|
89
|
+
after_iteration: nil,
|
|
90
|
+
before_generation: nil,
|
|
91
|
+
after_generation: nil
|
|
92
|
+
)
|
|
93
|
+
model_descriptor = normalize_model(model)
|
|
94
|
+
adapter = provider_adapter(model_descriptor.provider, provider_options)
|
|
95
|
+
tool_map = normalize_tools(tools)
|
|
96
|
+
llm_messages = normalize_messages(messages: messages, system: system, prompt: prompt)
|
|
97
|
+
after_generation_called = false
|
|
98
|
+
|
|
99
|
+
call_callback!(
|
|
100
|
+
before_generation,
|
|
101
|
+
{
|
|
102
|
+
model: model_descriptor,
|
|
103
|
+
messages: llm_messages,
|
|
104
|
+
tool_names: tool_map.keys
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
iterations = []
|
|
109
|
+
iteration_count = 0
|
|
110
|
+
|
|
111
|
+
while iteration_count < max_steps
|
|
112
|
+
current_iteration = iteration_count + 1
|
|
113
|
+
call_callback!(
|
|
114
|
+
before_iteration,
|
|
115
|
+
{
|
|
116
|
+
iteration_index: current_iteration,
|
|
117
|
+
messages: llm_messages
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
payload = build_payload(
|
|
122
|
+
model_id: model_descriptor.id,
|
|
123
|
+
messages: llm_messages,
|
|
124
|
+
tools: tool_map,
|
|
125
|
+
temperature: temperature,
|
|
126
|
+
max_tokens: max_tokens,
|
|
127
|
+
provider_options: provider_options
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
response = adapter.chat(payload)
|
|
131
|
+
message = response.dig("choices", 0, "message") || {}
|
|
132
|
+
tool_calls = Array(message["tool_calls"])
|
|
133
|
+
|
|
134
|
+
iteration_record = {
|
|
135
|
+
index: current_iteration,
|
|
136
|
+
message: message,
|
|
137
|
+
tool_calls: tool_calls,
|
|
138
|
+
usage: response["usage"],
|
|
139
|
+
finish_reason: response.dig("choices", 0, "finish_reason"),
|
|
140
|
+
tool_results: []
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if tool_calls.empty?
|
|
144
|
+
iterations << iteration_record
|
|
145
|
+
call_callback!(
|
|
146
|
+
after_iteration,
|
|
147
|
+
{
|
|
148
|
+
iteration_index: current_iteration,
|
|
149
|
+
iteration: iteration_record
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
result = {
|
|
154
|
+
text: extract_message_text(message),
|
|
155
|
+
message: message,
|
|
156
|
+
usage: response["usage"],
|
|
157
|
+
finish_reason: response.dig("choices", 0, "finish_reason"),
|
|
158
|
+
iterations: iterations,
|
|
159
|
+
raw_response: response
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
after_generation_called = true
|
|
163
|
+
call_callback!(after_generation, { ok: true, result: result })
|
|
164
|
+
return result
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
llm_messages << build_assistant_tool_call_message(message: message, tool_calls: tool_calls)
|
|
168
|
+
|
|
169
|
+
tool_calls.each do |tool_call|
|
|
170
|
+
tool_call_id = normalize_tool_call_id(tool_call["id"])
|
|
171
|
+
arguments = parse_arguments(tool_call.dig("function", "arguments"))
|
|
172
|
+
tool_name = tool_call.dig("function", "name").to_s
|
|
173
|
+
|
|
174
|
+
call_callback!(
|
|
175
|
+
before_tool_execution,
|
|
176
|
+
{
|
|
177
|
+
iteration_index: current_iteration,
|
|
178
|
+
tool_call_id: tool_call_id,
|
|
179
|
+
tool_name: tool_name,
|
|
180
|
+
input: arguments,
|
|
181
|
+
raw_tool_call: tool_call
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
tool_result = execute_tool_call(
|
|
186
|
+
tool_call: tool_call,
|
|
187
|
+
tools: tool_map,
|
|
188
|
+
tool_call_id: tool_call_id,
|
|
189
|
+
arguments: arguments
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
iteration_record[:tool_results] << tool_result
|
|
193
|
+
call_callback!(after_tool_execution, tool_result.merge(iteration_index: current_iteration))
|
|
194
|
+
|
|
195
|
+
llm_messages << {
|
|
196
|
+
"role" => "tool",
|
|
197
|
+
"tool_call_id" => tool_result[:tool_call_id],
|
|
198
|
+
"content" => serialize_tool_content(tool_result[:output])
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
iterations << iteration_record
|
|
203
|
+
call_callback!(
|
|
204
|
+
after_iteration,
|
|
205
|
+
{
|
|
206
|
+
iteration_index: current_iteration,
|
|
207
|
+
iteration: iteration_record
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
iteration_count += 1
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
raise MaxStepsExceeded, "Reached max_steps=#{max_steps} without a final assistant response"
|
|
215
|
+
rescue ProviderError, MaxStepsExceeded => e
|
|
216
|
+
unless after_generation_called
|
|
217
|
+
after_generation_called = true
|
|
218
|
+
call_callback!(after_generation, { ok: false, error: e })
|
|
219
|
+
end
|
|
220
|
+
raise
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
unless after_generation_called
|
|
223
|
+
after_generation_called = true
|
|
224
|
+
call_callback!(after_generation, { ok: false, error: e })
|
|
225
|
+
end
|
|
226
|
+
raise Error, e.message
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def stream(
|
|
230
|
+
model:,
|
|
231
|
+
messages: nil,
|
|
232
|
+
system: nil,
|
|
233
|
+
prompt: nil,
|
|
234
|
+
temperature: nil,
|
|
235
|
+
max_tokens: nil,
|
|
236
|
+
provider_options: {},
|
|
237
|
+
&block
|
|
238
|
+
)
|
|
239
|
+
raise ArgumentError, "stream requires a block callback" unless block_given?
|
|
240
|
+
|
|
241
|
+
model_descriptor = normalize_model(model)
|
|
242
|
+
adapter = provider_adapter(model_descriptor.provider, provider_options)
|
|
243
|
+
llm_messages = normalize_messages(messages: messages, system: system, prompt: prompt)
|
|
244
|
+
|
|
245
|
+
payload = build_payload(
|
|
246
|
+
model_id: model_descriptor.id,
|
|
247
|
+
messages: llm_messages,
|
|
248
|
+
tools: {},
|
|
249
|
+
temperature: temperature,
|
|
250
|
+
max_tokens: max_tokens,
|
|
251
|
+
provider_options: provider_options
|
|
252
|
+
).merge("stream" => true)
|
|
253
|
+
|
|
254
|
+
block.call(type: :start, model: model_descriptor.id, provider: model_descriptor.provider)
|
|
255
|
+
|
|
256
|
+
emitted_finish = false
|
|
257
|
+
|
|
258
|
+
adapter.stream(payload) do |raw_data|
|
|
259
|
+
next if raw_data == "[DONE]"
|
|
260
|
+
|
|
261
|
+
parsed = parse_json(raw_data)
|
|
262
|
+
raise StreamingError, "Malformed SSE payload: #{raw_data}" if parsed.nil?
|
|
263
|
+
|
|
264
|
+
usage = parsed["usage"]
|
|
265
|
+
block.call(type: :usage, usage: usage, raw: parsed) if usage.is_a?(Hash)
|
|
266
|
+
|
|
267
|
+
choice = Array(parsed["choices"]).first || {}
|
|
268
|
+
delta = choice["delta"] || {}
|
|
269
|
+
|
|
270
|
+
text_delta = extract_text_delta(delta)
|
|
271
|
+
block.call(type: :text_delta, text: text_delta, raw: parsed) if text_delta && !text_delta.empty?
|
|
272
|
+
|
|
273
|
+
Array(delta["tool_calls"]).each do |tool_call_delta|
|
|
274
|
+
tool_delta = {
|
|
275
|
+
index: tool_call_delta["index"],
|
|
276
|
+
id: tool_call_delta["id"],
|
|
277
|
+
type: tool_call_delta["type"],
|
|
278
|
+
name: tool_call_delta.dig("function", "name"),
|
|
279
|
+
arguments_delta: tool_call_delta.dig("function", "arguments")
|
|
280
|
+
}
|
|
281
|
+
block.call(type: :tool_call_delta, tool_call: tool_delta, raw: parsed)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
finish_reason = choice["finish_reason"]
|
|
285
|
+
next if finish_reason.nil? || finish_reason.to_s.empty?
|
|
286
|
+
|
|
287
|
+
emitted_finish = true
|
|
288
|
+
block.call(type: :finish, finish_reason: finish_reason, raw: parsed)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
block.call(type: :finish, finish_reason: "stop") unless emitted_finish
|
|
292
|
+
true
|
|
293
|
+
rescue ProviderError => e
|
|
294
|
+
wrapped_error = StreamingError.new(e.message)
|
|
295
|
+
block.call(type: :error, error: wrapped_error.message) if block_given?
|
|
296
|
+
raise wrapped_error
|
|
297
|
+
rescue StreamingError => e
|
|
298
|
+
block.call(type: :error, error: e.message) if block_given?
|
|
299
|
+
raise
|
|
300
|
+
rescue StandardError => e
|
|
301
|
+
wrapped_error = StreamingError.new(e.message)
|
|
302
|
+
block.call(type: :error, error: wrapped_error.message) if block_given?
|
|
303
|
+
raise wrapped_error
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def normalize_model(input)
|
|
307
|
+
return input if input.is_a?(ModelDescriptor)
|
|
308
|
+
return model(input, provider: :openrouter) if input.is_a?(String)
|
|
309
|
+
|
|
310
|
+
if input.is_a?(Hash)
|
|
311
|
+
return model(
|
|
312
|
+
input[:id] || input["id"],
|
|
313
|
+
provider: input[:provider] || input["provider"] || :openrouter
|
|
314
|
+
)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
raise Error, "Unsupported model value: #{input.inspect}"
|
|
318
|
+
end
|
|
319
|
+
private_class_method :normalize_model
|
|
320
|
+
|
|
321
|
+
def normalize_tools(tools)
|
|
322
|
+
return {} if tools.nil?
|
|
323
|
+
|
|
324
|
+
if tools.is_a?(Array)
|
|
325
|
+
return tools.each_with_object({}) do |entry, acc|
|
|
326
|
+
next unless entry.is_a?(ToolDefinition)
|
|
327
|
+
|
|
328
|
+
acc[entry.name] = entry
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
raise ToolError, "tools must be a Hash or Array" unless tools.is_a?(Hash)
|
|
333
|
+
|
|
334
|
+
tools.each_with_object({}) do |(name, value), acc|
|
|
335
|
+
tool_name = name.to_s
|
|
336
|
+
acc[tool_name] = normalize_tool_entry(tool_name, value)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
private_class_method :normalize_tools
|
|
340
|
+
|
|
341
|
+
def normalize_tool_entry(name, value)
|
|
342
|
+
return value if value.is_a?(ToolDefinition)
|
|
343
|
+
|
|
344
|
+
if value.is_a?(Hash)
|
|
345
|
+
execute_proc = value[:execute] || value["execute"]
|
|
346
|
+
return ToolDefinition.new(
|
|
347
|
+
name: name,
|
|
348
|
+
description: value[:description] || value["description"] || "",
|
|
349
|
+
input_schema: value[:input_schema] || value["input_schema"] || value[:parameters] || value["parameters"] || {},
|
|
350
|
+
execute_proc: execute_proc
|
|
351
|
+
)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
raise ToolError, "Tool '#{name}' has invalid definition"
|
|
355
|
+
end
|
|
356
|
+
private_class_method :normalize_tool_entry
|
|
357
|
+
|
|
358
|
+
def normalize_messages(messages:, system:, prompt:)
|
|
359
|
+
if messages.nil? || messages.empty?
|
|
360
|
+
normalized = []
|
|
361
|
+
normalized << { "role" => "system", "content" => system.to_s } if system
|
|
362
|
+
normalized << { "role" => "user", "content" => prompt.to_s } if prompt
|
|
363
|
+
return normalized
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
normalized = deep_stringify(messages)
|
|
367
|
+
normalized.unshift({ "role" => "system", "content" => system.to_s }) if system
|
|
368
|
+
normalized << { "role" => "user", "content" => prompt.to_s } if prompt
|
|
369
|
+
normalized
|
|
370
|
+
end
|
|
371
|
+
private_class_method :normalize_messages
|
|
372
|
+
|
|
373
|
+
def build_payload(model_id:, messages:, tools:, temperature:, max_tokens:, provider_options:)
|
|
374
|
+
payload = {
|
|
375
|
+
"model" => model_id,
|
|
376
|
+
"messages" => messages
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
payload["temperature"] = temperature unless temperature.nil?
|
|
380
|
+
payload["max_tokens"] = max_tokens unless max_tokens.nil?
|
|
381
|
+
payload["tools"] = tools.values.map(&:as_provider_tool) unless tools.empty?
|
|
382
|
+
|
|
383
|
+
request_options = reject_keys(provider_options, ADAPTER_CONFIG_KEYS)
|
|
384
|
+
payload.merge!(deep_stringify(request_options)) if request_options.is_a?(Hash)
|
|
385
|
+
payload
|
|
13
386
|
end
|
|
387
|
+
private_class_method :build_payload
|
|
388
|
+
|
|
389
|
+
def provider_adapter(provider, provider_options)
|
|
390
|
+
config = pick_keys(provider_options, ADAPTER_CONFIG_KEYS)
|
|
391
|
+
|
|
392
|
+
case provider.to_sym
|
|
393
|
+
when :openrouter
|
|
394
|
+
Providers::OpenRouter.new(**config)
|
|
395
|
+
else
|
|
396
|
+
raise ProviderError, "Unsupported provider: #{provider}"
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
private_class_method :provider_adapter
|
|
400
|
+
|
|
401
|
+
def build_assistant_tool_call_message(message:, tool_calls:)
|
|
402
|
+
{
|
|
403
|
+
"role" => "assistant",
|
|
404
|
+
"content" => extract_message_text(message),
|
|
405
|
+
"tool_calls" => tool_calls
|
|
406
|
+
}
|
|
407
|
+
end
|
|
408
|
+
private_class_method :build_assistant_tool_call_message
|
|
409
|
+
|
|
410
|
+
def execute_tool_call(tool_call:, tools:, tool_call_id: nil, arguments: nil)
|
|
411
|
+
tool_name = tool_call.dig("function", "name").to_s
|
|
412
|
+
tool_call_id = normalize_tool_call_id(tool_call_id || tool_call["id"])
|
|
413
|
+
arguments = parse_arguments(tool_call.dig("function", "arguments")) if arguments.nil?
|
|
414
|
+
tool = tools[tool_name]
|
|
415
|
+
|
|
416
|
+
unless tool
|
|
417
|
+
return {
|
|
418
|
+
tool_call_id: tool_call_id,
|
|
419
|
+
tool_name: tool_name,
|
|
420
|
+
input: arguments,
|
|
421
|
+
ok: false,
|
|
422
|
+
output: { "ok" => false, "error" => "Unknown tool: #{tool_name}" }
|
|
423
|
+
}
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
output = normalize_output_payload(tool.execute(arguments))
|
|
427
|
+
{
|
|
428
|
+
tool_call_id: tool_call_id,
|
|
429
|
+
tool_name: tool_name,
|
|
430
|
+
input: arguments,
|
|
431
|
+
ok: true,
|
|
432
|
+
output: output
|
|
433
|
+
}
|
|
434
|
+
rescue StandardError => e
|
|
435
|
+
{
|
|
436
|
+
tool_call_id: tool_call_id,
|
|
437
|
+
tool_name: tool_name,
|
|
438
|
+
input: arguments,
|
|
439
|
+
ok: false,
|
|
440
|
+
output: { "ok" => false, "error" => "Tool execution failed: #{e.message}" }
|
|
441
|
+
}
|
|
442
|
+
end
|
|
443
|
+
private_class_method :execute_tool_call
|
|
444
|
+
|
|
445
|
+
def normalize_tool_call_id(value)
|
|
446
|
+
normalized = value.to_s.strip
|
|
447
|
+
return normalized unless normalized.empty?
|
|
448
|
+
|
|
449
|
+
"call_#{SecureRandom.uuid}"
|
|
450
|
+
end
|
|
451
|
+
private_class_method :normalize_tool_call_id
|
|
452
|
+
|
|
453
|
+
def call_callback!(callback, payload)
|
|
454
|
+
return if callback.nil?
|
|
455
|
+
raise Error, "Callback must respond to #call" unless callback.respond_to?(:call)
|
|
456
|
+
|
|
457
|
+
callback.call(payload)
|
|
458
|
+
end
|
|
459
|
+
private_class_method :call_callback!
|
|
460
|
+
|
|
461
|
+
def normalize_output_payload(payload)
|
|
462
|
+
case payload
|
|
463
|
+
when Hash, Array
|
|
464
|
+
deep_stringify(payload)
|
|
465
|
+
else
|
|
466
|
+
payload
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
private_class_method :normalize_output_payload
|
|
470
|
+
|
|
471
|
+
def extract_message_text(message)
|
|
472
|
+
content = message["content"]
|
|
473
|
+
return content if content.is_a?(String)
|
|
474
|
+
|
|
475
|
+
if content.is_a?(Array)
|
|
476
|
+
return content.filter_map { |part| part["text"] if part.is_a?(Hash) && part["text"] }.join
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
return content["text"].to_s if content.is_a?(Hash) && content["text"]
|
|
480
|
+
|
|
481
|
+
content.to_s
|
|
482
|
+
end
|
|
483
|
+
private_class_method :extract_message_text
|
|
484
|
+
|
|
485
|
+
def extract_text_delta(delta)
|
|
486
|
+
content = delta["content"]
|
|
487
|
+
return content if content.is_a?(String)
|
|
488
|
+
|
|
489
|
+
if content.is_a?(Array)
|
|
490
|
+
return content.filter_map { |part| part["text"] if part.is_a?(Hash) && part["text"] }.join
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
nil
|
|
494
|
+
end
|
|
495
|
+
private_class_method :extract_text_delta
|
|
496
|
+
|
|
497
|
+
def parse_arguments(arguments)
|
|
498
|
+
return arguments if arguments.is_a?(Hash)
|
|
499
|
+
return {} if arguments.nil? || arguments.to_s.strip.empty?
|
|
500
|
+
|
|
501
|
+
parse_json(arguments) || {}
|
|
502
|
+
end
|
|
503
|
+
private_class_method :parse_arguments
|
|
504
|
+
|
|
505
|
+
def parse_json(value)
|
|
506
|
+
JSON.parse(value)
|
|
507
|
+
rescue JSON::ParserError, TypeError
|
|
508
|
+
nil
|
|
509
|
+
end
|
|
510
|
+
private_class_method :parse_json
|
|
511
|
+
|
|
512
|
+
def serialize_tool_content(output)
|
|
513
|
+
return output if output.is_a?(String)
|
|
514
|
+
|
|
515
|
+
JSON.generate(output)
|
|
516
|
+
rescue StandardError
|
|
517
|
+
output.to_s
|
|
518
|
+
end
|
|
519
|
+
private_class_method :serialize_tool_content
|
|
520
|
+
|
|
521
|
+
def pick_keys(hash, keys)
|
|
522
|
+
return {} unless hash.is_a?(Hash)
|
|
523
|
+
|
|
524
|
+
hash.each_with_object({}) do |(key, value), acc|
|
|
525
|
+
symbol_key = key.to_sym
|
|
526
|
+
acc[symbol_key] = value if keys.include?(symbol_key)
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
private_class_method :pick_keys
|
|
530
|
+
|
|
531
|
+
def reject_keys(hash, keys)
|
|
532
|
+
return {} unless hash.is_a?(Hash)
|
|
533
|
+
|
|
534
|
+
hash.each_with_object({}) do |(key, value), acc|
|
|
535
|
+
symbol_key = key.to_sym
|
|
536
|
+
acc[key] = value unless keys.include?(symbol_key)
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
private_class_method :reject_keys
|
|
540
|
+
|
|
541
|
+
def deep_stringify(value)
|
|
542
|
+
case value
|
|
543
|
+
when Hash
|
|
544
|
+
value.each_with_object({}) do |(key, item), acc|
|
|
545
|
+
acc[key.to_s] = deep_stringify(item)
|
|
546
|
+
end
|
|
547
|
+
when Array
|
|
548
|
+
value.map { |item| deep_stringify(item) }
|
|
549
|
+
else
|
|
550
|
+
value
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
private_class_method :deep_stringify
|
|
554
|
+
|
|
555
|
+
module Providers
|
|
556
|
+
class OpenRouter
|
|
557
|
+
CHAT_COMPLETIONS_URL = "https://openrouter.ai/api/v1/chat/completions".freeze
|
|
558
|
+
DEFAULT_TIMEOUT = 120_000
|
|
559
|
+
|
|
560
|
+
def initialize(api_key: nil, app_url: nil, title: nil, timeout: DEFAULT_TIMEOUT)
|
|
561
|
+
@api_key = api_key || resolve_api_key
|
|
562
|
+
raise ProviderError, "OpenRouter API key not configured" if @api_key.nil? || @api_key.to_s.empty?
|
|
563
|
+
|
|
564
|
+
@app_url = app_url || ENV["OPENROUTER_HTTP_REFERER"] || "http://localhost"
|
|
565
|
+
@title = title || ENV["OPENROUTER_APP_TITLE"] || "zuno-ruby"
|
|
566
|
+
@timeout = timeout
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def chat(payload)
|
|
570
|
+
response = Typhoeus.post(
|
|
571
|
+
CHAT_COMPLETIONS_URL,
|
|
572
|
+
headers: headers,
|
|
573
|
+
body: JSON.generate(payload),
|
|
574
|
+
timeout: @timeout
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
validate_response!(response)
|
|
578
|
+
parsed = JSON.parse(response.body)
|
|
579
|
+
raise ProviderError, "OpenRouter returned invalid JSON" unless parsed.is_a?(Hash)
|
|
580
|
+
|
|
581
|
+
parsed
|
|
582
|
+
rescue JSON::ParserError => e
|
|
583
|
+
raise ProviderError, "Failed to parse OpenRouter response: #{e.message}"
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def stream(payload)
|
|
587
|
+
raise ArgumentError, "stream requires a block callback" unless block_given?
|
|
588
|
+
|
|
589
|
+
request = Typhoeus::Request.new(
|
|
590
|
+
CHAT_COMPLETIONS_URL,
|
|
591
|
+
method: :post,
|
|
592
|
+
headers: headers,
|
|
593
|
+
body: JSON.generate(payload),
|
|
594
|
+
timeout: @timeout
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
parser = SseParser.new { |data| yield(data) }
|
|
598
|
+
request.on_body do |chunk|
|
|
599
|
+
parser.push(chunk)
|
|
600
|
+
nil
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
request.run
|
|
604
|
+
validate_response!(request.response)
|
|
605
|
+
parser.flush
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
private
|
|
609
|
+
|
|
610
|
+
def headers
|
|
611
|
+
{
|
|
612
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
613
|
+
"Content-Type" => "application/json",
|
|
614
|
+
"HTTP-Referer" => @app_url,
|
|
615
|
+
"X-Title" => @title
|
|
616
|
+
}
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def validate_response!(response)
|
|
620
|
+
raise ProviderError, "No response returned from OpenRouter" if response.nil?
|
|
621
|
+
raise ProviderError, "OpenRouter request timed out" if response.timed_out?
|
|
622
|
+
raise ProviderError, "OpenRouter request failed: #{response.return_code}" unless response.success?
|
|
623
|
+
|
|
624
|
+
status = response.code.to_i
|
|
625
|
+
return if status >= 200 && status < 300
|
|
626
|
+
|
|
627
|
+
body = response.body.to_s
|
|
628
|
+
message = body.length > 300 ? "#{body[0, 300]}..." : body
|
|
629
|
+
raise ProviderError, "OpenRouter responded with HTTP #{status}: #{message}"
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def resolve_api_key
|
|
633
|
+
ENV["OPENROUTER_API_KEY"]
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
class SseParser
|
|
639
|
+
def initialize(&on_data)
|
|
640
|
+
@on_data = on_data
|
|
641
|
+
@buffer = +""
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def push(chunk)
|
|
645
|
+
return if chunk.nil? || chunk.empty?
|
|
646
|
+
|
|
647
|
+
@buffer << chunk
|
|
648
|
+
parse_buffer
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def flush
|
|
652
|
+
parse_buffer(final: true)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
private
|
|
656
|
+
|
|
657
|
+
def parse_buffer(final: false)
|
|
658
|
+
delimiter = "\n\n"
|
|
659
|
+
|
|
660
|
+
loop do
|
|
661
|
+
index = @buffer.index(delimiter)
|
|
662
|
+
break if index.nil?
|
|
663
|
+
|
|
664
|
+
raw_event = @buffer.slice!(0, index + delimiter.length)
|
|
665
|
+
emit_event(raw_event)
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
emit_event(@buffer.dup) if final && !@buffer.empty?
|
|
669
|
+
@buffer.clear if final
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def emit_event(raw_event)
|
|
673
|
+
lines = raw_event.split("\n")
|
|
674
|
+
payload_lines = lines.filter_map do |line|
|
|
675
|
+
stripped = line.strip
|
|
676
|
+
next if stripped.empty?
|
|
677
|
+
next unless stripped.start_with?("data:")
|
|
678
|
+
|
|
679
|
+
stripped.sub(/^data:\s?/, "")
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
return if payload_lines.empty?
|
|
14
683
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
yield(configuration)
|
|
684
|
+
@on_data.call(payload_lines.join("\n"))
|
|
685
|
+
end
|
|
18
686
|
end
|
|
19
687
|
end
|
metadata
CHANGED
|
@@ -1,74 +1,73 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: zuno
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- Hyperaide
|
|
8
8
|
autorequire:
|
|
9
|
-
bindir:
|
|
9
|
+
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-03-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
|
-
name:
|
|
14
|
+
name: typhoeus
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
|
-
- - "
|
|
17
|
+
- - "~>"
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: '
|
|
19
|
+
version: '1.5'
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
|
-
- - "
|
|
24
|
+
- - "~>"
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: '
|
|
26
|
+
version: '1.5'
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
|
-
name:
|
|
28
|
+
name: rake
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
30
30
|
requirements:
|
|
31
31
|
- - ">="
|
|
32
32
|
- !ruby/object:Gem::Version
|
|
33
|
-
version: '0'
|
|
34
|
-
type: :
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
35
|
prerelease: false
|
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
37
|
requirements:
|
|
38
38
|
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
|
-
version: '0'
|
|
40
|
+
version: '13.0'
|
|
41
41
|
- !ruby/object:Gem::Dependency
|
|
42
|
-
name:
|
|
42
|
+
name: rspec
|
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
|
44
44
|
requirements:
|
|
45
|
-
- - "
|
|
45
|
+
- - "~>"
|
|
46
46
|
- !ruby/object:Gem::Version
|
|
47
|
-
version: '
|
|
48
|
-
type: :
|
|
47
|
+
version: '3.13'
|
|
48
|
+
type: :development
|
|
49
49
|
prerelease: false
|
|
50
50
|
version_requirements: !ruby/object:Gem::Requirement
|
|
51
51
|
requirements:
|
|
52
|
-
- - "
|
|
52
|
+
- - "~>"
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
|
-
version: '
|
|
55
|
-
description:
|
|
54
|
+
version: '3.13'
|
|
55
|
+
description: Standalone Ruby SDK for AI generation with tool loops and SSE streaming.
|
|
56
56
|
email:
|
|
57
|
-
-
|
|
57
|
+
- team@hyperaide.dev
|
|
58
58
|
executables: []
|
|
59
59
|
extensions: []
|
|
60
60
|
extra_rdoc_files: []
|
|
61
61
|
files:
|
|
62
|
-
-
|
|
62
|
+
- README.md
|
|
63
63
|
- lib/zuno.rb
|
|
64
|
-
- lib/zuno/chat.rb
|
|
65
|
-
- lib/zuno/configuration.rb
|
|
66
64
|
- lib/zuno/version.rb
|
|
67
|
-
homepage: https://github.com/
|
|
65
|
+
homepage: https://github.com/hyperaide/hyperaide
|
|
68
66
|
licenses:
|
|
69
67
|
- MIT
|
|
70
68
|
metadata:
|
|
71
|
-
homepage_uri: https://github.com/
|
|
69
|
+
homepage_uri: https://github.com/hyperaide/hyperaide
|
|
70
|
+
source_code_uri: https://github.com/hyperaide/hyperaide
|
|
72
71
|
post_install_message:
|
|
73
72
|
rdoc_options: []
|
|
74
73
|
require_paths:
|
|
@@ -77,15 +76,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
77
76
|
requirements:
|
|
78
77
|
- - ">="
|
|
79
78
|
- !ruby/object:Gem::Version
|
|
80
|
-
version: 3.
|
|
79
|
+
version: '3.1'
|
|
81
80
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
82
81
|
requirements:
|
|
83
82
|
- - ">="
|
|
84
83
|
- !ruby/object:Gem::Version
|
|
85
84
|
version: '0'
|
|
86
85
|
requirements: []
|
|
87
|
-
rubygems_version: 3.
|
|
86
|
+
rubygems_version: 3.5.22
|
|
88
87
|
signing_key:
|
|
89
88
|
specification_version: 4
|
|
90
|
-
summary:
|
|
89
|
+
summary: Ruby Agent SDK with provider/model abstraction, tools, and streaming
|
|
91
90
|
test_files: []
|
data/lib/providers/openai.rb
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
require 'ostruct'
|
|
2
|
-
require 'faraday'
|
|
3
|
-
require 'byebug'
|
|
4
|
-
require 'event_stream_parser'
|
|
5
|
-
|
|
6
|
-
module Providers
|
|
7
|
-
class OpenAI
|
|
8
|
-
def initialize
|
|
9
|
-
@connection = Faraday.new(url: "https://api.openai.com") do |faraday|
|
|
10
|
-
faraday.request :json
|
|
11
|
-
faraday.response :json
|
|
12
|
-
faraday.adapter Faraday.default_adapter
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
@api_key = Zuno.configuration.openai_api_key
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def chat_completion(messages, model, options = {}, raw_response)
|
|
19
|
-
response = @connection.post("/v1/chat/completions") do |req|
|
|
20
|
-
req.headers["Content-Type"] = "application/json"
|
|
21
|
-
req.headers["Authorization"] = "Bearer #{@api_key}"
|
|
22
|
-
req.body = {
|
|
23
|
-
model: model,
|
|
24
|
-
messages: messages,
|
|
25
|
-
}.merge(options).to_json
|
|
26
|
-
|
|
27
|
-
if options[:stream]
|
|
28
|
-
parser = EventStreamParser::Parser.new
|
|
29
|
-
req.options.on_data = Proc.new do |chunk, size|
|
|
30
|
-
if raw_response
|
|
31
|
-
yield chunk
|
|
32
|
-
else
|
|
33
|
-
parser.feed(chunk) do |type, data, id, reconnection_time|
|
|
34
|
-
return if data == "[DONE]"
|
|
35
|
-
content = JSON.parse(data)["choices"][0]["delta"]["content"]
|
|
36
|
-
yield OpenStruct.new(content: content) if content
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
unless options[:stream]
|
|
44
|
-
if raw_response
|
|
45
|
-
return response.body
|
|
46
|
-
else
|
|
47
|
-
if response.body["error"]
|
|
48
|
-
raise response.body["error"]["message"]
|
|
49
|
-
elsif response.body["choices"][0]["message"]["content"]
|
|
50
|
-
OpenStruct.new(content: response.body["choices"][0]["message"]["content"])
|
|
51
|
-
elsif response.body["choices"][0]["message"]["tool_calls"]
|
|
52
|
-
OpenStruct.new(tool_calls: response.body["choices"][0]["message"]["tool_calls"])
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
data/lib/zuno/chat.rb
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "providers/openai"
|
|
4
|
-
|
|
5
|
-
module Zuno
|
|
6
|
-
OPENAI_MODELS = %w[gpt-3.5-turbo gpt-4-turbo gpt-4-turbo-preview gpt-4o gpt-4o-mini].freeze
|
|
7
|
-
|
|
8
|
-
class << self
|
|
9
|
-
def chat(messages:, model: nil, **options)
|
|
10
|
-
model ||= Zuno.configuration.chat_completion_model
|
|
11
|
-
provider = provider_for_model(model)
|
|
12
|
-
raw_response = options.delete(:raw_response) || false
|
|
13
|
-
|
|
14
|
-
if options[:stream]
|
|
15
|
-
provider.chat_completion(messages, model, options, raw_response) do |chunk|
|
|
16
|
-
yield chunk if block_given?
|
|
17
|
-
end
|
|
18
|
-
else
|
|
19
|
-
provider.chat_completion(messages, model, options, raw_response)
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
def provider_for_model(model)
|
|
26
|
-
case model
|
|
27
|
-
when *OPENAI_MODELS then Providers::OpenAI.new
|
|
28
|
-
else
|
|
29
|
-
raise ArgumentError, "Unsupported model: #{model}"
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def model_providers_mapping
|
|
34
|
-
@model_providers_mapping ||= {
|
|
35
|
-
**OPENAI_MODELS.to_h { |model| [model, Providers::OpenAI.new] },
|
|
36
|
-
}
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
data/lib/zuno/configuration.rb
DELETED