zuno 0.1.6 → 1.0.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 (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 +778 -41
  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: d1dea919bc404d8bafb4e970ffd4d2d418d5ea38123bc78abc4668fc72ce985d
4
+ data.tar.gz: 4953724ce6061eb7641d7e4c5ee7564204d4647968822fd8d88303b481cf09c1
5
5
  SHA512:
6
- metadata.gz: d9d1ce4c9e83825b6a227f6442865d7983a43fc31ca7956d09c38214a428ce1ea8c83f8852f24e66302f6906824f288d6fb0a8fbbad4253fd0d1d920fe16e603
7
- data.tar.gz: 54110db116e05eed86ac94eebd7416c2955a78fe691cafcff7ad39a733fd7e54245bfc659261aeb09dbeb20f26d5a836f433f1deefd6deda8242166947ac39d8
6
+ metadata.gz: 16d264d3143fca0a55bb4a2ed1ad4cc08e0f8dbbb6effdf8ed75c7425b5087fd1a8763135117bcc3b1ea8cf9061a579fa95b21e813b81d90c7de1076978d3a32
7
+ data.tar.gz: 8f19a868f736ec65ddf5484affe0a538a54690fc420c234afa7ead82f6943040441bce2336b334becb14fedad7babaaec03025854bf7e638ee31330f4eadddbb
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.0"
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,42 @@ 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
+ REPLICATE_ADAPTER_CONFIG_KEYS = %i[api_key timeout].freeze
80
+ DEFAULT_MAX_ITERATIONS = 1
81
+ REPLICATE_PREFER_WAIT_SECONDS = 60
82
+ REPLICATE_POLL_INTERVAL_SECONDS = 1
83
+ REPLICATE_WAIT_TIMEOUT_SECONDS = 600
84
+ REPLICATE_TERMINAL_STATUSES = %w[succeeded failed canceled aborted].freeze
58
85
 
59
86
  module_function
60
87
 
61
- def model(id, provider: :openrouter)
62
- ModelDescriptor.new(id: id, provider: provider)
88
+ def default_provider_options
89
+ @default_provider_options ||= {}
90
+ end
91
+
92
+ def default_provider_options=(options)
93
+ @default_provider_options = options.is_a?(Hash) ? options : {}
94
+ end
95
+
96
+ def model(id, provider: :openrouter, provider_options: {})
97
+ ModelDescriptor.new(id: id, provider: provider, provider_options: provider_options)
98
+ end
99
+
100
+ def openrouter(api_key: nil, app_url: nil, title: nil, timeout: Providers::OpenRouter::DEFAULT_TIMEOUT)
101
+ Providers::OpenRouter.new(
102
+ api_key: api_key,
103
+ app_url: app_url,
104
+ title: title,
105
+ timeout: timeout
106
+ )
107
+ end
108
+
109
+ def replicate(api_key: nil, timeout: Providers::Replicate::DEFAULT_TIMEOUT)
110
+ Providers::Replicate.new(
111
+ api_key: api_key,
112
+ timeout: timeout
113
+ )
63
114
  end
64
115
 
65
116
  def tool(name:, description:, input_schema:, &execute)
@@ -78,8 +129,111 @@ module Zuno
78
129
  messages: nil,
79
130
  system: nil,
80
131
  prompt: nil,
132
+ input: nil,
81
133
  tools: {},
82
- max_steps: DEFAULT_MAX_STEPS,
134
+ tool_choice: nil,
135
+ temperature: nil,
136
+ max_tokens: nil,
137
+ provider_options: {},
138
+ before_tool_execution: nil,
139
+ after_tool_execution: nil,
140
+ before_generation: nil,
141
+ after_generation: nil
142
+ )
143
+ callback_control = nil
144
+ after_generation_called = false
145
+ callback_control = CallbackControl.new
146
+
147
+ model_descriptor = normalize_model(model)
148
+ resolved_provider_options = merge_provider_options(
149
+ model_descriptor.provider_options,
150
+ provider_options
151
+ )
152
+ provider = model_descriptor.provider.to_sym
153
+
154
+ call_callback!(
155
+ before_generation,
156
+ {
157
+ model: model_descriptor,
158
+ mode: "single",
159
+ provider: provider
160
+ },
161
+ callback_control
162
+ )
163
+
164
+ if callback_control.stopped?
165
+ result = callback_stopped_result(
166
+ control: callback_control,
167
+ iterations: [],
168
+ message: {},
169
+ usage: nil,
170
+ raw_response: nil
171
+ )
172
+ after_generation_called = true
173
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
174
+ return result
175
+ end
176
+
177
+ result =
178
+ case provider
179
+ when :openrouter
180
+ adapter = provider_adapter(provider, resolved_provider_options)
181
+ generate_openrouter_single(
182
+ model_descriptor: model_descriptor,
183
+ adapter: adapter,
184
+ messages: messages,
185
+ system: system,
186
+ prompt: prompt,
187
+ tools: tools,
188
+ tool_choice: tool_choice,
189
+ temperature: temperature,
190
+ max_tokens: max_tokens,
191
+ provider_options: resolved_provider_options,
192
+ before_tool_execution: before_tool_execution,
193
+ after_tool_execution: after_tool_execution
194
+ )
195
+ when :replicate
196
+ raise Error, "tools are not supported for replicate generate" unless normalize_tools(tools).empty?
197
+ raise Error, "tool_choice is not supported for replicate generate" unless tool_choice.nil?
198
+
199
+ validate_no_webhook_support!(resolved_provider_options)
200
+ adapter = provider_adapter(provider, resolved_provider_options)
201
+ generate_replicate_single(
202
+ model_descriptor: model_descriptor,
203
+ adapter: adapter,
204
+ input: input,
205
+ provider_options: resolved_provider_options
206
+ )
207
+ else
208
+ raise ProviderError, "Unsupported provider: #{provider}"
209
+ end
210
+
211
+ after_generation_called = true
212
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
213
+ result
214
+ rescue ProviderError => e
215
+ unless after_generation_called
216
+ after_generation_called = true
217
+ call_callback!(after_generation, { ok: false, error: e }, callback_control)
218
+ end
219
+ raise
220
+ rescue StandardError => e
221
+ unless after_generation_called
222
+ after_generation_called = true
223
+ call_callback!(after_generation, { ok: false, error: e }, callback_control)
224
+ end
225
+ raise Error, e.message
226
+ end
227
+
228
+ def loop(
229
+ model:,
230
+ messages: nil,
231
+ system: nil,
232
+ prompt: nil,
233
+ tools: {},
234
+ tool_choice: nil,
235
+ stop_when: nil,
236
+ max_iterations: DEFAULT_MAX_ITERATIONS,
83
237
  temperature: nil,
84
238
  max_tokens: nil,
85
239
  provider_options: {},
@@ -90,41 +244,88 @@ module Zuno
90
244
  before_generation: nil,
91
245
  after_generation: nil
92
246
  )
247
+ callback_control = nil
93
248
  model_descriptor = normalize_model(model)
94
- adapter = provider_adapter(model_descriptor.provider, provider_options)
249
+ raise Error, "loop only supports openrouter provider" unless model_descriptor.provider.to_sym == :openrouter
250
+
251
+ resolved_provider_options = merge_provider_options(
252
+ model_descriptor.provider_options,
253
+ provider_options
254
+ )
255
+ adapter = provider_adapter(model_descriptor.provider, resolved_provider_options)
95
256
  tool_map = normalize_tools(tools)
96
257
  llm_messages = normalize_messages(messages: messages, system: system, prompt: prompt)
258
+ resolved_tool_choice = normalize_tool_choice(
259
+ explicit_tool_choice: tool_choice,
260
+ provider_options: resolved_provider_options,
261
+ tools: tool_map
262
+ )
263
+ resolved_stop_when = normalize_stop_when(stop_when)
264
+ resolved_max_iterations = normalize_max_iterations(max_iterations)
97
265
  after_generation_called = false
266
+ callback_control = CallbackControl.new
98
267
 
99
268
  call_callback!(
100
269
  before_generation,
101
270
  {
102
271
  model: model_descriptor,
103
272
  messages: llm_messages,
104
- tool_names: tool_map.keys
105
- }
273
+ tool_names: tool_map.keys,
274
+ tool_choice: resolved_tool_choice,
275
+ max_iterations: resolved_max_iterations,
276
+ stop_when: resolved_stop_when
277
+ },
278
+ callback_control
106
279
  )
280
+ if callback_control.stopped?
281
+ result = callback_stopped_result(
282
+ control: callback_control,
283
+ iterations: [],
284
+ message: {},
285
+ usage: nil,
286
+ raw_response: nil
287
+ )
288
+ after_generation_called = true
289
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
290
+ return result
291
+ end
107
292
 
108
293
  iterations = []
109
294
  iteration_count = 0
110
295
 
111
- while iteration_count < max_steps
296
+ infinite_iterations = resolved_max_iterations == :infinite
297
+
298
+ while infinite_iterations || iteration_count < resolved_max_iterations
112
299
  current_iteration = iteration_count + 1
113
300
  call_callback!(
114
301
  before_iteration,
115
302
  {
116
303
  iteration_index: current_iteration,
117
304
  messages: llm_messages
118
- }
305
+ },
306
+ callback_control
119
307
  )
308
+ if callback_control.stopped?
309
+ result = callback_stopped_result(
310
+ control: callback_control,
311
+ iterations: iterations,
312
+ message: {},
313
+ usage: nil,
314
+ raw_response: nil
315
+ )
316
+ after_generation_called = true
317
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
318
+ return result
319
+ end
120
320
 
121
321
  payload = build_payload(
122
322
  model_id: model_descriptor.id,
123
323
  messages: llm_messages,
124
324
  tools: tool_map,
325
+ tool_choice: resolved_tool_choice,
125
326
  temperature: temperature,
126
327
  max_tokens: max_tokens,
127
- provider_options: provider_options
328
+ provider_options: resolved_provider_options
128
329
  )
129
330
 
130
331
  response = adapter.chat(payload)
@@ -147,8 +348,21 @@ module Zuno
147
348
  {
148
349
  iteration_index: current_iteration,
149
350
  iteration: iteration_record
150
- }
351
+ },
352
+ callback_control
151
353
  )
354
+ if callback_control.stopped?
355
+ result = callback_stopped_result(
356
+ control: callback_control,
357
+ iterations: iterations,
358
+ message: message,
359
+ usage: response["usage"],
360
+ raw_response: response
361
+ )
362
+ after_generation_called = true
363
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
364
+ return result
365
+ end
152
366
 
153
367
  result = {
154
368
  text: extract_message_text(message),
@@ -160,11 +374,13 @@ module Zuno
160
374
  }
161
375
 
162
376
  after_generation_called = true
163
- call_callback!(after_generation, { ok: true, result: result })
377
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
164
378
  return result
165
379
  end
166
380
 
167
381
  llm_messages << build_assistant_tool_call_message(message: message, tool_calls: tool_calls)
382
+ stop_triggered = false
383
+ stop_triggered_tool_name = nil
168
384
 
169
385
  tool_calls.each do |tool_call|
170
386
  tool_call_id = normalize_tool_call_id(tool_call["id"])
@@ -179,7 +395,8 @@ module Zuno
179
395
  tool_name: tool_name,
180
396
  input: arguments,
181
397
  raw_tool_call: tool_call
182
- }
398
+ },
399
+ callback_control
183
400
  )
184
401
 
185
402
  tool_result = execute_tool_call(
@@ -190,7 +407,15 @@ module Zuno
190
407
  )
191
408
 
192
409
  iteration_record[:tool_results] << tool_result
193
- call_callback!(after_tool_execution, tool_result.merge(iteration_index: current_iteration))
410
+ call_callback!(
411
+ after_tool_execution,
412
+ tool_result.merge(iteration_index: current_iteration),
413
+ callback_control
414
+ )
415
+ if tool_stop_condition_met?(resolved_stop_when, tool_result)
416
+ stop_triggered = true
417
+ stop_triggered_tool_name ||= tool_result[:tool_name]
418
+ end
194
419
 
195
420
  llm_messages << {
196
421
  "role" => "tool",
@@ -205,23 +430,56 @@ module Zuno
205
430
  {
206
431
  iteration_index: current_iteration,
207
432
  iteration: iteration_record
208
- }
433
+ },
434
+ callback_control
209
435
  )
436
+ if callback_control.stopped?
437
+ result = callback_stopped_result(
438
+ control: callback_control,
439
+ iterations: iterations,
440
+ message: message,
441
+ usage: response["usage"],
442
+ raw_response: response
443
+ )
444
+ after_generation_called = true
445
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
446
+ return result
447
+ end
448
+
449
+ if stop_triggered
450
+ result = {
451
+ text: extract_message_text(message),
452
+ message: message,
453
+ usage: response["usage"],
454
+ finish_reason: "stop_when_tool_called",
455
+ stop_reason: {
456
+ type: "tool_called",
457
+ tool_name: stop_triggered_tool_name
458
+ },
459
+ iterations: iterations,
460
+ raw_response: response
461
+ }
462
+
463
+ after_generation_called = true
464
+ call_callback!(after_generation, { ok: true, result: result }, callback_control)
465
+ return result
466
+ end
210
467
 
211
468
  iteration_count += 1
212
469
  end
213
470
 
214
- raise MaxStepsExceeded, "Reached max_steps=#{max_steps} without a final assistant response"
215
- rescue ProviderError, MaxStepsExceeded => e
471
+ raise MaxIterationsExceeded,
472
+ "Reached max_iterations=#{resolved_max_iterations} without a final assistant response" unless infinite_iterations
473
+ rescue ProviderError, MaxIterationsExceeded => e
216
474
  unless after_generation_called
217
475
  after_generation_called = true
218
- call_callback!(after_generation, { ok: false, error: e })
476
+ call_callback!(after_generation, { ok: false, error: e }, callback_control)
219
477
  end
220
478
  raise
221
479
  rescue StandardError => e
222
480
  unless after_generation_called
223
481
  after_generation_called = true
224
- call_callback!(after_generation, { ok: false, error: e })
482
+ call_callback!(after_generation, { ok: false, error: e }, callback_control)
225
483
  end
226
484
  raise Error, e.message
227
485
  end
@@ -239,16 +497,23 @@ module Zuno
239
497
  raise ArgumentError, "stream requires a block callback" unless block_given?
240
498
 
241
499
  model_descriptor = normalize_model(model)
242
- adapter = provider_adapter(model_descriptor.provider, provider_options)
500
+ raise ProviderError, "stream only supports openrouter provider" unless model_descriptor.provider.to_sym == :openrouter
501
+
502
+ resolved_provider_options = merge_provider_options(
503
+ model_descriptor.provider_options,
504
+ provider_options
505
+ )
506
+ adapter = provider_adapter(model_descriptor.provider, resolved_provider_options)
243
507
  llm_messages = normalize_messages(messages: messages, system: system, prompt: prompt)
244
508
 
245
509
  payload = build_payload(
246
510
  model_id: model_descriptor.id,
247
511
  messages: llm_messages,
248
512
  tools: {},
513
+ tool_choice: nil,
249
514
  temperature: temperature,
250
515
  max_tokens: max_tokens,
251
- provider_options: provider_options
516
+ provider_options: resolved_provider_options
252
517
  ).merge("stream" => true)
253
518
 
254
519
  block.call(type: :start, model: model_descriptor.id, provider: model_descriptor.provider)
@@ -310,7 +575,8 @@ module Zuno
310
575
  if input.is_a?(Hash)
311
576
  return model(
312
577
  input[:id] || input["id"],
313
- provider: input[:provider] || input["provider"] || :openrouter
578
+ provider: input[:provider] || input["provider"] || :openrouter,
579
+ provider_options: input[:provider_options] || input["provider_options"] || {}
314
580
  )
315
581
  end
316
582
 
@@ -318,6 +584,165 @@ module Zuno
318
584
  end
319
585
  private_class_method :normalize_model
320
586
 
587
+ def generate_openrouter_single(
588
+ model_descriptor:,
589
+ adapter:,
590
+ messages:,
591
+ system:,
592
+ prompt:,
593
+ tools:,
594
+ tool_choice:,
595
+ temperature:,
596
+ max_tokens:,
597
+ provider_options:,
598
+ before_tool_execution:,
599
+ after_tool_execution:
600
+ )
601
+ tool_map = normalize_tools(tools)
602
+ llm_messages = normalize_messages(messages: messages, system: system, prompt: prompt)
603
+ resolved_tool_choice = normalize_tool_choice(
604
+ explicit_tool_choice: tool_choice,
605
+ provider_options: provider_options,
606
+ tools: tool_map
607
+ )
608
+
609
+ payload = build_payload(
610
+ model_id: model_descriptor.id,
611
+ messages: llm_messages,
612
+ tools: tool_map,
613
+ tool_choice: resolved_tool_choice,
614
+ temperature: temperature,
615
+ max_tokens: max_tokens,
616
+ provider_options: provider_options
617
+ )
618
+
619
+ response = adapter.chat(payload)
620
+ message = response.dig("choices", 0, "message") || {}
621
+ tool_calls = Array(message["tool_calls"])
622
+ tool_results = []
623
+
624
+ unless tool_calls.empty? || tool_map.empty?
625
+ tool_calls.each do |tool_call|
626
+ tool_call_id = normalize_tool_call_id(tool_call["id"])
627
+ arguments = parse_arguments(tool_call.dig("function", "arguments"))
628
+ tool_name = tool_call.dig("function", "name").to_s
629
+
630
+ call_callback!(
631
+ before_tool_execution,
632
+ {
633
+ iteration_index: 1,
634
+ tool_call_id: tool_call_id,
635
+ tool_name: tool_name,
636
+ input: arguments,
637
+ raw_tool_call: tool_call
638
+ }
639
+ )
640
+
641
+ tool_result = execute_tool_call(
642
+ tool_call: tool_call,
643
+ tools: tool_map,
644
+ tool_call_id: tool_call_id,
645
+ arguments: arguments
646
+ )
647
+ tool_results << tool_result
648
+
649
+ call_callback!(
650
+ after_tool_execution,
651
+ tool_result.merge(iteration_index: 1)
652
+ )
653
+ end
654
+ end
655
+
656
+ result = {
657
+ text: extract_message_text(message),
658
+ message: message,
659
+ usage: response["usage"],
660
+ finish_reason: response.dig("choices", 0, "finish_reason"),
661
+ tool_calls: tool_calls,
662
+ raw_response: response
663
+ }
664
+ result[:tool_results] = tool_results unless tool_results.empty?
665
+ result
666
+ end
667
+ private_class_method :generate_openrouter_single
668
+
669
+ def generate_replicate_single(model_descriptor:, adapter:, input:, provider_options:)
670
+ raise Error, "generate with replicate requires input: Hash" unless input.is_a?(Hash)
671
+
672
+ reference = normalize_replicate_reference(
673
+ model_descriptor: model_descriptor,
674
+ provider_options: provider_options
675
+ )
676
+
677
+ prediction = adapter.create_prediction(
678
+ reference: reference,
679
+ input: input
680
+ )
681
+
682
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REPLICATE_WAIT_TIMEOUT_SECONDS
683
+
684
+ until replicate_terminal_status?(prediction["status"])
685
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
686
+ raise ProviderError, "Replicate prediction did not finish within #{REPLICATE_WAIT_TIMEOUT_SECONDS} seconds"
687
+ end
688
+
689
+ sleep(REPLICATE_POLL_INTERVAL_SECONDS)
690
+ prediction = adapter.get_prediction(prediction: prediction)
691
+ end
692
+
693
+ {
694
+ id: prediction["id"],
695
+ status: prediction["status"],
696
+ output: prediction["output"],
697
+ error: prediction["error"],
698
+ logs: prediction["logs"],
699
+ metrics: prediction["metrics"],
700
+ urls: prediction["urls"],
701
+ raw_response: prediction
702
+ }
703
+ end
704
+ private_class_method :generate_replicate_single
705
+
706
+ def normalize_replicate_reference(model_descriptor:, provider_options:)
707
+ type = provider_options[:replicate_target] || provider_options["replicate_target"] || :model
708
+ normalized_type = type.to_sym
709
+ model_id = model_descriptor.id.to_s.strip
710
+ raise Error, "Replicate model id is required" if model_id.empty?
711
+
712
+ if normalized_type == :model || normalized_type == :deployment
713
+ owner, name, extra = model_id.split("/", 3)
714
+ if owner.to_s.empty? || name.to_s.empty? || !extra.nil?
715
+ raise Error, "Replicate #{normalized_type} id must be in 'owner/name' format"
716
+ end
717
+ elsif normalized_type != :version
718
+ raise Error, "Unsupported replicate_target: #{normalized_type}"
719
+ end
720
+
721
+ {
722
+ type: normalized_type,
723
+ id: model_id
724
+ }
725
+ end
726
+ private_class_method :normalize_replicate_reference
727
+
728
+ def replicate_terminal_status?(status)
729
+ REPLICATE_TERMINAL_STATUSES.include?(status.to_s)
730
+ end
731
+ private_class_method :replicate_terminal_status?
732
+
733
+ def validate_no_webhook_support!(provider_options)
734
+ return unless provider_options.is_a?(Hash)
735
+
736
+ webhook_set = provider_options.key?(:webhook) || provider_options.key?("webhook")
737
+ events_set =
738
+ provider_options.key?(:webhook_events_filter) ||
739
+ provider_options.key?("webhook_events_filter")
740
+ return unless webhook_set || events_set
741
+
742
+ raise Error, "webhook and webhook_events_filter are not supported"
743
+ end
744
+ private_class_method :validate_no_webhook_support!
745
+
321
746
  def normalize_tools(tools)
322
747
  return {} if tools.nil?
323
748
 
@@ -370,7 +795,7 @@ module Zuno
370
795
  end
371
796
  private_class_method :normalize_messages
372
797
 
373
- def build_payload(model_id:, messages:, tools:, temperature:, max_tokens:, provider_options:)
798
+ def build_payload(model_id:, messages:, tools:, tool_choice:, temperature:, max_tokens:, provider_options:)
374
799
  payload = {
375
800
  "model" => model_id,
376
801
  "messages" => messages
@@ -379,19 +804,31 @@ module Zuno
379
804
  payload["temperature"] = temperature unless temperature.nil?
380
805
  payload["max_tokens"] = max_tokens unless max_tokens.nil?
381
806
  payload["tools"] = tools.values.map(&:as_provider_tool) unless tools.empty?
807
+ payload["tool_choice"] = deep_stringify(tool_choice) unless tool_choice.nil?
382
808
 
383
- request_options = reject_keys(provider_options, ADAPTER_CONFIG_KEYS)
809
+ request_options = reject_keys(provider_options, OPENROUTER_ADAPTER_CONFIG_KEYS + [ :tool_choice ])
384
810
  payload.merge!(deep_stringify(request_options)) if request_options.is_a?(Hash)
385
811
  payload
386
812
  end
387
813
  private_class_method :build_payload
388
814
 
389
- def provider_adapter(provider, provider_options)
390
- config = pick_keys(provider_options, ADAPTER_CONFIG_KEYS)
815
+ def merge_provider_options(model_provider_options, call_provider_options)
816
+ merged = {}
817
+ merged.merge!(default_provider_options) if default_provider_options.is_a?(Hash)
818
+ merged.merge!(model_provider_options) if model_provider_options.is_a?(Hash)
819
+ merged.merge!(call_provider_options) if call_provider_options.is_a?(Hash)
820
+ merged
821
+ end
822
+ private_class_method :merge_provider_options
391
823
 
824
+ def provider_adapter(provider, provider_options)
392
825
  case provider.to_sym
393
826
  when :openrouter
827
+ config = pick_keys(provider_options, OPENROUTER_ADAPTER_CONFIG_KEYS)
394
828
  Providers::OpenRouter.new(**config)
829
+ when :replicate
830
+ config = pick_keys(provider_options, REPLICATE_ADAPTER_CONFIG_KEYS)
831
+ Providers::Replicate.new(**config)
395
832
  else
396
833
  raise ProviderError, "Unsupported provider: #{provider}"
397
834
  end
@@ -450,14 +887,167 @@ module Zuno
450
887
  end
451
888
  private_class_method :normalize_tool_call_id
452
889
 
453
- def call_callback!(callback, payload)
890
+ def normalize_max_iterations(value)
891
+ return DEFAULT_MAX_ITERATIONS if value.nil?
892
+ return :infinite if value == :infinite || value == Float::INFINITY
893
+ return value if value.is_a?(Integer) && value.positive?
894
+
895
+ raise Error, "max_iterations must be a positive Integer or :infinite"
896
+ end
897
+ private_class_method :normalize_max_iterations
898
+
899
+ def normalize_tool_choice(explicit_tool_choice:, provider_options:, tools:)
900
+ requested_tool_choice = if explicit_tool_choice.nil? && provider_options.is_a?(Hash)
901
+ provider_options[:tool_choice] || provider_options["tool_choice"]
902
+ else
903
+ explicit_tool_choice
904
+ end
905
+
906
+ if requested_tool_choice.nil?
907
+ return nil if tools.empty?
908
+
909
+ return "auto"
910
+ end
911
+
912
+ normalized = normalize_tool_choice_value(requested_tool_choice)
913
+
914
+ if tools.empty?
915
+ return nil if normalized == "auto" || normalized == "none"
916
+
917
+ raise Error, "tool_choice requires at least one tool"
918
+ end
919
+
920
+ if normalized.is_a?(Hash)
921
+ tool_name = normalized.dig("function", "name").to_s
922
+ raise Error, "tool_choice references unknown tool '#{tool_name}'" unless tools.key?(tool_name)
923
+ end
924
+
925
+ normalized
926
+ end
927
+ private_class_method :normalize_tool_choice
928
+
929
+ def normalize_tool_choice_value(value)
930
+ case value
931
+ when Symbol, String
932
+ normalized = value.to_s.strip
933
+ return normalized if %w[auto required none].include?(normalized)
934
+
935
+ raise Error, "tool_choice must be one of auto, required, none, or { type: 'tool', toolName: '...' }"
936
+ when Hash
937
+ type = (value[:type] || value["type"]).to_s
938
+
939
+ if type == "tool"
940
+ tool_name =
941
+ value[:tool_name] || value["tool_name"] ||
942
+ value[:toolName] || value["toolName"]
943
+ normalized_tool_name = tool_name.to_s.strip
944
+ raise Error, "tool_choice[:toolName] is required when type is 'tool'" if normalized_tool_name.empty?
945
+
946
+ return {
947
+ "type" => "function",
948
+ "function" => {
949
+ "name" => normalized_tool_name
950
+ }
951
+ }
952
+ end
953
+
954
+ if type == "function"
955
+ tool_name = value.dig(:function, :name) || value.dig("function", "name")
956
+ normalized_tool_name = tool_name.to_s.strip
957
+ raise Error, "tool_choice function name is required when type is 'function'" if normalized_tool_name.empty?
958
+
959
+ return {
960
+ "type" => "function",
961
+ "function" => {
962
+ "name" => normalized_tool_name
963
+ }
964
+ }
965
+ end
966
+
967
+ raise Error, "tool_choice hash must use type: 'tool' (or provider-native type: 'function')"
968
+ else
969
+ raise Error, "tool_choice must be a String, Symbol, or Hash"
970
+ end
971
+ end
972
+ private_class_method :normalize_tool_choice_value
973
+
974
+ def normalize_stop_when(value)
975
+ return {} if value.nil?
976
+ raise Error, "stop_when must be a Hash when provided" unless value.is_a?(Hash)
977
+
978
+ unknown_keys = value.keys.map(&:to_sym) - [ :tool_called ]
979
+ raise Error, "stop_when only supports :tool_called" unless unknown_keys.empty?
980
+
981
+ tool_called = value[:tool_called] || value["tool_called"]
982
+ return {} if tool_called.nil?
983
+
984
+ tool_names =
985
+ case tool_called
986
+ when String, Symbol
987
+ [ tool_called.to_s ]
988
+ when Array
989
+ tool_called.map(&:to_s)
990
+ else
991
+ raise Error, "stop_when[:tool_called] must be a String, Symbol, or Array"
992
+ end
993
+
994
+ normalized_names = tool_names.map(&:strip).reject(&:empty?).uniq
995
+ raise Error, "stop_when[:tool_called] must include at least one tool name" if normalized_names.empty?
996
+
997
+ { tool_called: normalized_names }
998
+ end
999
+ private_class_method :normalize_stop_when
1000
+
1001
+ def tool_stop_condition_met?(stop_when, tool_result)
1002
+ return false unless stop_when.is_a?(Hash)
1003
+
1004
+ tool_names = Array(stop_when[:tool_called])
1005
+ return false if tool_names.empty?
1006
+ return false unless tool_result[:ok]
1007
+
1008
+ tool_names.include?(tool_result[:tool_name].to_s)
1009
+ end
1010
+ private_class_method :tool_stop_condition_met?
1011
+
1012
+ def callback_stopped_result(control:, iterations:, message:, usage:, raw_response:)
1013
+ {
1014
+ text: extract_message_text(message),
1015
+ message: message,
1016
+ usage: usage,
1017
+ finish_reason: "stopped_by_callback",
1018
+ stop_reason: {
1019
+ type: "callback",
1020
+ reason: control.stop_reason
1021
+ },
1022
+ iterations: iterations,
1023
+ raw_response: raw_response
1024
+ }
1025
+ end
1026
+ private_class_method :callback_stopped_result
1027
+
1028
+ def call_callback!(callback, payload, control = nil)
454
1029
  return if callback.nil?
455
1030
  raise Error, "Callback must respond to #call" unless callback.respond_to?(:call)
456
1031
 
457
- callback.call(payload)
1032
+ if control && callback_accepts_control?(callback)
1033
+ callback.call(payload, control)
1034
+ else
1035
+ callback.call(payload)
1036
+ end
458
1037
  end
459
1038
  private_class_method :call_callback!
460
1039
 
1040
+ def callback_accepts_control?(callback)
1041
+ return true unless callback.lambda?
1042
+
1043
+ params = callback.parameters
1044
+ return true if params.any? { |param_type, _| param_type == :rest }
1045
+
1046
+ positional_count = params.count { |param_type, _| param_type == :req || param_type == :opt }
1047
+ positional_count >= 2
1048
+ end
1049
+ private_class_method :callback_accepts_control?
1050
+
461
1051
  def normalize_output_payload(payload)
462
1052
  case payload
463
1053
  when Hash, Array
@@ -558,14 +1148,22 @@ module Zuno
558
1148
  DEFAULT_TIMEOUT = 120_000
559
1149
 
560
1150
  def initialize(api_key: nil, app_url: nil, title: nil, timeout: DEFAULT_TIMEOUT)
561
- @api_key = api_key || resolve_api_key
1151
+ @api_key = api_key
562
1152
  raise ProviderError, "OpenRouter API key not configured" if @api_key.nil? || @api_key.to_s.empty?
563
1153
 
564
- @app_url = app_url || ENV["OPENROUTER_HTTP_REFERER"] || "http://localhost"
565
- @title = title || ENV["OPENROUTER_APP_TITLE"] || "zuno-ruby"
1154
+ @app_url = app_url || "http://localhost"
1155
+ @title = title || "zuno-ruby"
566
1156
  @timeout = timeout
567
1157
  end
568
1158
 
1159
+ def model(model_id)
1160
+ ModelDescriptor.new(
1161
+ id: model_id,
1162
+ provider: :openrouter,
1163
+ provider_options: provider_options
1164
+ )
1165
+ end
1166
+
569
1167
  def chat(payload)
570
1168
  response = Typhoeus.post(
571
1169
  CHAT_COMPLETIONS_URL,
@@ -607,6 +1205,15 @@ module Zuno
607
1205
 
608
1206
  private
609
1207
 
1208
+ def provider_options
1209
+ {
1210
+ api_key: @api_key,
1211
+ app_url: @app_url,
1212
+ title: @title,
1213
+ timeout: @timeout
1214
+ }
1215
+ end
1216
+
610
1217
  def headers
611
1218
  {
612
1219
  "Authorization" => "Bearer #{@api_key}",
@@ -619,18 +1226,148 @@ module Zuno
619
1226
  def validate_response!(response)
620
1227
  raise ProviderError, "No response returned from OpenRouter" if response.nil?
621
1228
  raise ProviderError, "OpenRouter request timed out" if response.timed_out?
622
- raise ProviderError, "OpenRouter request failed: #{response.return_code}" unless response.success?
623
1229
 
624
1230
  status = response.code.to_i
1231
+ body = response.body.to_s
1232
+ message = body.length > 300 ? "#{body[0, 300]}..." : body
1233
+
625
1234
  return if status >= 200 && status < 300
626
1235
 
1236
+ if status.positive?
1237
+ raise ProviderError, "OpenRouter responded with HTTP #{status}: #{message}"
1238
+ end
1239
+
1240
+ suffix = message.empty? ? "" : ": #{message}"
1241
+ raise ProviderError, "OpenRouter request failed: #{response.return_code}#{suffix}"
1242
+ end
1243
+
1244
+ end
1245
+
1246
+ class Replicate
1247
+ API_BASE_URL = "https://api.replicate.com/v1".freeze
1248
+ DEFAULT_TIMEOUT = 120_000
1249
+
1250
+ def initialize(api_key: nil, timeout: DEFAULT_TIMEOUT)
1251
+ @api_key = api_key
1252
+ raise ProviderError, "Replicate API key not configured" if @api_key.nil? || @api_key.to_s.empty?
1253
+
1254
+ @timeout = timeout
1255
+ end
1256
+
1257
+ def model(model_id)
1258
+ model_descriptor(model_id: model_id, target: :model)
1259
+ end
1260
+
1261
+ def version(version_id)
1262
+ model_descriptor(model_id: version_id, target: :version)
1263
+ end
1264
+
1265
+ def deployment(deployment_id)
1266
+ model_descriptor(model_id: deployment_id, target: :deployment)
1267
+ end
1268
+
1269
+ def create_prediction(reference:, input:)
1270
+ path, payload = build_create_request(reference: reference, input: input)
1271
+
1272
+ response = Typhoeus.post(
1273
+ "#{API_BASE_URL}#{path}",
1274
+ headers: headers.merge("Prefer" => "wait=#{REPLICATE_PREFER_WAIT_SECONDS}"),
1275
+ body: JSON.generate(payload),
1276
+ timeout: @timeout
1277
+ )
1278
+ parse_response(response)
1279
+ end
1280
+
1281
+ def get_prediction(prediction:)
1282
+ url = prediction.dig("urls", "get")
1283
+
1284
+ if url.nil? || url.to_s.strip.empty?
1285
+ prediction_id = prediction["id"].to_s
1286
+ raise ProviderError, "Replicate prediction id is missing" if prediction_id.empty?
1287
+
1288
+ url = "#{API_BASE_URL}/predictions/#{CGI.escape(prediction_id)}"
1289
+ end
1290
+
1291
+ response = Typhoeus.get(
1292
+ url,
1293
+ headers: headers,
1294
+ timeout: @timeout
1295
+ )
1296
+ parse_response(response)
1297
+ end
1298
+
1299
+ private
1300
+
1301
+ def model_descriptor(model_id:, target:)
1302
+ ModelDescriptor.new(
1303
+ id: model_id,
1304
+ provider: :replicate,
1305
+ provider_options: provider_options(target: target)
1306
+ )
1307
+ end
1308
+
1309
+ def provider_options(target:)
1310
+ {
1311
+ api_key: @api_key,
1312
+ timeout: @timeout,
1313
+ replicate_target: target
1314
+ }
1315
+ end
1316
+
1317
+ def build_create_request(reference:, input:)
1318
+ type = reference[:type].to_sym
1319
+ id = reference[:id].to_s
1320
+
1321
+ case type
1322
+ when :version
1323
+ ["/predictions", { "version" => id, "input" => input }]
1324
+ when :model
1325
+ ["/models/#{escape_owner_and_name(id)}/predictions", { "input" => input }]
1326
+ when :deployment
1327
+ ["/deployments/#{escape_owner_and_name(id)}/predictions", { "input" => input }]
1328
+ else
1329
+ raise ProviderError, "Unsupported Replicate reference type: #{type}"
1330
+ end
1331
+ end
1332
+
1333
+ def escape_owner_and_name(value)
1334
+ owner, name = value.split("/", 2)
1335
+ "#{CGI.escape(owner.to_s)}/#{CGI.escape(name.to_s)}"
1336
+ end
1337
+
1338
+ def parse_response(response)
1339
+ validate_response!(response)
1340
+ parsed = JSON.parse(response.body)
1341
+ raise ProviderError, "Replicate returned invalid JSON" unless parsed.is_a?(Hash)
1342
+
1343
+ parsed
1344
+ rescue JSON::ParserError => e
1345
+ raise ProviderError, "Failed to parse Replicate response: #{e.message}"
1346
+ end
1347
+
1348
+ def headers
1349
+ {
1350
+ "Authorization" => "Bearer #{@api_key}",
1351
+ "Content-Type" => "application/json"
1352
+ }
1353
+ end
1354
+
1355
+ def validate_response!(response)
1356
+ raise ProviderError, "No response returned from Replicate" if response.nil?
1357
+ raise ProviderError, "Replicate request timed out" if response.timed_out?
1358
+
1359
+ status = response.code.to_i
627
1360
  body = response.body.to_s
628
1361
  message = body.length > 300 ? "#{body[0, 300]}..." : body
629
- raise ProviderError, "OpenRouter responded with HTTP #{status}: #{message}"
630
- end
631
1362
 
632
- def resolve_api_key
633
- ENV["OPENROUTER_API_KEY"]
1363
+ return if status >= 200 && status < 300
1364
+
1365
+ if status.positive?
1366
+ raise ProviderError, "Replicate responded with HTTP #{status}: #{message}"
1367
+ end
1368
+
1369
+ suffix = message.empty? ? "" : ": #{message}"
1370
+ raise ProviderError, "Replicate request failed: #{response.return_code}#{suffix}"
634
1371
  end
635
1372
  end
636
1373
  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.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-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: []