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.
@@ -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 'json'
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("[Cloudflare::AI] model=#{model || '?'} op=#{operation || 'run'}: #{message}")
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) && !input_options.empty?
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) && !input_options.empty?
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) && `(#{binding} != null && #{binding}.$$class === #{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
- raise AIError.new('AI binding not bound (env.AI is null)', model: model) unless bound
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
- # newer Workers AI shape) or `options: { stream: true }` (the
86
- # 3rd-arg "options" contract). Accept both so callers can use
87
- # whichever idiom matches the model docs they're following.
88
- streaming = (inputs.is_a?(Hash) && (inputs[:stream] == true || inputs['stream'] == true)) ||
89
- (options.is_a?(Hash) && (options[:stream] == true || options['stream'] == true))
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 streaming
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 = defined?(::Cloudflare::SSEStream) ?
178
- ::Cloudflare::SSEStream::DEFAULT_HEADERS :
179
- { 'content-type' => 'text/event-stream; charset=utf-8',
180
- 'cache-control' => 'no-cache, no-transform',
181
- 'x-accel-buffering' => 'no' }
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; end
186
- def close; end
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
- return js_val if `typeof #{js_val} === 'string' || typeof #{js_val} === 'number' || typeof #{js_val} === 'boolean'`
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`