zuno 1.0.0 → 1.0.2

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 (5) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/lib/zuno/version.rb +1 -1
  4. data/lib/zuno.rb +318 -5
  5. metadata +4 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1dea919bc404d8bafb4e970ffd4d2d418d5ea38123bc78abc4668fc72ce985d
4
- data.tar.gz: 4953724ce6061eb7641d7e4c5ee7564204d4647968822fd8d88303b481cf09c1
3
+ metadata.gz: 375f446f6d41ed1c8f05361915316c3da7fbc571ee5ec4d59316739f9fd6a14f
4
+ data.tar.gz: 641e201f64dc3076185e2a6593932e67847f05ce4c5a66866b200b8db4d38241
5
5
  SHA512:
6
- metadata.gz: 16d264d3143fca0a55bb4a2ed1ad4cc08e0f8dbbb6effdf8ed75c7425b5087fd1a8763135117bcc3b1ea8cf9061a579fa95b21e813b81d90c7de1076978d3a32
7
- data.tar.gz: 8f19a868f736ec65ddf5484affe0a538a54690fc420c234afa7ead82f6943040441bce2336b334becb14fedad7babaaec03025854bf7e638ee31330f4eadddbb
6
+ metadata.gz: 76d4be87dad8f35fc9e879a14705035344823f43c0ba45c6d8317d5b8e0a273c6349db11294b8e17c5dfb901dec9006fa488b8ebe8633baec33aa66e74be9166
7
+ data.tar.gz: 8555df550de0513330c086ec4b676d584a1908feb983d2ca59d32c8d685f626d2c767960e6e1dde23dbb17ac0cfc903aee251bef71519ffde27f8c1524cd2d68
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [1.0.2](https://github.com/dqnamo/zuno/compare/v1.0.1...v1.0.2) (2026-04-01)
4
+
5
+ ### Features
6
+
7
+ * 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/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.0"
4
+ VERSION = "1.0.2"
5
5
  end
data/lib/zuno.rb CHANGED
@@ -76,7 +76,9 @@ module Zuno
76
76
  end
77
77
 
78
78
  OPENROUTER_ADAPTER_CONFIG_KEYS = %i[api_key app_url title timeout].freeze
79
+ AI_GATEWAY_ADAPTER_CONFIG_KEYS = %i[api_key timeout base_url].freeze
79
80
  REPLICATE_ADAPTER_CONFIG_KEYS = %i[api_key timeout].freeze
81
+ ELEVENLABS_ADAPTER_CONFIG_KEYS = %i[api_key timeout base_url].freeze
80
82
  DEFAULT_MAX_ITERATIONS = 1
81
83
  REPLICATE_PREFER_WAIT_SECONDS = 60
82
84
  REPLICATE_POLL_INTERVAL_SECONDS = 1
@@ -113,6 +115,30 @@ module Zuno
113
115
  )
114
116
  end
115
117
 
118
+ def ai_gateway(
119
+ api_key: nil,
120
+ timeout: Providers::AIGateway::DEFAULT_TIMEOUT,
121
+ base_url: Providers::AIGateway::DEFAULT_BASE_URL
122
+ )
123
+ Providers::AIGateway.new(
124
+ api_key: api_key,
125
+ timeout: timeout,
126
+ base_url: base_url
127
+ )
128
+ end
129
+
130
+ def elevenlabs(
131
+ api_key: nil,
132
+ timeout: Providers::ElevenLabs::DEFAULT_TIMEOUT,
133
+ base_url: Providers::ElevenLabs::DEFAULT_BASE_URL
134
+ )
135
+ Providers::ElevenLabs.new(
136
+ api_key: api_key,
137
+ timeout: timeout,
138
+ base_url: base_url
139
+ )
140
+ end
141
+
116
142
  def tool(name:, description:, input_schema:, &execute)
117
143
  raise ToolError, "A block is required for tool '#{name}'" unless block_given?
118
144
 
@@ -124,6 +150,46 @@ module Zuno
124
150
  )
125
151
  end
126
152
 
153
+ def transcribe(
154
+ model_id: "scribe_v2",
155
+ file: nil,
156
+ cloud_storage_url: nil,
157
+ source_url: nil,
158
+ provider_options: {},
159
+ **options
160
+ )
161
+ validate_transcription_input!(
162
+ model_id: model_id,
163
+ file: file,
164
+ cloud_storage_url: cloud_storage_url,
165
+ source_url: source_url
166
+ )
167
+ resolved_provider_options = merge_provider_options({}, provider_options)
168
+ adapter = provider_adapter(:elevenlabs, resolved_provider_options)
169
+ response = adapter.transcribe(
170
+ model_id: model_id,
171
+ file: file,
172
+ cloud_storage_url: cloud_storage_url,
173
+ source_url: source_url,
174
+ options: options
175
+ )
176
+
177
+ result = {
178
+ text: response["text"],
179
+ language_code: response["language_code"],
180
+ language_probability: response["language_probability"],
181
+ words: response["words"],
182
+ transcripts: response["transcripts"],
183
+ transcription_id: response["transcription_id"],
184
+ raw_response: response
185
+ }
186
+ result.reject { |_key, value| value.nil? }
187
+ rescue ProviderError
188
+ raise
189
+ rescue StandardError => e
190
+ raise Error, e.message
191
+ end
192
+
127
193
  def generate(
128
194
  model:,
129
195
  messages: nil,
@@ -176,7 +242,7 @@ module Zuno
176
242
 
177
243
  result =
178
244
  case provider
179
- when :openrouter
245
+ when :openrouter, :ai_gateway
180
246
  adapter = provider_adapter(provider, resolved_provider_options)
181
247
  generate_openrouter_single(
182
248
  model_descriptor: model_descriptor,
@@ -246,7 +312,9 @@ module Zuno
246
312
  )
247
313
  callback_control = nil
248
314
  model_descriptor = normalize_model(model)
249
- raise Error, "loop only supports openrouter provider" unless model_descriptor.provider.to_sym == :openrouter
315
+ unless %i[openrouter ai_gateway].include?(model_descriptor.provider.to_sym)
316
+ raise Error, "loop only supports openrouter or ai_gateway provider"
317
+ end
250
318
 
251
319
  resolved_provider_options = merge_provider_options(
252
320
  model_descriptor.provider_options,
@@ -320,6 +388,7 @@ module Zuno
320
388
 
321
389
  payload = build_payload(
322
390
  model_id: model_descriptor.id,
391
+ provider: model_descriptor.provider,
323
392
  messages: llm_messages,
324
393
  tools: tool_map,
325
394
  tool_choice: resolved_tool_choice,
@@ -497,7 +566,9 @@ module Zuno
497
566
  raise ArgumentError, "stream requires a block callback" unless block_given?
498
567
 
499
568
  model_descriptor = normalize_model(model)
500
- raise ProviderError, "stream only supports openrouter provider" unless model_descriptor.provider.to_sym == :openrouter
569
+ unless %i[openrouter ai_gateway].include?(model_descriptor.provider.to_sym)
570
+ raise ProviderError, "stream only supports openrouter or ai_gateway provider"
571
+ end
501
572
 
502
573
  resolved_provider_options = merge_provider_options(
503
574
  model_descriptor.provider_options,
@@ -508,6 +579,7 @@ module Zuno
508
579
 
509
580
  payload = build_payload(
510
581
  model_id: model_descriptor.id,
582
+ provider: model_descriptor.provider,
511
583
  messages: llm_messages,
512
584
  tools: {},
513
585
  tool_choice: nil,
@@ -608,6 +680,7 @@ module Zuno
608
680
 
609
681
  payload = build_payload(
610
682
  model_id: model_descriptor.id,
683
+ provider: model_descriptor.provider,
611
684
  messages: llm_messages,
612
685
  tools: tool_map,
613
686
  tool_choice: resolved_tool_choice,
@@ -743,6 +816,25 @@ module Zuno
743
816
  end
744
817
  private_class_method :validate_no_webhook_support!
745
818
 
819
+ def validate_transcription_input!(model_id:, file:, cloud_storage_url:, source_url:)
820
+ model_id_value = model_id.to_s.strip
821
+ raise Error, "model_id is required" if model_id_value.empty?
822
+
823
+ inputs = []
824
+ inputs << :file unless file.nil?
825
+ inputs << :cloud_storage_url unless cloud_storage_url.nil? || cloud_storage_url.to_s.strip.empty?
826
+ inputs << :source_url unless source_url.nil? || source_url.to_s.strip.empty?
827
+
828
+ if inputs.empty?
829
+ raise Error, "transcribe requires one input: file, cloud_storage_url, or source_url"
830
+ end
831
+
832
+ return if inputs.length == 1
833
+
834
+ raise Error, "transcribe accepts exactly one input: file, cloud_storage_url, or source_url"
835
+ end
836
+ private_class_method :validate_transcription_input!
837
+
746
838
  def normalize_tools(tools)
747
839
  return {} if tools.nil?
748
840
 
@@ -795,7 +887,7 @@ module Zuno
795
887
  end
796
888
  private_class_method :normalize_messages
797
889
 
798
- def build_payload(model_id:, messages:, tools:, tool_choice:, temperature:, max_tokens:, provider_options:)
890
+ def build_payload(model_id:, provider:, messages:, tools:, tool_choice:, temperature:, max_tokens:, provider_options:)
799
891
  payload = {
800
892
  "model" => model_id,
801
893
  "messages" => messages
@@ -806,12 +898,28 @@ module Zuno
806
898
  payload["tools"] = tools.values.map(&:as_provider_tool) unless tools.empty?
807
899
  payload["tool_choice"] = deep_stringify(tool_choice) unless tool_choice.nil?
808
900
 
809
- request_options = reject_keys(provider_options, OPENROUTER_ADAPTER_CONFIG_KEYS + [ :tool_choice ])
901
+ request_options = reject_keys(provider_options, provider_adapter_config_keys(provider) + [ :tool_choice ])
810
902
  payload.merge!(deep_stringify(request_options)) if request_options.is_a?(Hash)
811
903
  payload
812
904
  end
813
905
  private_class_method :build_payload
814
906
 
907
+ def provider_adapter_config_keys(provider)
908
+ case provider.to_sym
909
+ when :openrouter
910
+ OPENROUTER_ADAPTER_CONFIG_KEYS
911
+ when :ai_gateway
912
+ AI_GATEWAY_ADAPTER_CONFIG_KEYS
913
+ when :replicate
914
+ REPLICATE_ADAPTER_CONFIG_KEYS
915
+ when :elevenlabs
916
+ ELEVENLABS_ADAPTER_CONFIG_KEYS
917
+ else
918
+ []
919
+ end
920
+ end
921
+ private_class_method :provider_adapter_config_keys
922
+
815
923
  def merge_provider_options(model_provider_options, call_provider_options)
816
924
  merged = {}
817
925
  merged.merge!(default_provider_options) if default_provider_options.is_a?(Hash)
@@ -826,9 +934,15 @@ module Zuno
826
934
  when :openrouter
827
935
  config = pick_keys(provider_options, OPENROUTER_ADAPTER_CONFIG_KEYS)
828
936
  Providers::OpenRouter.new(**config)
937
+ when :ai_gateway
938
+ config = pick_keys(provider_options, AI_GATEWAY_ADAPTER_CONFIG_KEYS)
939
+ Providers::AIGateway.new(**config)
829
940
  when :replicate
830
941
  config = pick_keys(provider_options, REPLICATE_ADAPTER_CONFIG_KEYS)
831
942
  Providers::Replicate.new(**config)
943
+ when :elevenlabs
944
+ config = pick_keys(provider_options, ELEVENLABS_ADAPTER_CONFIG_KEYS)
945
+ Providers::ElevenLabs.new(**config)
832
946
  else
833
947
  raise ProviderError, "Unsupported provider: #{provider}"
834
948
  end
@@ -1243,6 +1357,105 @@ module Zuno
1243
1357
 
1244
1358
  end
1245
1359
 
1360
+ class AIGateway
1361
+ DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1".freeze
1362
+ DEFAULT_TIMEOUT = 120_000
1363
+
1364
+ def initialize(api_key: nil, timeout: DEFAULT_TIMEOUT, base_url: DEFAULT_BASE_URL)
1365
+ @api_key = api_key
1366
+ raise ProviderError, "Vercel Gateway API key not configured" if @api_key.nil? || @api_key.to_s.empty?
1367
+
1368
+ @timeout = timeout
1369
+ @base_url = base_url.to_s.empty? ? DEFAULT_BASE_URL : base_url.to_s
1370
+ end
1371
+
1372
+ def model(model_id)
1373
+ ModelDescriptor.new(
1374
+ id: model_id,
1375
+ provider: :ai_gateway,
1376
+ provider_options: provider_options
1377
+ )
1378
+ end
1379
+
1380
+ def chat(payload)
1381
+ response = Typhoeus.post(
1382
+ chat_completions_url,
1383
+ headers: headers,
1384
+ body: JSON.generate(payload),
1385
+ timeout: @timeout
1386
+ )
1387
+
1388
+ validate_response!(response)
1389
+ parsed = JSON.parse(response.body)
1390
+ raise ProviderError, "Vercel Gateway returned invalid JSON" unless parsed.is_a?(Hash)
1391
+
1392
+ parsed
1393
+ rescue JSON::ParserError => e
1394
+ raise ProviderError, "Failed to parse Vercel Gateway response: #{e.message}"
1395
+ end
1396
+
1397
+ def stream(payload)
1398
+ raise ArgumentError, "stream requires a block callback" unless block_given?
1399
+
1400
+ request = Typhoeus::Request.new(
1401
+ chat_completions_url,
1402
+ method: :post,
1403
+ headers: headers,
1404
+ body: JSON.generate(payload),
1405
+ timeout: @timeout
1406
+ )
1407
+
1408
+ parser = SseParser.new { |data| yield(data) }
1409
+ request.on_body do |chunk|
1410
+ parser.push(chunk)
1411
+ nil
1412
+ end
1413
+
1414
+ request.run
1415
+ validate_response!(request.response)
1416
+ parser.flush
1417
+ end
1418
+
1419
+ private
1420
+
1421
+ def provider_options
1422
+ {
1423
+ api_key: @api_key,
1424
+ timeout: @timeout,
1425
+ base_url: @base_url
1426
+ }
1427
+ end
1428
+
1429
+ def chat_completions_url
1430
+ "#{@base_url}/chat/completions"
1431
+ end
1432
+
1433
+ def headers
1434
+ {
1435
+ "Authorization" => "Bearer #{@api_key}",
1436
+ "Content-Type" => "application/json"
1437
+ }
1438
+ end
1439
+
1440
+ def validate_response!(response)
1441
+ raise ProviderError, "No response returned from Vercel Gateway" if response.nil?
1442
+ raise ProviderError, "Vercel Gateway request timed out" if response.timed_out?
1443
+
1444
+ status = response.code.to_i
1445
+ body = response.body.to_s
1446
+ message = body.length > 300 ? "#{body[0, 300]}..." : body
1447
+
1448
+ return if status >= 200 && status < 300
1449
+
1450
+ if status.positive?
1451
+ raise ProviderError, "Vercel Gateway responded with HTTP #{status}: #{message}"
1452
+ end
1453
+
1454
+ suffix = message.empty? ? "" : ": #{message}"
1455
+ raise ProviderError, "Vercel Gateway request failed: #{response.return_code}#{suffix}"
1456
+ end
1457
+ end
1458
+
1246
1459
  class Replicate
1247
1460
  API_BASE_URL = "https://api.replicate.com/v1".freeze
1248
1461
  DEFAULT_TIMEOUT = 120_000
@@ -1370,6 +1583,106 @@ module Zuno
1370
1583
  raise ProviderError, "Replicate request failed: #{response.return_code}#{suffix}"
1371
1584
  end
1372
1585
  end
1586
+
1587
+ class ElevenLabs
1588
+ DEFAULT_BASE_URL = "https://api.elevenlabs.io".freeze
1589
+ SPEECH_TO_TEXT_PATH = "/v1/speech-to-text".freeze
1590
+ DEFAULT_TIMEOUT = 120_000
1591
+
1592
+ def initialize(api_key: nil, timeout: DEFAULT_TIMEOUT, base_url: DEFAULT_BASE_URL)
1593
+ @api_key = api_key
1594
+ raise ProviderError, "ElevenLabs API key not configured" if @api_key.nil? || @api_key.to_s.empty?
1595
+
1596
+ @timeout = timeout
1597
+ @base_url = base_url.to_s.empty? ? DEFAULT_BASE_URL : base_url.to_s
1598
+ end
1599
+
1600
+ def transcribe(model_id:, file:, cloud_storage_url:, source_url:, options:)
1601
+ opened_file = nil
1602
+ body = build_body(
1603
+ model_id: model_id,
1604
+ file: file,
1605
+ cloud_storage_url: cloud_storage_url,
1606
+ source_url: source_url,
1607
+ options: options
1608
+ ) do |candidate|
1609
+ opened_file = candidate
1610
+ end
1611
+
1612
+ response = Typhoeus.post(
1613
+ speech_to_text_url,
1614
+ headers: headers,
1615
+ body: body,
1616
+ multipart: true,
1617
+ timeout: @timeout
1618
+ )
1619
+
1620
+ validate_response!(response)
1621
+ parsed = JSON.parse(response.body)
1622
+ raise ProviderError, "ElevenLabs returned invalid JSON" unless parsed.is_a?(Hash)
1623
+
1624
+ parsed
1625
+ rescue JSON::ParserError => e
1626
+ raise ProviderError, "Failed to parse ElevenLabs response: #{e.message}"
1627
+ ensure
1628
+ opened_file.close if opened_file.is_a?(File) && !opened_file.closed?
1629
+ end
1630
+
1631
+ private
1632
+
1633
+ def speech_to_text_url
1634
+ "#{@base_url}#{SPEECH_TO_TEXT_PATH}"
1635
+ end
1636
+
1637
+ def headers
1638
+ { "xi-api-key" => @api_key }
1639
+ end
1640
+
1641
+ def build_body(model_id:, file:, cloud_storage_url:, source_url:, options:)
1642
+ body = {
1643
+ "model_id" => model_id.to_s
1644
+ }
1645
+
1646
+ normalized_options = options.is_a?(Hash) ? options : {}
1647
+ normalized_options.each do |key, value|
1648
+ next if value.nil?
1649
+
1650
+ body[key.to_s] = value
1651
+ end
1652
+
1653
+ if file.is_a?(String)
1654
+ opened_file = File.open(file, "rb")
1655
+ body["file"] = opened_file
1656
+ yield(opened_file) if block_given?
1657
+ elsif !file.nil?
1658
+ body["file"] = file
1659
+ end
1660
+
1661
+ body["cloud_storage_url"] = cloud_storage_url.to_s unless cloud_storage_url.nil? || cloud_storage_url.to_s.strip.empty?
1662
+ body["source_url"] = source_url.to_s unless source_url.nil? || source_url.to_s.strip.empty?
1663
+ body
1664
+ rescue Errno::ENOENT => e
1665
+ raise ProviderError, "Failed to open transcription file: #{e.message}"
1666
+ end
1667
+
1668
+ def validate_response!(response)
1669
+ raise ProviderError, "No response returned from ElevenLabs" if response.nil?
1670
+ raise ProviderError, "ElevenLabs request timed out" if response.timed_out?
1671
+
1672
+ status = response.code.to_i
1673
+ body = response.body.to_s
1674
+ message = body.length > 300 ? "#{body[0, 300]}..." : body
1675
+
1676
+ return if status >= 200 && status < 300
1677
+
1678
+ if status.positive?
1679
+ raise ProviderError, "ElevenLabs responded with HTTP #{status}: #{message}"
1680
+ end
1681
+
1682
+ suffix = message.empty? ? "" : ": #{message}"
1683
+ raise ProviderError, "ElevenLabs request failed: #{response.return_code}#{suffix}"
1684
+ end
1685
+ end
1373
1686
  end
1374
1687
 
1375
1688
  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.0
4
+ version: 1.0.2
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-01 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
58
  - team@hyperaide.dev
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