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.
- checksums.yaml +4 -4
- data/README.md +126 -6
- data/lib/zuno/version.rb +1 -1
- data/lib/zuno.rb +913 -40
- 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: 7d6d4d8dd97c7ac8743ddc66545aeda41ed824357965c693ac244fcf46de5df2
|
|
4
|
+
data.tar.gz: d7e0a1a6029482af54dbf7bc6e5196b39dd60e424b1fb18b4003543cfbe79867
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
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,55 @@ 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
|
+
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
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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!(
|
|
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
|
|
215
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
|
390
|
-
|
|
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
|
|
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
|
|
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
|
|
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 ||
|
|
565
|
-
@title = title ||
|
|
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
|
-
|
|
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
|
|
633
|
-
|
|
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
|
|
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-
|
|
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: []
|