zuno 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +33 -1
- data/lib/zuno/version.rb +1 -1
- data/lib/zuno.rb +493 -51
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf9be3e305d36fb21db7c92926787237b8db4322a993d3e8c8a38448c439d7b4
|
|
4
|
+
data.tar.gz: b004707c8eb242a35c631defafd13a13f0bf3533f9959e4b8ec2c62829097337
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4153961f2ece3493d232af7f742c40be7fa09adbf76a82fb2722923630033e778f846c7489582f1e4bc06afafdc40663dcd237c4f70f6af0a4004da6dad5b09b
|
|
7
|
+
data.tar.gz: 486f38c407011327c5a9c336341bcc742233cfda7112b8596f9f5050565791d7ab24a36777c6061fe14dbfc65126e310c9ad69f372423b221726533ab8c37fa9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.1.0](https://github.com/dqnamo/zuno/compare/v1.0.2...v1.1.0) (2026-04-04)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
* add `Zuno.embed` for OpenAI-compatible embedding providers including OpenRouter and AI Gateway
|
|
8
|
+
* add mutable loop callback context for tools, messages, and system prompt updates
|
|
9
|
+
* refresh callback, loop, tools, and API reference documentation for the split `generate` and `loop` APIs
|
|
10
|
+
|
|
11
|
+
## [1.0.2](https://github.com/dqnamo/zuno/compare/v1.0.1...v1.0.2) (2026-04-01)
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* Add ElevenLabs Scribe batch transcription via `Zuno.transcribe` and `Zuno.elevenlabs` ([ElevenLabs speech-to-text API](https://elevenlabs.io/docs/api-reference/speech-to-text/convert)).
|
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(
|
|
@@ -78,6 +160,7 @@ module Zuno
|
|
|
78
160
|
OPENROUTER_ADAPTER_CONFIG_KEYS = %i[api_key app_url title timeout].freeze
|
|
79
161
|
AI_GATEWAY_ADAPTER_CONFIG_KEYS = %i[api_key timeout base_url].freeze
|
|
80
162
|
REPLICATE_ADAPTER_CONFIG_KEYS = %i[api_key timeout].freeze
|
|
163
|
+
ELEVENLABS_ADAPTER_CONFIG_KEYS = %i[api_key timeout base_url].freeze
|
|
81
164
|
DEFAULT_MAX_ITERATIONS = 1
|
|
82
165
|
REPLICATE_PREFER_WAIT_SECONDS = 60
|
|
83
166
|
REPLICATE_POLL_INTERVAL_SECONDS = 1
|
|
@@ -126,6 +209,18 @@ module Zuno
|
|
|
126
209
|
)
|
|
127
210
|
end
|
|
128
211
|
|
|
212
|
+
def elevenlabs(
|
|
213
|
+
api_key: nil,
|
|
214
|
+
timeout: Providers::ElevenLabs::DEFAULT_TIMEOUT,
|
|
215
|
+
base_url: Providers::ElevenLabs::DEFAULT_BASE_URL
|
|
216
|
+
)
|
|
217
|
+
Providers::ElevenLabs.new(
|
|
218
|
+
api_key: api_key,
|
|
219
|
+
timeout: timeout,
|
|
220
|
+
base_url: base_url
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
129
224
|
def tool(name:, description:, input_schema:, &execute)
|
|
130
225
|
raise ToolError, "A block is required for tool '#{name}'" unless block_given?
|
|
131
226
|
|
|
@@ -137,6 +232,98 @@ module Zuno
|
|
|
137
232
|
)
|
|
138
233
|
end
|
|
139
234
|
|
|
235
|
+
def transcribe(
|
|
236
|
+
model_id: "scribe_v2",
|
|
237
|
+
file: nil,
|
|
238
|
+
cloud_storage_url: nil,
|
|
239
|
+
source_url: nil,
|
|
240
|
+
provider_options: {},
|
|
241
|
+
**options
|
|
242
|
+
)
|
|
243
|
+
validate_transcription_input!(
|
|
244
|
+
model_id: model_id,
|
|
245
|
+
file: file,
|
|
246
|
+
cloud_storage_url: cloud_storage_url,
|
|
247
|
+
source_url: source_url
|
|
248
|
+
)
|
|
249
|
+
resolved_provider_options = merge_provider_options({}, provider_options)
|
|
250
|
+
adapter = provider_adapter(:elevenlabs, resolved_provider_options)
|
|
251
|
+
response = adapter.transcribe(
|
|
252
|
+
model_id: model_id,
|
|
253
|
+
file: file,
|
|
254
|
+
cloud_storage_url: cloud_storage_url,
|
|
255
|
+
source_url: source_url,
|
|
256
|
+
options: options
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
result = {
|
|
260
|
+
text: response["text"],
|
|
261
|
+
language_code: response["language_code"],
|
|
262
|
+
language_probability: response["language_probability"],
|
|
263
|
+
words: response["words"],
|
|
264
|
+
transcripts: response["transcripts"],
|
|
265
|
+
transcription_id: response["transcription_id"],
|
|
266
|
+
raw_response: response
|
|
267
|
+
}
|
|
268
|
+
result.reject { |_key, value| value.nil? }
|
|
269
|
+
rescue ProviderError
|
|
270
|
+
raise
|
|
271
|
+
rescue StandardError => e
|
|
272
|
+
raise Error, e.message
|
|
273
|
+
end
|
|
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
|
+
|
|
140
327
|
def generate(
|
|
141
328
|
model:,
|
|
142
329
|
messages: nil,
|
|
@@ -258,6 +445,7 @@ module Zuno
|
|
|
258
445
|
after_generation: nil
|
|
259
446
|
)
|
|
260
447
|
callback_control = nil
|
|
448
|
+
callback_context = nil
|
|
261
449
|
model_descriptor = normalize_model(model)
|
|
262
450
|
unless %i[openrouter ai_gateway].include?(model_descriptor.provider.to_sym)
|
|
263
451
|
raise Error, "loop only supports openrouter or ai_gateway provider"
|
|
@@ -268,12 +456,11 @@ module Zuno
|
|
|
268
456
|
provider_options
|
|
269
457
|
)
|
|
270
458
|
adapter = provider_adapter(model_descriptor.provider, resolved_provider_options)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
tools: tool_map
|
|
459
|
+
callback_context = build_callback_context(
|
|
460
|
+
messages: messages,
|
|
461
|
+
system: system,
|
|
462
|
+
prompt: prompt,
|
|
463
|
+
tools: tools
|
|
277
464
|
)
|
|
278
465
|
resolved_stop_when = normalize_stop_when(stop_when)
|
|
279
466
|
resolved_max_iterations = normalize_max_iterations(max_iterations)
|
|
@@ -284,14 +471,20 @@ module Zuno
|
|
|
284
471
|
before_generation,
|
|
285
472
|
{
|
|
286
473
|
model: model_descriptor,
|
|
287
|
-
messages:
|
|
288
|
-
tool_names:
|
|
289
|
-
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
|
+
),
|
|
290
481
|
max_iterations: resolved_max_iterations,
|
|
291
482
|
stop_when: resolved_stop_when
|
|
292
483
|
},
|
|
293
|
-
callback_control
|
|
484
|
+
callback_control,
|
|
485
|
+
callback_context
|
|
294
486
|
)
|
|
487
|
+
normalize_callback_context!(callback_context)
|
|
295
488
|
if callback_control.stopped?
|
|
296
489
|
result = callback_stopped_result(
|
|
297
490
|
control: callback_control,
|
|
@@ -301,7 +494,7 @@ module Zuno
|
|
|
301
494
|
raw_response: nil
|
|
302
495
|
)
|
|
303
496
|
after_generation_called = true
|
|
304
|
-
call_callback!(after_generation, { ok: true, result: result }, callback_control)
|
|
497
|
+
call_callback!(after_generation, { ok: true, result: result }, callback_control, callback_context)
|
|
305
498
|
return result
|
|
306
499
|
end
|
|
307
500
|
|
|
@@ -316,10 +509,12 @@ module Zuno
|
|
|
316
509
|
before_iteration,
|
|
317
510
|
{
|
|
318
511
|
iteration_index: current_iteration,
|
|
319
|
-
messages:
|
|
512
|
+
messages: materialize_messages(messages: callback_context.messages, system: callback_context.system)
|
|
320
513
|
},
|
|
321
|
-
callback_control
|
|
514
|
+
callback_control,
|
|
515
|
+
callback_context
|
|
322
516
|
)
|
|
517
|
+
normalize_callback_context!(callback_context)
|
|
323
518
|
if callback_control.stopped?
|
|
324
519
|
result = callback_stopped_result(
|
|
325
520
|
control: callback_control,
|
|
@@ -329,15 +524,20 @@ module Zuno
|
|
|
329
524
|
raw_response: nil
|
|
330
525
|
)
|
|
331
526
|
after_generation_called = true
|
|
332
|
-
call_callback!(after_generation, { ok: true, result: result }, callback_control)
|
|
527
|
+
call_callback!(after_generation, { ok: true, result: result }, callback_control, callback_context)
|
|
333
528
|
return result
|
|
334
529
|
end
|
|
335
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
|
+
)
|
|
336
536
|
payload = build_payload(
|
|
337
537
|
model_id: model_descriptor.id,
|
|
338
538
|
provider: model_descriptor.provider,
|
|
339
|
-
messages:
|
|
340
|
-
tools:
|
|
539
|
+
messages: materialize_messages(messages: callback_context.messages, system: callback_context.system),
|
|
540
|
+
tools: callback_context.tools,
|
|
341
541
|
tool_choice: resolved_tool_choice,
|
|
342
542
|
temperature: temperature,
|
|
343
543
|
max_tokens: max_tokens,
|
|
@@ -365,8 +565,10 @@ module Zuno
|
|
|
365
565
|
iteration_index: current_iteration,
|
|
366
566
|
iteration: iteration_record
|
|
367
567
|
},
|
|
368
|
-
callback_control
|
|
568
|
+
callback_control,
|
|
569
|
+
callback_context
|
|
369
570
|
)
|
|
571
|
+
normalize_callback_context!(callback_context)
|
|
370
572
|
if callback_control.stopped?
|
|
371
573
|
result = callback_stopped_result(
|
|
372
574
|
control: callback_control,
|
|
@@ -376,7 +578,7 @@ module Zuno
|
|
|
376
578
|
raw_response: response
|
|
377
579
|
)
|
|
378
580
|
after_generation_called = true
|
|
379
|
-
call_callback!(after_generation, { ok: true, result: result }, callback_control)
|
|
581
|
+
call_callback!(after_generation, { ok: true, result: result }, callback_control, callback_context)
|
|
380
582
|
return result
|
|
381
583
|
end
|
|
382
584
|
|
|
@@ -390,11 +592,11 @@ module Zuno
|
|
|
390
592
|
}
|
|
391
593
|
|
|
392
594
|
after_generation_called = true
|
|
393
|
-
call_callback!(after_generation, { ok: true, result: result }, callback_control)
|
|
595
|
+
call_callback!(after_generation, { ok: true, result: result }, callback_control, callback_context)
|
|
394
596
|
return result
|
|
395
597
|
end
|
|
396
598
|
|
|
397
|
-
|
|
599
|
+
callback_context.add_message(build_assistant_tool_call_message(message: message, tool_calls: tool_calls))
|
|
398
600
|
stop_triggered = false
|
|
399
601
|
stop_triggered_tool_name = nil
|
|
400
602
|
|
|
@@ -412,12 +614,14 @@ module Zuno
|
|
|
412
614
|
input: arguments,
|
|
413
615
|
raw_tool_call: tool_call
|
|
414
616
|
},
|
|
415
|
-
callback_control
|
|
617
|
+
callback_control,
|
|
618
|
+
callback_context
|
|
416
619
|
)
|
|
620
|
+
normalize_callback_context!(callback_context)
|
|
417
621
|
|
|
418
622
|
tool_result = execute_tool_call(
|
|
419
623
|
tool_call: tool_call,
|
|
420
|
-
tools:
|
|
624
|
+
tools: callback_context.tools,
|
|
421
625
|
tool_call_id: tool_call_id,
|
|
422
626
|
arguments: arguments
|
|
423
627
|
)
|
|
@@ -426,29 +630,33 @@ module Zuno
|
|
|
426
630
|
call_callback!(
|
|
427
631
|
after_tool_execution,
|
|
428
632
|
tool_result.merge(iteration_index: current_iteration),
|
|
429
|
-
callback_control
|
|
633
|
+
callback_control,
|
|
634
|
+
callback_context
|
|
430
635
|
)
|
|
636
|
+
normalize_callback_context!(callback_context)
|
|
431
637
|
if tool_stop_condition_met?(resolved_stop_when, tool_result)
|
|
432
638
|
stop_triggered = true
|
|
433
639
|
stop_triggered_tool_name ||= tool_result[:tool_name]
|
|
434
640
|
end
|
|
435
641
|
|
|
436
|
-
|
|
642
|
+
callback_context.add_message(
|
|
437
643
|
"role" => "tool",
|
|
438
644
|
"tool_call_id" => tool_result[:tool_call_id],
|
|
439
645
|
"content" => serialize_tool_content(tool_result[:output])
|
|
440
|
-
|
|
646
|
+
)
|
|
441
647
|
end
|
|
442
648
|
|
|
443
649
|
iterations << iteration_record
|
|
444
650
|
call_callback!(
|
|
445
651
|
after_iteration,
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
652
|
+
{
|
|
653
|
+
iteration_index: current_iteration,
|
|
654
|
+
iteration: iteration_record
|
|
655
|
+
},
|
|
656
|
+
callback_control,
|
|
657
|
+
callback_context
|
|
658
|
+
)
|
|
659
|
+
normalize_callback_context!(callback_context)
|
|
452
660
|
if callback_control.stopped?
|
|
453
661
|
result = callback_stopped_result(
|
|
454
662
|
control: callback_control,
|
|
@@ -458,7 +666,7 @@ module Zuno
|
|
|
458
666
|
raw_response: response
|
|
459
667
|
)
|
|
460
668
|
after_generation_called = true
|
|
461
|
-
call_callback!(after_generation, { ok: true, result: result }, callback_control)
|
|
669
|
+
call_callback!(after_generation, { ok: true, result: result }, callback_control, callback_context)
|
|
462
670
|
return result
|
|
463
671
|
end
|
|
464
672
|
|
|
@@ -477,7 +685,7 @@ module Zuno
|
|
|
477
685
|
}
|
|
478
686
|
|
|
479
687
|
after_generation_called = true
|
|
480
|
-
call_callback!(after_generation, { ok: true, result: result }, callback_control)
|
|
688
|
+
call_callback!(after_generation, { ok: true, result: result }, callback_control, callback_context)
|
|
481
689
|
return result
|
|
482
690
|
end
|
|
483
691
|
|
|
@@ -489,13 +697,13 @@ module Zuno
|
|
|
489
697
|
rescue ProviderError, MaxIterationsExceeded => e
|
|
490
698
|
unless after_generation_called
|
|
491
699
|
after_generation_called = true
|
|
492
|
-
call_callback!(after_generation, { ok: false, error: e }, callback_control)
|
|
700
|
+
call_callback!(after_generation, { ok: false, error: e }, callback_control, callback_context)
|
|
493
701
|
end
|
|
494
702
|
raise
|
|
495
703
|
rescue StandardError => e
|
|
496
704
|
unless after_generation_called
|
|
497
705
|
after_generation_called = true
|
|
498
|
-
call_callback!(after_generation, { ok: false, error: e }, callback_control)
|
|
706
|
+
call_callback!(after_generation, { ok: false, error: e }, callback_control, callback_context)
|
|
499
707
|
end
|
|
500
708
|
raise Error, e.message
|
|
501
709
|
end
|
|
@@ -763,6 +971,53 @@ module Zuno
|
|
|
763
971
|
end
|
|
764
972
|
private_class_method :validate_no_webhook_support!
|
|
765
973
|
|
|
974
|
+
def validate_transcription_input!(model_id:, file:, cloud_storage_url:, source_url:)
|
|
975
|
+
model_id_value = model_id.to_s.strip
|
|
976
|
+
raise Error, "model_id is required" if model_id_value.empty?
|
|
977
|
+
|
|
978
|
+
inputs = []
|
|
979
|
+
inputs << :file unless file.nil?
|
|
980
|
+
inputs << :cloud_storage_url unless cloud_storage_url.nil? || cloud_storage_url.to_s.strip.empty?
|
|
981
|
+
inputs << :source_url unless source_url.nil? || source_url.to_s.strip.empty?
|
|
982
|
+
|
|
983
|
+
if inputs.empty?
|
|
984
|
+
raise Error, "transcribe requires one input: file, cloud_storage_url, or source_url"
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
return if inputs.length == 1
|
|
988
|
+
|
|
989
|
+
raise Error, "transcribe accepts exactly one input: file, cloud_storage_url, or source_url"
|
|
990
|
+
end
|
|
991
|
+
private_class_method :validate_transcription_input!
|
|
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
|
+
|
|
766
1021
|
def normalize_tools(tools)
|
|
767
1022
|
return {} if tools.nil?
|
|
768
1023
|
|
|
@@ -801,19 +1056,57 @@ module Zuno
|
|
|
801
1056
|
private_class_method :normalize_tool_entry
|
|
802
1057
|
|
|
803
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:)
|
|
804
1084
|
if messages.nil? || messages.empty?
|
|
805
1085
|
normalized = []
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1086
|
+
else
|
|
1087
|
+
raise Error, "messages must be an Array" unless messages.is_a?(Array)
|
|
1088
|
+
|
|
1089
|
+
normalized = deep_stringify(messages)
|
|
809
1090
|
end
|
|
810
1091
|
|
|
811
|
-
normalized = deep_stringify(messages)
|
|
812
|
-
normalized.unshift({ "role" => "system", "content" => system.to_s }) if system
|
|
813
1092
|
normalized << { "role" => "user", "content" => prompt.to_s } if prompt
|
|
814
1093
|
normalized
|
|
815
1094
|
end
|
|
816
|
-
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
|
|
817
1110
|
|
|
818
1111
|
def build_payload(model_id:, provider:, messages:, tools:, tool_choice:, temperature:, max_tokens:, provider_options:)
|
|
819
1112
|
payload = {
|
|
@@ -840,6 +1133,8 @@ module Zuno
|
|
|
840
1133
|
AI_GATEWAY_ADAPTER_CONFIG_KEYS
|
|
841
1134
|
when :replicate
|
|
842
1135
|
REPLICATE_ADAPTER_CONFIG_KEYS
|
|
1136
|
+
when :elevenlabs
|
|
1137
|
+
ELEVENLABS_ADAPTER_CONFIG_KEYS
|
|
843
1138
|
else
|
|
844
1139
|
[]
|
|
845
1140
|
end
|
|
@@ -866,6 +1161,9 @@ module Zuno
|
|
|
866
1161
|
when :replicate
|
|
867
1162
|
config = pick_keys(provider_options, REPLICATE_ADAPTER_CONFIG_KEYS)
|
|
868
1163
|
Providers::Replicate.new(**config)
|
|
1164
|
+
when :elevenlabs
|
|
1165
|
+
config = pick_keys(provider_options, ELEVENLABS_ADAPTER_CONFIG_KEYS)
|
|
1166
|
+
Providers::ElevenLabs.new(**config)
|
|
869
1167
|
else
|
|
870
1168
|
raise ProviderError, "Unsupported provider: #{provider}"
|
|
871
1169
|
end
|
|
@@ -1062,28 +1360,33 @@ module Zuno
|
|
|
1062
1360
|
end
|
|
1063
1361
|
private_class_method :callback_stopped_result
|
|
1064
1362
|
|
|
1065
|
-
def call_callback!(callback, payload, control = nil)
|
|
1363
|
+
def call_callback!(callback, payload, control = nil, context = nil)
|
|
1066
1364
|
return if callback.nil?
|
|
1067
1365
|
raise Error, "Callback must respond to #call" unless callback.respond_to?(:call)
|
|
1068
1366
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1367
|
+
unless callback.lambda?
|
|
1368
|
+
args = [payload]
|
|
1369
|
+
args << control if control
|
|
1370
|
+
args << context if context
|
|
1371
|
+
callback.call(*args)
|
|
1372
|
+
return
|
|
1073
1373
|
end
|
|
1374
|
+
|
|
1375
|
+
args = [payload]
|
|
1376
|
+
args << control if control && callback_accepts_argument_count?(callback, 2)
|
|
1377
|
+
args << context if context && callback_accepts_argument_count?(callback, 3)
|
|
1378
|
+
callback.call(*args)
|
|
1074
1379
|
end
|
|
1075
1380
|
private_class_method :call_callback!
|
|
1076
1381
|
|
|
1077
|
-
def
|
|
1078
|
-
return true unless callback.lambda?
|
|
1079
|
-
|
|
1382
|
+
def callback_accepts_argument_count?(callback, count)
|
|
1080
1383
|
params = callback.parameters
|
|
1081
1384
|
return true if params.any? { |param_type, _| param_type == :rest }
|
|
1082
1385
|
|
|
1083
1386
|
positional_count = params.count { |param_type, _| param_type == :req || param_type == :opt }
|
|
1084
|
-
positional_count >=
|
|
1387
|
+
positional_count >= count
|
|
1085
1388
|
end
|
|
1086
|
-
private_class_method :
|
|
1389
|
+
private_class_method :callback_accepts_argument_count?
|
|
1087
1390
|
|
|
1088
1391
|
def normalize_output_payload(payload)
|
|
1089
1392
|
case payload
|
|
@@ -1182,6 +1485,7 @@ module Zuno
|
|
|
1182
1485
|
module Providers
|
|
1183
1486
|
class OpenRouter
|
|
1184
1487
|
CHAT_COMPLETIONS_URL = "https://openrouter.ai/api/v1/chat/completions".freeze
|
|
1488
|
+
EMBEDDINGS_URL = "https://openrouter.ai/api/v1/embeddings".freeze
|
|
1185
1489
|
DEFAULT_TIMEOUT = 120_000
|
|
1186
1490
|
|
|
1187
1491
|
def initialize(api_key: nil, app_url: nil, title: nil, timeout: DEFAULT_TIMEOUT)
|
|
@@ -1218,6 +1522,23 @@ module Zuno
|
|
|
1218
1522
|
raise ProviderError, "Failed to parse OpenRouter response: #{e.message}"
|
|
1219
1523
|
end
|
|
1220
1524
|
|
|
1525
|
+
def embed(payload)
|
|
1526
|
+
response = Typhoeus.post(
|
|
1527
|
+
EMBEDDINGS_URL,
|
|
1528
|
+
headers: headers,
|
|
1529
|
+
body: JSON.generate(payload),
|
|
1530
|
+
timeout: @timeout
|
|
1531
|
+
)
|
|
1532
|
+
|
|
1533
|
+
validate_response!(response)
|
|
1534
|
+
parsed = JSON.parse(response.body)
|
|
1535
|
+
raise ProviderError, "OpenRouter returned invalid JSON" unless parsed.is_a?(Hash)
|
|
1536
|
+
|
|
1537
|
+
parsed
|
|
1538
|
+
rescue JSON::ParserError => e
|
|
1539
|
+
raise ProviderError, "Failed to parse OpenRouter response: #{e.message}"
|
|
1540
|
+
end
|
|
1541
|
+
|
|
1221
1542
|
def stream(payload)
|
|
1222
1543
|
raise ArgumentError, "stream requires a block callback" unless block_given?
|
|
1223
1544
|
|
|
@@ -1317,6 +1638,23 @@ module Zuno
|
|
|
1317
1638
|
raise ProviderError, "Failed to parse Vercel Gateway response: #{e.message}"
|
|
1318
1639
|
end
|
|
1319
1640
|
|
|
1641
|
+
def embed(payload)
|
|
1642
|
+
response = Typhoeus.post(
|
|
1643
|
+
embeddings_url,
|
|
1644
|
+
headers: headers,
|
|
1645
|
+
body: JSON.generate(payload),
|
|
1646
|
+
timeout: @timeout
|
|
1647
|
+
)
|
|
1648
|
+
|
|
1649
|
+
validate_response!(response)
|
|
1650
|
+
parsed = JSON.parse(response.body)
|
|
1651
|
+
raise ProviderError, "Vercel Gateway returned invalid JSON" unless parsed.is_a?(Hash)
|
|
1652
|
+
|
|
1653
|
+
parsed
|
|
1654
|
+
rescue JSON::ParserError => e
|
|
1655
|
+
raise ProviderError, "Failed to parse Vercel Gateway response: #{e.message}"
|
|
1656
|
+
end
|
|
1657
|
+
|
|
1320
1658
|
def stream(payload)
|
|
1321
1659
|
raise ArgumentError, "stream requires a block callback" unless block_given?
|
|
1322
1660
|
|
|
@@ -1353,6 +1691,10 @@ module Zuno
|
|
|
1353
1691
|
"#{@base_url}/chat/completions"
|
|
1354
1692
|
end
|
|
1355
1693
|
|
|
1694
|
+
def embeddings_url
|
|
1695
|
+
"#{@base_url}/embeddings"
|
|
1696
|
+
end
|
|
1697
|
+
|
|
1356
1698
|
def headers
|
|
1357
1699
|
{
|
|
1358
1700
|
"Authorization" => "Bearer #{@api_key}",
|
|
@@ -1506,6 +1848,106 @@ module Zuno
|
|
|
1506
1848
|
raise ProviderError, "Replicate request failed: #{response.return_code}#{suffix}"
|
|
1507
1849
|
end
|
|
1508
1850
|
end
|
|
1851
|
+
|
|
1852
|
+
class ElevenLabs
|
|
1853
|
+
DEFAULT_BASE_URL = "https://api.elevenlabs.io".freeze
|
|
1854
|
+
SPEECH_TO_TEXT_PATH = "/v1/speech-to-text".freeze
|
|
1855
|
+
DEFAULT_TIMEOUT = 120_000
|
|
1856
|
+
|
|
1857
|
+
def initialize(api_key: nil, timeout: DEFAULT_TIMEOUT, base_url: DEFAULT_BASE_URL)
|
|
1858
|
+
@api_key = api_key
|
|
1859
|
+
raise ProviderError, "ElevenLabs API key not configured" if @api_key.nil? || @api_key.to_s.empty?
|
|
1860
|
+
|
|
1861
|
+
@timeout = timeout
|
|
1862
|
+
@base_url = base_url.to_s.empty? ? DEFAULT_BASE_URL : base_url.to_s
|
|
1863
|
+
end
|
|
1864
|
+
|
|
1865
|
+
def transcribe(model_id:, file:, cloud_storage_url:, source_url:, options:)
|
|
1866
|
+
opened_file = nil
|
|
1867
|
+
body = build_body(
|
|
1868
|
+
model_id: model_id,
|
|
1869
|
+
file: file,
|
|
1870
|
+
cloud_storage_url: cloud_storage_url,
|
|
1871
|
+
source_url: source_url,
|
|
1872
|
+
options: options
|
|
1873
|
+
) do |candidate|
|
|
1874
|
+
opened_file = candidate
|
|
1875
|
+
end
|
|
1876
|
+
|
|
1877
|
+
response = Typhoeus.post(
|
|
1878
|
+
speech_to_text_url,
|
|
1879
|
+
headers: headers,
|
|
1880
|
+
body: body,
|
|
1881
|
+
multipart: true,
|
|
1882
|
+
timeout: @timeout
|
|
1883
|
+
)
|
|
1884
|
+
|
|
1885
|
+
validate_response!(response)
|
|
1886
|
+
parsed = JSON.parse(response.body)
|
|
1887
|
+
raise ProviderError, "ElevenLabs returned invalid JSON" unless parsed.is_a?(Hash)
|
|
1888
|
+
|
|
1889
|
+
parsed
|
|
1890
|
+
rescue JSON::ParserError => e
|
|
1891
|
+
raise ProviderError, "Failed to parse ElevenLabs response: #{e.message}"
|
|
1892
|
+
ensure
|
|
1893
|
+
opened_file.close if opened_file.is_a?(File) && !opened_file.closed?
|
|
1894
|
+
end
|
|
1895
|
+
|
|
1896
|
+
private
|
|
1897
|
+
|
|
1898
|
+
def speech_to_text_url
|
|
1899
|
+
"#{@base_url}#{SPEECH_TO_TEXT_PATH}"
|
|
1900
|
+
end
|
|
1901
|
+
|
|
1902
|
+
def headers
|
|
1903
|
+
{ "xi-api-key" => @api_key }
|
|
1904
|
+
end
|
|
1905
|
+
|
|
1906
|
+
def build_body(model_id:, file:, cloud_storage_url:, source_url:, options:)
|
|
1907
|
+
body = {
|
|
1908
|
+
"model_id" => model_id.to_s
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
normalized_options = options.is_a?(Hash) ? options : {}
|
|
1912
|
+
normalized_options.each do |key, value|
|
|
1913
|
+
next if value.nil?
|
|
1914
|
+
|
|
1915
|
+
body[key.to_s] = value
|
|
1916
|
+
end
|
|
1917
|
+
|
|
1918
|
+
if file.is_a?(String)
|
|
1919
|
+
opened_file = File.open(file, "rb")
|
|
1920
|
+
body["file"] = opened_file
|
|
1921
|
+
yield(opened_file) if block_given?
|
|
1922
|
+
elsif !file.nil?
|
|
1923
|
+
body["file"] = file
|
|
1924
|
+
end
|
|
1925
|
+
|
|
1926
|
+
body["cloud_storage_url"] = cloud_storage_url.to_s unless cloud_storage_url.nil? || cloud_storage_url.to_s.strip.empty?
|
|
1927
|
+
body["source_url"] = source_url.to_s unless source_url.nil? || source_url.to_s.strip.empty?
|
|
1928
|
+
body
|
|
1929
|
+
rescue Errno::ENOENT => e
|
|
1930
|
+
raise ProviderError, "Failed to open transcription file: #{e.message}"
|
|
1931
|
+
end
|
|
1932
|
+
|
|
1933
|
+
def validate_response!(response)
|
|
1934
|
+
raise ProviderError, "No response returned from ElevenLabs" if response.nil?
|
|
1935
|
+
raise ProviderError, "ElevenLabs request timed out" if response.timed_out?
|
|
1936
|
+
|
|
1937
|
+
status = response.code.to_i
|
|
1938
|
+
body = response.body.to_s
|
|
1939
|
+
message = body.length > 300 ? "#{body[0, 300]}..." : body
|
|
1940
|
+
|
|
1941
|
+
return if status >= 200 && status < 300
|
|
1942
|
+
|
|
1943
|
+
if status.positive?
|
|
1944
|
+
raise ProviderError, "ElevenLabs responded with HTTP #{status}: #{message}"
|
|
1945
|
+
end
|
|
1946
|
+
|
|
1947
|
+
suffix = message.empty? ? "" : ": #{message}"
|
|
1948
|
+
raise ProviderError, "ElevenLabs request failed: #{response.return_code}#{suffix}"
|
|
1949
|
+
end
|
|
1950
|
+
end
|
|
1509
1951
|
end
|
|
1510
1952
|
|
|
1511
1953
|
class SseParser
|
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.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hyperaide
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-04-05 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: typhoeus
|
|
@@ -53,13 +53,14 @@ dependencies:
|
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
54
|
version: '3.13'
|
|
55
55
|
description: Standalone Ruby SDK for AI generation across OpenRouter and Replicate,
|
|
56
|
-
with iterative tool loops and SSE streaming.
|
|
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: []
|
|
62
62
|
files:
|
|
63
|
+
- CHANGELOG.md
|
|
63
64
|
- README.md
|
|
64
65
|
- lib/zuno.rb
|
|
65
66
|
- lib/zuno/version.rb
|