zuno 1.0.2 → 1.1.1
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/CHANGELOG.md +14 -0
- data/README.md +33 -1
- data/lib/zuno/version.rb +1 -1
- data/lib/zuno.rb +338 -51
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7d6ca01a3009551afda766233cc241c30eff87eb7226142552c21f9d68450d0
|
|
4
|
+
data.tar.gz: f71bd9073d2d14829502216a82e28d4f11e4bbb1de0b9f330befb4d2a6d02757
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2228bcd4b752897c4757ad869fbe92ad0c189cc24be1d265d718f80e2cc23104194c737658022b0caa35c25f98d4a94006ba19746f1b30c6fd56317d6d3cee36
|
|
7
|
+
data.tar.gz: a24da98ad4c6ce0214e26224d6e8f65886901865df8e1c05ab1f0572028c2d7f797328388bfdf035e7cfb52ecc52c9e5e207e101165e478df517589a0505ae81
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.1.1](https://github.com/dqnamo/zuno/compare/v1.1.0...v1.1.1) (2026-04-06)
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
* disable parallel tool calls by default for Gemini models routed through Vercel AI Gateway so function responses stay provider-compatible
|
|
8
|
+
|
|
9
|
+
## [1.1.0](https://github.com/dqnamo/zuno/compare/v1.0.2...v1.1.0) (2026-04-04)
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add `Zuno.embed` for OpenAI-compatible embedding providers including OpenRouter and AI Gateway
|
|
14
|
+
* add mutable loop callback context for tools, messages, and system prompt updates
|
|
15
|
+
* refresh callback, loop, tools, and API reference documentation for the split `generate` and `loop` APIs
|
|
16
|
+
|
|
3
17
|
## [1.0.2](https://github.com/dqnamo/zuno/compare/v1.0.1...v1.0.2) (2026-04-01)
|
|
4
18
|
|
|
5
19
|
### Features
|
data/README.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Standalone Ruby SDK for:
|
|
4
4
|
|
|
5
5
|
- provider/model abstraction
|
|
6
|
+
- embeddings via OpenAI-compatible providers
|
|
6
7
|
- single-shot generation
|
|
7
8
|
- iterative tool loops
|
|
8
9
|
- streaming via SSE (OpenRouter)
|
|
@@ -54,6 +55,19 @@ result = Zuno.generate(
|
|
|
54
55
|
puts result[:text]
|
|
55
56
|
```
|
|
56
57
|
|
|
58
|
+
## Embeddings (`embed`)
|
|
59
|
+
|
|
60
|
+
`embed` supports OpenAI-compatible embedding providers such as OpenRouter and AI Gateway.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
result = Zuno.embed(
|
|
64
|
+
model: openrouter.model("openai/text-embedding-3-small"),
|
|
65
|
+
content: "Sunny day at the beach"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
pp result[:embedding]
|
|
69
|
+
```
|
|
70
|
+
|
|
57
71
|
`generate` supports tool definitions and executes returned tool calls once, without a follow-up LLM request.
|
|
58
72
|
|
|
59
73
|
### Replicate
|
|
@@ -114,7 +128,25 @@ result = Zuno.loop(
|
|
|
114
128
|
- `max_iterations` (`Integer`, `:infinite`, or `Float::INFINITY`)
|
|
115
129
|
- `stop_when: { tool_called: ... }`
|
|
116
130
|
|
|
117
|
-
|
|
131
|
+
Loop callbacks can accept:
|
|
132
|
+
|
|
133
|
+
- a second argument (`control`) and call `control.stop!(reason: "...")`
|
|
134
|
+
- a third argument (`context`) to mutate the next LLM request state
|
|
135
|
+
|
|
136
|
+
`context` supports:
|
|
137
|
+
|
|
138
|
+
- `context.system = "..."` or `context.clear_system!`
|
|
139
|
+
- `context.messages = [...]`, `context.add_message(...)`, `context.add_messages(...)`
|
|
140
|
+
- `context.tools = {...}`, `context.add_tool(:name, tool)`, `context.add_tools(...)`, `context.remove_tool(:name)`
|
|
141
|
+
|
|
142
|
+
Example:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
before_iteration: ->(_payload, _control, context) {
|
|
146
|
+
context.system = "Be concise"
|
|
147
|
+
context.add_message(role: "user", content: "Answer in bullet points")
|
|
148
|
+
}
|
|
149
|
+
```
|
|
118
150
|
|
|
119
151
|
## Tool choice
|
|
120
152
|
|
data/lib/zuno/version.rb
CHANGED
data/lib/zuno.rb
CHANGED
|
@@ -31,6 +31,88 @@ module Zuno
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
class CallbackContext
|
|
35
|
+
attr_reader :messages, :tools
|
|
36
|
+
attr_accessor :system
|
|
37
|
+
|
|
38
|
+
def initialize(messages:, system:, tools:)
|
|
39
|
+
@messages = messages
|
|
40
|
+
@system = system
|
|
41
|
+
@tools = tools
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def messages=(messages)
|
|
45
|
+
@messages = messages
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tools=(tools)
|
|
49
|
+
@tools = tools
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def add_message(message = nil, role: nil, content: nil, **attributes)
|
|
53
|
+
if message
|
|
54
|
+
raise ArgumentError, "Provide either a message hash or role/content attributes" unless role.nil? && content.nil? && attributes.empty?
|
|
55
|
+
|
|
56
|
+
entry = message
|
|
57
|
+
elsif role.nil? && content.nil? && !attributes.empty?
|
|
58
|
+
entry = attributes
|
|
59
|
+
else
|
|
60
|
+
raise ArgumentError, "add_message requires role:" if role.nil?
|
|
61
|
+
|
|
62
|
+
entry = { role: role, content: content }.merge(attributes)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@messages = [] unless @messages.is_a?(Array)
|
|
66
|
+
@messages << entry
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def add_messages(messages)
|
|
71
|
+
@messages = [] unless @messages.is_a?(Array)
|
|
72
|
+
@messages.concat(Array(messages))
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def add_tool(name, tool)
|
|
77
|
+
@tools = {} unless @tools.is_a?(Hash)
|
|
78
|
+
@tools[name] = tool
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def add_tools(tools)
|
|
83
|
+
@tools = {} unless @tools.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
case tools
|
|
86
|
+
when Hash
|
|
87
|
+
@tools.merge!(tools)
|
|
88
|
+
when Array
|
|
89
|
+
tools.each do |entry|
|
|
90
|
+
raise ToolError, "tools must be a Hash or Array of ToolDefinition entries" unless entry.is_a?(ToolDefinition)
|
|
91
|
+
|
|
92
|
+
@tools[entry.name] = entry
|
|
93
|
+
end
|
|
94
|
+
else
|
|
95
|
+
raise ToolError, "tools must be a Hash or Array"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def remove_tool(name)
|
|
102
|
+
return self unless @tools.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
@tools.delete(name)
|
|
105
|
+
@tools.delete(name.to_s)
|
|
106
|
+
@tools.delete(name.to_sym)
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def clear_system!
|
|
111
|
+
@system = nil
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
34
116
|
ModelDescriptor = Struct.new(:id, :provider, :provider_options, keyword_init: true) do
|
|
35
117
|
def initialize(id:, provider:, provider_options: {})
|
|
36
118
|
super(
|
|
@@ -190,6 +272,58 @@ module Zuno
|
|
|
190
272
|
raise Error, e.message
|
|
191
273
|
end
|
|
192
274
|
|
|
275
|
+
def embed(
|
|
276
|
+
model:,
|
|
277
|
+
content:,
|
|
278
|
+
dimensions: nil,
|
|
279
|
+
encoding_format: nil,
|
|
280
|
+
provider_options: {},
|
|
281
|
+
**options
|
|
282
|
+
)
|
|
283
|
+
validate_embedding_input!(content: content)
|
|
284
|
+
|
|
285
|
+
model_descriptor = normalize_model(model)
|
|
286
|
+
resolved_provider_options = merge_provider_options(
|
|
287
|
+
model_descriptor.provider_options,
|
|
288
|
+
provider_options
|
|
289
|
+
)
|
|
290
|
+
provider = model_descriptor.provider.to_sym
|
|
291
|
+
|
|
292
|
+
case provider
|
|
293
|
+
when :openrouter, :ai_gateway
|
|
294
|
+
adapter = provider_adapter(provider, resolved_provider_options)
|
|
295
|
+
response = adapter.embed(
|
|
296
|
+
build_embedding_payload(
|
|
297
|
+
model_id: model_descriptor.id,
|
|
298
|
+
provider: provider,
|
|
299
|
+
content: content,
|
|
300
|
+
dimensions: dimensions,
|
|
301
|
+
encoding_format: encoding_format,
|
|
302
|
+
provider_options: resolved_provider_options,
|
|
303
|
+
options: options
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
embeddings = Array(response["data"]).filter_map do |item|
|
|
308
|
+
item["embedding"] if item.is_a?(Hash)
|
|
309
|
+
end
|
|
310
|
+
result = {
|
|
311
|
+
embeddings: embeddings,
|
|
312
|
+
usage: response["usage"],
|
|
313
|
+
model: response["model"],
|
|
314
|
+
raw_response: response
|
|
315
|
+
}
|
|
316
|
+
result[:embedding] = embeddings.first if embeddings.length == 1
|
|
317
|
+
result
|
|
318
|
+
else
|
|
319
|
+
raise ProviderError, "embed only supports openrouter or ai_gateway provider"
|
|
320
|
+
end
|
|
321
|
+
rescue ProviderError
|
|
322
|
+
raise
|
|
323
|
+
rescue StandardError => e
|
|
324
|
+
raise Error, e.message
|
|
325
|
+
end
|
|
326
|
+
|
|
193
327
|
def generate(
|
|
194
328
|
model:,
|
|
195
329
|
messages: nil,
|
|
@@ -311,6 +445,7 @@ module Zuno
|
|
|
311
445
|
after_generation: nil
|
|
312
446
|
)
|
|
313
447
|
callback_control = nil
|
|
448
|
+
callback_context = nil
|
|
314
449
|
model_descriptor = normalize_model(model)
|
|
315
450
|
unless %i[openrouter ai_gateway].include?(model_descriptor.provider.to_sym)
|
|
316
451
|
raise Error, "loop only supports openrouter or ai_gateway provider"
|
|
@@ -321,12 +456,11 @@ module Zuno
|
|
|
321
456
|
provider_options
|
|
322
457
|
)
|
|
323
458
|
adapter = provider_adapter(model_descriptor.provider, resolved_provider_options)
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
tools: tool_map
|
|
459
|
+
callback_context = build_callback_context(
|
|
460
|
+
messages: messages,
|
|
461
|
+
system: system,
|
|
462
|
+
prompt: prompt,
|
|
463
|
+
tools: tools
|
|
330
464
|
)
|
|
331
465
|
resolved_stop_when = normalize_stop_when(stop_when)
|
|
332
466
|
resolved_max_iterations = normalize_max_iterations(max_iterations)
|
|
@@ -337,14 +471,20 @@ module Zuno
|
|
|
337
471
|
before_generation,
|
|
338
472
|
{
|
|
339
473
|
model: model_descriptor,
|
|
340
|
-
messages:
|
|
341
|
-
tool_names:
|
|
342
|
-
tool_choice:
|
|
474
|
+
messages: materialize_messages(messages: callback_context.messages, system: callback_context.system),
|
|
475
|
+
tool_names: callback_context.tools.keys,
|
|
476
|
+
tool_choice: normalize_tool_choice(
|
|
477
|
+
explicit_tool_choice: tool_choice,
|
|
478
|
+
provider_options: resolved_provider_options,
|
|
479
|
+
tools: callback_context.tools
|
|
480
|
+
),
|
|
343
481
|
max_iterations: resolved_max_iterations,
|
|
344
482
|
stop_when: resolved_stop_when
|
|
345
483
|
},
|
|
346
|
-
callback_control
|
|
484
|
+
callback_control,
|
|
485
|
+
callback_context
|
|
347
486
|
)
|
|
487
|
+
normalize_callback_context!(callback_context)
|
|
348
488
|
if callback_control.stopped?
|
|
349
489
|
result = callback_stopped_result(
|
|
350
490
|
control: callback_control,
|
|
@@ -354,7 +494,7 @@ module Zuno
|
|
|
354
494
|
raw_response: nil
|
|
355
495
|
)
|
|
356
496
|
after_generation_called = true
|
|
357
|
-
call_callback!(after_generation, { ok: true, result: result }, callback_control)
|
|
497
|
+
call_callback!(after_generation, { ok: true, result: result }, callback_control, callback_context)
|
|
358
498
|
return result
|
|
359
499
|
end
|
|
360
500
|
|
|
@@ -369,10 +509,12 @@ module Zuno
|
|
|
369
509
|
before_iteration,
|
|
370
510
|
{
|
|
371
511
|
iteration_index: current_iteration,
|
|
372
|
-
messages:
|
|
512
|
+
messages: materialize_messages(messages: callback_context.messages, system: callback_context.system)
|
|
373
513
|
},
|
|
374
|
-
callback_control
|
|
514
|
+
callback_control,
|
|
515
|
+
callback_context
|
|
375
516
|
)
|
|
517
|
+
normalize_callback_context!(callback_context)
|
|
376
518
|
if callback_control.stopped?
|
|
377
519
|
result = callback_stopped_result(
|
|
378
520
|
control: callback_control,
|
|
@@ -382,15 +524,20 @@ module Zuno
|
|
|
382
524
|
raw_response: nil
|
|
383
525
|
)
|
|
384
526
|
after_generation_called = true
|
|
385
|
-
call_callback!(after_generation, { ok: true, result: result }, callback_control)
|
|
527
|
+
call_callback!(after_generation, { ok: true, result: result }, callback_control, callback_context)
|
|
386
528
|
return result
|
|
387
529
|
end
|
|
388
530
|
|
|
531
|
+
resolved_tool_choice = normalize_tool_choice(
|
|
532
|
+
explicit_tool_choice: tool_choice,
|
|
533
|
+
provider_options: resolved_provider_options,
|
|
534
|
+
tools: callback_context.tools
|
|
535
|
+
)
|
|
389
536
|
payload = build_payload(
|
|
390
537
|
model_id: model_descriptor.id,
|
|
391
538
|
provider: model_descriptor.provider,
|
|
392
|
-
messages:
|
|
393
|
-
tools:
|
|
539
|
+
messages: materialize_messages(messages: callback_context.messages, system: callback_context.system),
|
|
540
|
+
tools: callback_context.tools,
|
|
394
541
|
tool_choice: resolved_tool_choice,
|
|
395
542
|
temperature: temperature,
|
|
396
543
|
max_tokens: max_tokens,
|
|
@@ -418,8 +565,10 @@ module Zuno
|
|
|
418
565
|
iteration_index: current_iteration,
|
|
419
566
|
iteration: iteration_record
|
|
420
567
|
},
|
|
421
|
-
callback_control
|
|
568
|
+
callback_control,
|
|
569
|
+
callback_context
|
|
422
570
|
)
|
|
571
|
+
normalize_callback_context!(callback_context)
|
|
423
572
|
if callback_control.stopped?
|
|
424
573
|
result = callback_stopped_result(
|
|
425
574
|
control: callback_control,
|
|
@@ -429,7 +578,7 @@ module Zuno
|
|
|
429
578
|
raw_response: response
|
|
430
579
|
)
|
|
431
580
|
after_generation_called = true
|
|
432
|
-
call_callback!(after_generation, { ok: true, result: result }, callback_control)
|
|
581
|
+
call_callback!(after_generation, { ok: true, result: result }, callback_control, callback_context)
|
|
433
582
|
return result
|
|
434
583
|
end
|
|
435
584
|
|
|
@@ -443,11 +592,11 @@ module Zuno
|
|
|
443
592
|
}
|
|
444
593
|
|
|
445
594
|
after_generation_called = true
|
|
446
|
-
call_callback!(after_generation, { ok: true, result: result }, callback_control)
|
|
595
|
+
call_callback!(after_generation, { ok: true, result: result }, callback_control, callback_context)
|
|
447
596
|
return result
|
|
448
597
|
end
|
|
449
598
|
|
|
450
|
-
|
|
599
|
+
callback_context.add_message(build_assistant_tool_call_message(message: message, tool_calls: tool_calls))
|
|
451
600
|
stop_triggered = false
|
|
452
601
|
stop_triggered_tool_name = nil
|
|
453
602
|
|
|
@@ -465,12 +614,14 @@ module Zuno
|
|
|
465
614
|
input: arguments,
|
|
466
615
|
raw_tool_call: tool_call
|
|
467
616
|
},
|
|
468
|
-
callback_control
|
|
617
|
+
callback_control,
|
|
618
|
+
callback_context
|
|
469
619
|
)
|
|
620
|
+
normalize_callback_context!(callback_context)
|
|
470
621
|
|
|
471
622
|
tool_result = execute_tool_call(
|
|
472
623
|
tool_call: tool_call,
|
|
473
|
-
tools:
|
|
624
|
+
tools: callback_context.tools,
|
|
474
625
|
tool_call_id: tool_call_id,
|
|
475
626
|
arguments: arguments
|
|
476
627
|
)
|
|
@@ -479,29 +630,33 @@ module Zuno
|
|
|
479
630
|
call_callback!(
|
|
480
631
|
after_tool_execution,
|
|
481
632
|
tool_result.merge(iteration_index: current_iteration),
|
|
482
|
-
callback_control
|
|
633
|
+
callback_control,
|
|
634
|
+
callback_context
|
|
483
635
|
)
|
|
636
|
+
normalize_callback_context!(callback_context)
|
|
484
637
|
if tool_stop_condition_met?(resolved_stop_when, tool_result)
|
|
485
638
|
stop_triggered = true
|
|
486
639
|
stop_triggered_tool_name ||= tool_result[:tool_name]
|
|
487
640
|
end
|
|
488
641
|
|
|
489
|
-
|
|
642
|
+
callback_context.add_message(
|
|
490
643
|
"role" => "tool",
|
|
491
644
|
"tool_call_id" => tool_result[:tool_call_id],
|
|
492
645
|
"content" => serialize_tool_content(tool_result[:output])
|
|
493
|
-
|
|
646
|
+
)
|
|
494
647
|
end
|
|
495
648
|
|
|
496
649
|
iterations << iteration_record
|
|
497
650
|
call_callback!(
|
|
498
651
|
after_iteration,
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
652
|
+
{
|
|
653
|
+
iteration_index: current_iteration,
|
|
654
|
+
iteration: iteration_record
|
|
655
|
+
},
|
|
656
|
+
callback_control,
|
|
657
|
+
callback_context
|
|
658
|
+
)
|
|
659
|
+
normalize_callback_context!(callback_context)
|
|
505
660
|
if callback_control.stopped?
|
|
506
661
|
result = callback_stopped_result(
|
|
507
662
|
control: callback_control,
|
|
@@ -511,7 +666,7 @@ module Zuno
|
|
|
511
666
|
raw_response: response
|
|
512
667
|
)
|
|
513
668
|
after_generation_called = true
|
|
514
|
-
call_callback!(after_generation, { ok: true, result: result }, callback_control)
|
|
669
|
+
call_callback!(after_generation, { ok: true, result: result }, callback_control, callback_context)
|
|
515
670
|
return result
|
|
516
671
|
end
|
|
517
672
|
|
|
@@ -530,7 +685,7 @@ module Zuno
|
|
|
530
685
|
}
|
|
531
686
|
|
|
532
687
|
after_generation_called = true
|
|
533
|
-
call_callback!(after_generation, { ok: true, result: result }, callback_control)
|
|
688
|
+
call_callback!(after_generation, { ok: true, result: result }, callback_control, callback_context)
|
|
534
689
|
return result
|
|
535
690
|
end
|
|
536
691
|
|
|
@@ -542,13 +697,13 @@ module Zuno
|
|
|
542
697
|
rescue ProviderError, MaxIterationsExceeded => e
|
|
543
698
|
unless after_generation_called
|
|
544
699
|
after_generation_called = true
|
|
545
|
-
call_callback!(after_generation, { ok: false, error: e }, callback_control)
|
|
700
|
+
call_callback!(after_generation, { ok: false, error: e }, callback_control, callback_context)
|
|
546
701
|
end
|
|
547
702
|
raise
|
|
548
703
|
rescue StandardError => e
|
|
549
704
|
unless after_generation_called
|
|
550
705
|
after_generation_called = true
|
|
551
|
-
call_callback!(after_generation, { ok: false, error: e }, callback_control)
|
|
706
|
+
call_callback!(after_generation, { ok: false, error: e }, callback_control, callback_context)
|
|
552
707
|
end
|
|
553
708
|
raise Error, e.message
|
|
554
709
|
end
|
|
@@ -835,6 +990,34 @@ module Zuno
|
|
|
835
990
|
end
|
|
836
991
|
private_class_method :validate_transcription_input!
|
|
837
992
|
|
|
993
|
+
def validate_embedding_input!(content:)
|
|
994
|
+
case content
|
|
995
|
+
when String
|
|
996
|
+
raise Error, "content is required" if content.strip.empty?
|
|
997
|
+
when Array
|
|
998
|
+
raise Error, "content must include at least one item" if content.empty?
|
|
999
|
+
when nil
|
|
1000
|
+
raise Error, "content is required"
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
private_class_method :validate_embedding_input!
|
|
1004
|
+
|
|
1005
|
+
def build_embedding_payload(model_id:, provider:, content:, dimensions:, encoding_format:, provider_options:, options:)
|
|
1006
|
+
payload = {
|
|
1007
|
+
"model" => model_id,
|
|
1008
|
+
"input" => deep_stringify(content)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
payload["dimensions"] = dimensions unless dimensions.nil?
|
|
1012
|
+
payload["encoding_format"] = encoding_format unless encoding_format.nil?
|
|
1013
|
+
|
|
1014
|
+
request_options = reject_keys(provider_options, provider_adapter_config_keys(provider) + [:tool_choice])
|
|
1015
|
+
payload.merge!(deep_stringify(request_options)) if request_options.is_a?(Hash)
|
|
1016
|
+
payload.merge!(deep_stringify(options)) if options.is_a?(Hash) && !options.empty?
|
|
1017
|
+
payload
|
|
1018
|
+
end
|
|
1019
|
+
private_class_method :build_embedding_payload
|
|
1020
|
+
|
|
838
1021
|
def normalize_tools(tools)
|
|
839
1022
|
return {} if tools.nil?
|
|
840
1023
|
|
|
@@ -873,19 +1056,57 @@ module Zuno
|
|
|
873
1056
|
private_class_method :normalize_tool_entry
|
|
874
1057
|
|
|
875
1058
|
def normalize_messages(messages:, system:, prompt:)
|
|
1059
|
+
materialize_messages(
|
|
1060
|
+
messages: normalize_callback_messages(messages: messages, prompt: prompt),
|
|
1061
|
+
system: normalize_callback_system(system)
|
|
1062
|
+
)
|
|
1063
|
+
end
|
|
1064
|
+
private_class_method :normalize_messages
|
|
1065
|
+
|
|
1066
|
+
def build_callback_context(messages:, system:, prompt:, tools:)
|
|
1067
|
+
CallbackContext.new(
|
|
1068
|
+
messages: normalize_callback_messages(messages: messages, prompt: prompt),
|
|
1069
|
+
system: normalize_callback_system(system),
|
|
1070
|
+
tools: normalize_tools(tools)
|
|
1071
|
+
)
|
|
1072
|
+
end
|
|
1073
|
+
private_class_method :build_callback_context
|
|
1074
|
+
|
|
1075
|
+
def normalize_callback_context!(context)
|
|
1076
|
+
context.messages = normalize_callback_messages(messages: context.messages, prompt: nil)
|
|
1077
|
+
context.system = normalize_callback_system(context.system)
|
|
1078
|
+
context.tools = normalize_tools(context.tools)
|
|
1079
|
+
context
|
|
1080
|
+
end
|
|
1081
|
+
private_class_method :normalize_callback_context!
|
|
1082
|
+
|
|
1083
|
+
def normalize_callback_messages(messages:, prompt:)
|
|
876
1084
|
if messages.nil? || messages.empty?
|
|
877
1085
|
normalized = []
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
1086
|
+
else
|
|
1087
|
+
raise Error, "messages must be an Array" unless messages.is_a?(Array)
|
|
1088
|
+
|
|
1089
|
+
normalized = deep_stringify(messages)
|
|
881
1090
|
end
|
|
882
1091
|
|
|
883
|
-
normalized = deep_stringify(messages)
|
|
884
|
-
normalized.unshift({ "role" => "system", "content" => system.to_s }) if system
|
|
885
1092
|
normalized << { "role" => "user", "content" => prompt.to_s } if prompt
|
|
886
1093
|
normalized
|
|
887
1094
|
end
|
|
888
|
-
private_class_method :
|
|
1095
|
+
private_class_method :normalize_callback_messages
|
|
1096
|
+
|
|
1097
|
+
def normalize_callback_system(system)
|
|
1098
|
+
return nil if system.nil?
|
|
1099
|
+
|
|
1100
|
+
system.to_s
|
|
1101
|
+
end
|
|
1102
|
+
private_class_method :normalize_callback_system
|
|
1103
|
+
|
|
1104
|
+
def materialize_messages(messages:, system:)
|
|
1105
|
+
normalized = normalize_callback_messages(messages: messages, prompt: nil)
|
|
1106
|
+
normalized.unshift({ "role" => "system", "content" => system.to_s }) if system
|
|
1107
|
+
normalized
|
|
1108
|
+
end
|
|
1109
|
+
private_class_method :materialize_messages
|
|
889
1110
|
|
|
890
1111
|
def build_payload(model_id:, provider:, messages:, tools:, tool_choice:, temperature:, max_tokens:, provider_options:)
|
|
891
1112
|
payload = {
|
|
@@ -897,6 +1118,12 @@ module Zuno
|
|
|
897
1118
|
payload["max_tokens"] = max_tokens unless max_tokens.nil?
|
|
898
1119
|
payload["tools"] = tools.values.map(&:as_provider_tool) unless tools.empty?
|
|
899
1120
|
payload["tool_choice"] = deep_stringify(tool_choice) unless tool_choice.nil?
|
|
1121
|
+
payload["parallel_tool_calls"] = false if disable_parallel_tool_calls_by_default?(
|
|
1122
|
+
model_id: model_id,
|
|
1123
|
+
provider: provider,
|
|
1124
|
+
tools: tools,
|
|
1125
|
+
provider_options: provider_options
|
|
1126
|
+
)
|
|
900
1127
|
|
|
901
1128
|
request_options = reject_keys(provider_options, provider_adapter_config_keys(provider) + [ :tool_choice ])
|
|
902
1129
|
payload.merge!(deep_stringify(request_options)) if request_options.is_a?(Hash)
|
|
@@ -904,6 +1131,22 @@ module Zuno
|
|
|
904
1131
|
end
|
|
905
1132
|
private_class_method :build_payload
|
|
906
1133
|
|
|
1134
|
+
def disable_parallel_tool_calls_by_default?(model_id:, provider:, tools:, provider_options:)
|
|
1135
|
+
return false if tools.empty?
|
|
1136
|
+
return false unless provider.to_sym == :ai_gateway
|
|
1137
|
+
return false unless gemini_model?(model_id)
|
|
1138
|
+
return false if provider_options&.key?(:parallel_tool_calls) || provider_options&.key?("parallel_tool_calls")
|
|
1139
|
+
|
|
1140
|
+
true
|
|
1141
|
+
end
|
|
1142
|
+
private_class_method :disable_parallel_tool_calls_by_default?
|
|
1143
|
+
|
|
1144
|
+
def gemini_model?(model_id)
|
|
1145
|
+
normalized = model_id.to_s.downcase
|
|
1146
|
+
normalized.start_with?("google/") || normalized.include?("gemini")
|
|
1147
|
+
end
|
|
1148
|
+
private_class_method :gemini_model?
|
|
1149
|
+
|
|
907
1150
|
def provider_adapter_config_keys(provider)
|
|
908
1151
|
case provider.to_sym
|
|
909
1152
|
when :openrouter
|
|
@@ -1139,28 +1382,33 @@ module Zuno
|
|
|
1139
1382
|
end
|
|
1140
1383
|
private_class_method :callback_stopped_result
|
|
1141
1384
|
|
|
1142
|
-
def call_callback!(callback, payload, control = nil)
|
|
1385
|
+
def call_callback!(callback, payload, control = nil, context = nil)
|
|
1143
1386
|
return if callback.nil?
|
|
1144
1387
|
raise Error, "Callback must respond to #call" unless callback.respond_to?(:call)
|
|
1145
1388
|
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1389
|
+
unless callback.lambda?
|
|
1390
|
+
args = [payload]
|
|
1391
|
+
args << control if control
|
|
1392
|
+
args << context if context
|
|
1393
|
+
callback.call(*args)
|
|
1394
|
+
return
|
|
1150
1395
|
end
|
|
1396
|
+
|
|
1397
|
+
args = [payload]
|
|
1398
|
+
args << control if control && callback_accepts_argument_count?(callback, 2)
|
|
1399
|
+
args << context if context && callback_accepts_argument_count?(callback, 3)
|
|
1400
|
+
callback.call(*args)
|
|
1151
1401
|
end
|
|
1152
1402
|
private_class_method :call_callback!
|
|
1153
1403
|
|
|
1154
|
-
def
|
|
1155
|
-
return true unless callback.lambda?
|
|
1156
|
-
|
|
1404
|
+
def callback_accepts_argument_count?(callback, count)
|
|
1157
1405
|
params = callback.parameters
|
|
1158
1406
|
return true if params.any? { |param_type, _| param_type == :rest }
|
|
1159
1407
|
|
|
1160
1408
|
positional_count = params.count { |param_type, _| param_type == :req || param_type == :opt }
|
|
1161
|
-
positional_count >=
|
|
1409
|
+
positional_count >= count
|
|
1162
1410
|
end
|
|
1163
|
-
private_class_method :
|
|
1411
|
+
private_class_method :callback_accepts_argument_count?
|
|
1164
1412
|
|
|
1165
1413
|
def normalize_output_payload(payload)
|
|
1166
1414
|
case payload
|
|
@@ -1259,6 +1507,7 @@ module Zuno
|
|
|
1259
1507
|
module Providers
|
|
1260
1508
|
class OpenRouter
|
|
1261
1509
|
CHAT_COMPLETIONS_URL = "https://openrouter.ai/api/v1/chat/completions".freeze
|
|
1510
|
+
EMBEDDINGS_URL = "https://openrouter.ai/api/v1/embeddings".freeze
|
|
1262
1511
|
DEFAULT_TIMEOUT = 120_000
|
|
1263
1512
|
|
|
1264
1513
|
def initialize(api_key: nil, app_url: nil, title: nil, timeout: DEFAULT_TIMEOUT)
|
|
@@ -1295,6 +1544,23 @@ module Zuno
|
|
|
1295
1544
|
raise ProviderError, "Failed to parse OpenRouter response: #{e.message}"
|
|
1296
1545
|
end
|
|
1297
1546
|
|
|
1547
|
+
def embed(payload)
|
|
1548
|
+
response = Typhoeus.post(
|
|
1549
|
+
EMBEDDINGS_URL,
|
|
1550
|
+
headers: headers,
|
|
1551
|
+
body: JSON.generate(payload),
|
|
1552
|
+
timeout: @timeout
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
validate_response!(response)
|
|
1556
|
+
parsed = JSON.parse(response.body)
|
|
1557
|
+
raise ProviderError, "OpenRouter returned invalid JSON" unless parsed.is_a?(Hash)
|
|
1558
|
+
|
|
1559
|
+
parsed
|
|
1560
|
+
rescue JSON::ParserError => e
|
|
1561
|
+
raise ProviderError, "Failed to parse OpenRouter response: #{e.message}"
|
|
1562
|
+
end
|
|
1563
|
+
|
|
1298
1564
|
def stream(payload)
|
|
1299
1565
|
raise ArgumentError, "stream requires a block callback" unless block_given?
|
|
1300
1566
|
|
|
@@ -1394,6 +1660,23 @@ module Zuno
|
|
|
1394
1660
|
raise ProviderError, "Failed to parse Vercel Gateway response: #{e.message}"
|
|
1395
1661
|
end
|
|
1396
1662
|
|
|
1663
|
+
def embed(payload)
|
|
1664
|
+
response = Typhoeus.post(
|
|
1665
|
+
embeddings_url,
|
|
1666
|
+
headers: headers,
|
|
1667
|
+
body: JSON.generate(payload),
|
|
1668
|
+
timeout: @timeout
|
|
1669
|
+
)
|
|
1670
|
+
|
|
1671
|
+
validate_response!(response)
|
|
1672
|
+
parsed = JSON.parse(response.body)
|
|
1673
|
+
raise ProviderError, "Vercel Gateway returned invalid JSON" unless parsed.is_a?(Hash)
|
|
1674
|
+
|
|
1675
|
+
parsed
|
|
1676
|
+
rescue JSON::ParserError => e
|
|
1677
|
+
raise ProviderError, "Failed to parse Vercel Gateway response: #{e.message}"
|
|
1678
|
+
end
|
|
1679
|
+
|
|
1397
1680
|
def stream(payload)
|
|
1398
1681
|
raise ArgumentError, "stream requires a block callback" unless block_given?
|
|
1399
1682
|
|
|
@@ -1430,6 +1713,10 @@ module Zuno
|
|
|
1430
1713
|
"#{@base_url}/chat/completions"
|
|
1431
1714
|
end
|
|
1432
1715
|
|
|
1716
|
+
def embeddings_url
|
|
1717
|
+
"#{@base_url}/embeddings"
|
|
1718
|
+
end
|
|
1719
|
+
|
|
1433
1720
|
def headers
|
|
1434
1721
|
{
|
|
1435
1722
|
"Authorization" => "Bearer #{@api_key}",
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: zuno
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hyperaide
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: typhoeus
|
|
@@ -55,7 +55,7 @@ dependencies:
|
|
|
55
55
|
description: Standalone Ruby SDK for AI generation across OpenRouter and Replicate,
|
|
56
56
|
ElevenLabs speech-to-text, with iterative tool loops and SSE streaming.
|
|
57
57
|
email:
|
|
58
|
-
- team@hyperaide.
|
|
58
|
+
- team@hyperaide.com
|
|
59
59
|
executables: []
|
|
60
60
|
extensions: []
|
|
61
61
|
extra_rdoc_files: []
|
|
@@ -85,7 +85,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
85
85
|
- !ruby/object:Gem::Version
|
|
86
86
|
version: '0'
|
|
87
87
|
requirements: []
|
|
88
|
-
rubygems_version: 3.5.
|
|
88
|
+
rubygems_version: 3.5.23
|
|
89
89
|
signing_key:
|
|
90
90
|
specification_version: 4
|
|
91
91
|
summary: Ruby SDK with provider/model abstraction, single-shot generation, loops,
|