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.
- checksums.yaml +4 -4
- data/README.md +126 -6
- data/lib/zuno/version.rb +1 -1
- data/lib/zuno.rb +778 -41
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d1dea919bc404d8bafb4e970ffd4d2d418d5ea38123bc78abc4668fc72ce985d
|
|
4
|
+
data.tar.gz: 4953724ce6061eb7641d7e4c5ee7564204d4647968822fd8d88303b481cf09c1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
7
|
-
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
`
|
|
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
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
|
|
14
|
+
class MaxIterationsExceeded < Error; end
|
|
14
15
|
class StreamingError < Error; end
|
|
16
|
+
class CallbackControl
|
|
17
|
+
attr_reader :stop_reason
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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!(
|
|
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
|
|
215
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
|
390
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ||
|
|
565
|
-
@title = title ||
|
|
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
|
-
|
|
633
|
-
|
|
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.
|
|
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-
|
|
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
|
|
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
|
|
90
|
+
summary: Ruby SDK with provider/model abstraction, single-shot generation, loops,
|
|
91
|
+
and streaming
|
|
90
92
|
test_files: []
|