zuno 0.1.6 → 1.0.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.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +126 -6
  3. data/lib/zuno/version.rb +1 -1
  4. data/lib/zuno.rb +913 -40
  5. metadata +6 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0b1a60f13b8d0f649d5a00df65846d3a35597f6298927c2e07b36f6f042528a
4
- data.tar.gz: a6312c76748d3fe27bc50e70fac5c7922f240ac7b61eee6ed6ab1e7e90639e2a
3
+ metadata.gz: 7d6d4d8dd97c7ac8743ddc66545aeda41ed824357965c693ac244fcf46de5df2
4
+ data.tar.gz: d7e0a1a6029482af54dbf7bc6e5196b39dd60e424b1fb18b4003543cfbe79867
5
5
  SHA512:
6
- metadata.gz: d9d1ce4c9e83825b6a227f6442865d7983a43fc31ca7956d09c38214a428ce1ea8c83f8852f24e66302f6906824f288d6fb0a8fbbad4253fd0d1d920fe16e603
7
- data.tar.gz: 54110db116e05eed86ac94eebd7416c2955a78fe691cafcff7ad39a733fd7e54245bfc659261aeb09dbeb20f26d5a836f433f1deefd6deda8242166947ac39d8
6
+ metadata.gz: 4862131a38d657f175bbc488599d5cbcd97899b43bdac7a26117a437c081bfdaa365e4a2e5255e7e48464aa5e5d129e14106364727b19bae4b8166ff779cd621
7
+ data.tar.gz: 96340e7d0b4a2153d58b328cbdcbec5836ddd76724941fd8efa39343de14c052a51c2031e916562e0b66de5044832a429f2332c92e4325f40d6eefc2d1e67454
data/README.md CHANGED
@@ -3,8 +3,9 @@
3
3
  Standalone Ruby SDK for:
4
4
 
5
5
  - provider/model abstraction
6
- - tool calling with iterative loop execution
7
- - streaming via SSE
6
+ - single-shot generation
7
+ - iterative tool loops
8
+ - streaming via SSE (OpenRouter)
8
9
 
9
10
  ## Install (local development)
10
11
 
@@ -13,22 +14,96 @@ bundle install
13
14
  bundle exec rspec
14
15
  ```
15
16
 
16
- ## Basic usage
17
+ ## Breaking change: `generate` vs `loop`
18
+
19
+ - `Zuno.generate` is now single-shot.
20
+ - `Zuno.loop` contains the previous iterative tool-loop behavior.
21
+
22
+ If you previously relied on iterative tool calls in `generate`, move that code to `loop`.
23
+
24
+ ## Providers
25
+
26
+ ### OpenRouter
17
27
 
18
28
  ```ruby
19
29
  require "zuno"
20
30
 
31
+ openrouter = Zuno.openrouter(
32
+ api_key: "your-openrouter-key", # required
33
+ app_url: "https://example.com",
34
+ title: "my-app"
35
+ )
36
+ ```
37
+
38
+ ### Replicate
39
+
40
+ ```ruby
41
+ replicate = Zuno.replicate(api_key: "your-replicate-key") # required
42
+ ```
43
+
44
+ ## Single-shot generation (`generate`)
45
+
46
+ ### OpenRouter
47
+
48
+ ```ruby
21
49
  result = Zuno.generate(
22
- model: "openai/gpt-5-mini",
50
+ model: openrouter.model("openai/gpt-5-mini"),
23
51
  prompt: "Say hello"
24
52
  )
25
53
 
26
54
  puts result[:text]
27
55
  ```
28
56
 
29
- ## Callbacks
57
+ `generate` supports tool definitions and executes returned tool calls once, without a follow-up LLM request.
58
+
59
+ ### Replicate
60
+
61
+ `generate` with Replicate requires `input:` and waits for completion using:
62
+
63
+ - `Prefer: wait=60` on create
64
+ - polling every 1 second
65
+ - hard timeout at 10 minutes
66
+
67
+ ```ruby
68
+ result = Zuno.generate(
69
+ model: replicate.model("owner/model"),
70
+ input: { prompt: "A watercolor fox" }
71
+ )
72
+
73
+ puts result[:status]
74
+ pp result[:output]
75
+ ```
76
+
77
+ Replicate reference types:
78
+
79
+ ```ruby
80
+ replicate.version("version-id")
81
+ replicate.model("owner/model")
82
+ replicate.deployment("owner/deployment")
83
+ ```
84
+
85
+ Webhooks are not supported. Passing `webhook` or `webhook_events_filter` raises an error.
30
86
 
31
- `generate` supports:
87
+ ## Iterative tool execution (`loop`)
88
+
89
+ `loop` is OpenRouter-only and preserves the previous iterative behavior.
90
+
91
+ ```ruby
92
+ ping = Zuno.tool(
93
+ name: "ping",
94
+ description: "Ping tool",
95
+ input_schema: { type: "object", properties: {} }
96
+ ) { { ok: true } }
97
+
98
+ result = Zuno.loop(
99
+ model: openrouter.model("openai/gpt-5-mini"),
100
+ prompt: "Run tools until done",
101
+ tools: { ping: ping },
102
+ max_iterations: 24
103
+ )
104
+ ```
105
+
106
+ `loop` supports:
32
107
 
33
108
  - `before_generation`
34
109
  - `after_generation`
@@ -36,3 +111,48 @@ puts result[:text]
36
111
  - `after_iteration`
37
112
  - `before_tool_execution`
38
113
  - `after_tool_execution`
114
+ - `max_iterations` (`Integer`, `:infinite`, or `Float::INFINITY`)
115
+ - `stop_when: { tool_called: ... }`
116
+
117
+ Callbacks can accept a second argument (`control`) and call `control.stop!(reason: "...")`.
118
+
119
+ ## Tool choice
120
+
121
+ `generate` and `loop` support AI SDK-style tool choice when tools are present:
122
+
123
+ - `"auto"` (default)
124
+ - `"required"`
125
+ - `"none"`
126
+ - `{ type: "tool", toolName: "my_tool" }`
127
+
128
+ ## Streaming (`stream`)
129
+
130
+ `stream` is OpenRouter-only.
131
+
132
+ ```ruby
133
+ Zuno.stream(
134
+ model: openrouter.model("openai/gpt-5-mini"),
135
+ prompt: "Stream hello"
136
+ ) do |event|
137
+ p event
138
+ end
139
+ ```
140
+
141
+ ## Automated releases
142
+
143
+ This repo includes `.github/workflows/release.yml` to automate versioning and gem publication:
144
+
145
+ - `release-please` inspects Conventional Commits on `main`, opens/updates a release PR, and bumps `lib/zuno/version.rb` when the release PR is merged.
146
+ - When a new GitHub release/tag is created, the workflow builds the gem and publishes it to RubyGems.
147
+
148
+ ### One-time setup
149
+
150
+ Add this GitHub repository secret:
151
+
152
+ - `RUBYGEMS_API_KEY`
153
+
154
+ ### Commit format for version bumping
155
+
156
+ - `fix: ...` -> patch
157
+ - `feat: ...` -> minor
158
+ - `feat!: ...` or any commit with `BREAKING CHANGE:` -> major
data/lib/zuno/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zuno
4
- VERSION = "0.1.6"
4
+ VERSION = "1.0.1"
5
5
  end
data/lib/zuno.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "securerandom"
5
+ require "cgi"
5
6
  require "typhoeus"
6
7
 
7
8
  require_relative "zuno/version"
@@ -10,12 +11,33 @@ module Zuno
10
11
  class Error < StandardError; end
11
12
  class ProviderError < Error; end
12
13
  class ToolError < Error; end
13
- class MaxStepsExceeded < Error; end
14
+ class MaxIterationsExceeded < Error; end
14
15
  class StreamingError < Error; end
16
+ class CallbackControl
17
+ attr_reader :stop_reason
15
18
 
16
- ModelDescriptor = Struct.new(:id, :provider, keyword_init: true) do
17
- def initialize(id:, provider:)
18
- super(id: id.to_s, provider: provider.to_sym)
19
+ def initialize
20
+ @stopped = false
21
+ @stop_reason = nil
22
+ end
23
+
24
+ def stop!(reason: nil)
25
+ @stopped = true
26
+ @stop_reason = reason
27
+ end
28
+
29
+ def stopped?
30
+ @stopped
31
+ end
32
+ end
33
+
34
+ ModelDescriptor = Struct.new(:id, :provider, :provider_options, keyword_init: true) do
35
+ def initialize(id:, provider:, provider_options: {})
36
+ super(
37
+ id: id.to_s,
38
+ provider: provider.to_sym,
39
+ provider_options: provider_options.is_a?(Hash) ? provider_options : {}
40
+ )
19
41
  end
20
42
  end
21
43
 
@@ -53,13 +75,55 @@ module Zuno
53
75
  end
54
76
  end
55
77
 
56
- ADAPTER_CONFIG_KEYS = %i[api_key app_url title timeout].freeze
57
- DEFAULT_MAX_STEPS = 8
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
80
+ REPLICATE_ADAPTER_CONFIG_KEYS = %i[api_key timeout].freeze
81
+ DEFAULT_MAX_ITERATIONS = 1
82
+ REPLICATE_PREFER_WAIT_SECONDS = 60
83
+ REPLICATE_POLL_INTERVAL_SECONDS = 1
84
+ REPLICATE_WAIT_TIMEOUT_SECONDS = 600
85
+ REPLICATE_TERMINAL_STATUSES = %w[succeeded failed canceled aborted].freeze
58
86
 
59
87
  module_function
60
88
 
61
- def model(id, provider: :openrouter)
62
- ModelDescriptor.new(id: id, provider: provider)
89
+ def default_provider_options
90
+ @default_provider_options ||= {}
91
+ end
92
+
93
+ def default_provider_options=(options)
94
+ @default_provider_options = options.is_a?(Hash) ? options : {}
95
+ end
96
+
97
+ def model(id, provider: :openrouter, provider_options: {})
98
+ ModelDescriptor.new(id: id, provider: provider, provider_options: provider_options)
99
+ end
100
+
101
+ def openrouter(api_key: nil, app_url: nil, title: nil, timeout: Providers::OpenRouter::DEFAULT_TIMEOUT)
102
+ Providers::OpenRouter.new(
103
+ api_key: api_key,
104
+ app_url: app_url,
105
+ title: title,
106
+ timeout: timeout
107
+ )
108
+ end
109
+
110
+ def replicate(api_key: nil, timeout: Providers::Replicate::DEFAULT_TIMEOUT)
111
+ Providers::Replicate.new(
112
+ api_key: api_key,
113
+ timeout: timeout
114
+ )
115
+ end
116
+
117
+ def ai_gateway(
118
+ api_key: nil,
119
+ timeout: Providers::AIGateway::DEFAULT_TIMEOUT,
120
+ base_url: Providers::AIGateway::DEFAULT_BASE_URL
121
+ )
122
+ Providers::AIGateway.new(
123
+ api_key: api_key,
124
+ timeout: timeout,
125
+ base_url: base_url
126
+ )
63
127
  end
64
128
 
65
129
  def tool(name:, description:, input_schema:, &execute)
@@ -78,8 +142,111 @@ module Zuno
78
142
  messages: nil,
79
143
  system: nil,
80
144
  prompt: nil,
145
+ input: nil,
81
146
  tools: {},
82
- max_steps: DEFAULT_MAX_STEPS,
147
+ tool_choice: nil,
148
+ temperature: nil,
149
+ max_tokens: nil,
150
+ provider_options: {},
151
+ before_tool_execution: nil,
152
+ after_tool_execution: nil,
153
+ before_generation: nil,
154
+ after_generation: nil
155
+ )
156
+ callback_control = nil
157
+ after_generation_called = false
158
+ callback_control = CallbackControl.new
159
+
160
+ model_descriptor = normalize_model(model)
161
+ resolved_provider_options = merge_provider_options(
162
+ model_descriptor.provider_options,
163
+ provider_options
164
+ )
165
+ provider = model_descriptor.provider.to_sym
166
+
167
+ call_callback!(
168
+ before_generation,
169
+ {
170
+ model: model_descriptor,
171
+ mode: "single",
172
+ provider: provider
173
+ },
174
+ callback_control
175
+ )
176
+
177
+ if callback_control.stopped?
178
+ result = callback_stopped_result(
179
+ control: callback_control,
180
+ iterations: [],
181
+ message: {},
182
+ usage: nil,
183
+ raw_response: nil
184
+ )
185
+ after_generation_called = true
186
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
187
+ return result
188
+ end
189
+
190
+ result =
191
+ case provider
192
+ when :openrouter, :ai_gateway
193
+ adapter = provider_adapter(provider, resolved_provider_options)
194
+ generate_openrouter_single(
195
+ model_descriptor: model_descriptor,
196
+ adapter: adapter,
197
+ messages: messages,
198
+ system: system,
199
+ prompt: prompt,
200
+ tools: tools,
201
+ tool_choice: tool_choice,
202
+ temperature: temperature,
203
+ max_tokens: max_tokens,
204
+ provider_options: resolved_provider_options,
205
+ before_tool_execution: before_tool_execution,
206
+ after_tool_execution: after_tool_execution
207
+ )
208
+ when :replicate
209
+ raise Error, "tools are not supported for replicate generate" unless normalize_tools(tools).empty?
210
+ raise Error, "tool_choice is not supported for replicate generate" unless tool_choice.nil?
211
+
212
+ validate_no_webhook_support!(resolved_provider_options)
213
+ adapter = provider_adapter(provider, resolved_provider_options)
214
+ generate_replicate_single(
215
+ model_descriptor: model_descriptor,
216
+ adapter: adapter,
217
+ input: input,
218
+ provider_options: resolved_provider_options
219
+ )
220
+ else
221
+ raise ProviderError, "Unsupported provider: #{provider}"
222
+ end
223
+
224
+ after_generation_called = true
225
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
226
+ result
227
+ rescue ProviderError => e
228
+ unless after_generation_called
229
+ after_generation_called = true
230
+ call_callback!(after_generation, { ok: false, error: e }, callback_control)
231
+ end
232
+ raise
233
+ rescue StandardError => e
234
+ unless after_generation_called
235
+ after_generation_called = true
236
+ call_callback!(after_generation, { ok: false, error: e }, callback_control)
237
+ end
238
+ raise Error, e.message
239
+ end
240
+
241
+ def loop(
242
+ model:,
243
+ messages: nil,
244
+ system: nil,
245
+ prompt: nil,
246
+ tools: {},
247
+ tool_choice: nil,
248
+ stop_when: nil,
249
+ max_iterations: DEFAULT_MAX_ITERATIONS,
83
250
  temperature: nil,
84
251
  max_tokens: nil,
85
252
  provider_options: {},
@@ -90,41 +257,91 @@ module Zuno
90
257
  before_generation: nil,
91
258
  after_generation: nil
92
259
  )
260
+ callback_control = nil
93
261
  model_descriptor = normalize_model(model)
94
- adapter = provider_adapter(model_descriptor.provider, provider_options)
262
+ unless %i[openrouter ai_gateway].include?(model_descriptor.provider.to_sym)
263
+ raise Error, "loop only supports openrouter or ai_gateway provider"
264
+ end
265
+
266
+ resolved_provider_options = merge_provider_options(
267
+ model_descriptor.provider_options,
268
+ provider_options
269
+ )
270
+ adapter = provider_adapter(model_descriptor.provider, resolved_provider_options)
95
271
  tool_map = normalize_tools(tools)
96
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
277
+ )
278
+ resolved_stop_when = normalize_stop_when(stop_when)
279
+ resolved_max_iterations = normalize_max_iterations(max_iterations)
97
280
  after_generation_called = false
281
+ callback_control = CallbackControl.new
98
282
 
99
283
  call_callback!(
100
284
  before_generation,
101
285
  {
102
286
  model: model_descriptor,
103
287
  messages: llm_messages,
104
- tool_names: tool_map.keys
105
- }
288
+ tool_names: tool_map.keys,
289
+ tool_choice: resolved_tool_choice,
290
+ max_iterations: resolved_max_iterations,
291
+ stop_when: resolved_stop_when
292
+ },
293
+ callback_control
106
294
  )
295
+ if callback_control.stopped?
296
+ result = callback_stopped_result(
297
+ control: callback_control,
298
+ iterations: [],
299
+ message: {},
300
+ usage: nil,
301
+ raw_response: nil
302
+ )
303
+ after_generation_called = true
304
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
305
+ return result
306
+ end
107
307
 
108
308
  iterations = []
109
309
  iteration_count = 0
110
310
 
111
- while iteration_count < max_steps
311
+ infinite_iterations = resolved_max_iterations == :infinite
312
+
313
+ while infinite_iterations || iteration_count < resolved_max_iterations
112
314
  current_iteration = iteration_count + 1
113
315
  call_callback!(
114
316
  before_iteration,
115
317
  {
116
318
  iteration_index: current_iteration,
117
319
  messages: llm_messages
118
- }
320
+ },
321
+ callback_control
119
322
  )
323
+ if callback_control.stopped?
324
+ result = callback_stopped_result(
325
+ control: callback_control,
326
+ iterations: iterations,
327
+ message: {},
328
+ usage: nil,
329
+ raw_response: nil
330
+ )
331
+ after_generation_called = true
332
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
333
+ return result
334
+ end
120
335
 
121
336
  payload = build_payload(
122
337
  model_id: model_descriptor.id,
338
+ provider: model_descriptor.provider,
123
339
  messages: llm_messages,
124
340
  tools: tool_map,
341
+ tool_choice: resolved_tool_choice,
125
342
  temperature: temperature,
126
343
  max_tokens: max_tokens,
127
- provider_options: provider_options
344
+ provider_options: resolved_provider_options
128
345
  )
129
346
 
130
347
  response = adapter.chat(payload)
@@ -147,8 +364,21 @@ module Zuno
147
364
  {
148
365
  iteration_index: current_iteration,
149
366
  iteration: iteration_record
150
- }
367
+ },
368
+ callback_control
151
369
  )
370
+ if callback_control.stopped?
371
+ result = callback_stopped_result(
372
+ control: callback_control,
373
+ iterations: iterations,
374
+ message: message,
375
+ usage: response["usage"],
376
+ raw_response: response
377
+ )
378
+ after_generation_called = true
379
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
380
+ return result
381
+ end
152
382
 
153
383
  result = {
154
384
  text: extract_message_text(message),
@@ -160,11 +390,13 @@ module Zuno
160
390
  }
161
391
 
162
392
  after_generation_called = true
163
- call_callback!(after_generation, { ok: true, result: result })
393
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
164
394
  return result
165
395
  end
166
396
 
167
397
  llm_messages << build_assistant_tool_call_message(message: message, tool_calls: tool_calls)
398
+ stop_triggered = false
399
+ stop_triggered_tool_name = nil
168
400
 
169
401
  tool_calls.each do |tool_call|
170
402
  tool_call_id = normalize_tool_call_id(tool_call["id"])
@@ -179,7 +411,8 @@ module Zuno
179
411
  tool_name: tool_name,
180
412
  input: arguments,
181
413
  raw_tool_call: tool_call
182
- }
414
+ },
415
+ callback_control
183
416
  )
184
417
 
185
418
  tool_result = execute_tool_call(
@@ -190,7 +423,15 @@ module Zuno
190
423
  )
191
424
 
192
425
  iteration_record[:tool_results] << tool_result
193
- call_callback!(after_tool_execution, tool_result.merge(iteration_index: current_iteration))
426
+ call_callback!(
427
+ after_tool_execution,
428
+ tool_result.merge(iteration_index: current_iteration),
429
+ callback_control
430
+ )
431
+ if tool_stop_condition_met?(resolved_stop_when, tool_result)
432
+ stop_triggered = true
433
+ stop_triggered_tool_name ||= tool_result[:tool_name]
434
+ end
194
435
 
195
436
  llm_messages << {
196
437
  "role" => "tool",
@@ -205,23 +446,56 @@ module Zuno
205
446
  {
206
447
  iteration_index: current_iteration,
207
448
  iteration: iteration_record
208
- }
449
+ },
450
+ callback_control
209
451
  )
452
+ if callback_control.stopped?
453
+ result = callback_stopped_result(
454
+ control: callback_control,
455
+ iterations: iterations,
456
+ message: message,
457
+ usage: response["usage"],
458
+ raw_response: response
459
+ )
460
+ after_generation_called = true
461
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
462
+ return result
463
+ end
464
+
465
+ if stop_triggered
466
+ result = {
467
+ text: extract_message_text(message),
468
+ message: message,
469
+ usage: response["usage"],
470
+ finish_reason: "stop_when_tool_called",
471
+ stop_reason: {
472
+ type: "tool_called",
473
+ tool_name: stop_triggered_tool_name
474
+ },
475
+ iterations: iterations,
476
+ raw_response: response
477
+ }
478
+
479
+ after_generation_called = true
480
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
481
+ return result
482
+ end
210
483
 
211
484
  iteration_count += 1
212
485
  end
213
486
 
214
- raise MaxStepsExceeded, "Reached max_steps=#{max_steps} without a final assistant response"
215
- rescue ProviderError, MaxStepsExceeded => e
487
+ raise MaxIterationsExceeded,
488
+ "Reached max_iterations=#{resolved_max_iterations} without a final assistant response" unless infinite_iterations
489
+ rescue ProviderError, MaxIterationsExceeded => e
216
490
  unless after_generation_called
217
491
  after_generation_called = true
218
- call_callback!(after_generation, { ok: false, error: e })
492
+ call_callback!(after_generation, { ok: false, error: e }, callback_control)
219
493
  end
220
494
  raise
221
495
  rescue StandardError => e
222
496
  unless after_generation_called
223
497
  after_generation_called = true
224
- call_callback!(after_generation, { ok: false, error: e })
498
+ call_callback!(after_generation, { ok: false, error: e }, callback_control)
225
499
  end
226
500
  raise Error, e.message
227
501
  end
@@ -239,16 +513,26 @@ module Zuno
239
513
  raise ArgumentError, "stream requires a block callback" unless block_given?
240
514
 
241
515
  model_descriptor = normalize_model(model)
242
- adapter = provider_adapter(model_descriptor.provider, provider_options)
516
+ unless %i[openrouter ai_gateway].include?(model_descriptor.provider.to_sym)
517
+ raise ProviderError, "stream only supports openrouter or ai_gateway provider"
518
+ end
519
+
520
+ resolved_provider_options = merge_provider_options(
521
+ model_descriptor.provider_options,
522
+ provider_options
523
+ )
524
+ adapter = provider_adapter(model_descriptor.provider, resolved_provider_options)
243
525
  llm_messages = normalize_messages(messages: messages, system: system, prompt: prompt)
244
526
 
245
527
  payload = build_payload(
246
528
  model_id: model_descriptor.id,
529
+ provider: model_descriptor.provider,
247
530
  messages: llm_messages,
248
531
  tools: {},
532
+ tool_choice: nil,
249
533
  temperature: temperature,
250
534
  max_tokens: max_tokens,
251
- provider_options: provider_options
535
+ provider_options: resolved_provider_options
252
536
  ).merge("stream" => true)
253
537
 
254
538
  block.call(type: :start, model: model_descriptor.id, provider: model_descriptor.provider)
@@ -310,7 +594,8 @@ module Zuno
310
594
  if input.is_a?(Hash)
311
595
  return model(
312
596
  input[:id] || input["id"],
313
- provider: input[:provider] || input["provider"] || :openrouter
597
+ provider: input[:provider] || input["provider"] || :openrouter,
598
+ provider_options: input[:provider_options] || input["provider_options"] || {}
314
599
  )
315
600
  end
316
601
 
@@ -318,6 +603,166 @@ module Zuno
318
603
  end
319
604
  private_class_method :normalize_model
320
605
 
606
+ def generate_openrouter_single(
607
+ model_descriptor:,
608
+ adapter:,
609
+ messages:,
610
+ system:,
611
+ prompt:,
612
+ tools:,
613
+ tool_choice:,
614
+ temperature:,
615
+ max_tokens:,
616
+ provider_options:,
617
+ before_tool_execution:,
618
+ after_tool_execution:
619
+ )
620
+ tool_map = normalize_tools(tools)
621
+ llm_messages = normalize_messages(messages: messages, system: system, prompt: prompt)
622
+ resolved_tool_choice = normalize_tool_choice(
623
+ explicit_tool_choice: tool_choice,
624
+ provider_options: provider_options,
625
+ tools: tool_map
626
+ )
627
+
628
+ payload = build_payload(
629
+ model_id: model_descriptor.id,
630
+ provider: model_descriptor.provider,
631
+ messages: llm_messages,
632
+ tools: tool_map,
633
+ tool_choice: resolved_tool_choice,
634
+ temperature: temperature,
635
+ max_tokens: max_tokens,
636
+ provider_options: provider_options
637
+ )
638
+
639
+ response = adapter.chat(payload)
640
+ message = response.dig("choices", 0, "message") || {}
641
+ tool_calls = Array(message["tool_calls"])
642
+ tool_results = []
643
+
644
+ unless tool_calls.empty? || tool_map.empty?
645
+ tool_calls.each do |tool_call|
646
+ tool_call_id = normalize_tool_call_id(tool_call["id"])
647
+ arguments = parse_arguments(tool_call.dig("function", "arguments"))
648
+ tool_name = tool_call.dig("function", "name").to_s
649
+
650
+ call_callback!(
651
+ before_tool_execution,
652
+ {
653
+ iteration_index: 1,
654
+ tool_call_id: tool_call_id,
655
+ tool_name: tool_name,
656
+ input: arguments,
657
+ raw_tool_call: tool_call
658
+ }
659
+ )
660
+
661
+ tool_result = execute_tool_call(
662
+ tool_call: tool_call,
663
+ tools: tool_map,
664
+ tool_call_id: tool_call_id,
665
+ arguments: arguments
666
+ )
667
+ tool_results << tool_result
668
+
669
+ call_callback!(
670
+ after_tool_execution,
671
+ tool_result.merge(iteration_index: 1)
672
+ )
673
+ end
674
+ end
675
+
676
+ result = {
677
+ text: extract_message_text(message),
678
+ message: message,
679
+ usage: response["usage"],
680
+ finish_reason: response.dig("choices", 0, "finish_reason"),
681
+ tool_calls: tool_calls,
682
+ raw_response: response
683
+ }
684
+ result[:tool_results] = tool_results unless tool_results.empty?
685
+ result
686
+ end
687
+ private_class_method :generate_openrouter_single
688
+
689
+ def generate_replicate_single(model_descriptor:, adapter:, input:, provider_options:)
690
+ raise Error, "generate with replicate requires input: Hash" unless input.is_a?(Hash)
691
+
692
+ reference = normalize_replicate_reference(
693
+ model_descriptor: model_descriptor,
694
+ provider_options: provider_options
695
+ )
696
+
697
+ prediction = adapter.create_prediction(
698
+ reference: reference,
699
+ input: input
700
+ )
701
+
702
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REPLICATE_WAIT_TIMEOUT_SECONDS
703
+
704
+ until replicate_terminal_status?(prediction["status"])
705
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
706
+ raise ProviderError, "Replicate prediction did not finish within #{REPLICATE_WAIT_TIMEOUT_SECONDS} seconds"
707
+ end
708
+
709
+ sleep(REPLICATE_POLL_INTERVAL_SECONDS)
710
+ prediction = adapter.get_prediction(prediction: prediction)
711
+ end
712
+
713
+ {
714
+ id: prediction["id"],
715
+ status: prediction["status"],
716
+ output: prediction["output"],
717
+ error: prediction["error"],
718
+ logs: prediction["logs"],
719
+ metrics: prediction["metrics"],
720
+ urls: prediction["urls"],
721
+ raw_response: prediction
722
+ }
723
+ end
724
+ private_class_method :generate_replicate_single
725
+
726
+ def normalize_replicate_reference(model_descriptor:, provider_options:)
727
+ type = provider_options[:replicate_target] || provider_options["replicate_target"] || :model
728
+ normalized_type = type.to_sym
729
+ model_id = model_descriptor.id.to_s.strip
730
+ raise Error, "Replicate model id is required" if model_id.empty?
731
+
732
+ if normalized_type == :model || normalized_type == :deployment
733
+ owner, name, extra = model_id.split("/", 3)
734
+ if owner.to_s.empty? || name.to_s.empty? || !extra.nil?
735
+ raise Error, "Replicate #{normalized_type} id must be in 'owner/name' format"
736
+ end
737
+ elsif normalized_type != :version
738
+ raise Error, "Unsupported replicate_target: #{normalized_type}"
739
+ end
740
+
741
+ {
742
+ type: normalized_type,
743
+ id: model_id
744
+ }
745
+ end
746
+ private_class_method :normalize_replicate_reference
747
+
748
+ def replicate_terminal_status?(status)
749
+ REPLICATE_TERMINAL_STATUSES.include?(status.to_s)
750
+ end
751
+ private_class_method :replicate_terminal_status?
752
+
753
+ def validate_no_webhook_support!(provider_options)
754
+ return unless provider_options.is_a?(Hash)
755
+
756
+ webhook_set = provider_options.key?(:webhook) || provider_options.key?("webhook")
757
+ events_set =
758
+ provider_options.key?(:webhook_events_filter) ||
759
+ provider_options.key?("webhook_events_filter")
760
+ return unless webhook_set || events_set
761
+
762
+ raise Error, "webhook and webhook_events_filter are not supported"
763
+ end
764
+ private_class_method :validate_no_webhook_support!
765
+
321
766
  def normalize_tools(tools)
322
767
  return {} if tools.nil?
323
768
 
@@ -370,7 +815,7 @@ module Zuno
370
815
  end
371
816
  private_class_method :normalize_messages
372
817
 
373
- def build_payload(model_id:, messages:, tools:, temperature:, max_tokens:, provider_options:)
818
+ def build_payload(model_id:, provider:, messages:, tools:, tool_choice:, temperature:, max_tokens:, provider_options:)
374
819
  payload = {
375
820
  "model" => model_id,
376
821
  "messages" => messages
@@ -379,19 +824,48 @@ module Zuno
379
824
  payload["temperature"] = temperature unless temperature.nil?
380
825
  payload["max_tokens"] = max_tokens unless max_tokens.nil?
381
826
  payload["tools"] = tools.values.map(&:as_provider_tool) unless tools.empty?
827
+ payload["tool_choice"] = deep_stringify(tool_choice) unless tool_choice.nil?
382
828
 
383
- request_options = reject_keys(provider_options, ADAPTER_CONFIG_KEYS)
829
+ request_options = reject_keys(provider_options, provider_adapter_config_keys(provider) + [ :tool_choice ])
384
830
  payload.merge!(deep_stringify(request_options)) if request_options.is_a?(Hash)
385
831
  payload
386
832
  end
387
833
  private_class_method :build_payload
388
834
 
389
- def provider_adapter(provider, provider_options)
390
- config = pick_keys(provider_options, ADAPTER_CONFIG_KEYS)
835
+ def provider_adapter_config_keys(provider)
836
+ case provider.to_sym
837
+ when :openrouter
838
+ OPENROUTER_ADAPTER_CONFIG_KEYS
839
+ when :ai_gateway
840
+ AI_GATEWAY_ADAPTER_CONFIG_KEYS
841
+ when :replicate
842
+ REPLICATE_ADAPTER_CONFIG_KEYS
843
+ else
844
+ []
845
+ end
846
+ end
847
+ private_class_method :provider_adapter_config_keys
848
+
849
+ def merge_provider_options(model_provider_options, call_provider_options)
850
+ merged = {}
851
+ merged.merge!(default_provider_options) if default_provider_options.is_a?(Hash)
852
+ merged.merge!(model_provider_options) if model_provider_options.is_a?(Hash)
853
+ merged.merge!(call_provider_options) if call_provider_options.is_a?(Hash)
854
+ merged
855
+ end
856
+ private_class_method :merge_provider_options
391
857
 
858
+ def provider_adapter(provider, provider_options)
392
859
  case provider.to_sym
393
860
  when :openrouter
861
+ config = pick_keys(provider_options, OPENROUTER_ADAPTER_CONFIG_KEYS)
394
862
  Providers::OpenRouter.new(**config)
863
+ when :ai_gateway
864
+ config = pick_keys(provider_options, AI_GATEWAY_ADAPTER_CONFIG_KEYS)
865
+ Providers::AIGateway.new(**config)
866
+ when :replicate
867
+ config = pick_keys(provider_options, REPLICATE_ADAPTER_CONFIG_KEYS)
868
+ Providers::Replicate.new(**config)
395
869
  else
396
870
  raise ProviderError, "Unsupported provider: #{provider}"
397
871
  end
@@ -450,14 +924,167 @@ module Zuno
450
924
  end
451
925
  private_class_method :normalize_tool_call_id
452
926
 
453
- def call_callback!(callback, payload)
927
+ def normalize_max_iterations(value)
928
+ return DEFAULT_MAX_ITERATIONS if value.nil?
929
+ return :infinite if value == :infinite || value == Float::INFINITY
930
+ return value if value.is_a?(Integer) && value.positive?
931
+
932
+ raise Error, "max_iterations must be a positive Integer or :infinite"
933
+ end
934
+ private_class_method :normalize_max_iterations
935
+
936
+ def normalize_tool_choice(explicit_tool_choice:, provider_options:, tools:)
937
+ requested_tool_choice = if explicit_tool_choice.nil? && provider_options.is_a?(Hash)
938
+ provider_options[:tool_choice] || provider_options["tool_choice"]
939
+ else
940
+ explicit_tool_choice
941
+ end
942
+
943
+ if requested_tool_choice.nil?
944
+ return nil if tools.empty?
945
+
946
+ return "auto"
947
+ end
948
+
949
+ normalized = normalize_tool_choice_value(requested_tool_choice)
950
+
951
+ if tools.empty?
952
+ return nil if normalized == "auto" || normalized == "none"
953
+
954
+ raise Error, "tool_choice requires at least one tool"
955
+ end
956
+
957
+ if normalized.is_a?(Hash)
958
+ tool_name = normalized.dig("function", "name").to_s
959
+ raise Error, "tool_choice references unknown tool '#{tool_name}'" unless tools.key?(tool_name)
960
+ end
961
+
962
+ normalized
963
+ end
964
+ private_class_method :normalize_tool_choice
965
+
966
+ def normalize_tool_choice_value(value)
967
+ case value
968
+ when Symbol, String
969
+ normalized = value.to_s.strip
970
+ return normalized if %w[auto required none].include?(normalized)
971
+
972
+ raise Error, "tool_choice must be one of auto, required, none, or { type: 'tool', toolName: '...' }"
973
+ when Hash
974
+ type = (value[:type] || value["type"]).to_s
975
+
976
+ if type == "tool"
977
+ tool_name =
978
+ value[:tool_name] || value["tool_name"] ||
979
+ value[:toolName] || value["toolName"]
980
+ normalized_tool_name = tool_name.to_s.strip
981
+ raise Error, "tool_choice[:toolName] is required when type is 'tool'" if normalized_tool_name.empty?
982
+
983
+ return {
984
+ "type" => "function",
985
+ "function" => {
986
+ "name" => normalized_tool_name
987
+ }
988
+ }
989
+ end
990
+
991
+ if type == "function"
992
+ tool_name = value.dig(:function, :name) || value.dig("function", "name")
993
+ normalized_tool_name = tool_name.to_s.strip
994
+ raise Error, "tool_choice function name is required when type is 'function'" if normalized_tool_name.empty?
995
+
996
+ return {
997
+ "type" => "function",
998
+ "function" => {
999
+ "name" => normalized_tool_name
1000
+ }
1001
+ }
1002
+ end
1003
+
1004
+ raise Error, "tool_choice hash must use type: 'tool' (or provider-native type: 'function')"
1005
+ else
1006
+ raise Error, "tool_choice must be a String, Symbol, or Hash"
1007
+ end
1008
+ end
1009
+ private_class_method :normalize_tool_choice_value
1010
+
1011
+ def normalize_stop_when(value)
1012
+ return {} if value.nil?
1013
+ raise Error, "stop_when must be a Hash when provided" unless value.is_a?(Hash)
1014
+
1015
+ unknown_keys = value.keys.map(&:to_sym) - [ :tool_called ]
1016
+ raise Error, "stop_when only supports :tool_called" unless unknown_keys.empty?
1017
+
1018
+ tool_called = value[:tool_called] || value["tool_called"]
1019
+ return {} if tool_called.nil?
1020
+
1021
+ tool_names =
1022
+ case tool_called
1023
+ when String, Symbol
1024
+ [ tool_called.to_s ]
1025
+ when Array
1026
+ tool_called.map(&:to_s)
1027
+ else
1028
+ raise Error, "stop_when[:tool_called] must be a String, Symbol, or Array"
1029
+ end
1030
+
1031
+ normalized_names = tool_names.map(&:strip).reject(&:empty?).uniq
1032
+ raise Error, "stop_when[:tool_called] must include at least one tool name" if normalized_names.empty?
1033
+
1034
+ { tool_called: normalized_names }
1035
+ end
1036
+ private_class_method :normalize_stop_when
1037
+
1038
+ def tool_stop_condition_met?(stop_when, tool_result)
1039
+ return false unless stop_when.is_a?(Hash)
1040
+
1041
+ tool_names = Array(stop_when[:tool_called])
1042
+ return false if tool_names.empty?
1043
+ return false unless tool_result[:ok]
1044
+
1045
+ tool_names.include?(tool_result[:tool_name].to_s)
1046
+ end
1047
+ private_class_method :tool_stop_condition_met?
1048
+
1049
+ def callback_stopped_result(control:, iterations:, message:, usage:, raw_response:)
1050
+ {
1051
+ text: extract_message_text(message),
1052
+ message: message,
1053
+ usage: usage,
1054
+ finish_reason: "stopped_by_callback",
1055
+ stop_reason: {
1056
+ type: "callback",
1057
+ reason: control.stop_reason
1058
+ },
1059
+ iterations: iterations,
1060
+ raw_response: raw_response
1061
+ }
1062
+ end
1063
+ private_class_method :callback_stopped_result
1064
+
1065
+ def call_callback!(callback, payload, control = nil)
454
1066
  return if callback.nil?
455
1067
  raise Error, "Callback must respond to #call" unless callback.respond_to?(:call)
456
1068
 
457
- callback.call(payload)
1069
+ if control && callback_accepts_control?(callback)
1070
+ callback.call(payload, control)
1071
+ else
1072
+ callback.call(payload)
1073
+ end
458
1074
  end
459
1075
  private_class_method :call_callback!
460
1076
 
1077
+ def callback_accepts_control?(callback)
1078
+ return true unless callback.lambda?
1079
+
1080
+ params = callback.parameters
1081
+ return true if params.any? { |param_type, _| param_type == :rest }
1082
+
1083
+ positional_count = params.count { |param_type, _| param_type == :req || param_type == :opt }
1084
+ positional_count >= 2
1085
+ end
1086
+ private_class_method :callback_accepts_control?
1087
+
461
1088
  def normalize_output_payload(payload)
462
1089
  case payload
463
1090
  when Hash, Array
@@ -558,14 +1185,22 @@ module Zuno
558
1185
  DEFAULT_TIMEOUT = 120_000
559
1186
 
560
1187
  def initialize(api_key: nil, app_url: nil, title: nil, timeout: DEFAULT_TIMEOUT)
561
- @api_key = api_key || resolve_api_key
1188
+ @api_key = api_key
562
1189
  raise ProviderError, "OpenRouter API key not configured" if @api_key.nil? || @api_key.to_s.empty?
563
1190
 
564
- @app_url = app_url || ENV["OPENROUTER_HTTP_REFERER"] || "http://localhost"
565
- @title = title || ENV["OPENROUTER_APP_TITLE"] || "zuno-ruby"
1191
+ @app_url = app_url || "http://localhost"
1192
+ @title = title || "zuno-ruby"
566
1193
  @timeout = timeout
567
1194
  end
568
1195
 
1196
+ def model(model_id)
1197
+ ModelDescriptor.new(
1198
+ id: model_id,
1199
+ provider: :openrouter,
1200
+ provider_options: provider_options
1201
+ )
1202
+ end
1203
+
569
1204
  def chat(payload)
570
1205
  response = Typhoeus.post(
571
1206
  CHAT_COMPLETIONS_URL,
@@ -607,6 +1242,15 @@ module Zuno
607
1242
 
608
1243
  private
609
1244
 
1245
+ def provider_options
1246
+ {
1247
+ api_key: @api_key,
1248
+ app_url: @app_url,
1249
+ title: @title,
1250
+ timeout: @timeout
1251
+ }
1252
+ end
1253
+
610
1254
  def headers
611
1255
  {
612
1256
  "Authorization" => "Bearer #{@api_key}",
@@ -619,18 +1263,247 @@ module Zuno
619
1263
  def validate_response!(response)
620
1264
  raise ProviderError, "No response returned from OpenRouter" if response.nil?
621
1265
  raise ProviderError, "OpenRouter request timed out" if response.timed_out?
622
- raise ProviderError, "OpenRouter request failed: #{response.return_code}" unless response.success?
623
1266
 
624
1267
  status = response.code.to_i
1268
+ body = response.body.to_s
1269
+ message = body.length > 300 ? "#{body[0, 300]}..." : body
1270
+
625
1271
  return if status >= 200 && status < 300
626
1272
 
1273
+ if status.positive?
1274
+ raise ProviderError, "OpenRouter responded with HTTP #{status}: #{message}"
1275
+ end
1276
+
1277
+ suffix = message.empty? ? "" : ": #{message}"
1278
+ raise ProviderError, "OpenRouter request failed: #{response.return_code}#{suffix}"
1279
+ end
1280
+
1281
+ end
1282
+
1283
+ class AIGateway
1284
+ DEFAULT_BASE_URL = "https://ai-gateway.vercel.sh/v1".freeze
1285
+ DEFAULT_TIMEOUT = 120_000
1286
+
1287
+ def initialize(api_key: nil, timeout: DEFAULT_TIMEOUT, base_url: DEFAULT_BASE_URL)
1288
+ @api_key = api_key
1289
+ raise ProviderError, "Vercel Gateway API key not configured" if @api_key.nil? || @api_key.to_s.empty?
1290
+
1291
+ @timeout = timeout
1292
+ @base_url = base_url.to_s.empty? ? DEFAULT_BASE_URL : base_url.to_s
1293
+ end
1294
+
1295
+ def model(model_id)
1296
+ ModelDescriptor.new(
1297
+ id: model_id,
1298
+ provider: :ai_gateway,
1299
+ provider_options: provider_options
1300
+ )
1301
+ end
1302
+
1303
+ def chat(payload)
1304
+ response = Typhoeus.post(
1305
+ chat_completions_url,
1306
+ headers: headers,
1307
+ body: JSON.generate(payload),
1308
+ timeout: @timeout
1309
+ )
1310
+
1311
+ validate_response!(response)
1312
+ parsed = JSON.parse(response.body)
1313
+ raise ProviderError, "Vercel Gateway returned invalid JSON" unless parsed.is_a?(Hash)
1314
+
1315
+ parsed
1316
+ rescue JSON::ParserError => e
1317
+ raise ProviderError, "Failed to parse Vercel Gateway response: #{e.message}"
1318
+ end
1319
+
1320
+ def stream(payload)
1321
+ raise ArgumentError, "stream requires a block callback" unless block_given?
1322
+
1323
+ request = Typhoeus::Request.new(
1324
+ chat_completions_url,
1325
+ method: :post,
1326
+ headers: headers,
1327
+ body: JSON.generate(payload),
1328
+ timeout: @timeout
1329
+ )
1330
+
1331
+ parser = SseParser.new { |data| yield(data) }
1332
+ request.on_body do |chunk|
1333
+ parser.push(chunk)
1334
+ nil
1335
+ end
1336
+
1337
+ request.run
1338
+ validate_response!(request.response)
1339
+ parser.flush
1340
+ end
1341
+
1342
+ private
1343
+
1344
+ def provider_options
1345
+ {
1346
+ api_key: @api_key,
1347
+ timeout: @timeout,
1348
+ base_url: @base_url
1349
+ }
1350
+ end
1351
+
1352
+ def chat_completions_url
1353
+ "#{@base_url}/chat/completions"
1354
+ end
1355
+
1356
+ def headers
1357
+ {
1358
+ "Authorization" => "Bearer #{@api_key}",
1359
+ "Content-Type" => "application/json"
1360
+ }
1361
+ end
1362
+
1363
+ def validate_response!(response)
1364
+ raise ProviderError, "No response returned from Vercel Gateway" if response.nil?
1365
+ raise ProviderError, "Vercel Gateway request timed out" if response.timed_out?
1366
+
1367
+ status = response.code.to_i
627
1368
  body = response.body.to_s
628
1369
  message = body.length > 300 ? "#{body[0, 300]}..." : body
629
- raise ProviderError, "OpenRouter responded with HTTP #{status}: #{message}"
1370
+
1371
+ return if status >= 200 && status < 300
1372
+
1373
+ if status.positive?
1374
+ raise ProviderError, "Vercel Gateway responded with HTTP #{status}: #{message}"
1375
+ end
1376
+
1377
+ suffix = message.empty? ? "" : ": #{message}"
1378
+ raise ProviderError, "Vercel Gateway request failed: #{response.return_code}#{suffix}"
1379
+ end
1380
+ end
1381
+
1382
+ class Replicate
1383
+ API_BASE_URL = "https://api.replicate.com/v1".freeze
1384
+ DEFAULT_TIMEOUT = 120_000
1385
+
1386
+ def initialize(api_key: nil, timeout: DEFAULT_TIMEOUT)
1387
+ @api_key = api_key
1388
+ raise ProviderError, "Replicate API key not configured" if @api_key.nil? || @api_key.to_s.empty?
1389
+
1390
+ @timeout = timeout
1391
+ end
1392
+
1393
+ def model(model_id)
1394
+ model_descriptor(model_id: model_id, target: :model)
1395
+ end
1396
+
1397
+ def version(version_id)
1398
+ model_descriptor(model_id: version_id, target: :version)
1399
+ end
1400
+
1401
+ def deployment(deployment_id)
1402
+ model_descriptor(model_id: deployment_id, target: :deployment)
1403
+ end
1404
+
1405
+ def create_prediction(reference:, input:)
1406
+ path, payload = build_create_request(reference: reference, input: input)
1407
+
1408
+ response = Typhoeus.post(
1409
+ "#{API_BASE_URL}#{path}",
1410
+ headers: headers.merge("Prefer" => "wait=#{REPLICATE_PREFER_WAIT_SECONDS}"),
1411
+ body: JSON.generate(payload),
1412
+ timeout: @timeout
1413
+ )
1414
+ parse_response(response)
630
1415
  end
631
1416
 
632
- def resolve_api_key
633
- ENV["OPENROUTER_API_KEY"]
1417
+ def get_prediction(prediction:)
1418
+ url = prediction.dig("urls", "get")
1419
+
1420
+ if url.nil? || url.to_s.strip.empty?
1421
+ prediction_id = prediction["id"].to_s
1422
+ raise ProviderError, "Replicate prediction id is missing" if prediction_id.empty?
1423
+
1424
+ url = "#{API_BASE_URL}/predictions/#{CGI.escape(prediction_id)}"
1425
+ end
1426
+
1427
+ response = Typhoeus.get(
1428
+ url,
1429
+ headers: headers,
1430
+ timeout: @timeout
1431
+ )
1432
+ parse_response(response)
1433
+ end
1434
+
1435
+ private
1436
+
1437
+ def model_descriptor(model_id:, target:)
1438
+ ModelDescriptor.new(
1439
+ id: model_id,
1440
+ provider: :replicate,
1441
+ provider_options: provider_options(target: target)
1442
+ )
1443
+ end
1444
+
1445
+ def provider_options(target:)
1446
+ {
1447
+ api_key: @api_key,
1448
+ timeout: @timeout,
1449
+ replicate_target: target
1450
+ }
1451
+ end
1452
+
1453
+ def build_create_request(reference:, input:)
1454
+ type = reference[:type].to_sym
1455
+ id = reference[:id].to_s
1456
+
1457
+ case type
1458
+ when :version
1459
+ ["/predictions", { "version" => id, "input" => input }]
1460
+ when :model
1461
+ ["/models/#{escape_owner_and_name(id)}/predictions", { "input" => input }]
1462
+ when :deployment
1463
+ ["/deployments/#{escape_owner_and_name(id)}/predictions", { "input" => input }]
1464
+ else
1465
+ raise ProviderError, "Unsupported Replicate reference type: #{type}"
1466
+ end
1467
+ end
1468
+
1469
+ def escape_owner_and_name(value)
1470
+ owner, name = value.split("/", 2)
1471
+ "#{CGI.escape(owner.to_s)}/#{CGI.escape(name.to_s)}"
1472
+ end
1473
+
1474
+ def parse_response(response)
1475
+ validate_response!(response)
1476
+ parsed = JSON.parse(response.body)
1477
+ raise ProviderError, "Replicate returned invalid JSON" unless parsed.is_a?(Hash)
1478
+
1479
+ parsed
1480
+ rescue JSON::ParserError => e
1481
+ raise ProviderError, "Failed to parse Replicate response: #{e.message}"
1482
+ end
1483
+
1484
+ def headers
1485
+ {
1486
+ "Authorization" => "Bearer #{@api_key}",
1487
+ "Content-Type" => "application/json"
1488
+ }
1489
+ end
1490
+
1491
+ def validate_response!(response)
1492
+ raise ProviderError, "No response returned from Replicate" if response.nil?
1493
+ raise ProviderError, "Replicate request timed out" if response.timed_out?
1494
+
1495
+ status = response.code.to_i
1496
+ body = response.body.to_s
1497
+ message = body.length > 300 ? "#{body[0, 300]}..." : body
1498
+
1499
+ return if status >= 200 && status < 300
1500
+
1501
+ if status.positive?
1502
+ raise ProviderError, "Replicate responded with HTTP #{status}: #{message}"
1503
+ end
1504
+
1505
+ suffix = message.empty? ? "" : ": #{message}"
1506
+ raise ProviderError, "Replicate request failed: #{response.return_code}#{suffix}"
634
1507
  end
635
1508
  end
636
1509
  end
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: 0.1.6
4
+ version: 1.0.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-03-19 00:00:00.000000000 Z
11
+ date: 2026-03-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: typhoeus
@@ -52,7 +52,8 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '3.13'
55
- description: Standalone Ruby SDK for AI generation with tool loops and SSE streaming.
55
+ description: Standalone Ruby SDK for AI generation across OpenRouter and Replicate,
56
+ with iterative tool loops and SSE streaming.
56
57
  email:
57
58
  - team@hyperaide.dev
58
59
  executables: []
@@ -86,5 +87,6 @@ requirements: []
86
87
  rubygems_version: 3.5.22
87
88
  signing_key:
88
89
  specification_version: 4
89
- summary: Ruby Agent SDK with provider/model abstraction, tools, and streaming
90
+ summary: Ruby SDK with provider/model abstraction, single-shot generation, loops,
91
+ and streaming
90
92
  test_files: []