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.
Files changed (6) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +33 -1
  4. data/lib/zuno/version.rb +1 -1
  5. data/lib/zuno.rb +493 -51
  6. metadata +5 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d6d4d8dd97c7ac8743ddc66545aeda41ed824357965c693ac244fcf46de5df2
4
- data.tar.gz: d7e0a1a6029482af54dbf7bc6e5196b39dd60e424b1fb18b4003543cfbe79867
3
+ metadata.gz: cf9be3e305d36fb21db7c92926787237b8db4322a993d3e8c8a38448c439d7b4
4
+ data.tar.gz: b004707c8eb242a35c631defafd13a13f0bf3533f9959e4b8ec2c62829097337
5
5
  SHA512:
6
- metadata.gz: 4862131a38d657f175bbc488599d5cbcd97899b43bdac7a26117a437c081bfdaa365e4a2e5255e7e48464aa5e5d129e14106364727b19bae4b8166ff779cd621
7
- data.tar.gz: 96340e7d0b4a2153d58b328cbdcbec5836ddd76724941fd8efa39343de14c052a51c2031e916562e0b66de5044832a429f2332c92e4325f40d6eefc2d1e67454
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
- Callbacks can accept a second argument (`control`) and call `control.stop!(reason: "...")`.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zuno
4
- VERSION = "1.0.1"
4
+ VERSION = "1.1.0"
5
5
  end
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
- tool_map = normalize_tools(tools)
272
- llm_messages = normalize_messages(messages: messages, system: system, prompt: prompt)
273
- resolved_tool_choice = normalize_tool_choice(
274
- explicit_tool_choice: tool_choice,
275
- provider_options: resolved_provider_options,
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: llm_messages,
288
- tool_names: tool_map.keys,
289
- tool_choice: resolved_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: llm_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: llm_messages,
340
- tools: tool_map,
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
- llm_messages << build_assistant_tool_call_message(message: message, tool_calls: tool_calls)
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: tool_map,
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
- llm_messages << {
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
- iteration_index: current_iteration,
448
- iteration: iteration_record
449
- },
450
- callback_control
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
- normalized << { "role" => "system", "content" => system.to_s } if system
807
- normalized << { "role" => "user", "content" => prompt.to_s } if prompt
808
- return normalized
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 :normalize_messages
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
- if control && callback_accepts_control?(callback)
1070
- callback.call(payload, control)
1071
- else
1072
- callback.call(payload)
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 callback_accepts_control?(callback)
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 >= 2
1387
+ positional_count >= count
1085
1388
  end
1086
- private_class_method :callback_accepts_control?
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.1
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-03-31 00:00:00.000000000 Z
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.dev
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