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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6809336cae0fd3e0ff6510407f7a29ca852f833dc7f567e05f246b958982a968
4
- data.tar.gz: 007fe0df2ef864d4dc8ce8afe903a733c85e0da41921fea7ecb53f5c1da56263
3
+ metadata.gz: e0b1a60f13b8d0f649d5a00df65846d3a35597f6298927c2e07b36f6f042528a
4
+ data.tar.gz: a6312c76748d3fe27bc50e70fac5c7922f240ac7b61eee6ed6ab1e7e90639e2a
5
5
  SHA512:
6
- metadata.gz: 373a15dfb01d69283621579bc58a58561bceb30da4c309014632cb1e5404f659e7ba1f5d206f70f7650838a488b9483ecb02ecd36523d35f79e9d12fe43d9e89
7
- data.tar.gz: ade9ff32f23eb1ccb60660c3372076e6c6f8b92d4ca64bebf0e617381d1b2ee4204de6bfc29eb54dd53765d0235bfc544e78107d09a086b8439bfed72f05f3f1
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zuno
4
- VERSION = "0.1.4"
4
+ VERSION = "0.1.6"
5
5
  end
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 << self
12
- attr_accessor :configuration
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
- def self.configure
16
- self.configuration ||= Configuration.new
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
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
- - John Paul
7
+ - Hyperaide
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2024-09-06 00:00:00.000000000 Z
11
+ date: 2026-03-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: httparty
14
+ name: typhoeus
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0'
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: '0'
26
+ version: '1.5'
27
27
  - !ruby/object:Gem::Dependency
28
- name: faraday
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: :runtime
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: event_stream_parser
42
+ name: rspec
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
- - - ">="
45
+ - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '0'
48
- type: :runtime
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: '0'
55
- description: AI toolkit for Ruby
54
+ version: '3.13'
55
+ description: Standalone Ruby SDK for AI generation with tool loops and SSE streaming.
56
56
  email:
57
- - johnarpaul@gmail.com
57
+ - team@hyperaide.dev
58
58
  executables: []
59
59
  extensions: []
60
60
  extra_rdoc_files: []
61
61
  files:
62
- - lib/providers/openai.rb
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/dqnamo
65
+ homepage: https://github.com/hyperaide/hyperaide
68
66
  licenses:
69
67
  - MIT
70
68
  metadata:
71
- homepage_uri: https://github.com/dqnamo
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.0.0
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.4.10
86
+ rubygems_version: 3.5.22
88
87
  signing_key:
89
88
  specification_version: 4
90
- summary: AI toolkit for Ruby
89
+ summary: Ruby Agent SDK with provider/model abstraction, tools, and streaming
91
90
  test_files: []
@@ -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
@@ -1,11 +0,0 @@
1
- module Zuno
2
- class Configuration
3
- attr_accessor :chat_completion_model, :openai_api_key, :anthropic_api_key, :groq_cloud_api_key
4
-
5
- def initialize
6
- @chat_completion_model = nil
7
- @openai_api_key = nil
8
- @anthropic_api_key = nil
9
- end
10
- end
11
- end