homura-runtime 0.3.6 → 0.3.7
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/CHANGELOG.md +11 -0
- data/exe/auto-await +42 -27
- data/exe/compile-assets +46 -37
- data/exe/compile-erb +86 -61
- data/exe/homura-build +223 -119
- data/lib/homura/runtime/ai.rb +316 -22
- data/lib/homura/runtime/async_registry.rb +135 -98
- data/lib/homura/runtime/auto_await/analyzer.rb +34 -19
- data/lib/homura/runtime/auto_await/transformer.rb +1 -1
- data/lib/homura/runtime/build_support.rb +74 -38
- data/lib/homura/runtime/cache.rb +29 -22
- data/lib/homura/runtime/durable_object.rb +110 -56
- data/lib/homura/runtime/email.rb +28 -14
- data/lib/homura/runtime/http.rb +5 -4
- data/lib/homura/runtime/multipart.rb +47 -47
- data/lib/homura/runtime/queue.rb +82 -29
- data/lib/homura/runtime/scheduled.rb +29 -19
- data/lib/homura/runtime/stream.rb +30 -24
- data/lib/homura/runtime/version.rb +1 -1
- data/lib/homura/runtime.rb +330 -131
- data/lib/homura_vendor_tempfile.rb +5 -4
- data/lib/homura_vendor_tilt.rb +4 -3
- data/lib/homura_vendor_zlib.rb +20 -13
- data/lib/opal_patches.rb +196 -109
- metadata +1 -1
data/lib/homura/runtime/ai.rb
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
# Events response. See `lib/homura/runtime.rb#build_js_response`
|
|
22
22
|
# for the SSE / ReadableStream pass-through.
|
|
23
23
|
|
|
24
|
-
require
|
|
24
|
+
require "json"
|
|
25
25
|
|
|
26
26
|
module Cloudflare
|
|
27
27
|
class AIError < StandardError
|
|
@@ -29,13 +29,18 @@ module Cloudflare
|
|
|
29
29
|
def initialize(message, model: nil, operation: nil)
|
|
30
30
|
@model = model
|
|
31
31
|
@operation = operation
|
|
32
|
-
super(
|
|
32
|
+
super(
|
|
33
|
+
"[Cloudflare::AI] model=#{model || "?"} op=#{operation || "run"}: #{message}"
|
|
34
|
+
)
|
|
33
35
|
end
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
module AI
|
|
37
39
|
# Default REST options forwarded to env.AI.run as the third argument.
|
|
38
40
|
DEFAULT_OPTIONS = {}.freeze
|
|
41
|
+
DEFAULT_CHAT_MODEL = "@cf/moonshotai/kimi-k2.6"
|
|
42
|
+
DEFAULT_TRANSCRIBE_MODEL = "@cf/openai/whisper"
|
|
43
|
+
DEFAULT_SPEAK_MODEL = "@cf/deepgram/aura-1"
|
|
39
44
|
|
|
40
45
|
class Binding
|
|
41
46
|
attr_reader :js
|
|
@@ -51,15 +56,108 @@ module Cloudflare
|
|
|
51
56
|
|
|
52
57
|
def run(model, inputs = nil, options: nil, **input_options)
|
|
53
58
|
payload = inputs || input_options
|
|
54
|
-
payload = payload.merge(input_options) if inputs.is_a?(Hash) &&
|
|
59
|
+
payload = payload.merge(input_options) if inputs.is_a?(Hash) &&
|
|
60
|
+
!input_options.empty?
|
|
55
61
|
Cloudflare::AI.run(model, payload, binding: @js, options: options)
|
|
56
62
|
end
|
|
57
63
|
|
|
58
64
|
def run_stream(model, inputs = nil, **input_options)
|
|
59
65
|
payload = inputs || input_options
|
|
60
|
-
payload = payload.merge(input_options) if inputs.is_a?(Hash) &&
|
|
66
|
+
payload = payload.merge(input_options) if inputs.is_a?(Hash) &&
|
|
67
|
+
!input_options.empty?
|
|
61
68
|
run(model, payload.merge(stream: true))
|
|
62
69
|
end
|
|
70
|
+
|
|
71
|
+
def chat(
|
|
72
|
+
prompt = nil,
|
|
73
|
+
messages: nil,
|
|
74
|
+
system: nil,
|
|
75
|
+
model: DEFAULT_CHAT_MODEL,
|
|
76
|
+
options: nil,
|
|
77
|
+
**input_options
|
|
78
|
+
)
|
|
79
|
+
chat_options = Cloudflare::AI.chat_input_options(model, input_options)
|
|
80
|
+
payload = {
|
|
81
|
+
messages:
|
|
82
|
+
Cloudflare::AI.build_messages(
|
|
83
|
+
prompt,
|
|
84
|
+
messages: messages,
|
|
85
|
+
system: system
|
|
86
|
+
)
|
|
87
|
+
}.merge(chat_options)
|
|
88
|
+
run(model, payload, options: options)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def chat_text(
|
|
92
|
+
prompt = nil,
|
|
93
|
+
messages: nil,
|
|
94
|
+
system: nil,
|
|
95
|
+
model: DEFAULT_CHAT_MODEL,
|
|
96
|
+
options: nil,
|
|
97
|
+
**input_options
|
|
98
|
+
)
|
|
99
|
+
response =
|
|
100
|
+
chat(
|
|
101
|
+
prompt,
|
|
102
|
+
messages: messages,
|
|
103
|
+
system: system,
|
|
104
|
+
model: model,
|
|
105
|
+
options: options,
|
|
106
|
+
**input_options
|
|
107
|
+
)
|
|
108
|
+
response = response.__await__ if Cloudflare.js_promise?(response)
|
|
109
|
+
Cloudflare::AI.extract_text(response)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def transcribe(
|
|
113
|
+
audio,
|
|
114
|
+
model: DEFAULT_TRANSCRIBE_MODEL,
|
|
115
|
+
options: nil,
|
|
116
|
+
**input_options
|
|
117
|
+
)
|
|
118
|
+
payload = { audio: Cloudflare::AI.audio_input(audio) }.merge(
|
|
119
|
+
input_options
|
|
120
|
+
)
|
|
121
|
+
run(model, payload, options: options)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def transcribe_text(
|
|
125
|
+
audio,
|
|
126
|
+
model: DEFAULT_TRANSCRIBE_MODEL,
|
|
127
|
+
options: nil,
|
|
128
|
+
**input_options
|
|
129
|
+
)
|
|
130
|
+
response =
|
|
131
|
+
transcribe(audio, model: model, options: options, **input_options)
|
|
132
|
+
response = response.__await__ if Cloudflare.js_promise?(response)
|
|
133
|
+
Cloudflare::AI.extract_text(response)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def speak(text, model: DEFAULT_SPEAK_MODEL, options: nil, **input_options)
|
|
137
|
+
payload = { text: text.to_s }.merge(input_options)
|
|
138
|
+
response =
|
|
139
|
+
Cloudflare::AI.speak(model, payload, binding: @js, options: options)
|
|
140
|
+
response = response.__await__ if Cloudflare.js_promise?(response)
|
|
141
|
+
response
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def speak_data_url(
|
|
145
|
+
text,
|
|
146
|
+
model: DEFAULT_SPEAK_MODEL,
|
|
147
|
+
options: nil,
|
|
148
|
+
**input_options
|
|
149
|
+
)
|
|
150
|
+
payload = { text: text.to_s }.merge(input_options)
|
|
151
|
+
response =
|
|
152
|
+
Cloudflare::AI.speak_data_url(
|
|
153
|
+
model,
|
|
154
|
+
payload,
|
|
155
|
+
binding: @js,
|
|
156
|
+
options: options
|
|
157
|
+
)
|
|
158
|
+
response = response.__await__ if Cloudflare.js_promise?(response)
|
|
159
|
+
response.to_s
|
|
160
|
+
end
|
|
63
161
|
end
|
|
64
162
|
|
|
65
163
|
# Run a Workers AI model. Returns a JS Promise that resolves to a
|
|
@@ -70,23 +168,33 @@ module Cloudflare
|
|
|
70
168
|
# @param inputs [Hash] model inputs (messages / prompt / max_tokens / etc.)
|
|
71
169
|
# @param binding [JS object] env.AI binding (required)
|
|
72
170
|
# @param options [Hash] gateway / extra options forwarded as the 3rd arg
|
|
73
|
-
def self.run(model, inputs, binding: nil, options: nil)
|
|
74
|
-
binding = binding.js if defined?(Binding) &&
|
|
171
|
+
def self.run(model, inputs, binding: nil, options: nil, raw_response: false)
|
|
172
|
+
binding = binding.js if defined?(Binding) &&
|
|
173
|
+
`(#{binding} != null && #{binding}.$$class === #{Binding})`
|
|
75
174
|
# Use a JS-side null check because `binding` may be a raw JS object
|
|
76
175
|
# (env.AI), which has no Ruby `#nil?` method on the prototype.
|
|
77
176
|
bound = !`(#{binding} == null)`
|
|
78
|
-
|
|
177
|
+
unless bound
|
|
178
|
+
raise AIError.new("AI binding not bound (env.AI is null)", model: model)
|
|
179
|
+
end
|
|
79
180
|
js_inputs = ruby_to_js(inputs)
|
|
80
181
|
js_options = options ? ruby_to_js(options) : `({})`
|
|
81
182
|
ai_binding = binding
|
|
82
183
|
err_klass = Cloudflare::AIError
|
|
83
184
|
stream_klass = Cloudflare::AI::Stream
|
|
84
185
|
# Streaming may be requested either via `inputs[:stream]` (the
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
streaming =
|
|
89
|
-
|
|
186
|
+
# newer Workers AI shape) or `options: { stream: true }` (the
|
|
187
|
+
# 3rd-arg "options" contract). Accept both so callers can use
|
|
188
|
+
# whichever idiom matches the model docs they're following.
|
|
189
|
+
streaming =
|
|
190
|
+
(
|
|
191
|
+
inputs.is_a?(Hash) &&
|
|
192
|
+
(inputs[:stream] == true || inputs["stream"] == true)
|
|
193
|
+
) ||
|
|
194
|
+
(
|
|
195
|
+
options.is_a?(Hash) &&
|
|
196
|
+
(options[:stream] == true || options["stream"] == true)
|
|
197
|
+
)
|
|
90
198
|
cf = Cloudflare
|
|
91
199
|
|
|
92
200
|
# NOTE: multi-line backtick → Promise works HERE because the
|
|
@@ -96,7 +204,8 @@ module Cloudflare
|
|
|
96
204
|
# or the Promise will be silently dropped (same pitfall
|
|
97
205
|
# documented in lib/homura/runtime/{cache,queue}.rb —
|
|
98
206
|
# Phase 11B audit).
|
|
99
|
-
js_promise =
|
|
207
|
+
js_promise =
|
|
208
|
+
`
|
|
100
209
|
(async function() {
|
|
101
210
|
var out;
|
|
102
211
|
try {
|
|
@@ -110,7 +219,9 @@ module Cloudflare
|
|
|
110
219
|
|
|
111
220
|
js_result = js_promise.__await__
|
|
112
221
|
|
|
113
|
-
if
|
|
222
|
+
if raw_response
|
|
223
|
+
RawResponse.new(js_result)
|
|
224
|
+
elsif streaming
|
|
114
225
|
# Workers AI returns a ReadableStream<Uint8Array> when stream:true.
|
|
115
226
|
# Wrap it so the Sinatra route can return it as an SSE body.
|
|
116
227
|
stream_klass.new(js_result)
|
|
@@ -119,9 +230,181 @@ module Cloudflare
|
|
|
119
230
|
end
|
|
120
231
|
end
|
|
121
232
|
|
|
233
|
+
def self.speak(model, inputs, binding: nil, options: nil)
|
|
234
|
+
binding = binding.js if defined?(Binding) &&
|
|
235
|
+
`(#{binding} != null && #{binding}.$$class === #{Binding})`
|
|
236
|
+
bound = !`(#{binding} == null)`
|
|
237
|
+
unless bound
|
|
238
|
+
raise AIError.new("AI binding not bound (env.AI is null)", model: model)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
js_inputs = ruby_to_js(inputs)
|
|
242
|
+
js_options = ruby_to_js((options || {}).merge(returnRawResponse: true))
|
|
243
|
+
ai_binding = binding
|
|
244
|
+
err_klass = Cloudflare::AIError
|
|
245
|
+
|
|
246
|
+
js_response =
|
|
247
|
+
`
|
|
248
|
+
(async function() {
|
|
249
|
+
try {
|
|
250
|
+
return await #{ai_binding}.run(#{model}, #{js_inputs}, #{js_options});
|
|
251
|
+
} catch (e) {
|
|
252
|
+
#{Kernel}.$raise(#{err_klass}.$new(e && e.message ? e.message : String(e), Opal.hash({ model: #{model}, operation: 'speak' })));
|
|
253
|
+
}
|
|
254
|
+
})()
|
|
255
|
+
`.__await__
|
|
256
|
+
|
|
257
|
+
content_type =
|
|
258
|
+
`#{js_response}.headers.get("content-type") || "application/octet-stream"`
|
|
259
|
+
cache_control = `#{js_response}.headers.get("cache-control")`
|
|
260
|
+
BinaryBody.new(`#{js_response}.body`, content_type, cache_control)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def self.speak_data_url(model, inputs, binding: nil, options: nil)
|
|
264
|
+
binding = binding.js if defined?(Binding) &&
|
|
265
|
+
`(#{binding} != null && #{binding}.$$class === #{Binding})`
|
|
266
|
+
bound = !`(#{binding} == null)`
|
|
267
|
+
unless bound
|
|
268
|
+
raise AIError.new("AI binding not bound (env.AI is null)", model: model)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
js_inputs = ruby_to_js(inputs)
|
|
272
|
+
js_options = ruby_to_js((options || {}).merge(returnRawResponse: true))
|
|
273
|
+
ai_binding = binding
|
|
274
|
+
err_klass = Cloudflare::AIError
|
|
275
|
+
|
|
276
|
+
js_response =
|
|
277
|
+
`
|
|
278
|
+
(async function() {
|
|
279
|
+
try {
|
|
280
|
+
return await #{ai_binding}.run(#{model}, #{js_inputs}, #{js_options});
|
|
281
|
+
} catch (e) {
|
|
282
|
+
#{Kernel}.$raise(#{err_klass}.$new(e && e.message ? e.message : String(e), Opal.hash({ model: #{model}, operation: 'speak_data_url' })));
|
|
283
|
+
}
|
|
284
|
+
})()
|
|
285
|
+
`.__await__
|
|
286
|
+
|
|
287
|
+
content_type =
|
|
288
|
+
`#{js_response}.headers.get("content-type") || "application/octet-stream"`
|
|
289
|
+
`
|
|
290
|
+
(async function(resp, ct) {
|
|
291
|
+
var buf = await resp.arrayBuffer();
|
|
292
|
+
var bytes = new Uint8Array(buf);
|
|
293
|
+
var bin = '';
|
|
294
|
+
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
295
|
+
return 'data:' + ct + ';base64,' + globalThis.btoa(bin);
|
|
296
|
+
})(#{js_response}, #{content_type})
|
|
297
|
+
`.__await__
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def self.build_messages(prompt = nil, messages: nil, system: nil)
|
|
301
|
+
out = []
|
|
302
|
+
out << { role: "system", content: system.to_s } if system
|
|
303
|
+
if messages
|
|
304
|
+
unless messages.is_a?(Array)
|
|
305
|
+
raise ArgumentError, "messages must be an Array of chat messages"
|
|
306
|
+
end
|
|
307
|
+
out.concat(messages)
|
|
308
|
+
end
|
|
309
|
+
out << { role: "user", content: prompt.to_s } unless prompt.nil?
|
|
310
|
+
raise ArgumentError, "chat requires a prompt or messages" if out.empty?
|
|
311
|
+
out
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def self.audio_input(audio)
|
|
315
|
+
if audio.respond_to?(:to_uint8_array)
|
|
316
|
+
uint8 = audio.to_uint8_array
|
|
317
|
+
return `Array.from(#{uint8})`
|
|
318
|
+
end
|
|
319
|
+
return audio if audio.is_a?(Array)
|
|
320
|
+
if `typeof #{audio} !== 'undefined' && #{audio} instanceof Uint8Array`
|
|
321
|
+
return `Array.from(#{audio})`
|
|
322
|
+
end
|
|
323
|
+
audio
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def self.chat_input_options(model, input_options)
|
|
327
|
+
return input_options unless model.to_s == DEFAULT_CHAT_MODEL
|
|
328
|
+
|
|
329
|
+
options = input_options.dup
|
|
330
|
+
key =
|
|
331
|
+
if options.key?(:chat_template_kwargs)
|
|
332
|
+
:chat_template_kwargs
|
|
333
|
+
elsif options.key?("chat_template_kwargs")
|
|
334
|
+
"chat_template_kwargs"
|
|
335
|
+
end
|
|
336
|
+
template_kwargs = key ? options[key] : nil
|
|
337
|
+
if template_kwargs && !template_kwargs.is_a?(Hash)
|
|
338
|
+
raise ArgumentError, "chat_template_kwargs must be a Hash"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
merged = (template_kwargs || {}).dup
|
|
342
|
+
unless merged.key?(:thinking) || merged.key?("thinking")
|
|
343
|
+
merged[:thinking] = false
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
options[key || :chat_template_kwargs] = merged
|
|
347
|
+
options
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def self.extract_text(out)
|
|
351
|
+
return out.to_s unless out.is_a?(Hash)
|
|
352
|
+
if out["choices"].is_a?(Array) && !out["choices"].empty?
|
|
353
|
+
msg = out["choices"][0].is_a?(Hash) ? out["choices"][0]["message"] : nil
|
|
354
|
+
text = message_hash_text(msg)
|
|
355
|
+
return text unless text.empty?
|
|
356
|
+
end
|
|
357
|
+
if out["messages"].is_a?(Array) && !out["messages"].empty?
|
|
358
|
+
msg =
|
|
359
|
+
out["messages"].find do |entry|
|
|
360
|
+
entry.is_a?(Hash) && entry["role"].to_s == "assistant"
|
|
361
|
+
end || out["messages"][0]
|
|
362
|
+
text = message_hash_text(msg)
|
|
363
|
+
return text unless text.empty?
|
|
364
|
+
end
|
|
365
|
+
%w[text transcription response result output].each do |key|
|
|
366
|
+
value = message_content_text(out[key])
|
|
367
|
+
return value unless value.empty?
|
|
368
|
+
end
|
|
369
|
+
nested = out["result"]
|
|
370
|
+
return extract_text(nested) if nested.is_a?(Hash)
|
|
371
|
+
""
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def self.message_content_text(value)
|
|
375
|
+
case value
|
|
376
|
+
when String
|
|
377
|
+
value
|
|
378
|
+
when Array
|
|
379
|
+
value
|
|
380
|
+
.map { |part| part.is_a?(Hash) ? part["text"].to_s : part.to_s }
|
|
381
|
+
.join
|
|
382
|
+
else
|
|
383
|
+
""
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def self.message_hash_text(value)
|
|
388
|
+
return "" unless value.is_a?(Hash)
|
|
389
|
+
%w[
|
|
390
|
+
content
|
|
391
|
+
reasoning
|
|
392
|
+
reasoning_content
|
|
393
|
+
reasoningContent
|
|
394
|
+
text
|
|
395
|
+
].each do |key|
|
|
396
|
+
text = message_content_text(value[key])
|
|
397
|
+
return text unless text.empty?
|
|
398
|
+
end
|
|
399
|
+
""
|
|
400
|
+
end
|
|
401
|
+
|
|
122
402
|
# Convert a Ruby value (Hash / Array / String / Numeric / true / false / nil)
|
|
123
403
|
# into a plain JS object suitable for env.AI.run inputs.
|
|
124
404
|
def self.ruby_to_js(val)
|
|
405
|
+
if `#{val} != null && typeof #{val} === 'object' && #{val}.$$class == null`
|
|
406
|
+
return val
|
|
407
|
+
end
|
|
125
408
|
if val.is_a?(Hash)
|
|
126
409
|
obj = `({})`
|
|
127
410
|
val.each do |k, v|
|
|
@@ -174,16 +457,25 @@ module Cloudflare
|
|
|
174
457
|
# this file still loads if stream.rb hasn't been required yet
|
|
175
458
|
# (Phase 11A load-order flip: ai.rb currently loads first).
|
|
176
459
|
def response_headers
|
|
177
|
-
defaults =
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
460
|
+
defaults =
|
|
461
|
+
(
|
|
462
|
+
if defined?(::Cloudflare::SSEStream)
|
|
463
|
+
::Cloudflare::SSEStream::DEFAULT_HEADERS
|
|
464
|
+
else
|
|
465
|
+
{
|
|
466
|
+
"content-type" => "text/event-stream; charset=utf-8",
|
|
467
|
+
"cache-control" => "no-cache, no-transform",
|
|
468
|
+
"x-accel-buffering" => "no"
|
|
469
|
+
}
|
|
470
|
+
end
|
|
471
|
+
)
|
|
182
472
|
defaults.merge(@extra_headers)
|
|
183
473
|
end
|
|
184
474
|
|
|
185
|
-
def each
|
|
186
|
-
|
|
475
|
+
def each
|
|
476
|
+
end
|
|
477
|
+
def close
|
|
478
|
+
end
|
|
187
479
|
end
|
|
188
480
|
end
|
|
189
481
|
|
|
@@ -192,7 +484,9 @@ module Cloudflare
|
|
|
192
484
|
# Recursively converts nested objects + arrays.
|
|
193
485
|
def self.js_to_ruby(js_val)
|
|
194
486
|
return nil if `#{js_val} == null`
|
|
195
|
-
|
|
487
|
+
if `typeof #{js_val} === 'string' || typeof #{js_val} === 'number' || typeof #{js_val} === 'boolean'`
|
|
488
|
+
return js_val
|
|
489
|
+
end
|
|
196
490
|
if `Array.isArray(#{js_val})`
|
|
197
491
|
out = []
|
|
198
492
|
len = `#{js_val}.length`
|